1use super::*;
6
7#[derive(Debug)]
12pub struct ResolvedStage {
13 pub provider_name: String,
15 pub model: String,
17 pub tools: Vec<Tool>,
19 pub fallbacks: Vec<leviath_core::blueprint::ModelEntry>,
22 pub output: Option<leviath_core::output::OutputSpec>,
27}
28
29pub(crate) const DEFAULT_CONTEXT_WINDOW_TOKENS: usize = 8192;
33
34pub(crate) fn context_window_tokens(world: &World, provider_name: &str, model: &str) -> usize {
39 match world
40 .get_resource::<Providers>()
41 .and_then(|p| p.0.get(provider_name))
42 {
43 Some(provider) => provider.max_context_tokens(model),
44 None => {
45 tracing::warn!(
46 provider = provider_name,
47 model,
48 "provider not registered; using default context window for percentage budgets"
49 );
50 DEFAULT_CONTEXT_WINDOW_TOKENS
51 }
52 }
53}
54
55pub(crate) fn stage_setup_from(
63 stage: &leviath_core::Stage,
64 global_hints: leviath_core::config::PromptHints,
65 agent_hints: leviath_core::config::PromptHintOverrides,
66 output: Option<leviath_core::output::OutputSpec>,
67) -> StageSetup {
68 let temperature = stage
69 .model
70 .parameters
71 .get("temperature")
72 .and_then(|v| v.as_f64())
73 .map(|t| t as f32);
74 let extra_params: serde_json::Map<String, serde_json::Value> = stage
78 .model
79 .parameters
80 .iter()
81 .filter(|(k, _)| k.as_str() != "temperature" && k.as_str() != "max_output_tokens")
82 .map(|(k, v)| (k.clone(), v.clone()))
83 .collect();
84 let max_output_tokens = stage
85 .model
86 .parameters
87 .get("max_output_tokens")
88 .and_then(|v| v.as_u64())
89 .map(|t| t as usize);
90 let base_prompt = stage
91 .config
92 .get("system_prompt")
93 .and_then(|v| v.as_str())
94 .map(String::from);
95 let system_prompt = match &stage.mode {
99 leviath_core::blueprint::StageMode::FanOut { config }
100 if !config.split_prompt.trim().is_empty() =>
101 {
102 Some(match base_prompt {
103 Some(base) => format!("{base}\n\n{}", config.split_prompt),
104 None => config.split_prompt.clone(),
105 })
106 }
107 _ => base_prompt,
108 };
109 let system_prompt = match (&output, stage.require_output) {
115 (Some(spec), true) => {
116 let described = leviath_core::describe_spec(spec);
117 let demand = match described.is_empty() {
118 true => format!(
119 "Before this stage ends you must call `{tool}` with your final answer. It is \
120 the only thing the caller receives.",
121 tool = leviath_core::blueprint::SUBMIT_OUTPUT_TOOL
122 ),
123 false => format!(
124 "Before this stage ends you must call `{tool}` with your final answer. It is \
125 the only thing the caller receives.\n\n{described}",
126 tool = leviath_core::blueprint::SUBMIT_OUTPUT_TOOL
127 ),
128 };
129 Some(match system_prompt {
130 Some(base) => format!("{base}\n\n{demand}"),
131 None => demand,
132 })
133 }
134 _ => system_prompt,
135 };
136 let batch_tool_hint = leviath_core::taint::resolve_batch_tool_hint(
138 global_hints.batch_tool,
139 agent_hints.batch_tool,
140 stage.batch_tool_hint,
141 );
142 let shell_hint = leviath_core::taint::resolve_shell_hint(
143 global_hints.shell,
144 agent_hints.shell,
145 stage.shell_hint,
146 );
147 StageSetup {
148 inference_config: InferenceConfig {
149 temperature,
150 max_output_tokens,
151 extra_params,
152 batch_tool_hint,
153 shell_hint,
154 request_timeout_secs: stage.model.request_timeout_secs,
155 },
156 routing: stage.tool_result_routing.clone(),
157 accepts_messages: stage.accepts_messages,
158 context_layout: stage.context_layout.clone(),
159 system_prompt,
160 output,
161 }
162}
163
164pub fn spawn_agent(
178 world: &mut World,
179 agent_id: String,
180 blueprint: leviath_core::Blueprint,
181 task: &str,
182 stages: Vec<ResolvedStage>,
183 global_hints: leviath_core::config::PromptHints,
184) -> Result<Entity, String> {
185 let seeds = std::collections::HashMap::from([("task".to_string(), task.to_string())]);
186 spawn_agent_seeded(
193 world,
194 SeededSpawn {
195 agent_id,
196 blueprint,
197 seeds,
198 stages,
199 global_hints,
200 global_nudge: leviath_core::NudgeConfig::default(),
201 region_scripts: std::collections::HashMap::new(),
202 },
203 )
204}
205
206pub struct SeededSpawn {
212 pub agent_id: String,
214 pub blueprint: leviath_core::Blueprint,
216 pub seeds: std::collections::HashMap<String, String>,
218 pub stages: Vec<ResolvedStage>,
220 pub global_hints: leviath_core::config::PromptHints,
222 pub global_nudge: leviath_core::NudgeConfig,
224 pub region_scripts: std::collections::HashMap<
226 String,
227 std::sync::Arc<leviath_scripting::region_hook::RegionScript>,
228 >,
229}
230
231pub fn spawn_agent_seeded(world: &mut World, spawn: SeededSpawn) -> Result<Entity, String> {
241 let SeededSpawn {
242 agent_id,
243 mut blueprint,
244 seeds,
245 stages,
246 global_hints,
247 global_nudge,
248 region_scripts,
249 } = spawn;
250 let seeds = &seeds;
251 let stage_windows: Vec<usize> = stages
257 .iter()
258 .map(|rs| context_window_tokens(world, &rs.provider_name, &rs.model))
259 .collect();
260 blueprint.context_layout = blueprint.context_layout.resolved(stage_windows[0]);
261 for (i, stage) in blueprint.stages.iter_mut().enumerate() {
262 if let Some(layout) = &stage.context_layout {
263 stage.context_layout = Some(layout.resolved(stage_windows[i]));
264 }
265 }
266 blueprint
269 .context_layout
270 .validate()
271 .map_err(|e| e.to_string())?;
272 for stage in &blueprint.stages {
273 if let Some(layout) = &stage.context_layout {
274 layout.validate().map_err(|e| e.to_string())?;
275 }
276 }
277
278 let stage_outputs: Vec<Option<leviath_core::output::OutputSpec>> =
281 stages.iter().map(|rs| rs.output.clone()).collect();
282 let stage_infs: Vec<StageInference> = stages
283 .into_iter()
284 .map(|rs| StageInference {
285 provider_name: rs.provider_name,
286 model: rs.model,
287 tools: rs.tools,
288 tool_filter: None, fallbacks: rs.fallbacks,
290 output: rs.output,
291 })
292 .collect();
293 let agent_hints = leviath_core::config::PromptHintOverrides {
294 batch_tool: blueprint.batch_tool_hint,
295 shell: blueprint.shell_hint,
296 };
297 let setups: Vec<StageSetup> = blueprint
298 .stages
299 .iter()
300 .zip(stage_outputs)
301 .map(|(s, output)| stage_setup_from(s, global_hints, agent_hints, output))
302 .collect();
303
304 let mut window = ContextWindow::new(blueprint.context_layout.total_budget_tokens);
308 window.region_scripts = region_scripts;
311 crate::context_setup::init_window_seeded(&mut window, &blueprint, seeds);
312 let prompts: Vec<Option<String>> = setups.iter().map(|s| s.system_prompt.clone()).collect();
315 crate::context_setup::ensure_stage_instructions_region(&mut window, &prompts);
316 apply_stage_context(&setups[0], &mut window)?;
317
318 let stage0_name = blueprint.stages[0].name.clone();
319 let stage0_inf = stage_infs[0].clone();
320 let setup0 = &setups[0];
321 let stage0_cfg = setup0.inference_config.clone();
322 let stage0_routing = setup0.routing.clone();
323 let accepts_messages = setup0.accepts_messages;
324
325 let mut visits = VisitCounts::default();
329 *visits.0.entry(stage0_name.clone()).or_insert(0) += 1;
330
331 let ledger = StageLedger(
334 blueprint
335 .stages
336 .iter()
337 .enumerate()
338 .map(|(i, s)| leviath_core::run_meta::StageRecord::new(s.name.clone(), i))
339 .collect(),
340 );
341
342 let repetition = blueprint
344 .repetition_detection
345 .as_ref()
346 .map(crate::repetition::RepetitionDetector::from_detection_config);
347
348 let entity = world
349 .spawn((
350 AgentBlueprint(blueprint),
351 AgentState {
352 agent_id,
353 current_stage: stage0_name,
354 iteration: 0,
355 status: AgentStatus::Active,
356 spawned_children_ids: vec![],
357 pending_wait: None,
358 accepts_messages,
359 },
360 MessageInbox::default(),
361 StageCursor { index: 0 },
362 StageProgress::default(),
363 StageInferences(stage_infs),
364 StageSetups(setups),
365 visits,
366 window,
367 stage0_inf,
368 stage0_cfg,
369 ReadyToInfer,
370 ))
371 .id();
372 world.entity_mut(entity).insert((
374 ledger,
375 StageIoBuffer::default(),
376 crate::pipeline::response::GlobalNudge(global_nudge),
377 ));
378 if let Some(detector) = repetition {
379 world.entity_mut(entity).insert(detector);
380 }
381 if let Some(routing) = stage0_routing {
382 world
383 .entity_mut(entity)
384 .insert(crate::components::ToolResultRoutingComponent { routing });
385 }
386 Ok(entity)
387}
388
389#[cfg(test)]
390mod stage_instructions_fit_tests {
391 fn layout(window: usize) -> leviath_core::layout::ContextLayout {
396 use leviath_core::layout::{BudgetSpec, ContextLayout, RegionDefinition};
397 let pct = |p: f64| BudgetSpec::Percent {
398 percent: p,
399 min: None,
400 max: None,
401 };
402 let mut task =
403 RegionDefinition::new("task".to_string(), leviath_core::RegionKind::Pinned, 0);
404 task.budget = pct(0.02);
405 let mut instr = RegionDefinition::new(
406 leviath_core::layout::STAGE_INSTRUCTIONS_REGION.to_string(),
407 leviath_core::RegionKind::Pinned,
408 0,
409 );
410 instr.budget = pct(0.03);
411 ContextLayout::new(vec![task, instr], window).resolved(window)
412 }
413
414 fn big_prompt() -> String {
417 "word ".repeat(2_600)
418 }
419
420 #[test]
421 fn a_stage_prompt_measured_at_spawn_uses_the_declared_region() {
422 let window_tokens = 128_000;
423 let layout = layout(window_tokens);
424 let task_max = layout
425 .regions
426 .iter()
427 .find(|r| r.name == "task")
428 .expect("task")
429 .max_tokens;
430 let instr_max = layout
431 .regions
432 .iter()
433 .find(|r| r.name == leviath_core::layout::STAGE_INSTRUCTIONS_REGION)
434 .expect("stage_instructions")
435 .max_tokens;
436 let prompt = big_prompt();
437 let tokens = leviath_core::estimate_tokens(&format!("[Stage instructions: {prompt}]"));
438 assert!(
439 tokens > task_max && tokens < instr_max,
440 "the fixture must reproduce the reported shape: {tokens} vs task {task_max} / \
441 stage_instructions {instr_max}"
442 );
443
444 let bp = leviath_core::Blueprint::new(
445 "t".to_string(),
446 "d".to_string(),
447 vec![leviath_core::Stage::new(
448 "work".to_string(),
449 leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
450 )],
451 layout,
452 );
453 let mut window = crate::components::ContextWindow::new(window_tokens);
454 crate::context_setup::init_window_seeded(
455 &mut window,
456 &bp,
457 &std::collections::HashMap::new(),
458 );
459 let setup = crate::pipeline::transition::StageSetup {
460 inference_config: crate::components::InferenceConfig {
461 temperature: None,
462 max_output_tokens: None,
463 extra_params: Default::default(),
464 batch_tool_hint: false,
465 shell_hint: false,
466 request_timeout_secs: None,
467 },
468 routing: None,
469 accepts_messages: true,
470 context_layout: None,
471 system_prompt: Some(prompt),
472 output: None,
473 };
474 crate::pipeline::transition::apply_stage_context(&setup, &mut window)
475 .expect("the prompt fits the region declared for it");
476
477 let instr = window
478 .get_region(leviath_core::layout::STAGE_INSTRUCTIONS_REGION)
479 .expect("region exists");
480 assert!(
481 instr.content.iter().any(|e| e.content.contains("word")),
482 "the prompt landed in stage_instructions"
483 );
484 }
485
486 #[test]
495 fn a_blueprint_that_declares_no_region_still_gets_one() {
496 use leviath_core::layout::{BudgetSpec, ContextLayout, RegionDefinition};
497 let window_tokens = 128_000;
498 let prompt = big_prompt();
499
500 let mut task =
502 RegionDefinition::new("task".to_string(), leviath_core::RegionKind::Pinned, 0);
503 task.budget = BudgetSpec::Percent {
504 percent: 0.02,
505 min: None,
506 max: None,
507 };
508 let only_task = ContextLayout::new(vec![task], window_tokens).resolved(window_tokens);
509 let bp = leviath_core::Blueprint::new(
510 "t".to_string(),
511 "d".to_string(),
512 vec![leviath_core::Stage::new(
513 "work".to_string(),
514 leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
515 )],
516 only_task,
517 );
518
519 let mut window = crate::components::ContextWindow::new(window_tokens);
520 crate::context_setup::init_window_seeded(
521 &mut window,
522 &bp,
523 &std::collections::HashMap::new(),
524 );
525 let prompts = vec![Some(prompt.clone())];
526 crate::context_setup::ensure_stage_instructions_region(&mut window, &prompts);
527
528 let setup = crate::pipeline::transition::StageSetup {
529 inference_config: crate::components::InferenceConfig {
530 temperature: None,
531 max_output_tokens: None,
532 extra_params: Default::default(),
533 batch_tool_hint: false,
534 shell_hint: false,
535 request_timeout_secs: None,
536 },
537 routing: None,
538 accepts_messages: true,
539 context_layout: None,
540 system_prompt: Some(prompt),
541 output: None,
542 };
543 crate::pipeline::transition::apply_stage_context(&setup, &mut window)
544 .expect("the prompt no longer has to fit the caller's task region");
545
546 let task_region = window.get_region("task").expect("task");
547 assert!(
548 task_region.content.is_empty(),
549 "the task region is left for the caller's task"
550 );
551 let instr = window
552 .get_region(leviath_core::layout::STAGE_INSTRUCTIONS_REGION)
553 .expect("the runtime made one");
554 assert!(instr.content.iter().any(|e| e.content.contains("word")));
555 }
556
557 #[test]
560 fn no_region_is_made_when_no_stage_has_a_prompt() {
561 let mut window = crate::components::ContextWindow::new(1_000);
562 crate::context_setup::ensure_stage_instructions_region(&mut window, &[None, None]);
563 assert!(
564 window
565 .get_region(leviath_core::layout::STAGE_INSTRUCTIONS_REGION)
566 .is_none()
567 );
568 }
569
570 #[test]
572 fn a_declared_region_is_not_resized() {
573 let mut window = crate::components::ContextWindow::new(100_000);
574 window.add_region(leviath_core::Region::new(
575 leviath_core::layout::STAGE_INSTRUCTIONS_REGION.to_string(),
576 leviath_core::RegionKind::Pinned,
577 4_242,
578 ));
579 crate::context_setup::ensure_stage_instructions_region(&mut window, &[Some(big_prompt())]);
580 assert_eq!(
581 window
582 .get_region(leviath_core::layout::STAGE_INSTRUCTIONS_REGION)
583 .expect("declared")
584 .max_tokens,
585 4_242
586 );
587 }
588
589 #[test]
593 fn an_impossible_prompt_is_still_refused_and_names_the_right_region() {
594 let mut window = crate::components::ContextWindow::new(1_000);
595 window.add_region(leviath_core::Region::new(
596 "task".to_string(),
597 leviath_core::RegionKind::Pinned,
598 40,
599 ));
600 let prompt = "z".repeat(100_000);
601 crate::context_setup::ensure_stage_instructions_region(
602 &mut window,
603 &[Some(prompt.clone())],
604 );
605 assert_eq!(
607 window
608 .get_region(leviath_core::layout::STAGE_INSTRUCTIONS_REGION)
609 .expect("made")
610 .max_tokens,
611 250
612 );
613
614 let setup = crate::pipeline::transition::StageSetup {
615 inference_config: crate::components::InferenceConfig {
616 temperature: None,
617 max_output_tokens: None,
618 extra_params: Default::default(),
619 batch_tool_hint: false,
620 shell_hint: false,
621 request_timeout_secs: None,
622 },
623 routing: None,
624 accepts_messages: true,
625 context_layout: None,
626 system_prompt: Some(prompt),
627 output: None,
628 };
629 let err = crate::pipeline::transition::apply_stage_context(&setup, &mut window)
630 .expect_err("a prompt larger than the window cannot be housed");
631 assert!(
632 err.contains(leviath_core::layout::STAGE_INSTRUCTIONS_REGION),
633 "{err}"
634 );
635 }
636
637 #[test]
640 fn the_region_is_sized_for_the_widest_prompt() {
641 let mut window = crate::components::ContextWindow::new(100_000);
642 let small = "word ".repeat(10);
643 let large = big_prompt();
644 let expected = leviath_core::estimate_tokens(&format!("[Stage instructions: {large}]"));
645 crate::context_setup::ensure_stage_instructions_region(
646 &mut window,
647 &[Some(small), Some(large)],
648 );
649 assert_eq!(
650 window
651 .get_region(leviath_core::layout::STAGE_INSTRUCTIONS_REGION)
652 .expect("made")
653 .max_tokens,
654 expected
655 );
656 }
657
658 #[test]
661 fn a_scoped_stage_layout_still_routes_to_the_declared_region() {
662 use leviath_core::layout::{BudgetSpec, ContextLayout, RegionDefinition};
663 let window_tokens = 128_000;
664 let prompt = big_prompt();
665
666 let mut scoped_task =
669 RegionDefinition::new("task".to_string(), leviath_core::RegionKind::Pinned, 0);
670 scoped_task.budget = BudgetSpec::Percent {
671 percent: 0.02,
672 min: None,
673 max: None,
674 };
675 let scoped = ContextLayout::new(vec![scoped_task], window_tokens).resolved(window_tokens);
676
677 let bp = leviath_core::Blueprint::new(
678 "t".to_string(),
679 "d".to_string(),
680 vec![leviath_core::Stage::new(
681 "work".to_string(),
682 leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
683 )],
684 layout(window_tokens),
685 );
686
687 let mut window = crate::components::ContextWindow::new(window_tokens);
688 crate::context_setup::init_window_seeded(
689 &mut window,
690 &bp,
691 &std::collections::HashMap::new(),
692 );
693 let setup = crate::pipeline::transition::StageSetup {
694 inference_config: crate::components::InferenceConfig {
695 temperature: None,
696 max_output_tokens: None,
697 extra_params: Default::default(),
698 batch_tool_hint: false,
699 shell_hint: false,
700 request_timeout_secs: None,
701 },
702 routing: None,
703 accepts_messages: true,
704 context_layout: Some(scoped),
705 system_prompt: Some(prompt),
706 output: None,
707 };
708 crate::pipeline::transition::apply_stage_context(&setup, &mut window)
709 .expect("the prompt fits the region declared for it");
710
711 let instr = window
712 .get_region(leviath_core::layout::STAGE_INSTRUCTIONS_REGION)
713 .expect("carried through the scoped layout");
714 assert!(
715 instr.content.iter().any(|e| e.content.contains("word")),
716 "the prompt landed in stage_instructions, not in the scoped task region"
717 );
718 }
719}