Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions rust/src/core/autonomy.rs
Original file line number Diff line number Diff line change
@@ -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<HashMap<String, SearchHistory>>,
/// One-shot keys for large-output hints (`ctx_shell` bytes, `ctx_read` full tokens).
pub large_output_hints_shown: Mutex<HashSet<String>>,
}

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<AutonomyState>`. 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<String> {
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));
}
}
1 change: 1 addition & 0 deletions rust/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

// ---------------------------------------------------------------------------
Expand Down
6 changes: 2 additions & 4 deletions rust/src/core/tool_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion rust/src/server/post_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {
Expand Down
2 changes: 1 addition & 1 deletion rust/src/server/tool_trait.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ pub struct ToolContext {
/// Global call counter for context tools.
pub call_count: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
/// Autonomy state for search repeat detection.
pub autonomy: Option<std::sync::Arc<crate::tools::autonomy::AutonomyState>>,
pub autonomy: Option<std::sync::Arc<crate::core::autonomy::AutonomyState>>,
/// Pre-computed context pressure snapshot for synchronous gate decisions.
pub pressure_snapshot: Option<crate::core::context_ledger::ContextPressure>,
/// Errors from path resolution (PathJail rejection, secret path, etc.).
Expand Down
113 changes: 2 additions & 111 deletions rust/src/tools/autonomy.rs
Original file line number Diff line number Diff line change
@@ -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<HashMap<String, SearchHistory>>,
/// One-shot keys for large-output hints (`ctx_shell` bytes, `ctx_read` full tokens).
pub large_output_hints_shown: Mutex<HashSet<String>>,
}

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<AutonomyState>`. 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<String> {
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
}
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion rust/src/tools/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ pub struct LeanCtxServer {
pub agent_id: Arc<RwLock<Option<String>>>,
pub(crate) presence_agent_id: Arc<RwLock<Option<String>>>,
pub client_name: Arc<RwLock<String>>,
pub autonomy: Arc<super::autonomy::AutonomyState>,
pub autonomy: Arc<crate::core::autonomy::AutonomyState>,
pub loop_detector: Arc<RwLock<crate::core::loop_detection::LoopDetector>>,
pub workflow: Arc<RwLock<Option<crate::core::workflow::WorkflowRun>>>,
pub ledger: Arc<RwLock<crate::core::context_ledger::ContextLedger>>,
Expand Down
3 changes: 1 addition & 2 deletions rust/src/tools/server_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
Loading