Skip to main content

oxios_kernel/token_maxing/
maxer.rs

1//! TokenMaxer orchestrator (RFC-031 §5).
2//!
3//! The drain → rotate → wait → resume loop. Activated by a time window or a
4//! manual toggle. Each tick: pick the most-available eligible provider, ask the
5//! WorkPlanner for a bounded task, execute it with the provider's model pinned
6//! (`ExecEnv.model_override`) under a restricted capability set
7//! (`ExecEnv.cspace_hint = "standard"`), credit the self-tracked counter, and
8//! record the outcome in the session.
9//!
10//! ## §6.4 — fail-closed, structurally
11//! The maxer's agents run through the AgentRuntime **gated** registration path
12//! (`register_tools_from_cspace_gated`), which:
13//! (a) scopes capabilities to `standard()` so high-risk tools
14//!     (rm/osascript/wildcard/ManageRBAC/SystemConfig) **DENY** at the
15//!     `AccessGate`, and
16//! (b) does **not** register `ask_user` (that lives in `register_all_kernel_tools`
17//!     / the bridge path, not this one) — so an unattended agent cannot pend on
18//!     a clarification.
19//! As defense-in-depth, each task is wrapped in a hard per-task timeout so any
20//! tool that blocks is bounded (`execute_directive` also enforces
21//! `max_execution_time`).
22
23use std::collections::HashSet;
24use std::sync::Arc;
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::time::Duration;
27
28use parking_lot::RwLock;
29use tokio::time::timeout;
30
31use oxios_ouroboros::{Directive, ExecEnv};
32
33use crate::agent_lifecycle::AgentLifecycleManager;
34use crate::resilience::FailureClass;
35use crate::state_store::StateStore;
36
37use super::TokenMaxingConfig;
38use super::planner::{PlannedTask, WorkPlanner};
39use super::quota_tracker::{Availability, QuotaTracker};
40use super::session::{MaxerStatus, MaxingStart, StopReason, TokenMaxingSession};
41
42/// Per-task hard timeout (defense-in-depth). Bounds any blocking tool.
43const MAX_TASK_SECS: u64 = 600;
44/// Sleep between ticks when every eligible provider is cooled down / drained,
45/// before re-checking for a reset (RFC-031 §5 "wait for reset").
46const COOLDOWN_POLL_SECS: u64 = 60;
47/// Cap on retained session history (memory bound).
48const MAX_HISTORY: usize = 50;
49
50/// The token-maxing orchestrator. Constructed once at boot with a clone of the
51/// `AgentLifecycleManager`, the shared `QuotaTracker`, a `WorkPlanner`, and the
52/// `StateStore` for session persistence.
53pub struct TokenMaxer {
54    lifecycle: AgentLifecycleManager,
55    tracker: Arc<QuotaTracker>,
56    planner: WorkPlanner,
57    state_store: Arc<StateStore>,
58    state: RwLock<MaxerRuntimeState>,
59    cancel: Arc<AtomicBool>,
60}
61
62#[derive(Default)]
63struct MaxerRuntimeState {
64    running: bool,
65    current: Option<TokenMaxingSession>,
66    current_provider: Option<String>,
67    current_task: Option<String>,
68    history: Vec<TokenMaxingSession>,
69}
70
71impl TokenMaxer {
72    #[allow(clippy::too_many_arguments)]
73    pub fn new(
74        lifecycle: AgentLifecycleManager,
75        tracker: Arc<QuotaTracker>,
76        planner: WorkPlanner,
77        state_store: Arc<StateStore>,
78    ) -> Self {
79        Self {
80            lifecycle,
81            tracker,
82            planner,
83            state_store,
84            state: RwLock::new(MaxerRuntimeState::default()),
85            cancel: Arc::new(AtomicBool::new(false)),
86        }
87    }
88
89    /// Launch a session. Refuses if the mode is disabled or no eligible provider
90    /// has at least one model to pin. Returns the new session id; the drain loop
91    /// runs on a spawned task.
92    pub fn launch(self: &Arc<Self>, start: MaxingStart) -> anyhow::Result<String> {
93        let cfg = self.tracker.config();
94        if !cfg.enabled {
95            anyhow::bail!("token-maxing is disabled (token-maxing.enabled = false)");
96        }
97        let eligible = cfg
98            .providers
99            .iter()
100            .filter(|p| cfg.is_eligible(&p.provider) && !p.models.is_empty());
101        if eligible.count() == 0 {
102            anyhow::bail!(
103                "no eligible token-maxing provider: each needs \
104                 billing_model = \"subscription\" and a non-empty models list"
105            );
106        }
107
108        let (window, manual) = match &start {
109            MaxingStart::Scheduled(w) => (Some(w.clone()), false),
110            MaxingStart::Manual => (None, true),
111        };
112        let session = TokenMaxingSession::start(window, manual);
113        let id = session.id.clone();
114
115        {
116            let mut st = self.state.write();
117            if st.running {
118                anyhow::bail!("a token-maxing session is already running");
119            }
120            st.running = true;
121            st.current = Some(session);
122        }
123        self.cancel.store(false, Ordering::Relaxed);
124
125        let me = Arc::clone(self);
126        tokio::spawn(async move {
127            me.run_loop(start).await;
128        });
129        Ok(id)
130    }
131
132    /// Request a graceful stop after the in-flight task completes.
133    pub fn stop(&self) {
134        self.cancel.store(true, Ordering::Relaxed);
135    }
136
137    /// Live status (RFC-031 §9 `GET /api/token-maxing/status`).
138    pub fn status(&self) -> MaxerStatus {
139        let st = self.state.read();
140        let current = st.current.as_ref();
141        MaxerStatus {
142            running: st.running,
143            current_session_id: current.map(|s| s.id.clone()),
144            current_provider: st.current_provider.clone(),
145            current_task: st.current_task.clone(),
146            manual: current.map(|s| s.manual).unwrap_or(false),
147            window: current.and_then(|s| s.window.clone()),
148            tokens_this_session: current.map(|s| s.totals.tokens).unwrap_or(0),
149            tasks_this_session: current.map(|s| s.totals.tasks).unwrap_or(0),
150            providers: self.tracker.snapshots(),
151        }
152    }
153
154    /// Past sessions (most-recent last), capped at [`MAX_HISTORY`].
155    pub fn sessions(&self) -> Vec<TokenMaxingSession> {
156        self.state.read().history.clone()
157    }
158
159    /// One past or in-flight session by id.
160    pub fn session(&self, id: &str) -> Option<TokenMaxingSession> {
161        let st = self.state.read();
162        if let Some(c) = &st.current
163            && c.id == id
164        {
165            return Some(c.clone());
166        }
167        st.history.iter().find(|s| s.id == id).cloned()
168    }
169
170    fn cancelled(&self) -> bool {
171        self.cancel.load(Ordering::Relaxed)
172    }
173
174    /// The drain → rotate → wait → resume loop.
175    async fn run_loop(self: Arc<Self>, start: MaxingStart) {
176        let (window, manual) = match &start {
177            MaxingStart::Scheduled(w) => (Some(w.clone()), false),
178            MaxingStart::Manual => (None, true),
179        };
180        let mut session = TokenMaxingSession::start(window, manual);
181        self.persist(&session).await;
182
183        let mut done_goals: HashSet<String> = HashSet::new();
184
185        let stop_reason = loop {
186            if self.cancelled() {
187                break StopReason::Cancelled;
188            }
189            if !session.within_window() {
190                break StopReason::WindowEnded;
191            }
192
193            let cfg = self.tracker.config();
194
195            // Pick the most-available eligible provider with a model to pin.
196            let provider = match self.pick_provider(&cfg) {
197                Some(p) => p,
198                None => {
199                    // All cooled/drained — wait for a reset, then re-check.
200                    self.sleep_cancellable(COOLDOWN_POLL_SECS).await;
201                    continue;
202                }
203            };
204            let model = match self.pick_model(&cfg, &provider, &session) {
205                Some(m) => m,
206                None => {
207                    self.sleep_cancellable(COOLDOWN_POLL_SECS).await;
208                    continue;
209                }
210            };
211
212            let task = match self.planner.next_task(&done_goals).await {
213                Some(t) => t,
214                None => break StopReason::NoWork,
215            };
216            done_goals.insert(task.goal.clone());
217
218            {
219                let mut st = self.state.write();
220                st.current_provider = Some(provider.clone());
221                st.current_task = Some(task.source_name.clone());
222            }
223
224            self.dispatch(&mut session, task, provider, model).await;
225            self.persist(&session).await;
226            self.state.write().current = Some(session.clone());
227        };
228
229        session.finalize(stop_reason);
230        self.persist(&session).await;
231
232        let mut st = self.state.write();
233        st.running = false;
234        st.current_provider = None;
235        st.current_task = None;
236        st.current = None;
237        st.history.push(session);
238        if st.history.len() > MAX_HISTORY {
239            let drop_n = st.history.len() - MAX_HISTORY;
240            st.history.drain(0..drop_n);
241        }
242    }
243
244    /// Execute one task, credit the self-tracked counter, and record the outcome.
245    async fn dispatch(
246        &self,
247        session: &mut TokenMaxingSession,
248        task: PlannedTask,
249        provider: String,
250        model: String,
251    ) {
252        let env = ExecEnv {
253            // §6.1/§6.4: restricted capability set → high-risk tools DENY at the
254            // gate; ask_user is not registered on this path either.
255            cspace_hint: Some("standard".into()),
256            model_override: Some(model.clone()),
257            mount_paths: task.mount_paths.clone(),
258            ..Default::default()
259        };
260        let directive = Directive {
261            goal: task.goal.clone(),
262            ..Default::default()
263        };
264
265        let t0 = std::time::Instant::now();
266        let result = timeout(
267            Duration::from_secs(MAX_TASK_SECS),
268            self.lifecycle.execute_directive(&directive, &env),
269        )
270        .await;
271        let dur = t0.elapsed().as_secs_f64();
272
273        let source = task.source;
274        let source_name = task.source_name;
275        let goal = task.goal;
276
277        match result {
278            Ok(Ok(r)) => {
279                let tokens = r.tokens_input + r.tokens_output;
280                // Phase 1 feed: credit the self-tracked counter.
281                let _ = self.tracker.reserve(&provider, tokens);
282                // Reactive override: a classified failure cools the provider.
283                if let Some(class) = r.failure_class {
284                    self.tracker.record_failure(&provider, class, None);
285                }
286                session.record_task(
287                    source,
288                    source_name,
289                    goal,
290                    provider,
291                    model,
292                    r.success,
293                    tokens,
294                    dur,
295                    truncate(&r.output, 800),
296                );
297            }
298            Ok(Err(e)) => {
299                tracing::warn!(error = %e, "token-maxing task failed");
300                session.record_task(
301                    source,
302                    source_name,
303                    goal,
304                    provider,
305                    model,
306                    false,
307                    0,
308                    dur,
309                    format!("error: {e}"),
310                );
311            }
312            Err(_) => {
313                tracing::warn!("token-maxing task timed out — cooling provider");
314                // A timeout often signals a rate-limit/quota wall — cool the
315                // provider so the loop rotates to another.
316                self.tracker
317                    .record_failure(&provider, FailureClass::Transient, None);
318                session.record_task(
319                    source,
320                    source_name,
321                    goal,
322                    provider,
323                    model,
324                    false,
325                    0,
326                    dur,
327                    "timed out".into(),
328                );
329            }
330        }
331    }
332
333    /// First dispatchable eligible provider — Available preferred over Draining
334    /// (the rotation across providers when one hits its floor/cooldown).
335    fn pick_provider(&self, cfg: &TokenMaxingConfig) -> Option<String> {
336        let snaps = self.tracker.snapshots();
337        if let Some(s) = snaps.iter().find(|s| {
338            matches!(s.availability, Availability::Available { .. })
339                && self.has_model(cfg, &s.provider)
340        }) {
341            return Some(s.provider.clone());
342        }
343        snaps
344            .iter()
345            .find(|s| {
346                matches!(s.availability, Availability::Draining { .. })
347                    && self.has_model(cfg, &s.provider)
348            })
349            .map(|s| s.provider.clone())
350    }
351
352    fn has_model(&self, cfg: &TokenMaxingConfig, provider: &str) -> bool {
353        cfg.get(provider)
354            .map(|p| !p.models.is_empty())
355            .unwrap_or(false)
356    }
357
358    /// Round-robin a model from the provider's configured list.
359    fn pick_model(
360        &self,
361        cfg: &TokenMaxingConfig,
362        provider: &str,
363        session: &TokenMaxingSession,
364    ) -> Option<String> {
365        let p = cfg.get(provider)?;
366        if p.models.is_empty() {
367            return None;
368        }
369        let used = session
370            .tasks
371            .iter()
372            .filter(|t| t.provider == provider)
373            .count();
374        Some(p.models[used % p.models.len()].clone())
375    }
376
377    async fn persist(&self, session: &TokenMaxingSession) {
378        if let Err(e) = self
379            .state_store
380            .save_json("token-maxing", &session.id, session)
381            .await
382        {
383            tracing::warn!(error = %e, "failed to persist token-maxing session");
384        }
385    }
386
387    async fn sleep_cancellable(&self, secs: u64) {
388        tokio::time::sleep(Duration::from_secs(secs)).await;
389    }
390}
391
392fn truncate(s: &str, max: usize) -> String {
393    if s.len() <= max {
394        s.to_string()
395    } else {
396        // Cut on a char boundary to avoid splitting a multi-byte sequence.
397        let mut end = max;
398        while end > 0 && !s.is_char_boundary(end) {
399            end -= 1;
400        }
401        format!("{}…", &s[..end])
402    }
403}