leviath_runtime/pipeline/mod.rs
1//! The ECS pipeline (Phase 2): components + systems that drive every agent
2//! through check-input → infer → tools → apply → repeat, entirely as data.
3//!
4//! Agents are entities; their execution phase is a **marker component**
5//! (`ReadyToInfer`, `AwaitingInference`, …) so systems can query by phase. A
6//! system never blocks on I/O: the dispatch systems hand work to the async
7//! bridges (`inference_bridge`, [`crate::tool_bridge`]) and the collect
8//! systems apply the results on a later tick. This module is built alongside the
9//! existing imperative engine; the two are unified in a later phase.
10
11use std::sync::Arc;
12
13use bevy_ecs::prelude::*;
14use tokio::runtime::Handle;
15use tokio::sync::Notify;
16use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
17
18use leviath_providers::{InferenceRequest, Provider, Tool};
19
20use crate::compaction_bridge::{CompactionJob, CompactionOutcome, run_compaction_job};
21use crate::components::{
22 AgentMessage, AgentState, AgentStatus, AwaitingInteraction, ContextWindow, InferenceConfig,
23 MessageInbox,
24};
25use crate::fanout::FanOutWaiting;
26use crate::inference_bridge::{InferenceJob, InferenceOutcome, run_inference_job};
27use crate::inference_pool::InferencePools;
28use crate::interaction_hub::InteractionHub;
29use crate::persistence::{RunMetadata, TokenTotals, build_context_snapshot, build_run_meta};
30use crate::persistence_bridge::{PersistJob, PersistMsg};
31use crate::providers::ProviderRegistry;
32use crate::tool_bridge::{BoxedToolExec, ToolJob, ToolOutcome};
33
34// Sections of the former single-file pipeline, one per concern.
35mod transition;
36pub use transition::*;
37mod hooks;
38pub use hooks::*;
39mod watchdog;
40pub use watchdog::*;
41mod requirements;
42pub use requirements::*;
43mod spawn;
44pub use spawn::*;
45mod transition_choice;
46pub use transition_choice::*;
47mod tool_stages;
48pub use tool_stages::*;
49mod messaging;
50pub use messaging::*;
51mod persist;
52pub use persist::*;
53mod compaction;
54pub use compaction::*;
55mod tool_results;
56pub use tool_results::*;
57mod gate;
58pub use gate::*;
59mod tools;
60pub use tools::*;
61mod response;
62pub use response::*;
63mod inference;
64pub use inference::*;
65mod resolve;
66pub use resolve::*;
67mod stall;
68pub use stall::*;
69mod wedge;
70pub use wedge::*;
71mod circuit;
72pub use circuit::*;
73
74// ─── Phase marker components (an agent is in exactly one) ────────────────────
75//
76// A marker's presence is a claim that some system has this agent queued, which
77// is what keeps it reachable. Anything new here must also be added to
78// [`Unreachable`], or the wedge watchdog will read an agent resting on it as one
79// nothing can drive.
80
81/// The agent is active and ready to build a request and (permits allowing)
82/// dispatch inference.
83#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
84pub struct ReadyToInfer;
85
86/// Inference has been dispatched to the pool; the agent is waiting for its
87/// result (which the inference-collect system will apply).
88#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
89pub struct AwaitingInference;
90
91/// Transient tag: the agent just entered a stage (index + name). The
92/// [`sync_tool_stages`] system reads it to notify the [`ToolService`] of the
93/// stage change, then removes it. Carries the data so the tool service need not
94/// query the world.
95#[derive(Component, Debug, Clone)]
96pub struct StageJustEntered {
97 /// The new stage's index.
98 pub index: usize,
99 /// The new stage's name.
100 pub name: String,
101}
102
103// ─── Per-agent stage data the dispatch system reads ──────────────────────────
104
105/// Resolved inference parameters for the agent's current stage, set when it
106/// enters that stage. Pure data - the dispatch system reads it to build the
107/// request.
108#[derive(Component, Debug, Clone)]
109pub struct StageInference {
110 /// Registered provider to call.
111 pub provider_name: String,
112 /// Model id (also the key into the per-model inference pools).
113 pub model: String,
114 /// Tools advertised at this stage.
115 pub tools: Vec<Tool>,
116 /// Optional allow-list of tool names (`None`/empty = all `tools`).
117 pub tool_filter: Option<Vec<String>>,
118 /// Providers to fail over to, best first, when the current one turns out
119 /// to be unusable. Consumed from the front by `collect_inference`, so an
120 /// exhausted list means "nowhere left to go" (issue #201).
121 pub fallbacks: Vec<leviath_core::blueprint::ModelEntry>,
122 /// The output shape resolved for this stage, carried alongside the tools it
123 /// was already folded into. Dispatch reads it to know which format label to
124 /// record and, when the author supplied a schema, what to validate against.
125 pub output: Option<leviath_core::output::OutputSpec>,
126}
127
128// ─── World resources for the inference stage ─────────────────────────────────
129
130/// The registered providers, as a world resource.
131#[derive(Resource)]
132pub struct Providers(pub ProviderRegistry);
133
134/// The operator's retry schedule for inference, from `[limits]`.
135///
136/// A world resource rather than constants because the daemon serves it from
137/// `[limits] inference_retry_attempts` and `inference_retry_base_ms`. Absent
138/// means the built-in schedule, which is what these defaults are.
139///
140/// Only the two ordinary-failure numbers are configurable. The capacity
141/// schedule and the total-backoff ceiling stay fixed (see
142/// [`crate::inference_bridge::RetryPolicy`]): they exist to bound a provider
143/// outage, and a bound an operator can raise without limit is not one.
144#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq)]
145pub struct InferenceRetryTuning {
146 /// Total attempts including the first. See
147 /// [`crate::inference_bridge::RetryPolicy::max_attempts`].
148 pub max_attempts: u32,
149 /// The first backoff after an ordinary transient failure, in milliseconds,
150 /// doubling per retry. See
151 /// [`crate::inference_bridge::RetryPolicy::base_delay`].
152 pub base_delay_ms: u64,
153}
154
155impl Default for InferenceRetryTuning {
156 fn default() -> Self {
157 Self {
158 max_attempts: crate::inference_bridge::DEFAULT_RETRY_ATTEMPTS,
159 base_delay_ms: crate::inference_bridge::DEFAULT_RETRY_BASE_DELAY_MS,
160 }
161 }
162}
163
164/// The plumbing the inference-dispatch system needs: the per-model pools, the
165/// channel to report outcomes on, the tick wake handle, and a runtime handle to
166/// spawn the (bounded, per-request) worker tasks onto.
167#[derive(Resource, Clone)]
168pub struct InferenceStage {
169 /// Per-model concurrency pools.
170 pub pools: Arc<InferencePools>,
171 /// Where completed inferences are reported.
172 pub outcomes: UnboundedSender<InferenceOutcome>,
173 /// Where completed *transition-choice* inferences are reported (a separate
174 /// lane so the collect systems don't confuse a routing decision with a normal
175 /// agent turn).
176 pub transition_outcomes: UnboundedSender<InferenceOutcome>,
177 /// Where completed *compaction* jobs (LLM context summarization) are
178 /// reported - again a separate lane so a summary isn't mistaken for a turn.
179 pub compaction_outcomes: UnboundedSender<crate::compaction_bridge::CompactionOutcome>,
180 /// Where completed *content-summary transform* jobs are reported (the
181 /// Summarize context-transform lane - see `context_transform`).
182 pub content_summary_outcomes: UnboundedSender<crate::compaction_bridge::CompactionOutcome>,
183 /// Signalled when an inference completes, to wake the tick loop.
184 pub wake: Arc<Notify>,
185 /// Runtime the worker tasks are spawned onto.
186 pub runtime: Handle,
187 /// Opt-in: perform an exact pre-inference token count and reject requests
188 /// that would overflow the model's context window (see
189 /// `InferenceJob::exact_token_counting`). Off by default.
190 pub exact_token_counting: bool,
191}
192
193/// Truncate `text` to at most `max_chars` characters, never splitting a
194/// multi-byte UTF-8 char. `max_chars` is an approximate char budget the caller
195/// derives from a token estimate.
196fn truncate_on_char_boundary(text: &str, max_chars: usize) -> String {
197 text.chars().take(max_chars).collect()
198}
199
200#[cfg(test)]
201mod tests;