Skip to main content

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}