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/// Rewrite the `submit_output` definition in `tools` to describe the shape this
245/// stage is meant to produce.
246///
247/// This is the entire mechanism for arbitrary output formats. There is no
248/// per-format code path anywhere: what makes a model emit a2ui, a house schema,
249/// or something invented after this was written is that the format label, the
250/// author's instructions, and a literal example are pasted into the description
251/// the model reads. A stage that declares nothing keeps the generic wording.
252///
253/// A no-op when the stage does not offer the tool, which is most stages.
254fn apply_output_shape(tools: &mut [Tool], spec: Option<&leviath_core::output::OutputSpec>) {
255    let Some(spec) = spec else { return };
256    let described = leviath_core::describe_spec(spec);
257    if described.is_empty() {
258        return;
259    }
260    for tool in tools
261        .iter_mut()
262        .filter(|t| t.name == leviath_tools::SUBMIT_OUTPUT_TOOL)
263    {
264        tool.description = leviath_tools::submit_output_description(&described);
265    }
266}
267
268/// Every provider a stage could have used, in the order they were tried, for
269/// the error message when none of them is configured.
270///
271/// A `--model provider/model` override is the whole list on its own: it names
272/// exactly one provider and skips the blueprint's fallbacks entirely.
273///
274/// Public because [`resolve_stages`] is not the only place that has to explain
275/// an unusable resolution: `lev doctor` runs the same chain against an empty
276/// [`ModelConfig`] to report what the user's config alone would pick, and it
277/// must name the same providers in the same order rather than reimplement this.
278pub fn providers_tried(
279    model_cfg: &ModelConfig,
280    model_override: Option<&str>,
281    defaults: &ModelDefaults,
282) -> String {
283    let mut names: Vec<String> = match model_override {
284        Some(ov) if ov.contains('/') => vec![
285            ov.split_once('/')
286                .map(|(p, _)| p.to_string())
287                .expect("the `contains('/')` guard guarantees a split"),
288        ],
289        _ => {
290            let mut listed: Vec<String> = model_cfg
291                .models
292                .iter()
293                .map(|e| e.provider.clone())
294                .collect();
295            if model_cfg.allow_user_default && !defaults.provider.is_empty() {
296                listed.push(defaults.provider.clone());
297            }
298            listed
299        }
300    };
301    names.dedup();
302    names.join(", ")
303}
304
305/// Resolve every stage's provider/model + effective tool set from the
306/// blueprint, or report the first stage that has no usable provider.
307///
308/// The last fallback in [`resolve_stage_model`] is unchecked - it hands back
309/// the blueprint's own first entry whether or not anything answers to that
310/// name, and a full `provider/model` override skips the registry outright. So
311/// a stage could resolve to a provider that does not exist, and the agent
312/// spawned anyway: `Active`, iteration 0, and unable to take a single turn for
313/// as long as the host lived (issue #190). Catching it here turns a silently
314/// wedged run into an error the caller sees.
315///
316/// `unattended` is the run's `--yolo` setting: it decides whether a stage's
317/// human-in-the-loop tools are advertised at all (see
318/// [`filter_tools_for_stage`]).
319///
320/// `output_request` is the shape whoever launched the run asked for, if any. It
321/// is resolved here, alongside the model and tool choices, because this is the
322/// one place that can see all three levels at once - and because a caller's
323/// request only exists at launch.
324pub fn resolve_stages(
325    blueprint: &Blueprint,
326    model_override: Option<&str>,
327    defaults: &ModelDefaults,
328    registry: &ProviderRegistry,
329    all_tool_defs: &[Tool],
330    unattended: bool,
331    output_request: Option<&leviath_core::output::OutputSpec>,
332) -> Result<Vec<ResolvedStage>, String> {
333    blueprint
334        .stages
335        .iter()
336        .map(|stage| {
337            let mut candidates =
338                resolve_stage_candidates(&stage.model, model_override, defaults, registry);
339            let head = candidates.remove(0);
340            // `registry.has` also consults the script layer, so a `.rhai`
341            // provider sitting on disk counts as usable and is never
342            // false-rejected here.
343            if !registry.has(&head.provider) {
344                return Err(format!(
345                    "stage '{}' has no usable provider (tried: {}). Configure one \
346                     with `lev setup`, or add it to config.toml and restart the daemon.",
347                    stage.name,
348                    providers_tried(&stage.model, model_override, defaults)
349                ));
350            }
351            // Empty `available_tools` exposes no tools; otherwise filter the full
352            // set by name (alias-resolved). A name matching nothing (a typo, or an
353            // MCP tool whose server isn't installed) is simply omitted. An
354            // unattended run also loses the tools that block on a person.
355            let mut tools = filter_tools_for_stage(
356                all_tool_defs,
357                &stage.available_tools,
358                &stage.required_tools,
359                unattended,
360            );
361            let output = leviath_core::resolve_output_spec(
362                blueprint.output.as_ref(),
363                stage.output.as_ref(),
364                output_request,
365            );
366            apply_output_shape(&mut tools, output.as_ref());
367            Ok(ResolvedStage {
368                provider_name: head.provider,
369                model: head.model,
370                tools,
371                fallbacks: candidates,
372                output,
373            })
374        })
375        .collect()
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use leviath_core::blueprint::ModelEntry;
382    use std::collections::HashMap;
383    use std::sync::Arc;
384
385    fn model_cfg(models: Vec<(&str, &str)>) -> ModelConfig {
386        ModelConfig {
387            models: models
388                .into_iter()
389                .map(|(p, m)| ModelEntry {
390                    provider: p.to_string(),
391                    model: m.to_string(),
392                })
393                .collect(),
394            allow_user_default: true,
395            parameters: HashMap::new(),
396            request_timeout_secs: None,
397        }
398    }
399
400    fn registry_with(providers: &[&str]) -> ProviderRegistry {
401        let mut r = ProviderRegistry::new();
402        for p in providers {
403            r.register(p.to_string(), Arc::new(FakeProvider));
404        }
405        r
406    }
407
408    struct FakeProvider;
409    #[async_trait::async_trait]
410    impl leviath_providers::Provider for FakeProvider {
411        async fn infer(
412            &self,
413            _r: &leviath_providers::InferenceRequest,
414        ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
415            Err(leviath_providers::ProviderError::Other(
416                "test provider".to_string(),
417            ))
418        }
419        async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
420            1
421        }
422        fn max_context_tokens(&self, _m: &str) -> usize {
423            1000
424        }
425        fn name(&self) -> &str {
426            "fake"
427        }
428        fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
429            leviath_providers::ModelCapabilities::default()
430        }
431    }
432
433    #[tokio::test]
434    async fn fake_provider_is_a_minimal_registry_stub() {
435        // The resolver only asks the registry `has()`, so the fixture provider
436        // is inert; this pins its stub answers so the impl stays measured.
437        use leviath_providers::Provider as _;
438        let p = FakeProvider;
439        let request: leviath_providers::InferenceRequest =
440            serde_json::from_value(serde_json::json!({
441                "messages": [],
442                "model": "m",
443                "max_tokens": 1,
444                "temperature": 0.0,
445                "tools": [],
446                "extra": null,
447            }))
448            .unwrap();
449        assert!(p.infer(&request).await.is_err());
450        assert_eq!(p.count_tokens("x", "m").await, 1);
451        assert_eq!(p.max_context_tokens("m"), 1000);
452        assert_eq!(p.name(), "fake");
453        let _ = p.capabilities("m");
454    }
455
456    #[test]
457    fn resolve_full_override_wins() {
458        let (p, m) = resolve_stage_model(
459            &model_cfg(vec![("anthropic", "x")]),
460            Some("openai/gpt-5"),
461            &ModelDefaults::default(),
462            &registry_with(&[]),
463        );
464        assert_eq!((p.as_str(), m.as_str()), ("openai", "gpt-5"));
465    }
466
467    #[test]
468    fn resolve_first_available_model() {
469        // anthropic not registered, openai is → picks openai.
470        let (p, m) = resolve_stage_model(
471            &model_cfg(vec![("anthropic", "a"), ("openai", "o")]),
472            None,
473            &ModelDefaults::default(),
474            &registry_with(&["openai"]),
475        );
476        assert_eq!((p.as_str(), m.as_str()), ("openai", "o"));
477    }
478
479    #[test]
480    fn resolve_model_only_override_keeps_available_provider() {
481        let (p, m) = resolve_stage_model(
482            &model_cfg(vec![("openai", "o")]),
483            Some("gpt-override"),
484            &ModelDefaults::default(),
485            &registry_with(&["openai"]),
486        );
487        assert_eq!((p.as_str(), m.as_str()), ("openai", "gpt-override"));
488    }
489
490    #[test]
491    fn resolve_user_default_when_nothing_listed_available() {
492        // Listed provider "ghost" is unavailable; anthropic (the default) is.
493        let defaults = ModelDefaults {
494            provider: "anthropic".to_string(),
495            model: Some("claude-default".to_string()),
496            fallback_order: Vec::new(),
497        };
498        let (p, m) = resolve_stage_model(
499            &model_cfg(vec![("ghost", "g")]),
500            None,
501            &defaults,
502            &registry_with(&["anthropic"]),
503        );
504        assert_eq!((p.as_str(), m.as_str()), ("anthropic", "claude-default"));
505    }
506
507    #[test]
508    fn resolve_user_default_with_model_override() {
509        let defaults = ModelDefaults {
510            provider: "anthropic".to_string(),
511            model: None,
512            fallback_order: Vec::new(),
513        };
514        let (p, m) = resolve_stage_model(
515            &model_cfg(vec![("ghost", "g")]),
516            Some("just-a-model"),
517            &defaults,
518            &registry_with(&[]),
519        );
520        assert_eq!((p.as_str(), m.as_str()), ("anthropic", "just-a-model"));
521    }
522
523    #[test]
524    fn resolve_user_default_provider_unavailable_falls_through() {
525        // allow_user_default, a default model set, but the default provider isn't
526        // registered ⇒ neither user-default branch fires ⇒ last resort.
527        let defaults = ModelDefaults {
528            provider: "ghost-default".to_string(),
529            model: Some("dm".to_string()),
530            fallback_order: Vec::new(),
531        };
532        let (p, m) = resolve_stage_model(
533            &model_cfg(vec![("ghost", "g")]),
534            None,
535            &defaults,
536            &registry_with(&[]),
537        );
538        assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
539    }
540
541    #[test]
542    fn resolve_last_resort_first_listed() {
543        // No override, nothing available, no usable default → first listed entry.
544        let (p, m) = resolve_stage_model(
545            &model_cfg(vec![("ghost", "g")]),
546            None,
547            &ModelDefaults::default(),
548            &registry_with(&[]),
549        );
550        assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
551    }
552
553    #[test]
554    fn resolve_no_user_default_uses_last_resort() {
555        let mut cfg = model_cfg(vec![("ghost", "g")]);
556        cfg.allow_user_default = false; // forbid the default fallback
557        let defaults = ModelDefaults {
558            provider: "anthropic".to_string(),
559            model: Some("would-be-default".to_string()),
560            fallback_order: Vec::new(),
561        };
562        let (p, m) = resolve_stage_model(&cfg, None, &defaults, &registry_with(&["anthropic"]));
563        assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
564    }
565
566    #[test]
567    fn resolve_stages_empty_available_tools_gets_none() {
568        let mut stage =
569            leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
570        stage.available_tools = vec![]; // empty ⇒ no tools
571        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
572        let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
573        let tools = vec![Tool {
574            name: "read_file".to_string(),
575            description: String::new(),
576            parameters: serde_json::Value::Null,
577        }];
578        let resolved = resolve_stages(
579            &bp,
580            None,
581            &ModelDefaults::default(),
582            &registry_with(&["anthropic"]),
583            &tools,
584            false,
585            None,
586        )
587        .expect("anthropic is registered");
588        assert!(resolved[0].tools.is_empty());
589    }
590
591    #[test]
592    fn resolve_stages_refuses_a_stage_with_no_usable_provider() {
593        // Issue #190: the last fallback in `resolve_stage_model` is unchecked,
594        // so this used to resolve to "ghost" and produce an agent that could
595        // never take a turn. It has to be an error the caller sees.
596        let stage = leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("ghost", "m")]));
597        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
598        let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
599
600        let err = resolve_stages(
601            &bp,
602            None,
603            &ModelDefaults::default(),
604            &registry_with(&[]),
605            &[],
606            false,
607            None,
608        )
609        .expect_err("no provider is configured");
610
611        assert!(err.contains("plan"), "names the stage: {err}");
612        assert!(err.contains("ghost"), "names what it tried: {err}");
613        assert!(err.contains("lev setup"), "says what to do: {err}");
614    }
615
616    #[test]
617    fn resolve_stages_refuses_an_override_naming_an_unregistered_provider() {
618        // `--model ghost/x` short-circuits every fallback, so the override is
619        // the only provider that was tried.
620        let stage =
621            leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("anthropic", "m")]));
622        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
623        let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
624
625        let err = resolve_stages(
626            &bp,
627            Some("ghost/x"),
628            &ModelDefaults::default(),
629            &registry_with(&["anthropic"]),
630            &[],
631            false,
632            None,
633        )
634        .expect_err("the override names a provider that isn't registered");
635
636        assert!(err.contains("tried: ghost"), "got: {err}");
637        assert!(
638            !err.contains("anthropic"),
639            "the override skipped the blueprint's list entirely: {err}"
640        );
641    }
642
643    #[test]
644    fn providers_tried_lists_the_blueprint_entries_and_the_user_default() {
645        let defaults = ModelDefaults {
646            provider: "fallback".to_string(),
647            model: None,
648            fallback_order: Vec::new(),
649        };
650        let cfg = model_cfg(vec![("one", "m"), ("two", "m")]);
651        assert_eq!(providers_tried(&cfg, None, &defaults), "one, two, fallback");
652
653        // A stage that opts out of the user default doesn't claim to have tried it.
654        let mut no_default = cfg.clone();
655        no_default.allow_user_default = false;
656        assert_eq!(providers_tried(&no_default, None, &defaults), "one, two");
657
658        // Neither does an embedder that configured no default at all.
659        assert_eq!(
660            providers_tried(&cfg, None, &ModelDefaults::default()),
661            "one, two"
662        );
663
664        // A bare `--model m` override still uses the blueprint's providers.
665        assert_eq!(
666            providers_tried(&cfg, Some("m"), &defaults),
667            "one, two, fallback"
668        );
669    }
670
671    #[test]
672    fn resolve_stages_matches_by_alias_and_skips_unknown_names() {
673        // A stage names `bash` (an alias) and a not-installed MCP tool. The
674        // filter must select the canonical `shell` definition for the alias and
675        // silently omit the unknown name (no error, no panic).
676        let mut stage =
677            leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
678        stage.available_tools = vec!["bash".to_string(), "acme__uninstalled".to_string()];
679        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
680        let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
681        let tools = vec![
682            Tool {
683                name: "shell".to_string(),
684                description: String::new(),
685                parameters: serde_json::Value::Null,
686            },
687            Tool {
688                name: "read_file".to_string(),
689                description: String::new(),
690                parameters: serde_json::Value::Null,
691            },
692        ];
693        let resolved = resolve_stages(
694            &bp,
695            None,
696            &ModelDefaults::default(),
697            &registry_with(&["anthropic"]),
698            &tools,
699            false,
700            None,
701        )
702        .expect("anthropic is registered");
703        let selected: Vec<&str> = resolved[0].tools.iter().map(|t| t.name.as_str()).collect();
704        // `bash` resolved to `shell`; the unknown MCP name and unlisted
705        // `read_file` were both excluded.
706        assert_eq!(selected, vec!["shell"]);
707    }
708
709    // ── failover candidates (issue #201) ──────────────────────────────────
710
711    /// `[(provider, model), ...]` for readable assertions.
712    fn pairs(entries: &[ModelEntry]) -> Vec<(&str, &str)> {
713        entries
714            .iter()
715            .map(|e| (e.provider.as_str(), e.model.as_str()))
716            .collect()
717    }
718
719    #[test]
720    fn candidates_keep_every_registered_entry_in_blueprint_order() {
721        let cfg = model_cfg(vec![
722            ("openrouter", "deepseek"),
723            ("anthropic", "sonnet"),
724            ("openai", "gpt"),
725        ]);
726        let registry = registry_with(&["openrouter", "anthropic", "openai"]);
727        let got = resolve_stage_candidates(&cfg, None, &ModelDefaults::default(), &registry);
728        // The head is what `resolve_stage_model` picks; the tail is where
729        // failover goes. Before this, the tail was discarded at spawn.
730        assert_eq!(
731            pairs(&got),
732            vec![
733                ("openrouter", "deepseek"),
734                ("anthropic", "sonnet"),
735                ("openai", "gpt"),
736            ]
737        );
738    }
739
740    // ─── the unattended cut (issue #204) ─────────────────────────────────────
741
742    /// A stage's tool defs for the three tools every one of these tests uses.
743    fn ask_and_read_defs() -> Vec<Tool> {
744        ["read_file", "ask_user_text", "ask_user_choice"]
745            .iter()
746            .map(|n| Tool {
747                name: n.to_string(),
748                description: String::new(),
749                parameters: serde_json::Value::Null,
750            })
751            .collect()
752    }
753
754    fn names(tools: &[Tool]) -> Vec<&str> {
755        tools.iter().map(|t| t.name.as_str()).collect()
756    }
757
758    #[test]
759    fn an_attended_run_keeps_every_tool_the_stage_lists() {
760        let available = vec![
761            "read_file".to_string(),
762            "ask_user_text".to_string(),
763            "ask_user_choice".to_string(),
764        ];
765        let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &[], false);
766        assert_eq!(
767            names(&tools),
768            vec!["read_file", "ask_user_text", "ask_user_choice"]
769        );
770    }
771
772    #[test]
773    fn candidates_skip_providers_that_are_not_registered() {
774        let cfg = model_cfg(vec![("ghost", "nope"), ("anthropic", "sonnet")]);
775        let registry = registry_with(&["anthropic"]);
776        let got = resolve_stage_candidates(&cfg, None, &ModelDefaults::default(), &registry);
777        assert_eq!(pairs(&got), vec![("anthropic", "sonnet")]);
778    }
779
780    #[test]
781    fn the_global_chain_rescues_a_single_model_stage() {
782        // The reported configuration: every stage names one OpenRouter model,
783        // so the blueprint alone offers nowhere to fail over to.
784        let cfg = ModelConfig {
785            allow_user_default: false,
786            ..model_cfg(vec![("openrouter", "deepseek")])
787        };
788        let defaults = ModelDefaults {
789            fallback_order: vec![
790                ModelEntry::new("anthropic".to_string(), "sonnet".to_string()),
791                ModelEntry::new("ghost".to_string(), "nope".to_string()),
792            ],
793            ..Default::default()
794        };
795        let registry = registry_with(&["openrouter", "anthropic"]);
796        let got = resolve_stage_candidates(&cfg, None, &defaults, &registry);
797        assert_eq!(
798            pairs(&got),
799            vec![("openrouter", "deepseek"), ("anthropic", "sonnet")],
800            "the unregistered global entry is skipped"
801        );
802    }
803
804    #[test]
805    fn the_global_chain_comes_after_the_user_default() {
806        let cfg = model_cfg(vec![("openrouter", "deepseek")]);
807        let defaults = ModelDefaults {
808            provider: "anthropic".to_string(),
809            model: Some("sonnet".to_string()),
810            fallback_order: vec![ModelEntry::new("openai".to_string(), "gpt".to_string())],
811        };
812        let registry = registry_with(&["openrouter", "anthropic", "openai"]);
813        let got = resolve_stage_candidates(&cfg, None, &defaults, &registry);
814        // The user default heads the list (it is on `default_provider`), the
815        // stage's own entry follows, and the host-wide chain is last - which is
816        // the ordering this test exists to pin.
817        assert_eq!(
818            pairs(&got),
819            vec![
820                ("anthropic", "sonnet"),
821                ("openrouter", "deepseek"),
822                ("openai", "gpt"),
823            ]
824        );
825    }
826
827    #[test]
828    fn the_default_provider_outranks_the_stages_own_list() {
829        // `default_provider = "openrouter"` used to buy nothing: the bundled
830        // blueprints all name anthropic/openai/ollama, ollama registers with no
831        // key, so an OpenRouter-only install dispatched every stage at a
832        // localhost server that was not running.
833        let cfg = model_cfg(vec![
834            ("anthropic", "claude-sonnet-5"),
835            ("ollama", "qwen3.5:9b"),
836        ]);
837        let defaults = ModelDefaults {
838            provider: "openrouter".to_string(),
839            model: Some("openai/gpt-4o-mini".to_string()),
840            ..Default::default()
841        };
842        let registry = registry_with(&["openrouter", "ollama"]);
843        let got = resolve_stage_candidates(&cfg, None, &defaults, &registry);
844        assert_eq!(
845            pairs(&got),
846            vec![
847                ("openrouter", "openai/gpt-4o-mini"),
848                ("ollama", "qwen3.5:9b"),
849            ],
850            "the user's default provider heads the list; the registered stage \
851             entry stays behind it as a fallback"
852        );
853    }
854
855    #[test]
856    fn a_stage_that_forbids_the_user_default_keeps_its_own_order() {
857        // `allow_user_default = false` is the existing way a blueprint pins its
858        // provider, and it has to suppress the preference too - otherwise there
859        // is no way left to say "this stage runs where I said".
860        let cfg = ModelConfig {
861            allow_user_default: false,
862            ..model_cfg(vec![("anthropic", "sonnet"), ("openrouter", "deepseek")])
863        };
864        let defaults = ModelDefaults {
865            provider: "openrouter".to_string(),
866            model: Some("deepseek".to_string()),
867            ..Default::default()
868        };
869        let registry = registry_with(&["openrouter", "anthropic"]);
870        let got = resolve_stage_candidates(&cfg, None, &defaults, &registry);
871        assert_eq!(
872            pairs(&got),
873            vec![("anthropic", "sonnet"), ("openrouter", "deepseek")]
874        );
875    }
876
877    #[test]
878    fn an_unregistered_default_provider_changes_nothing() {
879        // The preference is over *registered* candidates only: a default
880        // provider with no key must not reorder anything, and must certainly
881        // not promote itself into the head where dispatch would park the run
882        // on `ProviderMissing`.
883        let cfg = model_cfg(vec![("anthropic", "sonnet"), ("ollama", "qwen")]);
884        let defaults = ModelDefaults {
885            provider: "openrouter".to_string(),
886            model: Some("deepseek".to_string()),
887            ..Default::default()
888        };
889        let registry = registry_with(&["anthropic", "ollama"]);
890        let got = resolve_stage_candidates(&cfg, None, &defaults, &registry);
891        assert_eq!(
892            pairs(&got),
893            vec![("anthropic", "sonnet"), ("ollama", "qwen")]
894        );
895    }
896
897    #[test]
898    fn candidates_are_deduplicated() {
899        // The same pair arriving twice would spend a failover step going
900        // nowhere, which reads to the operator as a swap that did nothing.
901        let cfg = model_cfg(vec![("anthropic", "sonnet"), ("anthropic", "sonnet")]);
902        let defaults = ModelDefaults {
903            provider: "anthropic".to_string(),
904            model: Some("sonnet".to_string()),
905            fallback_order: vec![ModelEntry::new(
906                "anthropic".to_string(),
907                "sonnet".to_string(),
908            )],
909        };
910        let registry = registry_with(&["anthropic"]);
911        let got = resolve_stage_candidates(&cfg, None, &defaults, &registry);
912        assert_eq!(pairs(&got), vec![("anthropic", "sonnet")]);
913    }
914
915    #[test]
916    fn a_full_override_names_exactly_one_candidate() {
917        // `--model provider/model` asked for that model, not a substitute.
918        let cfg = model_cfg(vec![("anthropic", "sonnet"), ("openai", "gpt")]);
919        let defaults = ModelDefaults {
920            fallback_order: vec![ModelEntry::new("openai".to_string(), "gpt".to_string())],
921            ..Default::default()
922        };
923        let registry = registry_with(&["anthropic", "openai", "ollama"]);
924        let got = resolve_stage_candidates(&cfg, Some("ollama/llama"), &defaults, &registry);
925        assert_eq!(pairs(&got), vec![("ollama", "llama")]);
926    }
927
928    #[test]
929    fn a_bare_override_renames_the_model_on_every_candidate() {
930        let cfg = model_cfg(vec![("anthropic", "sonnet"), ("openai", "gpt")]);
931        let registry = registry_with(&["anthropic", "openai"]);
932        let got =
933            resolve_stage_candidates(&cfg, Some("haiku"), &ModelDefaults::default(), &registry);
934        assert_eq!(
935            pairs(&got),
936            vec![("anthropic", "haiku"), ("openai", "haiku")]
937        );
938    }
939
940    #[test]
941    fn candidates_are_never_empty_even_with_nothing_registered() {
942        // `resolve_stages` needs a name the user wrote to report against.
943        let cfg = ModelConfig {
944            allow_user_default: false,
945            ..model_cfg(vec![("ghost", "nope")])
946        };
947        let got =
948            resolve_stage_candidates(&cfg, None, &ModelDefaults::default(), &registry_with(&[]));
949        assert_eq!(pairs(&got), vec![("ghost", "nope")]);
950    }
951
952    #[test]
953    fn resolve_stages_carries_the_tail_onto_the_resolved_stage() {
954        let mut stage = leviath_core::Stage::new(
955            "work".to_string(),
956            model_cfg(vec![("openrouter", "deepseek"), ("anthropic", "sonnet")]),
957        );
958        stage.available_tools = vec![];
959        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
960        let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
961        let registry = registry_with(&["openrouter", "anthropic"]);
962        let resolved = resolve_stages(
963            &bp,
964            None,
965            &ModelDefaults::default(),
966            &registry,
967            &[],
968            false,
969            None,
970        )
971        .expect("both providers are registered");
972        assert_eq!(resolved[0].provider_name, "openrouter");
973        assert_eq!(pairs(&resolved[0].fallbacks), vec![("anthropic", "sonnet")]);
974    }
975
976    #[test]
977    fn an_unattended_run_loses_the_tools_that_wait_on_a_person() {
978        // The whole point of issue #204: with nobody watching, a call to
979        // `ask_user_text` can only park the agent, so the model never sees it.
980        let available = vec![
981            "read_file".to_string(),
982            "ask_user_text".to_string(),
983            "ask_user_choice".to_string(),
984        ];
985        let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &[], true);
986        assert_eq!(names(&tools), vec!["read_file"]);
987    }
988
989    #[test]
990    fn required_tools_survive_an_unattended_run() {
991        // The opt-out: a stage that says it genuinely needs a person keeps the
992        // named tool, and only that one.
993        let available = vec![
994            "read_file".to_string(),
995            "ask_user_text".to_string(),
996            "ask_user_choice".to_string(),
997        ];
998        let required = vec!["ask_user_text".to_string()];
999        let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &required, true);
1000        assert_eq!(names(&tools), vec!["read_file", "ask_user_text"]);
1001    }
1002
1003    #[test]
1004    fn a_required_tool_the_stage_never_offered_adds_nothing() {
1005        // `required_tools` narrows the unattended cut; it is not a second way to
1006        // grant a tool. (`Stage::validate` rejects this combination outright -
1007        // this is the belt to that pair of braces.)
1008        let available = vec!["read_file".to_string()];
1009        let required = vec!["ask_user_text".to_string()];
1010        let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &required, true);
1011        assert_eq!(names(&tools), vec!["read_file"]);
1012    }
1013
1014    #[test]
1015    fn resolve_stages_applies_the_unattended_cut_per_stage() {
1016        // Two stages, one opting out, resolved in a single unattended run: the
1017        // cut is per stage, not per run.
1018        let mut plan =
1019            leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("anthropic", "m")]));
1020        plan.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
1021        plan.required_tools = vec!["ask_user_text".to_string()];
1022        let mut build =
1023            leviath_core::Stage::new("build".to_string(), model_cfg(vec![("anthropic", "m")]));
1024        build.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
1025        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
1026        let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![plan, build], layout);
1027
1028        let resolved = resolve_stages(
1029            &bp,
1030            None,
1031            &ModelDefaults::default(),
1032            &registry_with(&["anthropic"]),
1033            &ask_and_read_defs(),
1034            true,
1035            None,
1036        )
1037        .expect("anthropic is registered");
1038
1039        assert_eq!(
1040            names(&resolved[0].tools),
1041            vec!["read_file", "ask_user_text"]
1042        );
1043        assert_eq!(names(&resolved[1].tools), vec!["read_file"]);
1044    }
1045
1046    #[test]
1047    fn the_unattended_cut_resolves_aliases_on_both_sides() {
1048        // `edit_document` under an alias would be a hole in the cut, and a
1049        // `required_tools` entry written as an alias would be a hole in the
1050        // opt-out. Neither is: both sides canonicalise. `bash`/`shell` is the
1051        // only alias pair that exists, so it stands in for the mechanism - a
1052        // non-human tool is never cut whatever it is called.
1053        let defs = vec![Tool {
1054            name: "shell".to_string(),
1055            description: String::new(),
1056            parameters: serde_json::Value::Null,
1057        }];
1058        let available = vec!["bash".to_string()];
1059        let tools = filter_tools_for_stage(&defs, &available, &[], true);
1060        assert_eq!(names(&tools), vec!["shell"]);
1061    }
1062
1063    // ── The output shape reaches the tool description ────────────────────────
1064
1065    /// A helper mirroring how a stage that can submit is set up.
1066    fn output_stage_blueprint(
1067        agent: Option<leviath_core::output::OutputSpec>,
1068        stage_spec: Option<leviath_core::output::OutputSpec>,
1069    ) -> Blueprint {
1070        let mut stage =
1071            leviath_core::Stage::new("summary".to_string(), model_cfg(vec![("anthropic", "m")]));
1072        stage.available_tools = vec![leviath_tools::SUBMIT_OUTPUT_TOOL.to_string()];
1073        stage.output = stage_spec;
1074        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
1075        let mut bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
1076        bp.output = agent;
1077        bp
1078    }
1079
1080    fn submit_tool_defs() -> Vec<Tool> {
1081        vec![Tool {
1082            name: leviath_tools::SUBMIT_OUTPUT_TOOL.to_string(),
1083            description: leviath_tools::submit_output_description(""),
1084            parameters: serde_json::Value::Null,
1085        }]
1086    }
1087
1088    fn resolve_one(
1089        bp: &Blueprint,
1090        request: Option<&leviath_core::output::OutputSpec>,
1091    ) -> ResolvedStage {
1092        resolve_stages(
1093            bp,
1094            None,
1095            &ModelDefaults::default(),
1096            &registry_with(&["anthropic"]),
1097            &submit_tool_defs(),
1098            false,
1099            request,
1100        )
1101        .expect("anthropic is registered")
1102        .remove(0)
1103    }
1104
1105    /// A stage can require an output without saying anything about its shape.
1106    /// There is nothing to paste into the description then, and the generic
1107    /// wording is what the model should read: an invented sentence about a
1108    /// format nobody declared would be worse than none.
1109    #[test]
1110    fn a_spec_that_says_nothing_leaves_the_description_generic() {
1111        let generic = leviath_tools::submit_output_description("");
1112        let bp = output_stage_blueprint(None, Some(leviath_core::output::OutputSpec::default()));
1113
1114        let resolved = resolve_one(&bp, None);
1115
1116        assert_eq!(
1117            resolved
1118                .tools
1119                .iter()
1120                .find(|t| t.name == leviath_tools::SUBMIT_OUTPUT_TOOL)
1121                .expect("the stage offers the tool")
1122                .description,
1123            generic
1124        );
1125    }
1126
1127    /// The whole mechanism for arbitrary formats: a label this crate has never
1128    /// heard of is pasted into the description the model reads, with no parsing
1129    /// and no per-format branch anywhere.
1130    #[test]
1131    fn an_unrecognized_format_reaches_the_submit_tool_description() {
1132        let bp = output_stage_blueprint(
1133            None,
1134            Some(leviath_core::output::OutputSpec {
1135                format: Some("a2ui".to_string()),
1136                instructions: Some("One card per finding.".to_string()),
1137                example: Some("{\"root\": {}}".to_string()),
1138                schema: None,
1139                validator: None,
1140            }),
1141        );
1142        let resolved = resolve_one(&bp, None);
1143        let description = &resolved
1144            .tools
1145            .iter()
1146            .find(|t| t.name == leviath_tools::SUBMIT_OUTPUT_TOOL)
1147            .expect("the stage offers the tool")
1148            .description;
1149        assert!(description.contains("a2ui"), "{description}");
1150        assert!(
1151            description.contains("One card per finding."),
1152            "{description}"
1153        );
1154        assert!(description.contains("{\"root\": {}}"), "{description}");
1155        assert_eq!(
1156            resolved.output.and_then(|s| s.format).as_deref(),
1157            Some("a2ui")
1158        );
1159    }
1160
1161    /// A stage that declares nothing keeps the generic wording rather than
1162    /// growing an empty shape paragraph.
1163    #[test]
1164    fn a_stage_declaring_no_shape_keeps_the_generic_description() {
1165        let bp = output_stage_blueprint(None, None);
1166        let resolved = resolve_one(&bp, None);
1167        assert_eq!(
1168            resolved.tools[0].description,
1169            leviath_tools::submit_output_description("")
1170        );
1171        assert!(resolved.output.is_none());
1172    }
1173
1174    /// The caller's request wins over the blueprint, and naming a format
1175    /// without a schema retires the one the blueprint declared: a check written
1176    /// for one shape says nothing about another.
1177    #[test]
1178    fn a_callers_request_overrides_the_blueprint_and_drops_its_schema() {
1179        let bp = output_stage_blueprint(
1180            Some(leviath_core::output::OutputSpec {
1181                format: Some("json".to_string()),
1182                schema: Some(serde_json::json!({"type": "object"})),
1183                ..Default::default()
1184            }),
1185            None,
1186        );
1187        let request = leviath_core::output::OutputSpec {
1188            format: Some("xml".to_string()),
1189            ..Default::default()
1190        };
1191        let resolved = resolve_one(&bp, Some(&request));
1192        let spec = resolved.output.expect("a shape was asked for");
1193        assert_eq!(spec.format.as_deref(), Some("xml"));
1194        assert_eq!(spec.schema, None);
1195        assert!(resolved.tools[0].description.contains("xml"));
1196    }
1197
1198    /// A stage that never offers the tool is untouched, which is most stages.
1199    #[test]
1200    fn a_stage_without_the_submit_tool_is_left_alone() {
1201        let mut stage =
1202            leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("anthropic", "m")]));
1203        stage.available_tools = vec!["read_file".to_string()];
1204        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
1205        let mut bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
1206        bp.output = Some(leviath_core::output::OutputSpec {
1207            format: Some("a2ui".to_string()),
1208            ..Default::default()
1209        });
1210        let resolved = resolve_stages(
1211            &bp,
1212            None,
1213            &ModelDefaults::default(),
1214            &registry_with(&["anthropic"]),
1215            &[Tool {
1216                name: "read_file".to_string(),
1217                description: "read a file".to_string(),
1218                parameters: serde_json::Value::Null,
1219            }],
1220            false,
1221            None,
1222        )
1223        .expect("anthropic is registered");
1224        assert_eq!(resolved[0].tools[0].description, "read a file");
1225    }
1226}