Skip to main content

lean_ctx/core/
behavior_nudge.rs

1//! In-band behavior nudges + turn-economy telemetry.
2//!
3//! `tools health` measured two waste patterns this module targets (#848
4//! follow-up):
5//!
6//! 1. **Heavy reads** — explicit `mode=full`/`raw` dominated recorded
7//!    `ctx_read` modes (62% at introduction time) while the structural modes
8//!    (`signatures`/`map`) cover orientation at a fraction of the tokens.
9//! 2. **Tool-call chains** — `ctx_search` → `ctx_read` → `ctx_search`/
10//!    `ctx_callgraph` sequences that a single `ctx_compose` call bundles;
11//!    every extra turn re-reads the whole conversation server-side.
12//!
13//! The mechanism is one trailing hint line appended to a tool result at the
14//! moment of waste — in-conversation context outweighs static rules. Hints
15//! are budgeted hard: at most one hint per pattern per server process
16//! (~30 tokens each), gated by the `behavior_nudges` config key
17//! (`"auto"` default | `"off"`). Every detection is also counted in a small
18//! local store so `tools health` reports the pattern honestly even with
19//! hints off. Deterministic thresholds, local-only state, no network.
20
21use std::sync::Mutex;
22use std::time::{Duration, Instant};
23
24use serde::{Deserialize, Serialize};
25
26/// An explicit full/raw read below this many output tokens is cheap enough to
27/// ignore — nudging small reads would be noise.
28const HEAVY_READ_TOKEN_FLOOR: usize = 1500;
29/// Chain steps further apart than this are unrelated work, not a chain.
30const CHAIN_WINDOW: Duration = Duration::from_mins(2);
31
32/// Cumulative detection counters, persisted across sessions for
33/// `tools health`.
34#[derive(Debug, Default, Clone, Serialize, Deserialize)]
35pub(crate) struct BehaviorStore {
36    /// Explicit full/raw reads at or above [`HEAVY_READ_TOKEN_FLOOR`].
37    pub heavy_reads_detected: u64,
38    /// search→read→search/callgraph sequences inside [`CHAIN_WINDOW`] steps.
39    pub chains_detected: u64,
40    /// Hints actually appended to a tool result.
41    pub hints_shown: u64,
42    pub updated_at: Option<String>,
43}
44
45impl BehaviorStore {
46    pub(crate) fn load() -> Self {
47        let Some(path) = store_path() else {
48            return Self::default();
49        };
50        std::fs::read_to_string(path)
51            .ok()
52            .and_then(|raw| serde_json::from_str(&raw).ok())
53            .unwrap_or_default()
54    }
55
56    fn save(&self) {
57        let Some(path) = store_path() else {
58            return;
59        };
60        if let Some(parent) = path.parent()
61            && std::fs::create_dir_all(parent).is_err()
62        {
63            return;
64        }
65        if let Ok(json) = serde_json::to_string(self) {
66            let tmp = path.with_extension("tmp");
67            if std::fs::write(&tmp, json).is_ok() {
68                let _ = std::fs::rename(&tmp, &path);
69            }
70        }
71    }
72}
73
74fn store_path() -> Option<std::path::PathBuf> {
75    crate::core::paths::state_dir()
76        .ok()
77        .map(|d| d.join("behavior_nudge.json"))
78}
79
80/// Per-process (session) state: the short recent-call window for chain
81/// detection plus the once-per-session hint gates.
82#[derive(Debug, Default)]
83struct SessionState {
84    recent: Vec<(&'static str, Instant)>,
85    heavy_hint_shown: bool,
86    chain_hint_shown: bool,
87}
88
89static SESSION: Mutex<Option<SessionState>> = Mutex::new(None);
90
91/// Which detection (if any) a call triggered.
92#[derive(Debug, PartialEq, Eq, Clone, Copy)]
93enum Detection {
94    HeavyRead,
95    Chain,
96}
97
98/// Classifies a tool call for the chain window. Only orientation tools
99/// participate; a `ctx_compose` call is the desired end state and resets the
100/// window instead of extending it.
101fn chain_step(name: &str) -> Option<&'static str> {
102    match name {
103        "ctx_search" => Some("search"),
104        "ctx_read" => Some("read"),
105        "ctx_callgraph" => Some("graph"),
106        _ => None,
107    }
108}
109
110/// Pure detection core, separated from global state and the clock for tests.
111fn detect(
112    state: &mut SessionState,
113    name: &str,
114    explicit_mode: Option<&str>,
115    output_tokens: usize,
116    now: Instant,
117) -> Option<Detection> {
118    // Heavy read: explicit full/raw at meaningful size.
119    if name == "ctx_read"
120        && matches!(explicit_mode, Some("full" | "raw"))
121        && output_tokens >= HEAVY_READ_TOKEN_FLOOR
122    {
123        return Some(Detection::HeavyRead);
124    }
125
126    if name == "ctx_compose" {
127        state.recent.clear();
128        return None;
129    }
130    let step = chain_step(name)?;
131    state
132        .recent
133        .retain(|(_, at)| now.saturating_duration_since(*at) <= CHAIN_WINDOW);
134    state.recent.push((step, now));
135    if state.recent.len() > 3 {
136        let excess = state.recent.len() - 3;
137        state.recent.drain(0..excess);
138    }
139    let steps: Vec<&str> = state.recent.iter().map(|(s, _)| *s).collect();
140    if steps == ["search", "read", "search"] || steps == ["search", "read", "graph"] {
141        state.recent.clear();
142        return Some(Detection::Chain);
143    }
144    None
145}
146
147/// The one-line hints. Kept short on purpose — a nudge that costs hundreds of
148/// tokens would defeat itself.
149fn hint_for(detection: Detection) -> &'static str {
150    match detection {
151        Detection::HeavyRead => {
152            "[lean-ctx nudge] large explicit full/raw read — mode=signatures/map covers orientation at a fraction of the tokens; reserve full/raw for edit prep. (once per session; behavior_nudges=off to silence)"
153        }
154        Detection::Chain => {
155            "[lean-ctx nudge] search→read→search chain — one ctx_compose call bundles search+read+symbols and saves the extra turns. (once per session; behavior_nudges=off to silence)"
156        }
157    }
158}
159
160/// Observes one dispatched tool call. Counts detections in the persistent
161/// store and returns a hint line to append at most once per pattern per
162/// session — `None` when there is nothing to say. `can_show` suppresses the
163/// hint (machine-readable or firewalled results) without burning the
164/// once-per-session budget; detections are counted either way. Called
165/// post-dispatch, so it must stay cheap: no I/O unless a (rare) detection
166/// fired.
167pub fn observe(
168    name: &str,
169    args: Option<&serde_json::Map<String, serde_json::Value>>,
170    output_tokens: usize,
171    can_show: bool,
172) -> Option<String> {
173    let nudges_on = can_show && crate::core::config::Config::load().behavior_nudges != "off";
174    let explicit_mode = args
175        .and_then(|a| a.get("mode"))
176        .and_then(|m| m.as_str())
177        .map(str::to_string);
178    let mut guard = SESSION
179        .lock()
180        .unwrap_or_else(std::sync::PoisonError::into_inner);
181    let state = guard.get_or_insert_with(SessionState::default);
182    let detection = detect(
183        state,
184        name,
185        explicit_mode.as_deref(),
186        output_tokens,
187        Instant::now(),
188    )?;
189
190    let mut store = BehaviorStore::load();
191    match detection {
192        Detection::HeavyRead => store.heavy_reads_detected += 1,
193        Detection::Chain => store.chains_detected += 1,
194    }
195    let show = nudges_on
196        && match detection {
197            Detection::HeavyRead => !std::mem::replace(&mut state.heavy_hint_shown, true),
198            Detection::Chain => !std::mem::replace(&mut state.chain_hint_shown, true),
199        };
200    if show {
201        store.hints_shown += 1;
202    }
203    store.updated_at = Some(chrono::Utc::now().to_rfc3339());
204    store.save();
205    show.then(|| hint_for(detection).to_string())
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    fn state() -> SessionState {
213        SessionState::default()
214    }
215
216    #[test]
217    fn heavy_read_needs_explicit_heavy_mode_and_size() {
218        let now = Instant::now();
219        let mut s = state();
220        assert_eq!(
221            detect(&mut s, "ctx_read", Some("full"), 2000, now),
222            Some(Detection::HeavyRead)
223        );
224        assert_eq!(detect(&mut s, "ctx_read", Some("full"), 200, now), None);
225        assert_eq!(
226            detect(&mut s, "ctx_read", Some("signatures"), 5000, now),
227            None
228        );
229        assert_eq!(
230            detect(&mut s, "ctx_read", None, 5000, now),
231            None,
232            "auto-resolved reads are the resolver's business, not the agent's"
233        );
234        assert_eq!(
235            detect(&mut s, "ctx_read", Some("raw"), 1500, now),
236            Some(Detection::HeavyRead)
237        );
238    }
239
240    #[test]
241    fn chain_detected_for_search_read_search_within_window() {
242        let now = Instant::now();
243        let mut s = state();
244        assert_eq!(detect(&mut s, "ctx_search", None, 10, now), None);
245        assert_eq!(detect(&mut s, "ctx_read", Some("map"), 10, now), None);
246        assert_eq!(
247            detect(&mut s, "ctx_search", None, 10, now),
248            Some(Detection::Chain)
249        );
250        // Window cleared after a detection — no immediate re-fire.
251        assert_eq!(detect(&mut s, "ctx_search", None, 10, now), None);
252    }
253
254    #[test]
255    fn chain_accepts_callgraph_as_final_step() {
256        let now = Instant::now();
257        let mut s = state();
258        detect(&mut s, "ctx_search", None, 10, now);
259        detect(&mut s, "ctx_read", Some("map"), 10, now);
260        assert_eq!(
261            detect(&mut s, "ctx_callgraph", None, 10, now),
262            Some(Detection::Chain)
263        );
264    }
265
266    #[test]
267    fn compose_resets_the_chain_window() {
268        let now = Instant::now();
269        let mut s = state();
270        detect(&mut s, "ctx_search", None, 10, now);
271        detect(&mut s, "ctx_read", Some("map"), 10, now);
272        detect(&mut s, "ctx_compose", None, 10, now);
273        assert_eq!(
274            detect(&mut s, "ctx_search", None, 10, now),
275            None,
276            "compose is the desired end state — it must not complete a chain"
277        );
278    }
279
280    #[test]
281    fn stale_steps_age_out_of_the_window() {
282        let start = Instant::now();
283        let later = start + CHAIN_WINDOW + Duration::from_secs(1);
284        let mut s = state();
285        detect(&mut s, "ctx_search", None, 10, start);
286        detect(&mut s, "ctx_read", Some("map"), 10, start);
287        assert_eq!(
288            detect(&mut s, "ctx_search", None, 10, later),
289            None,
290            "steps older than the window are unrelated work"
291        );
292    }
293
294    #[test]
295    fn unrelated_tools_do_not_extend_chains() {
296        let now = Instant::now();
297        let mut s = state();
298        detect(&mut s, "ctx_search", None, 10, now);
299        detect(&mut s, "ctx_read", Some("map"), 10, now);
300        assert_eq!(detect(&mut s, "ctx_shell", None, 10, now), None);
301        // ctx_shell is invisible to the window; the chain can still complete.
302        assert_eq!(
303            detect(&mut s, "ctx_search", None, 10, now),
304            Some(Detection::Chain)
305        );
306    }
307
308    #[test]
309    fn hints_stay_short() {
310        for d in [Detection::HeavyRead, Detection::Chain] {
311            assert!(
312                hint_for(d).len() < 220,
313                "a nudge must cost far less than what it saves"
314            );
315        }
316    }
317}