From 87fbccb307d3f21ca8f8eeee502f1a955d4da6a0 Mon Sep 17 00:00:00 2001 From: Cedric Stephan Date: Mon, 27 Jul 2026 14:57:51 +0200 Subject: [PATCH] refactor(core): move autonomy state out of tools layer --- rust/src/core/autonomy.rs | 148 +++++++++++++++++++++++++++++ rust/src/core/mod.rs | 1 + rust/src/core/tool_lifecycle.rs | 6 +- rust/src/server/post_dispatch.rs | 2 +- rust/src/server/tool_trait.rs | 2 +- rust/src/tools/autonomy.rs | 113 +--------------------- rust/src/tools/server.rs | 2 +- rust/src/tools/server_lifecycle.rs | 3 +- 8 files changed, 157 insertions(+), 120 deletions(-) create mode 100644 rust/src/core/autonomy.rs diff --git a/rust/src/core/autonomy.rs b/rust/src/core/autonomy.rs new file mode 100644 index 0000000000..3b9959410c --- /dev/null +++ b/rust/src/core/autonomy.rs @@ -0,0 +1,148 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use crate::core::config::AutonomyConfig; + +#[cfg(test)] +const SEARCH_REPEAT_IDLE_RESET: Duration = Duration::from_millis(500); +#[cfg(not(test))] +const SEARCH_REPEAT_IDLE_RESET: Duration = Duration::from_mins(5); + +/// Per-key stats for progressive search hints (`ctx_search` / `ctx_semantic_search`). +#[derive(Debug, Clone)] +pub struct SearchHistory { + pub call_count: u32, + pub last_call: Instant, +} + +/// Tracks autonomous action state independently of the MCP tool layer. +pub struct AutonomyState { + pub session_initialized: AtomicBool, + pub dedup_applied: AtomicBool, + pub last_consolidation_unix: AtomicU64, + pub config: AutonomyConfig, + /// Repeated `pattern|path` keys for search tools (see [`AutonomyState::track_search`]). + pub search_repetition: Mutex>, + /// One-shot keys for large-output hints (`ctx_shell` bytes, `ctx_read` full tokens). + pub large_output_hints_shown: Mutex>, +} + +impl Default for AutonomyState { + fn default() -> Self { + Self::new() + } +} + +impl AutonomyState { + /// Creates a new autonomy state with config loaded from disk. + pub fn new() -> Self { + Self { + session_initialized: AtomicBool::new(false), + dedup_applied: AtomicBool::new(false), + last_consolidation_unix: AtomicU64::new(0), + config: AutonomyConfig::load(), + search_repetition: Mutex::new(HashMap::new()), + large_output_hints_shown: Mutex::new(HashSet::new()), + } + } + + /// Returns true if autonomous actions are enabled in configuration. + pub fn is_enabled(&self) -> bool { + self.config.enabled + } + + /// Records a search (`pattern` + `path` key) and returns a progressive hint after repeated calls. + /// + /// Uses interior mutability so this can be called on `Arc`. Counters reset when + /// the idle gap since the last call for that key is at least five minutes (500ms in unit tests). + pub fn track_search(&self, pattern: &str, path: &str) -> Option { + if !autonomy_enabled_effective(self) { + return None; + } + let key = format!("{pattern}|{path}"); + let now = Instant::now(); + let mut map = self + .search_repetition + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let hist = map.entry(key).or_insert(SearchHistory { + call_count: 0, + last_call: now, + }); + if hist.last_call.elapsed() >= SEARCH_REPEAT_IDLE_RESET { + hist.call_count = 0; + } + hist.call_count = hist.call_count.saturating_add(1); + hist.last_call = now; + let n = hist.call_count; + + match n { + 1..=3 => None, + 4..=6 => Some(format!( + "[hint: repeated search ({n}/6). Consider ctx_knowledge remember to store findings]" + )), + _ => Some(format!( + "[throttle: search repeated {n} times on same pattern. Use ctx_pack or ctx_knowledge to consolidate]" + )), + } + } +} + +fn autonomy_enabled_effective(state: &AutonomyState) -> bool { + state.is_enabled() + && crate::core::profiles::active_profile() + .autonomy + .enabled_effective() +} + +/// Returns true if enough tool calls have elapsed to trigger auto-consolidation. +pub fn should_auto_consolidate(state: &AutonomyState, tool_calls: u32) -> bool { + if !state.is_enabled() || !state.config.auto_consolidate { + return false; + } + let every = state.config.consolidate_every_calls.max(1); + if !tool_calls.is_multiple_of(every) { + return false; + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + let last = state.last_consolidation_unix.load(Ordering::SeqCst); + if now.saturating_sub(last) < state.config.consolidate_cooldown_secs { + return false; + } + state.last_consolidation_unix.store(now, Ordering::SeqCst); + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn consolidation_respects_call_interval_and_cooldown() { + let mut state = AutonomyState::new(); + state.config.enabled = true; + state.config.auto_consolidate = true; + state.config.consolidate_every_calls = 5; + state.config.consolidate_cooldown_secs = 60; + + assert!(!should_auto_consolidate(&state, 4)); + assert!(should_auto_consolidate(&state, 5)); + assert!(!should_auto_consolidate(&state, 10)); + } + + #[test] + fn consolidation_disabled_never_triggers() { + let mut state = AutonomyState::new(); + state.config.enabled = false; + state.config.auto_consolidate = true; + state.config.consolidate_every_calls = 1; + state.config.consolidate_cooldown_secs = 0; + + assert!(!should_auto_consolidate(&state, 1)); + } +} diff --git a/rust/src/core/mod.rs b/rust/src/core/mod.rs index 55d8873cd9..76bf0be5e9 100644 --- a/rust/src/core/mod.rs +++ b/rust/src/core/mod.rs @@ -301,6 +301,7 @@ pub mod a2a_transport; pub mod agent_identity; pub mod agent_runtime_env; pub mod agents; +pub mod autonomy; pub mod autonomy_drivers; // --------------------------------------------------------------------------- diff --git a/rust/src/core/tool_lifecycle.rs b/rust/src/core/tool_lifecycle.rs index c936178487..9e2e9b8947 100644 --- a/rust/src/core/tool_lifecycle.rs +++ b/rust/src/core/tool_lifecycle.rs @@ -473,12 +473,10 @@ pub fn flush_all() { crate::core::litm_calibration::flush(); } -// TODO(arch): crate::tools::autonomy is still referenced here. Move AutonomyState -// and should_auto_consolidate to core::autonomy_drivers for a clean layer boundary. fn maybe_consolidate(project_root: Option<&str>, calls: u32) { let Some(root) = project_root else { return }; - let autonomy = crate::tools::autonomy::AutonomyState::new(); - if crate::tools::autonomy::should_auto_consolidate(&autonomy, calls) { + let autonomy = crate::core::autonomy::AutonomyState::new(); + if crate::core::autonomy::should_auto_consolidate(&autonomy, calls) { let root = root.to_string(); let _ = crate::core::consolidation_engine::consolidate_latest( &root, diff --git a/rust/src/server/post_dispatch.rs b/rust/src/server/post_dispatch.rs index 050b6e7b7c..869e355ae7 100644 --- a/rust/src/server/post_dispatch.rs +++ b/rust/src/server/post_dispatch.rs @@ -100,7 +100,7 @@ impl LeanCtxServer { }; if let Some(root) = project_root - && crate::tools::autonomy::should_auto_consolidate(&self.autonomy, calls) + && crate::core::autonomy::should_auto_consolidate(&self.autonomy, calls) { let root_clone = root.clone(); tokio::task::spawn_blocking(move || { diff --git a/rust/src/server/tool_trait.rs b/rust/src/server/tool_trait.rs index 6eb4dd3318..d4e1bed11d 100644 --- a/rust/src/server/tool_trait.rs +++ b/rust/src/server/tool_trait.rs @@ -190,7 +190,7 @@ pub struct ToolContext { /// Global call counter for context tools. pub call_count: Option>, /// Autonomy state for search repeat detection. - pub autonomy: Option>, + pub autonomy: Option>, /// Pre-computed context pressure snapshot for synchronous gate decisions. pub pressure_snapshot: Option, /// Errors from path resolution (PathJail rejection, secret path, etc.). diff --git a/rust/src/tools/autonomy.rs b/rust/src/tools/autonomy.rs index 1a67e9b102..3bfe8c49f2 100644 --- a/rust/src/tools/autonomy.rs +++ b/rust/src/tools/autonomy.rs @@ -1,104 +1,16 @@ -use std::collections::{HashMap, HashSet}; -use std::sync::Mutex; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::sync::atomic::Ordering; +pub use crate::core::autonomy::{AutonomyState, SearchHistory, should_auto_consolidate}; use crate::core::autonomy_drivers::{ AutonomyDriverDecisionV1, AutonomyDriverEventV1, AutonomyDriverKindV1, AutonomyPhaseV1, AutonomyVerdictV1, }; use crate::core::cache::SessionCache; -use crate::core::config::AutonomyConfig; use crate::core::graph_provider::GraphProvider; use crate::core::protocol; use crate::core::tokens::count_tokens; use crate::tools::CrpMode; -#[cfg(test)] -const SEARCH_REPEAT_IDLE_RESET: Duration = Duration::from_millis(500); -#[cfg(not(test))] -const SEARCH_REPEAT_IDLE_RESET: Duration = Duration::from_mins(5); - -/// Per-key stats for progressive search hints (`ctx_search` / `ctx_semantic_search`). -#[derive(Debug, Clone)] -pub struct SearchHistory { - pub call_count: u32, - pub last_call: Instant, -} - -/// Tracks autonomous action state: session init, dedup, and consolidation timing. -pub struct AutonomyState { - pub session_initialized: AtomicBool, - pub dedup_applied: AtomicBool, - pub last_consolidation_unix: AtomicU64, - pub config: AutonomyConfig, - /// Repeated `pattern|path` keys for search tools (see [`AutonomyState::track_search`]). - pub search_repetition: Mutex>, - /// One-shot keys for large-output hints (`ctx_shell` bytes, `ctx_read` full tokens). - pub large_output_hints_shown: Mutex>, -} - -impl Default for AutonomyState { - fn default() -> Self { - Self::new() - } -} - -impl AutonomyState { - /// Creates a new autonomy state with config loaded from disk. - pub fn new() -> Self { - Self { - session_initialized: AtomicBool::new(false), - dedup_applied: AtomicBool::new(false), - last_consolidation_unix: AtomicU64::new(0), - config: AutonomyConfig::load(), - search_repetition: Mutex::new(HashMap::new()), - large_output_hints_shown: Mutex::new(HashSet::new()), - } - } - - /// Returns true if autonomous actions are enabled in configuration. - pub fn is_enabled(&self) -> bool { - self.config.enabled - } - - /// Records a search (`pattern` + `path` key) and returns a progressive hint after repeated calls. - /// - /// Uses interior mutability so this can be called on `Arc`. Counters reset when - /// the idle gap since the last call for that key is at least five minutes (50ms in unit tests). - pub fn track_search(&self, pattern: &str, path: &str) -> Option { - if !autonomy_enabled_effective(self) { - return None; - } - let key = format!("{pattern}|{path}"); - let now = Instant::now(); - let mut map = self - .search_repetition - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let hist = map.entry(key).or_insert(SearchHistory { - call_count: 0, - last_call: now, - }); - if hist.last_call.elapsed() >= SEARCH_REPEAT_IDLE_RESET { - hist.call_count = 0; - } - hist.call_count = hist.call_count.saturating_add(1); - hist.last_call = now; - let n = hist.call_count; - - match n { - 1..=3 => None, - 4..=6 => Some(format!( - "[hint: repeated search ({n}/6). Consider ctx_knowledge remember to store findings]" - )), - _ => Some(format!( - "[throttle: search repeated {n} times on same pattern. Use ctx_pack or ctx_knowledge to consolidate]" - )), - } - } -} - fn profile_autonomy() -> crate::core::profiles::ProfileAutonomy { crate::core::profiles::active_profile().autonomy } @@ -456,27 +368,6 @@ pub fn maybe_auto_dedup(state: &AutonomyState, cache: &mut SessionCache, trigger record_event(AutonomyPhaseV1::PostRead, trigger_tool, None, decisions); } -/// Returns true if enough tool calls have elapsed to trigger auto-consolidation. -pub fn should_auto_consolidate(state: &AutonomyState, tool_calls: u32) -> bool { - if !state.is_enabled() || !state.config.auto_consolidate { - return false; - } - let every = state.config.consolidate_every_calls.max(1); - if !tool_calls.is_multiple_of(every) { - return false; - } - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |d| d.as_secs()); - let last = state.last_consolidation_unix.load(Ordering::SeqCst); - if now.saturating_sub(last) < state.config.consolidate_cooldown_secs { - return false; - } - state.last_consolidation_unix.store(now, Ordering::SeqCst); - true -} - fn take_large_output_hint_once(state: &AutonomyState, key: &str) -> bool { if !autonomy_enabled_effective(state) { return false; diff --git a/rust/src/tools/server.rs b/rust/src/tools/server.rs index 4b9f8a769d..446d897662 100644 --- a/rust/src/tools/server.rs +++ b/rust/src/tools/server.rs @@ -69,7 +69,7 @@ pub struct LeanCtxServer { pub agent_id: Arc>>, pub(crate) presence_agent_id: Arc>>, pub client_name: Arc>, - pub autonomy: Arc, + pub autonomy: Arc, pub loop_detector: Arc>, pub workflow: Arc>>, pub ledger: Arc>, diff --git a/rust/src/tools/server_lifecycle.rs b/rust/src/tools/server_lifecycle.rs index 07598357c0..650a7dc469 100644 --- a/rust/src/tools/server_lifecycle.rs +++ b/rust/src/tools/server_lifecycle.rs @@ -7,7 +7,6 @@ use tokio::sync::RwLock; use crate::core::cache::SessionCache; use crate::core::session::SessionState; -use super::autonomy; use super::server::{LeanCtxServer, SessionMode}; use super::startup::detect_startup_context; @@ -164,7 +163,7 @@ impl LeanCtxServer { agent_id: Arc::new(RwLock::new(None)), presence_agent_id: Arc::new(RwLock::new(presence_agent_id)), client_name: Arc::new(RwLock::new(String::new())), - autonomy: Arc::new(autonomy::AutonomyState::new()), + autonomy: Arc::new(crate::core::autonomy::AutonomyState::new()), loop_detector: Arc::new(RwLock::new( crate::core::loop_detection::LoopDetector::with_config( &crate::core::config::Config::load().loop_detection,