Skip to main content

leviath_runtime/pipeline/
resolve.rs

1//! Stage model and tool resolution: turning a blueprint's per-stage
2//! [`ModelConfig`] and `available_tools` into concrete [`ResolvedStage`]s
3//! against whatever providers and tools the host actually has.
4//!
5//! Lives in the runtime (rather than the CLI daemon, where it started) so an
6//! embedding host resolves stages exactly the way `lev run` does. The one
7//! policy input the CLI used to read from its config file - the user's default
8//! provider/model - arrives as a plain [`ModelDefaults`] value instead.
9
10use leviath_core::Blueprint;
11use leviath_core::blueprint::{ModelConfig, ModelEntry};
12
13use super::ResolvedStage;
14use crate::providers::ProviderRegistry;
15use leviath_providers::Tool;
16
17/// The user's default provider/model, the fallback when none of a stage's
18/// listed models has a registered provider. The CLI fills this from
19/// `config.toml`; an embedder sets it on the world builder (or leaves it
20/// empty, keeping the blueprint's own entries as the last resort).
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct ModelDefaults {
23    /// The default provider name (e.g. `anthropic`).
24    pub provider: String,
25    /// The default model, if the user configured one.
26    pub model: Option<String>,
27    /// The host-wide failover chain, from `[providers] fallback_order`.
28    ///
29    /// Appended after a stage's own entries and the user default, so a
30    /// blueprint that names exactly one model still has somewhere to go when
31    /// that provider stops answering. This is the case issue #201 reported:
32    /// every stage named a single OpenRouter model, so there was nothing to
33    /// fall back to when the account ran out of credits.
34    pub fallback_order: Vec<ModelEntry>,
35}
36
37/// Resolve a stage's [`ModelConfig`] to a concrete `(provider, model)` against
38/// the registered providers. Honors a `--model` override (`provider/model` or a
39/// bare `model`), otherwise picks the first listed model whose provider is
40/// registered, then falls back to the user default (when `allow_user_default`),
41/// and finally to the config's first listed entry. (Ported from the executor's
42/// inline resolution.)
43pub fn resolve_stage_model(
44    model_cfg: &ModelConfig,
45    model_override: Option<&str>,
46    defaults: &ModelDefaults,
47    registry: &ProviderRegistry,
48) -> (String, String) {
49    let first = resolve_stage_candidates(model_cfg, model_override, defaults, registry)
50        .into_iter()
51        .next()
52        .expect("resolve_stage_candidates always yields at least one entry");
53    (first.provider, first.model)
54}
55
56/// Every provider/model this stage may run on, best first.
57///
58/// [`resolve_stage_model`] is this list's head. The tail is what the runtime
59/// fails over to when a provider turns out to be unusable mid-run: the ordered
60/// list in `ModelConfig.models` was only ever consulted for *registration* at
61/// spawn time, so a provider that was configured but out of credits was picked
62/// and then never abandoned (issue #201).
63///
64/// Order: the stage's own registered entries, then the user default, then the
65/// host-wide `fallback_order`. Deduplicated, because the same pair reaching the
66/// list twice would spend a failover step going nowhere. Never empty: with
67/// nothing registered it yields the blueprint's own first entry, exactly as
68/// before, and `resolve_stages` rejects that unusable case with a clear error.
69pub fn resolve_stage_candidates(
70    model_cfg: &ModelConfig,
71    model_override: Option<&str>,
72    defaults: &ModelDefaults,
73    registry: &ProviderRegistry,
74) -> Vec<ModelEntry> {
75    let (override_provider, override_model) = match model_override {
76        Some(ov) if ov.contains('/') => {
77            let (p, m) = ov
78                .split_once('/')
79                .expect("the `contains('/')` guard splits");
80            (Some(p.to_string()), Some(m.to_string()))
81        }
82        Some(ov) => (None, Some(ov.to_string())),
83        None => (None, None),
84    };
85
86    // A full provider/model override names exactly one pair and deliberately
87    // skips every fallback: the caller asked for that model, not a substitute.
88    if let Some(provider) = override_provider {
89        return vec![ModelEntry::new(
90            provider,
91            override_model.unwrap_or_default(),
92        )];
93    }
94
95    let mut candidates: Vec<ModelEntry> = Vec::new();
96    let mut push = |provider: String, model: String| {
97        let entry = ModelEntry::new(provider, model);
98        if !candidates
99            .iter()
100            .any(|c| c.provider == entry.provider && c.model == entry.model)
101        {
102            candidates.push(entry);
103        }
104    };
105
106    // Every listed model whose provider is registered, in blueprint order. A
107    // bare `--model` override renames the model but keeps the provider order.
108    for entry in &model_cfg.models {
109        if registry.has(&entry.provider) {
110            let model = override_model
111                .clone()
112                .unwrap_or_else(|| entry.model.clone());
113            push(entry.provider.clone(), model);
114        }
115    }
116
117    if let Some((provider, model)) =
118        user_default_model(model_cfg, override_model.as_deref(), defaults, registry)
119    {
120        push(provider, model);
121    }
122
123    // The host-wide chain last: it is the safety net for a blueprint that names
124    // one model, not a preference over what the blueprint asked for.
125    for entry in &defaults.fallback_order {
126        if registry.has(&entry.provider) {
127            push(entry.provider.clone(), entry.model.clone());
128        }
129    }
130
131    if candidates.is_empty() {
132        // Nothing registered. Hand back the blueprint's own first entry so the
133        // caller reports "no usable provider" against a name the user wrote,
134        // rather than an empty list.
135        candidates.push(ModelEntry::new(
136            model_cfg.provider().to_string(),
137            model_cfg.model().to_string(),
138        ));
139    }
140
141    // The head keeps whatever `resolve_stage_model` has always produced, up to
142    // and including an unregistered provider that `resolve_stages` then
143    // rejects with a readable error. The *tail* is different: every entry in
144    // it is somewhere the runtime will actually dispatch to, so an
145    // unregistered one is not a fallback but a phantom that parks the run on
146    // `StallReason::ProviderMissing`. `user_default_model` hands one back
147    // whenever a bare `--model` override is in play, so filter here.
148    let tail: Vec<ModelEntry> = candidates
149        .split_off(1)
150        .into_iter()
151        .filter(|e| registry.has(&e.provider))
152        .collect();
153    candidates.extend(tail);
154    candidates
155}
156
157/// The user-default fallback for [`resolve_stage_model`]: `None` when the stage
158/// forbids it or no usable default exists.
159fn user_default_model(
160    model_cfg: &ModelConfig,
161    override_model: Option<&str>,
162    defaults: &ModelDefaults,
163    registry: &ProviderRegistry,
164) -> Option<(String, String)> {
165    if !model_cfg.allow_user_default {
166        return None;
167    }
168    if let Some(model) = override_model {
169        return Some((defaults.provider.clone(), model.to_string()));
170    }
171    if let Some(default_model) = &defaults.model
172        && registry.has(&defaults.provider)
173    {
174        return Some((defaults.provider.clone(), default_model.clone()));
175    }
176    None
177}
178
179/// Filter `all` tool defs down to those a stage's `available_tools` names
180/// (alias-resolved). Shared by spawn-time stage resolution and the mid-run
181/// tool-service refresh so both apply Layer-1 identically.
182pub fn filter_tools_by_available(all: &[Tool], available: &[String]) -> Vec<Tool> {
183    if available.is_empty() {
184        return Vec::new();
185    }
186    all.iter()
187        .filter(|t| {
188            available
189                .iter()
190                .any(|n| leviath_tools::canonical_tool_name(n) == t.name)
191        })
192        .cloned()
193        .collect()
194}
195
196/// The stage's Layer-1 tool set for a run that may have nobody watching.
197///
198/// Same filter as [`filter_tools_by_available`], then - for an unattended run -
199/// minus every tool whose only outcome is a prompt for a person
200/// ([`BLOCKING_INTERACTION_TOOLS`](crate::dynamic_interaction::BLOCKING_INTERACTION_TOOLS)),
201/// unless the stage named it in `required_tools`.
202///
203/// Dropping the definition rather than auto-answering the call is what makes the
204/// difference visible to the model: it never sees the tool, so it decides for
205/// itself instead of spending a round trip to be told nobody is there. A call
206/// that arrives anyway (a model repeating itself from context) meets the ordinary
207/// unoffered-tool refusal.
208pub fn filter_tools_for_stage(
209    all: &[Tool],
210    available: &[String],
211    required: &[String],
212    unattended: bool,
213) -> Vec<Tool> {
214    let mut tools = filter_tools_by_available(all, available);
215    if unattended {
216        tools.retain(|t| {
217            !crate::dynamic_interaction::BLOCKING_INTERACTION_TOOLS.contains(&t.name.as_str())
218                || required
219                    .iter()
220                    .any(|n| leviath_tools::canonical_tool_name(n) == t.name)
221        });
222    }
223    tools
224}
225
226/// Every provider a stage could have used, in the order they were tried, for
227/// the error message when none of them is configured.
228///
229/// A `--model provider/model` override is the whole list on its own: it names
230/// exactly one provider and skips the blueprint's fallbacks entirely.
231///
232/// Public because [`resolve_stages`] is not the only place that has to explain
233/// an unusable resolution: `lev doctor` runs the same chain against an empty
234/// [`ModelConfig`] to report what the user's config alone would pick, and it
235/// must name the same providers in the same order rather than reimplement this.
236pub fn providers_tried(
237    model_cfg: &ModelConfig,
238    model_override: Option<&str>,
239    defaults: &ModelDefaults,
240) -> String {
241    let mut names: Vec<String> = match model_override {
242        Some(ov) if ov.contains('/') => vec![
243            ov.split_once('/')
244                .map(|(p, _)| p.to_string())
245                .expect("the `contains('/')` guard guarantees a split"),
246        ],
247        _ => {
248            let mut listed: Vec<String> = model_cfg
249                .models
250                .iter()
251                .map(|e| e.provider.clone())
252                .collect();
253            if model_cfg.allow_user_default && !defaults.provider.is_empty() {
254                listed.push(defaults.provider.clone());
255            }
256            listed
257        }
258    };
259    names.dedup();
260    names.join(", ")
261}
262
263/// Resolve every stage's provider/model + effective tool set from the
264/// blueprint, or report the first stage that has no usable provider.
265///
266/// The last fallback in [`resolve_stage_model`] is unchecked - it hands back
267/// the blueprint's own first entry whether or not anything answers to that
268/// name, and a full `provider/model` override skips the registry outright. So
269/// a stage could resolve to a provider that does not exist, and the agent
270/// spawned anyway: `Active`, iteration 0, and unable to take a single turn for
271/// as long as the host lived (issue #190). Catching it here turns a silently
272/// wedged run into an error the caller sees.
273///
274/// `unattended` is the run's `--yolo` setting: it decides whether a stage's
275/// human-in-the-loop tools are advertised at all (see
276/// [`filter_tools_for_stage`]).
277pub fn resolve_stages(
278    blueprint: &Blueprint,
279    model_override: Option<&str>,
280    defaults: &ModelDefaults,
281    registry: &ProviderRegistry,
282    all_tool_defs: &[Tool],
283    unattended: bool,
284) -> Result<Vec<ResolvedStage>, String> {
285    blueprint
286        .stages
287        .iter()
288        .map(|stage| {
289            let mut candidates =
290                resolve_stage_candidates(&stage.model, model_override, defaults, registry);
291            let head = candidates.remove(0);
292            // `registry.has` also consults the script layer, so a `.rhai`
293            // provider sitting on disk counts as usable and is never
294            // false-rejected here.
295            if !registry.has(&head.provider) {
296                return Err(format!(
297                    "stage '{}' has no usable provider (tried: {}). Configure one \
298                     with `lev setup`, or add it to config.toml and restart the daemon.",
299                    stage.name,
300                    providers_tried(&stage.model, model_override, defaults)
301                ));
302            }
303            // Empty `available_tools` exposes no tools; otherwise filter the full
304            // set by name (alias-resolved). A name matching nothing (a typo, or an
305            // MCP tool whose server isn't installed) is simply omitted. An
306            // unattended run also loses the tools that block on a person.
307            let tools = filter_tools_for_stage(
308                all_tool_defs,
309                &stage.available_tools,
310                &stage.required_tools,
311                unattended,
312            );
313            Ok(ResolvedStage {
314                provider_name: head.provider,
315                model: head.model,
316                tools,
317                fallbacks: candidates,
318            })
319        })
320        .collect()
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use leviath_core::blueprint::ModelEntry;
327    use std::collections::HashMap;
328    use std::sync::Arc;
329
330    fn model_cfg(models: Vec<(&str, &str)>) -> ModelConfig {
331        ModelConfig {
332            models: models
333                .into_iter()
334                .map(|(p, m)| ModelEntry {
335                    provider: p.to_string(),
336                    model: m.to_string(),
337                })
338                .collect(),
339            allow_user_default: true,
340            parameters: HashMap::new(),
341            request_timeout_secs: None,
342        }
343    }
344
345    fn registry_with(providers: &[&str]) -> ProviderRegistry {
346        let mut r = ProviderRegistry::new();
347        for p in providers {
348            r.register(p.to_string(), Arc::new(FakeProvider));
349        }
350        r
351    }
352
353    struct FakeProvider;
354    #[async_trait::async_trait]
355    impl leviath_providers::Provider for FakeProvider {
356        async fn infer(
357            &self,
358            _r: leviath_providers::InferenceRequest,
359        ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
360            Err(leviath_providers::ProviderError::Other(
361                "test provider".to_string(),
362            ))
363        }
364        async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
365            1
366        }
367        fn max_context_tokens(&self, _m: &str) -> usize {
368            1000
369        }
370        fn name(&self) -> &str {
371            "fake"
372        }
373        fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
374            leviath_providers::ModelCapabilities::default()
375        }
376    }
377
378    #[tokio::test]
379    async fn fake_provider_is_a_minimal_registry_stub() {
380        // The resolver only asks the registry `has()`, so the fixture provider
381        // is inert; this pins its stub answers so the impl stays measured.
382        use leviath_providers::Provider as _;
383        let p = FakeProvider;
384        let request: leviath_providers::InferenceRequest =
385            serde_json::from_value(serde_json::json!({
386                "messages": [],
387                "model": "m",
388                "max_tokens": 1,
389                "temperature": 0.0,
390                "tools": [],
391                "extra": null,
392            }))
393            .unwrap();
394        assert!(p.infer(request).await.is_err());
395        assert_eq!(p.count_tokens("x", "m").await, 1);
396        assert_eq!(p.max_context_tokens("m"), 1000);
397        assert_eq!(p.name(), "fake");
398        let _ = p.capabilities("m");
399    }
400
401    #[test]
402    fn resolve_full_override_wins() {
403        let (p, m) = resolve_stage_model(
404            &model_cfg(vec![("anthropic", "x")]),
405            Some("openai/gpt-5"),
406            &ModelDefaults::default(),
407            &registry_with(&[]),
408        );
409        assert_eq!((p.as_str(), m.as_str()), ("openai", "gpt-5"));
410    }
411
412    #[test]
413    fn resolve_first_available_model() {
414        // anthropic not registered, openai is → picks openai.
415        let (p, m) = resolve_stage_model(
416            &model_cfg(vec![("anthropic", "a"), ("openai", "o")]),
417            None,
418            &ModelDefaults::default(),
419            &registry_with(&["openai"]),
420        );
421        assert_eq!((p.as_str(), m.as_str()), ("openai", "o"));
422    }
423
424    #[test]
425    fn resolve_model_only_override_keeps_available_provider() {
426        let (p, m) = resolve_stage_model(
427            &model_cfg(vec![("openai", "o")]),
428            Some("gpt-override"),
429            &ModelDefaults::default(),
430            &registry_with(&["openai"]),
431        );
432        assert_eq!((p.as_str(), m.as_str()), ("openai", "gpt-override"));
433    }
434
435    #[test]
436    fn resolve_user_default_when_nothing_listed_available() {
437        // Listed provider "ghost" is unavailable; anthropic (the default) is.
438        let defaults = ModelDefaults {
439            provider: "anthropic".to_string(),
440            model: Some("claude-default".to_string()),
441            fallback_order: Vec::new(),
442        };
443        let (p, m) = resolve_stage_model(
444            &model_cfg(vec![("ghost", "g")]),
445            None,
446            &defaults,
447            &registry_with(&["anthropic"]),
448        );
449        assert_eq!((p.as_str(), m.as_str()), ("anthropic", "claude-default"));
450    }
451
452    #[test]
453    fn resolve_user_default_with_model_override() {
454        let defaults = ModelDefaults {
455            provider: "anthropic".to_string(),
456            model: None,
457            fallback_order: Vec::new(),
458        };
459        let (p, m) = resolve_stage_model(
460            &model_cfg(vec![("ghost", "g")]),
461            Some("just-a-model"),
462            &defaults,
463            &registry_with(&[]),
464        );
465        assert_eq!((p.as_str(), m.as_str()), ("anthropic", "just-a-model"));
466    }
467
468    #[test]
469    fn resolve_user_default_provider_unavailable_falls_through() {
470        // allow_user_default, a default model set, but the default provider isn't
471        // registered ⇒ neither user-default branch fires ⇒ last resort.
472        let defaults = ModelDefaults {
473            provider: "ghost-default".to_string(),
474            model: Some("dm".to_string()),
475            fallback_order: Vec::new(),
476        };
477        let (p, m) = resolve_stage_model(
478            &model_cfg(vec![("ghost", "g")]),
479            None,
480            &defaults,
481            &registry_with(&[]),
482        );
483        assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
484    }
485
486    #[test]
487    fn resolve_last_resort_first_listed() {
488        // No override, nothing available, no usable default → first listed entry.
489        let (p, m) = resolve_stage_model(
490            &model_cfg(vec![("ghost", "g")]),
491            None,
492            &ModelDefaults::default(),
493            &registry_with(&[]),
494        );
495        assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
496    }
497
498    #[test]
499    fn resolve_no_user_default_uses_last_resort() {
500        let mut cfg = model_cfg(vec![("ghost", "g")]);
501        cfg.allow_user_default = false; // forbid the default fallback
502        let defaults = ModelDefaults {
503            provider: "anthropic".to_string(),
504            model: Some("would-be-default".to_string()),
505            fallback_order: Vec::new(),
506        };
507        let (p, m) = resolve_stage_model(&cfg, None, &defaults, &registry_with(&["anthropic"]));
508        assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
509    }
510
511    #[test]
512    fn resolve_stages_empty_available_tools_gets_none() {
513        let mut stage =
514            leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
515        stage.available_tools = vec![]; // empty ⇒ no tools
516        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
517        let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
518        let tools = vec![Tool {
519            name: "read_file".to_string(),
520            description: String::new(),
521            parameters: serde_json::Value::Null,
522        }];
523        let resolved = resolve_stages(
524            &bp,
525            None,
526            &ModelDefaults::default(),
527            &registry_with(&["anthropic"]),
528            &tools,
529            false,
530        )
531        .expect("anthropic is registered");
532        assert!(resolved[0].tools.is_empty());
533    }
534
535    #[test]
536    fn resolve_stages_refuses_a_stage_with_no_usable_provider() {
537        // Issue #190: the last fallback in `resolve_stage_model` is unchecked,
538        // so this used to resolve to "ghost" and produce an agent that could
539        // never take a turn. It has to be an error the caller sees.
540        let stage = leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("ghost", "m")]));
541        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
542        let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
543
544        let err = resolve_stages(
545            &bp,
546            None,
547            &ModelDefaults::default(),
548            &registry_with(&[]),
549            &[],
550            false,
551        )
552        .expect_err("no provider is configured");
553
554        assert!(err.contains("plan"), "names the stage: {err}");
555        assert!(err.contains("ghost"), "names what it tried: {err}");
556        assert!(err.contains("lev setup"), "says what to do: {err}");
557    }
558
559    #[test]
560    fn resolve_stages_refuses_an_override_naming_an_unregistered_provider() {
561        // `--model ghost/x` short-circuits every fallback, so the override is
562        // the only provider that was tried.
563        let stage =
564            leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("anthropic", "m")]));
565        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
566        let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
567
568        let err = resolve_stages(
569            &bp,
570            Some("ghost/x"),
571            &ModelDefaults::default(),
572            &registry_with(&["anthropic"]),
573            &[],
574            false,
575        )
576        .expect_err("the override names a provider that isn't registered");
577
578        assert!(err.contains("tried: ghost"), "got: {err}");
579        assert!(
580            !err.contains("anthropic"),
581            "the override skipped the blueprint's list entirely: {err}"
582        );
583    }
584
585    #[test]
586    fn providers_tried_lists_the_blueprint_entries_and_the_user_default() {
587        let defaults = ModelDefaults {
588            provider: "fallback".to_string(),
589            model: None,
590            fallback_order: Vec::new(),
591        };
592        let cfg = model_cfg(vec![("one", "m"), ("two", "m")]);
593        assert_eq!(providers_tried(&cfg, None, &defaults), "one, two, fallback");
594
595        // A stage that opts out of the user default doesn't claim to have tried it.
596        let mut no_default = cfg.clone();
597        no_default.allow_user_default = false;
598        assert_eq!(providers_tried(&no_default, None, &defaults), "one, two");
599
600        // Neither does an embedder that configured no default at all.
601        assert_eq!(
602            providers_tried(&cfg, None, &ModelDefaults::default()),
603            "one, two"
604        );
605
606        // A bare `--model m` override still uses the blueprint's providers.
607        assert_eq!(
608            providers_tried(&cfg, Some("m"), &defaults),
609            "one, two, fallback"
610        );
611    }
612
613    #[test]
614    fn resolve_stages_matches_by_alias_and_skips_unknown_names() {
615        // A stage names `bash` (an alias) and a not-installed MCP tool. The
616        // filter must select the canonical `shell` definition for the alias and
617        // silently omit the unknown name (no error, no panic).
618        let mut stage =
619            leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
620        stage.available_tools = vec!["bash".to_string(), "acme__uninstalled".to_string()];
621        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
622        let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
623        let tools = vec![
624            Tool {
625                name: "shell".to_string(),
626                description: String::new(),
627                parameters: serde_json::Value::Null,
628            },
629            Tool {
630                name: "read_file".to_string(),
631                description: String::new(),
632                parameters: serde_json::Value::Null,
633            },
634        ];
635        let resolved = resolve_stages(
636            &bp,
637            None,
638            &ModelDefaults::default(),
639            &registry_with(&["anthropic"]),
640            &tools,
641            false,
642        )
643        .expect("anthropic is registered");
644        let selected: Vec<&str> = resolved[0].tools.iter().map(|t| t.name.as_str()).collect();
645        // `bash` resolved to `shell`; the unknown MCP name and unlisted
646        // `read_file` were both excluded.
647        assert_eq!(selected, vec!["shell"]);
648    }
649
650    // ── failover candidates (issue #201) ──────────────────────────────────
651
652    /// `[(provider, model), ...]` for readable assertions.
653    fn pairs(entries: &[ModelEntry]) -> Vec<(&str, &str)> {
654        entries
655            .iter()
656            .map(|e| (e.provider.as_str(), e.model.as_str()))
657            .collect()
658    }
659
660    #[test]
661    fn candidates_keep_every_registered_entry_in_blueprint_order() {
662        let cfg = model_cfg(vec![
663            ("openrouter", "deepseek"),
664            ("anthropic", "sonnet"),
665            ("openai", "gpt"),
666        ]);
667        let registry = registry_with(&["openrouter", "anthropic", "openai"]);
668        let got = resolve_stage_candidates(&cfg, None, &ModelDefaults::default(), &registry);
669        // The head is what `resolve_stage_model` picks; the tail is where
670        // failover goes. Before this, the tail was discarded at spawn.
671        assert_eq!(
672            pairs(&got),
673            vec![
674                ("openrouter", "deepseek"),
675                ("anthropic", "sonnet"),
676                ("openai", "gpt"),
677            ]
678        );
679    }
680
681    // ─── the unattended cut (issue #204) ─────────────────────────────────────
682
683    /// A stage's tool defs for the three tools every one of these tests uses.
684    fn ask_and_read_defs() -> Vec<Tool> {
685        ["read_file", "ask_user_text", "ask_user_choice"]
686            .iter()
687            .map(|n| Tool {
688                name: n.to_string(),
689                description: String::new(),
690                parameters: serde_json::Value::Null,
691            })
692            .collect()
693    }
694
695    fn names(tools: &[Tool]) -> Vec<&str> {
696        tools.iter().map(|t| t.name.as_str()).collect()
697    }
698
699    #[test]
700    fn an_attended_run_keeps_every_tool_the_stage_lists() {
701        let available = vec![
702            "read_file".to_string(),
703            "ask_user_text".to_string(),
704            "ask_user_choice".to_string(),
705        ];
706        let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &[], false);
707        assert_eq!(
708            names(&tools),
709            vec!["read_file", "ask_user_text", "ask_user_choice"]
710        );
711    }
712
713    #[test]
714    fn candidates_skip_providers_that_are_not_registered() {
715        let cfg = model_cfg(vec![("ghost", "nope"), ("anthropic", "sonnet")]);
716        let registry = registry_with(&["anthropic"]);
717        let got = resolve_stage_candidates(&cfg, None, &ModelDefaults::default(), &registry);
718        assert_eq!(pairs(&got), vec![("anthropic", "sonnet")]);
719    }
720
721    #[test]
722    fn the_global_chain_rescues_a_single_model_stage() {
723        // The reported configuration: every stage names one OpenRouter model,
724        // so the blueprint alone offers nowhere to fail over to.
725        let cfg = ModelConfig {
726            allow_user_default: false,
727            ..model_cfg(vec![("openrouter", "deepseek")])
728        };
729        let defaults = ModelDefaults {
730            fallback_order: vec![
731                ModelEntry::new("anthropic".to_string(), "sonnet".to_string()),
732                ModelEntry::new("ghost".to_string(), "nope".to_string()),
733            ],
734            ..Default::default()
735        };
736        let registry = registry_with(&["openrouter", "anthropic"]);
737        let got = resolve_stage_candidates(&cfg, None, &defaults, &registry);
738        assert_eq!(
739            pairs(&got),
740            vec![("openrouter", "deepseek"), ("anthropic", "sonnet")],
741            "the unregistered global entry is skipped"
742        );
743    }
744
745    #[test]
746    fn the_global_chain_comes_after_the_user_default() {
747        let cfg = model_cfg(vec![("openrouter", "deepseek")]);
748        let defaults = ModelDefaults {
749            provider: "anthropic".to_string(),
750            model: Some("sonnet".to_string()),
751            fallback_order: vec![ModelEntry::new("openai".to_string(), "gpt".to_string())],
752        };
753        let registry = registry_with(&["openrouter", "anthropic", "openai"]);
754        let got = resolve_stage_candidates(&cfg, None, &defaults, &registry);
755        assert_eq!(
756            pairs(&got),
757            vec![
758                ("openrouter", "deepseek"),
759                ("anthropic", "sonnet"),
760                ("openai", "gpt"),
761            ]
762        );
763    }
764
765    #[test]
766    fn candidates_are_deduplicated() {
767        // The same pair arriving twice would spend a failover step going
768        // nowhere, which reads to the operator as a swap that did nothing.
769        let cfg = model_cfg(vec![("anthropic", "sonnet"), ("anthropic", "sonnet")]);
770        let defaults = ModelDefaults {
771            provider: "anthropic".to_string(),
772            model: Some("sonnet".to_string()),
773            fallback_order: vec![ModelEntry::new(
774                "anthropic".to_string(),
775                "sonnet".to_string(),
776            )],
777        };
778        let registry = registry_with(&["anthropic"]);
779        let got = resolve_stage_candidates(&cfg, None, &defaults, &registry);
780        assert_eq!(pairs(&got), vec![("anthropic", "sonnet")]);
781    }
782
783    #[test]
784    fn a_full_override_names_exactly_one_candidate() {
785        // `--model provider/model` asked for that model, not a substitute.
786        let cfg = model_cfg(vec![("anthropic", "sonnet"), ("openai", "gpt")]);
787        let defaults = ModelDefaults {
788            fallback_order: vec![ModelEntry::new("openai".to_string(), "gpt".to_string())],
789            ..Default::default()
790        };
791        let registry = registry_with(&["anthropic", "openai", "ollama"]);
792        let got = resolve_stage_candidates(&cfg, Some("ollama/llama"), &defaults, &registry);
793        assert_eq!(pairs(&got), vec![("ollama", "llama")]);
794    }
795
796    #[test]
797    fn a_bare_override_renames_the_model_on_every_candidate() {
798        let cfg = model_cfg(vec![("anthropic", "sonnet"), ("openai", "gpt")]);
799        let registry = registry_with(&["anthropic", "openai"]);
800        let got =
801            resolve_stage_candidates(&cfg, Some("haiku"), &ModelDefaults::default(), &registry);
802        assert_eq!(
803            pairs(&got),
804            vec![("anthropic", "haiku"), ("openai", "haiku")]
805        );
806    }
807
808    #[test]
809    fn candidates_are_never_empty_even_with_nothing_registered() {
810        // `resolve_stages` needs a name the user wrote to report against.
811        let cfg = ModelConfig {
812            allow_user_default: false,
813            ..model_cfg(vec![("ghost", "nope")])
814        };
815        let got =
816            resolve_stage_candidates(&cfg, None, &ModelDefaults::default(), &registry_with(&[]));
817        assert_eq!(pairs(&got), vec![("ghost", "nope")]);
818    }
819
820    #[test]
821    fn resolve_stages_carries_the_tail_onto_the_resolved_stage() {
822        let mut stage = leviath_core::Stage::new(
823            "work".to_string(),
824            model_cfg(vec![("openrouter", "deepseek"), ("anthropic", "sonnet")]),
825        );
826        stage.available_tools = vec![];
827        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
828        let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
829        let registry = registry_with(&["openrouter", "anthropic"]);
830        let resolved = resolve_stages(&bp, None, &ModelDefaults::default(), &registry, &[], false)
831            .expect("both providers are registered");
832        assert_eq!(resolved[0].provider_name, "openrouter");
833        assert_eq!(pairs(&resolved[0].fallbacks), vec![("anthropic", "sonnet")]);
834    }
835
836    #[test]
837    fn an_unattended_run_loses_the_tools_that_wait_on_a_person() {
838        // The whole point of issue #204: with nobody watching, a call to
839        // `ask_user_text` can only park the agent, so the model never sees it.
840        let available = vec![
841            "read_file".to_string(),
842            "ask_user_text".to_string(),
843            "ask_user_choice".to_string(),
844        ];
845        let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &[], true);
846        assert_eq!(names(&tools), vec!["read_file"]);
847    }
848
849    #[test]
850    fn required_tools_survive_an_unattended_run() {
851        // The opt-out: a stage that says it genuinely needs a person keeps the
852        // named tool, and only that one.
853        let available = vec![
854            "read_file".to_string(),
855            "ask_user_text".to_string(),
856            "ask_user_choice".to_string(),
857        ];
858        let required = vec!["ask_user_text".to_string()];
859        let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &required, true);
860        assert_eq!(names(&tools), vec!["read_file", "ask_user_text"]);
861    }
862
863    #[test]
864    fn a_required_tool_the_stage_never_offered_adds_nothing() {
865        // `required_tools` narrows the unattended cut; it is not a second way to
866        // grant a tool. (`Stage::validate` rejects this combination outright -
867        // this is the belt to that pair of braces.)
868        let available = vec!["read_file".to_string()];
869        let required = vec!["ask_user_text".to_string()];
870        let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &required, true);
871        assert_eq!(names(&tools), vec!["read_file"]);
872    }
873
874    #[test]
875    fn resolve_stages_applies_the_unattended_cut_per_stage() {
876        // Two stages, one opting out, resolved in a single unattended run: the
877        // cut is per stage, not per run.
878        let mut plan =
879            leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("anthropic", "m")]));
880        plan.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
881        plan.required_tools = vec!["ask_user_text".to_string()];
882        let mut build =
883            leviath_core::Stage::new("build".to_string(), model_cfg(vec![("anthropic", "m")]));
884        build.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
885        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
886        let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![plan, build], layout);
887
888        let resolved = resolve_stages(
889            &bp,
890            None,
891            &ModelDefaults::default(),
892            &registry_with(&["anthropic"]),
893            &ask_and_read_defs(),
894            true,
895        )
896        .expect("anthropic is registered");
897
898        assert_eq!(
899            names(&resolved[0].tools),
900            vec!["read_file", "ask_user_text"]
901        );
902        assert_eq!(names(&resolved[1].tools), vec!["read_file"]);
903    }
904
905    #[test]
906    fn the_unattended_cut_resolves_aliases_on_both_sides() {
907        // `edit_document` under an alias would be a hole in the cut, and a
908        // `required_tools` entry written as an alias would be a hole in the
909        // opt-out. Neither is: both sides canonicalise. `bash`/`shell` is the
910        // only alias pair that exists, so it stands in for the mechanism - a
911        // non-human tool is never cut whatever it is called.
912        let defs = vec![Tool {
913            name: "shell".to_string(),
914            description: String::new(),
915            parameters: serde_json::Value::Null,
916        }];
917        let available = vec!["bash".to_string()];
918        let tools = filter_tools_for_stage(&defs, &available, &[], true);
919        assert_eq!(names(&tools), vec!["shell"]);
920    }
921}