leviath_runtime/pipeline/spawn.rs
1//! Spawning an agent: the caller-resolved per-stage inputs
2//! ([`ResolvedStage`]), the per-stage setup derived from them, and the two
3//! spawn entry points.
4
5use super::*;
6
7/// A blueprint stage resolved to a concrete provider, model, and effective tool
8/// set - the per-stage input to [`spawn_agent`]. The caller (CLI / daemon) owns
9/// the model-selection policy (overrides, availability, user defaults) and tool
10/// filtering; the runtime just turns the result into agent data.
11#[derive(Debug)]
12pub struct ResolvedStage {
13 /// The provider to call for this stage.
14 pub provider_name: String,
15 /// The resolved model name.
16 pub model: String,
17 /// The effective tool set for this stage (already filtered).
18 pub tools: Vec<Tool>,
19 /// Where to go if `provider_name` turns out to be unusable, best first.
20 /// See [`crate::pipeline::resolve_stage_candidates`].
21 pub fallbacks: Vec<leviath_core::blueprint::ModelEntry>,
22 /// The output shape resolved for this stage: the blueprint's default, the
23 /// stage's override, and the launching caller's request, combined. Resolved
24 /// caller-side (like the model and tool choices beside it) because only the
25 /// caller knows what was asked for at launch.
26 pub output: Option<leviath_core::output::OutputSpec>,
27}
28
29/// Fallback context window used when a stage's provider isn't registered (so
30/// percentage budgets can't be resolved against a real model). Matches
31/// [`leviath_providers::ModelCapabilities`]'s default `max_context_tokens`.
32pub(crate) const DEFAULT_CONTEXT_WINDOW_TOKENS: usize = 8192;
33
34/// Look up a model's context window (for resolving percentage region budgets)
35/// via the registered [`Providers`]. Falls back to
36/// [`DEFAULT_CONTEXT_WINDOW_TOKENS`] with a warning when the provider isn't
37/// registered - non-fatal, and `min_tokens` floors still protect regions.
38pub(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
55/// Build a stage's [`StageSetup`] from its blueprint definition: inference config
56/// (from the model parameters), tool-result routing, accepts-messages, layout,
57/// and system prompt.
58///
59/// `global_hints` is the caller's config-level toggle for each system-prompt
60/// hint; `agent_hints` the blueprint's agent-level override of the same. Each
61/// one cascades stage → agent → global here.
62pub(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 // Every other model parameter (top_p, stop, seed, frequency_penalty, …) is
75 // passed through to the provider verbatim; only temperature/max_output_tokens
76 // are consumed specially above.
77 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 // A fan-out stage's single inference IS the "split": fold its `split_prompt`
96 // (which asks for the JSON array of work items) onto any base instructions so
97 // the stage's normal inference produces the work items the split system parses.
98 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 // A stage that must hand something back says so in its own instructions, on
110 // top of the `submit_output` tool description carrying the same shape. Both,
111 // because a format the model has no prior knowledge of - a2ui, a house
112 // schema - is exactly the case where one mention is easy to miss, and there
113 // is no parser downstream to catch a near miss.
114 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 // Cascade each hint toggle: stage > agent > global (both default on).
137 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
164/// Spawn a fully-formed agent into `world` from its blueprint, task, and
165/// per-stage resolution, and return its entity. Builds every stage's
166/// `StageInference`/`StageSetup` up front (so transitions are pure component
167/// swaps), seeds the context window, applies the **first** stage's setup (its
168/// layout and system prompt), pre-counts the first stage's visit, and marks the
169/// agent `ReadyToInfer`. Returns `Err` if the first stage's system prompt doesn't fit
170/// its region (the same hard failure the imperative loop raises at stage 0).
171///
172/// `stages` must be aligned with `blueprint.stages` (one [`ResolvedStage`] each).
173///
174/// `global_hints` is the caller's global config toggle for each system-prompt
175/// hint; each is resolved per stage against the blueprint's agent-level and
176/// per-stage override of the same name.
177pub 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 // No compiled custom-region scripts on this path: script-backed regions
187 // require the seeded spawn (the CLI resolves and compiles them). A custom
188 // region spawned through here renders its fallback shape. Global nudge
189 // defaults are likewise a seeded-spawn concern (the CLI reads them from
190 // config.toml); agents spawned through here cascade straight from the
191 // blueprint to the built-in defaults.
192 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
206/// Everything a seeded spawn needs besides the world it spawns into.
207///
208/// The blueprint and its resolved stages travel with the seeds and the global
209/// defaults because all six are the same decision made at different layers:
210/// what this agent starts with. The caller resolves them; this consumes them.
211pub struct SeededSpawn {
212 /// The run id this agent is registered under.
213 pub agent_id: String,
214 /// The blueprint being spawned.
215 pub blueprint: leviath_core::Blueprint,
216 /// Content for named caller-input regions, keyed by region name.
217 pub seeds: std::collections::HashMap<String, String>,
218 /// The blueprint's stages, already resolved against the provider registry.
219 pub stages: Vec<ResolvedStage>,
220 /// Config-level prompt hints, applied where the blueprint says nothing.
221 pub global_hints: leviath_core::config::PromptHints,
222 /// The config-level nudge, likewise.
223 pub global_nudge: leviath_core::NudgeConfig,
224 /// Compiled render hooks, keyed by region name.
225 pub region_scripts: std::collections::HashMap<
226 String,
227 std::sync::Arc<leviath_scripting::region_hook::RegionScript>,
228 >,
229}
230
231/// Like [`spawn_agent`], but seeds the context window from a name→content map
232/// (caller-input regions filled by the CLI/ACP/API, plus blueprint-resolved
233/// seeds) rather than a single task string. `spawn_agent` is the thin wrapper
234/// that seeds only the `task` key.
235///
236/// `global_nudge` is the caller's config-level `[nudge]` defaults, captured on
237/// the agent as a [`crate::pipeline::response::GlobalNudge`] component; each
238/// field is resolved per stage against the blueprint's agent-level and
239/// per-stage nudge settings when an empty response is handled.
240pub 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 // Resolve any percentage region budgets against each stage's model context
252 // window (the only place the model - and hence the window - is known). The
253 // global layout resolves against the entry stage (stage 0); each per-stage
254 // layout resolves against that stage's own model. Absolute layouts resolve to
255 // themselves, so this is a no-op for legacy blueprints.
256 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 // Validate the resolved (fully-absolute) layouts, now that percentages are
267 // concrete numbers judged against the real model window.
268 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 // Kept before `stages` is consumed, so each stage's setup can fold the same
279 // shape into its system prompt that its tool description already carries.
280 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, // tools already resolved to the effective set
289 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 // Seed the window from the blueprint layout + task, then apply stage 0's
305 // context setup (layout swap + system-prompt injection) just as entering any
306 // later stage would.
307 let mut window = ContextWindow::new(blueprint.context_layout.total_budget_tokens);
308 // Attach compiled custom-region scripts BEFORE seeding, so seed writes
309 // pass through each region's on_write hook like any other entry.
310 window.region_scripts = region_scripts;
311 crate::context_setup::init_window_seeded(&mut window, &blueprint, seeds);
312 apply_stage_context(&setups[0], &mut window)?;
313
314 let stage0_name = blueprint.stages[0].name.clone();
315 let stage0_inf = stage_infs[0].clone();
316 let setup0 = &setups[0];
317 let stage0_cfg = setup0.inference_config.clone();
318 let stage0_routing = setup0.routing.clone();
319 let accepts_messages = setup0.accepts_messages;
320
321 // Pre-count stage 0's visit: the imperative loop bumps a stage's visit after
322 // it runs and before resolving its transition, so stage 0 must read as
323 // visited once by the time its first transition resolves.
324 let mut visits = VisitCounts::default();
325 *visits.0.entry(stage0_name.clone()).or_insert(0) += 1;
326
327 // Seed the per-stage ledger (names + Pending) so the dashboard shows every
328 // stage's real name from the first persist, not just the active one.
329 let ledger = StageLedger(
330 blueprint
331 .stages
332 .iter()
333 .enumerate()
334 .map(|(i, s)| leviath_core::run_meta::StageRecord::new(s.name.clone(), i))
335 .collect(),
336 );
337
338 // Repetition detection is opt-in per blueprint.
339 let repetition = blueprint
340 .repetition_detection
341 .as_ref()
342 .map(crate::repetition::RepetitionDetector::from_detection_config);
343
344 let entity = world
345 .spawn((
346 AgentBlueprint(blueprint),
347 AgentState {
348 agent_id,
349 current_stage: stage0_name,
350 iteration: 0,
351 status: AgentStatus::Active,
352 spawned_children_ids: vec![],
353 pending_wait: None,
354 accepts_messages,
355 },
356 MessageInbox::default(),
357 StageCursor { index: 0 },
358 StageProgress::default(),
359 StageInferences(stage_infs),
360 StageSetups(setups),
361 visits,
362 window,
363 stage0_inf,
364 stage0_cfg,
365 ReadyToInfer,
366 ))
367 .id();
368 // Inserted after spawn: the bundle above is already at bevy's 15-tuple limit.
369 world.entity_mut(entity).insert((
370 ledger,
371 StageIoBuffer::default(),
372 crate::pipeline::response::GlobalNudge(global_nudge),
373 ));
374 if let Some(detector) = repetition {
375 world.entity_mut(entity).insert(detector);
376 }
377 if let Some(routing) = stage0_routing {
378 world
379 .entity_mut(entity)
380 .insert(crate::components::ToolResultRoutingComponent { routing });
381 }
382 Ok(entity)
383}