1use leviath_core::Blueprint;
11use leviath_core::blueprint::{ModelConfig, ModelEntry};
12
13use super::ResolvedStage;
14use crate::providers::ProviderRegistry;
15use leviath_providers::Tool;
16
17#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct ModelDefaults {
23 pub provider: String,
25 pub model: Option<String>,
27 pub fallback_order: Vec<ModelEntry>,
35}
36
37pub 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
56pub 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 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 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 for entry in &defaults.fallback_order {
126 if registry.has(&entry.provider) {
127 push(entry.provider.clone(), entry.model.clone());
128 }
129 }
130
131 if candidates.is_empty() {
132 candidates.push(ModelEntry::new(
136 model_cfg.provider().to_string(),
137 model_cfg.model().to_string(),
138 ));
139 }
140
141 let tail: Vec<ModelEntry> = candidates
149 .split_off(1)
150 .into_iter()
151 .filter(|e| registry.has(&e.provider))
152 .collect();
153 candidates.extend(tail);
154 candidates
155}
156
157fn user_default_model(
160 model_cfg: &ModelConfig,
161 override_model: Option<&str>,
162 defaults: &ModelDefaults,
163 registry: &ProviderRegistry,
164) -> Option<(String, String)> {
165 if !model_cfg.allow_user_default {
166 return None;
167 }
168 if let Some(model) = override_model {
169 return Some((defaults.provider.clone(), model.to_string()));
170 }
171 if let Some(default_model) = &defaults.model
172 && registry.has(&defaults.provider)
173 {
174 return Some((defaults.provider.clone(), default_model.clone()));
175 }
176 None
177}
178
179pub fn filter_tools_by_available(all: &[Tool], available: &[String]) -> Vec<Tool> {
183 if available.is_empty() {
184 return Vec::new();
185 }
186 all.iter()
187 .filter(|t| {
188 available
189 .iter()
190 .any(|n| leviath_tools::canonical_tool_name(n) == t.name)
191 })
192 .cloned()
193 .collect()
194}
195
196pub fn filter_tools_for_stage(
209 all: &[Tool],
210 available: &[String],
211 required: &[String],
212 unattended: bool,
213) -> Vec<Tool> {
214 let mut tools = filter_tools_by_available(all, available);
215 if unattended {
216 tools.retain(|t| {
217 !crate::dynamic_interaction::BLOCKING_INTERACTION_TOOLS.contains(&t.name.as_str())
218 || required
219 .iter()
220 .any(|n| leviath_tools::canonical_tool_name(n) == t.name)
221 });
222 }
223 tools
224}
225
226pub fn providers_tried(
237 model_cfg: &ModelConfig,
238 model_override: Option<&str>,
239 defaults: &ModelDefaults,
240) -> String {
241 let mut names: Vec<String> = match model_override {
242 Some(ov) if ov.contains('/') => vec![
243 ov.split_once('/')
244 .map(|(p, _)| p.to_string())
245 .expect("the `contains('/')` guard guarantees a split"),
246 ],
247 _ => {
248 let mut listed: Vec<String> = model_cfg
249 .models
250 .iter()
251 .map(|e| e.provider.clone())
252 .collect();
253 if model_cfg.allow_user_default && !defaults.provider.is_empty() {
254 listed.push(defaults.provider.clone());
255 }
256 listed
257 }
258 };
259 names.dedup();
260 names.join(", ")
261}
262
263pub fn resolve_stages(
278 blueprint: &Blueprint,
279 model_override: Option<&str>,
280 defaults: &ModelDefaults,
281 registry: &ProviderRegistry,
282 all_tool_defs: &[Tool],
283 unattended: bool,
284) -> Result<Vec<ResolvedStage>, String> {
285 blueprint
286 .stages
287 .iter()
288 .map(|stage| {
289 let mut candidates =
290 resolve_stage_candidates(&stage.model, model_override, defaults, registry);
291 let head = candidates.remove(0);
292 if !registry.has(&head.provider) {
296 return Err(format!(
297 "stage '{}' has no usable provider (tried: {}). Configure one \
298 with `lev setup`, or add it to config.toml and restart the daemon.",
299 stage.name,
300 providers_tried(&stage.model, model_override, defaults)
301 ));
302 }
303 let tools = filter_tools_for_stage(
308 all_tool_defs,
309 &stage.available_tools,
310 &stage.required_tools,
311 unattended,
312 );
313 Ok(ResolvedStage {
314 provider_name: head.provider,
315 model: head.model,
316 tools,
317 fallbacks: candidates,
318 })
319 })
320 .collect()
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use leviath_core::blueprint::ModelEntry;
327 use std::collections::HashMap;
328 use std::sync::Arc;
329
330 fn model_cfg(models: Vec<(&str, &str)>) -> ModelConfig {
331 ModelConfig {
332 models: models
333 .into_iter()
334 .map(|(p, m)| ModelEntry {
335 provider: p.to_string(),
336 model: m.to_string(),
337 })
338 .collect(),
339 allow_user_default: true,
340 parameters: HashMap::new(),
341 request_timeout_secs: None,
342 }
343 }
344
345 fn registry_with(providers: &[&str]) -> ProviderRegistry {
346 let mut r = ProviderRegistry::new();
347 for p in providers {
348 r.register(p.to_string(), Arc::new(FakeProvider));
349 }
350 r
351 }
352
353 struct FakeProvider;
354 #[async_trait::async_trait]
355 impl leviath_providers::Provider for FakeProvider {
356 async fn infer(
357 &self,
358 _r: leviath_providers::InferenceRequest,
359 ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
360 Err(leviath_providers::ProviderError::Other(
361 "test provider".to_string(),
362 ))
363 }
364 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
365 1
366 }
367 fn max_context_tokens(&self, _m: &str) -> usize {
368 1000
369 }
370 fn name(&self) -> &str {
371 "fake"
372 }
373 fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
374 leviath_providers::ModelCapabilities::default()
375 }
376 }
377
378 #[tokio::test]
379 async fn fake_provider_is_a_minimal_registry_stub() {
380 use leviath_providers::Provider as _;
383 let p = FakeProvider;
384 let request: leviath_providers::InferenceRequest =
385 serde_json::from_value(serde_json::json!({
386 "messages": [],
387 "model": "m",
388 "max_tokens": 1,
389 "temperature": 0.0,
390 "tools": [],
391 "extra": null,
392 }))
393 .unwrap();
394 assert!(p.infer(request).await.is_err());
395 assert_eq!(p.count_tokens("x", "m").await, 1);
396 assert_eq!(p.max_context_tokens("m"), 1000);
397 assert_eq!(p.name(), "fake");
398 let _ = p.capabilities("m");
399 }
400
401 #[test]
402 fn resolve_full_override_wins() {
403 let (p, m) = resolve_stage_model(
404 &model_cfg(vec![("anthropic", "x")]),
405 Some("openai/gpt-5"),
406 &ModelDefaults::default(),
407 ®istry_with(&[]),
408 );
409 assert_eq!((p.as_str(), m.as_str()), ("openai", "gpt-5"));
410 }
411
412 #[test]
413 fn resolve_first_available_model() {
414 let (p, m) = resolve_stage_model(
416 &model_cfg(vec![("anthropic", "a"), ("openai", "o")]),
417 None,
418 &ModelDefaults::default(),
419 ®istry_with(&["openai"]),
420 );
421 assert_eq!((p.as_str(), m.as_str()), ("openai", "o"));
422 }
423
424 #[test]
425 fn resolve_model_only_override_keeps_available_provider() {
426 let (p, m) = resolve_stage_model(
427 &model_cfg(vec![("openai", "o")]),
428 Some("gpt-override"),
429 &ModelDefaults::default(),
430 ®istry_with(&["openai"]),
431 );
432 assert_eq!((p.as_str(), m.as_str()), ("openai", "gpt-override"));
433 }
434
435 #[test]
436 fn resolve_user_default_when_nothing_listed_available() {
437 let defaults = ModelDefaults {
439 provider: "anthropic".to_string(),
440 model: Some("claude-default".to_string()),
441 fallback_order: Vec::new(),
442 };
443 let (p, m) = resolve_stage_model(
444 &model_cfg(vec![("ghost", "g")]),
445 None,
446 &defaults,
447 ®istry_with(&["anthropic"]),
448 );
449 assert_eq!((p.as_str(), m.as_str()), ("anthropic", "claude-default"));
450 }
451
452 #[test]
453 fn resolve_user_default_with_model_override() {
454 let defaults = ModelDefaults {
455 provider: "anthropic".to_string(),
456 model: None,
457 fallback_order: Vec::new(),
458 };
459 let (p, m) = resolve_stage_model(
460 &model_cfg(vec![("ghost", "g")]),
461 Some("just-a-model"),
462 &defaults,
463 ®istry_with(&[]),
464 );
465 assert_eq!((p.as_str(), m.as_str()), ("anthropic", "just-a-model"));
466 }
467
468 #[test]
469 fn resolve_user_default_provider_unavailable_falls_through() {
470 let defaults = ModelDefaults {
473 provider: "ghost-default".to_string(),
474 model: Some("dm".to_string()),
475 fallback_order: Vec::new(),
476 };
477 let (p, m) = resolve_stage_model(
478 &model_cfg(vec![("ghost", "g")]),
479 None,
480 &defaults,
481 ®istry_with(&[]),
482 );
483 assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
484 }
485
486 #[test]
487 fn resolve_last_resort_first_listed() {
488 let (p, m) = resolve_stage_model(
490 &model_cfg(vec![("ghost", "g")]),
491 None,
492 &ModelDefaults::default(),
493 ®istry_with(&[]),
494 );
495 assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
496 }
497
498 #[test]
499 fn resolve_no_user_default_uses_last_resort() {
500 let mut cfg = model_cfg(vec![("ghost", "g")]);
501 cfg.allow_user_default = false; let defaults = ModelDefaults {
503 provider: "anthropic".to_string(),
504 model: Some("would-be-default".to_string()),
505 fallback_order: Vec::new(),
506 };
507 let (p, m) = resolve_stage_model(&cfg, None, &defaults, ®istry_with(&["anthropic"]));
508 assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
509 }
510
511 #[test]
512 fn resolve_stages_empty_available_tools_gets_none() {
513 let mut stage =
514 leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
515 stage.available_tools = vec![]; let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
517 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
518 let tools = vec![Tool {
519 name: "read_file".to_string(),
520 description: String::new(),
521 parameters: serde_json::Value::Null,
522 }];
523 let resolved = resolve_stages(
524 &bp,
525 None,
526 &ModelDefaults::default(),
527 ®istry_with(&["anthropic"]),
528 &tools,
529 false,
530 )
531 .expect("anthropic is registered");
532 assert!(resolved[0].tools.is_empty());
533 }
534
535 #[test]
536 fn resolve_stages_refuses_a_stage_with_no_usable_provider() {
537 let stage = leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("ghost", "m")]));
541 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
542 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
543
544 let err = resolve_stages(
545 &bp,
546 None,
547 &ModelDefaults::default(),
548 ®istry_with(&[]),
549 &[],
550 false,
551 )
552 .expect_err("no provider is configured");
553
554 assert!(err.contains("plan"), "names the stage: {err}");
555 assert!(err.contains("ghost"), "names what it tried: {err}");
556 assert!(err.contains("lev setup"), "says what to do: {err}");
557 }
558
559 #[test]
560 fn resolve_stages_refuses_an_override_naming_an_unregistered_provider() {
561 let stage =
564 leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("anthropic", "m")]));
565 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
566 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
567
568 let err = resolve_stages(
569 &bp,
570 Some("ghost/x"),
571 &ModelDefaults::default(),
572 ®istry_with(&["anthropic"]),
573 &[],
574 false,
575 )
576 .expect_err("the override names a provider that isn't registered");
577
578 assert!(err.contains("tried: ghost"), "got: {err}");
579 assert!(
580 !err.contains("anthropic"),
581 "the override skipped the blueprint's list entirely: {err}"
582 );
583 }
584
585 #[test]
586 fn providers_tried_lists_the_blueprint_entries_and_the_user_default() {
587 let defaults = ModelDefaults {
588 provider: "fallback".to_string(),
589 model: None,
590 fallback_order: Vec::new(),
591 };
592 let cfg = model_cfg(vec![("one", "m"), ("two", "m")]);
593 assert_eq!(providers_tried(&cfg, None, &defaults), "one, two, fallback");
594
595 let mut no_default = cfg.clone();
597 no_default.allow_user_default = false;
598 assert_eq!(providers_tried(&no_default, None, &defaults), "one, two");
599
600 assert_eq!(
602 providers_tried(&cfg, None, &ModelDefaults::default()),
603 "one, two"
604 );
605
606 assert_eq!(
608 providers_tried(&cfg, Some("m"), &defaults),
609 "one, two, fallback"
610 );
611 }
612
613 #[test]
614 fn resolve_stages_matches_by_alias_and_skips_unknown_names() {
615 let mut stage =
619 leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
620 stage.available_tools = vec!["bash".to_string(), "acme__uninstalled".to_string()];
621 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
622 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
623 let tools = vec![
624 Tool {
625 name: "shell".to_string(),
626 description: String::new(),
627 parameters: serde_json::Value::Null,
628 },
629 Tool {
630 name: "read_file".to_string(),
631 description: String::new(),
632 parameters: serde_json::Value::Null,
633 },
634 ];
635 let resolved = resolve_stages(
636 &bp,
637 None,
638 &ModelDefaults::default(),
639 ®istry_with(&["anthropic"]),
640 &tools,
641 false,
642 )
643 .expect("anthropic is registered");
644 let selected: Vec<&str> = resolved[0].tools.iter().map(|t| t.name.as_str()).collect();
645 assert_eq!(selected, vec!["shell"]);
648 }
649
650 fn pairs(entries: &[ModelEntry]) -> Vec<(&str, &str)> {
654 entries
655 .iter()
656 .map(|e| (e.provider.as_str(), e.model.as_str()))
657 .collect()
658 }
659
660 #[test]
661 fn candidates_keep_every_registered_entry_in_blueprint_order() {
662 let cfg = model_cfg(vec![
663 ("openrouter", "deepseek"),
664 ("anthropic", "sonnet"),
665 ("openai", "gpt"),
666 ]);
667 let registry = registry_with(&["openrouter", "anthropic", "openai"]);
668 let got = resolve_stage_candidates(&cfg, None, &ModelDefaults::default(), ®istry);
669 assert_eq!(
672 pairs(&got),
673 vec![
674 ("openrouter", "deepseek"),
675 ("anthropic", "sonnet"),
676 ("openai", "gpt"),
677 ]
678 );
679 }
680
681 fn ask_and_read_defs() -> Vec<Tool> {
685 ["read_file", "ask_user_text", "ask_user_choice"]
686 .iter()
687 .map(|n| Tool {
688 name: n.to_string(),
689 description: String::new(),
690 parameters: serde_json::Value::Null,
691 })
692 .collect()
693 }
694
695 fn names(tools: &[Tool]) -> Vec<&str> {
696 tools.iter().map(|t| t.name.as_str()).collect()
697 }
698
699 #[test]
700 fn an_attended_run_keeps_every_tool_the_stage_lists() {
701 let available = vec![
702 "read_file".to_string(),
703 "ask_user_text".to_string(),
704 "ask_user_choice".to_string(),
705 ];
706 let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &[], false);
707 assert_eq!(
708 names(&tools),
709 vec!["read_file", "ask_user_text", "ask_user_choice"]
710 );
711 }
712
713 #[test]
714 fn candidates_skip_providers_that_are_not_registered() {
715 let cfg = model_cfg(vec![("ghost", "nope"), ("anthropic", "sonnet")]);
716 let registry = registry_with(&["anthropic"]);
717 let got = resolve_stage_candidates(&cfg, None, &ModelDefaults::default(), ®istry);
718 assert_eq!(pairs(&got), vec![("anthropic", "sonnet")]);
719 }
720
721 #[test]
722 fn the_global_chain_rescues_a_single_model_stage() {
723 let cfg = ModelConfig {
726 allow_user_default: false,
727 ..model_cfg(vec![("openrouter", "deepseek")])
728 };
729 let defaults = ModelDefaults {
730 fallback_order: vec![
731 ModelEntry::new("anthropic".to_string(), "sonnet".to_string()),
732 ModelEntry::new("ghost".to_string(), "nope".to_string()),
733 ],
734 ..Default::default()
735 };
736 let registry = registry_with(&["openrouter", "anthropic"]);
737 let got = resolve_stage_candidates(&cfg, None, &defaults, ®istry);
738 assert_eq!(
739 pairs(&got),
740 vec![("openrouter", "deepseek"), ("anthropic", "sonnet")],
741 "the unregistered global entry is skipped"
742 );
743 }
744
745 #[test]
746 fn the_global_chain_comes_after_the_user_default() {
747 let cfg = model_cfg(vec![("openrouter", "deepseek")]);
748 let defaults = ModelDefaults {
749 provider: "anthropic".to_string(),
750 model: Some("sonnet".to_string()),
751 fallback_order: vec![ModelEntry::new("openai".to_string(), "gpt".to_string())],
752 };
753 let registry = registry_with(&["openrouter", "anthropic", "openai"]);
754 let got = resolve_stage_candidates(&cfg, None, &defaults, ®istry);
755 assert_eq!(
756 pairs(&got),
757 vec![
758 ("openrouter", "deepseek"),
759 ("anthropic", "sonnet"),
760 ("openai", "gpt"),
761 ]
762 );
763 }
764
765 #[test]
766 fn candidates_are_deduplicated() {
767 let cfg = model_cfg(vec![("anthropic", "sonnet"), ("anthropic", "sonnet")]);
770 let defaults = ModelDefaults {
771 provider: "anthropic".to_string(),
772 model: Some("sonnet".to_string()),
773 fallback_order: vec![ModelEntry::new(
774 "anthropic".to_string(),
775 "sonnet".to_string(),
776 )],
777 };
778 let registry = registry_with(&["anthropic"]);
779 let got = resolve_stage_candidates(&cfg, None, &defaults, ®istry);
780 assert_eq!(pairs(&got), vec![("anthropic", "sonnet")]);
781 }
782
783 #[test]
784 fn a_full_override_names_exactly_one_candidate() {
785 let cfg = model_cfg(vec![("anthropic", "sonnet"), ("openai", "gpt")]);
787 let defaults = ModelDefaults {
788 fallback_order: vec![ModelEntry::new("openai".to_string(), "gpt".to_string())],
789 ..Default::default()
790 };
791 let registry = registry_with(&["anthropic", "openai", "ollama"]);
792 let got = resolve_stage_candidates(&cfg, Some("ollama/llama"), &defaults, ®istry);
793 assert_eq!(pairs(&got), vec![("ollama", "llama")]);
794 }
795
796 #[test]
797 fn a_bare_override_renames_the_model_on_every_candidate() {
798 let cfg = model_cfg(vec![("anthropic", "sonnet"), ("openai", "gpt")]);
799 let registry = registry_with(&["anthropic", "openai"]);
800 let got =
801 resolve_stage_candidates(&cfg, Some("haiku"), &ModelDefaults::default(), ®istry);
802 assert_eq!(
803 pairs(&got),
804 vec![("anthropic", "haiku"), ("openai", "haiku")]
805 );
806 }
807
808 #[test]
809 fn candidates_are_never_empty_even_with_nothing_registered() {
810 let cfg = ModelConfig {
812 allow_user_default: false,
813 ..model_cfg(vec![("ghost", "nope")])
814 };
815 let got =
816 resolve_stage_candidates(&cfg, None, &ModelDefaults::default(), ®istry_with(&[]));
817 assert_eq!(pairs(&got), vec![("ghost", "nope")]);
818 }
819
820 #[test]
821 fn resolve_stages_carries_the_tail_onto_the_resolved_stage() {
822 let mut stage = leviath_core::Stage::new(
823 "work".to_string(),
824 model_cfg(vec![("openrouter", "deepseek"), ("anthropic", "sonnet")]),
825 );
826 stage.available_tools = vec![];
827 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
828 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
829 let registry = registry_with(&["openrouter", "anthropic"]);
830 let resolved = resolve_stages(&bp, None, &ModelDefaults::default(), ®istry, &[], false)
831 .expect("both providers are registered");
832 assert_eq!(resolved[0].provider_name, "openrouter");
833 assert_eq!(pairs(&resolved[0].fallbacks), vec![("anthropic", "sonnet")]);
834 }
835
836 #[test]
837 fn an_unattended_run_loses_the_tools_that_wait_on_a_person() {
838 let available = vec![
841 "read_file".to_string(),
842 "ask_user_text".to_string(),
843 "ask_user_choice".to_string(),
844 ];
845 let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &[], true);
846 assert_eq!(names(&tools), vec!["read_file"]);
847 }
848
849 #[test]
850 fn required_tools_survive_an_unattended_run() {
851 let available = vec![
854 "read_file".to_string(),
855 "ask_user_text".to_string(),
856 "ask_user_choice".to_string(),
857 ];
858 let required = vec!["ask_user_text".to_string()];
859 let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &required, true);
860 assert_eq!(names(&tools), vec!["read_file", "ask_user_text"]);
861 }
862
863 #[test]
864 fn a_required_tool_the_stage_never_offered_adds_nothing() {
865 let available = vec!["read_file".to_string()];
869 let required = vec!["ask_user_text".to_string()];
870 let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &required, true);
871 assert_eq!(names(&tools), vec!["read_file"]);
872 }
873
874 #[test]
875 fn resolve_stages_applies_the_unattended_cut_per_stage() {
876 let mut plan =
879 leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("anthropic", "m")]));
880 plan.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
881 plan.required_tools = vec!["ask_user_text".to_string()];
882 let mut build =
883 leviath_core::Stage::new("build".to_string(), model_cfg(vec![("anthropic", "m")]));
884 build.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
885 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
886 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![plan, build], layout);
887
888 let resolved = resolve_stages(
889 &bp,
890 None,
891 &ModelDefaults::default(),
892 ®istry_with(&["anthropic"]),
893 &ask_and_read_defs(),
894 true,
895 )
896 .expect("anthropic is registered");
897
898 assert_eq!(
899 names(&resolved[0].tools),
900 vec!["read_file", "ask_user_text"]
901 );
902 assert_eq!(names(&resolved[1].tools), vec!["read_file"]);
903 }
904
905 #[test]
906 fn the_unattended_cut_resolves_aliases_on_both_sides() {
907 let defs = vec![Tool {
913 name: "shell".to_string(),
914 description: String::new(),
915 parameters: serde_json::Value::Null,
916 }];
917 let available = vec!["bash".to_string()];
918 let tools = filter_tools_for_stage(&defs, &available, &[], true);
919 assert_eq!(names(&tools), vec!["shell"]);
920 }
921}