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