Skip to main content

lean_ctx/tools/
ctx_compose.rs

1//! `ctx_compose` — task composer (Phase 2 of the efficiency epic).
2//!
3//! The biggest agent win is a single "rich per call" tool that returns ranked
4//! files *with* inline bodies, replacing the typical search → read → outline →
5//! read chain (3-5 calls) with one.
6//!
7//! lean-ctx already has the building blocks as separate tools; this composes
8//! them into one response for a natural-language task:
9//!   1. extracted keywords,
10//!   2. semantically ranked files (BM25 / hybrid),
11//!   3. exact match locations (index-backed `ctx_search`),
12//!   4. the body of the most relevant symbol, inline.
13
14use std::collections::HashMap;
15use std::sync::mpsc;
16use std::time::Duration;
17
18use crate::core::graph_provider;
19use crate::core::tokens::count_tokens;
20use crate::tools::CrpMode;
21
22/// Wall-time budget for the semantic-ranking stage. The exact-match and symbol
23/// stages are index-backed and cheap; only semantic ranking can hit a cold
24/// `O(corpus)` BM25 build. We never let that block the agent loop: past the
25/// budget we return what we have and let the detached worker finish warming the
26/// resident cache for the next call. Override via `LEAN_CTX_COMPOSE_BUDGET_MS`.
27const DEFAULT_SEMANTIC_BUDGET_MS: u64 = 2500;
28
29fn semantic_budget() -> Duration {
30    let ms = std::env::var("LEAN_CTX_COMPOSE_BUDGET_MS")
31        .ok()
32        .and_then(|v| v.parse::<u64>().ok())
33        .filter(|&v| v > 0)
34        .unwrap_or(DEFAULT_SEMANTIC_BUDGET_MS);
35    Duration::from_millis(ms)
36}
37
38/// Token budget for the inlined symbol bodies. Submodular selection fills it
39/// with the most coverage-effective, non-redundant set of symbols.
40/// Override via `LEAN_CTX_COMPOSE_SYMBOL_TOKENS`.
41const DEFAULT_SYMBOL_BUDGET_TOKENS: usize = 600;
42
43fn symbol_budget_tokens() -> usize {
44    std::env::var("LEAN_CTX_COMPOSE_SYMBOL_TOKENS")
45        .ok()
46        .and_then(|v| v.parse::<usize>().ok())
47        .filter(|&v| v > 0)
48        .unwrap_or(DEFAULT_SYMBOL_BUDGET_TOKENS)
49}
50
51/// Wall-time budget for the associative (graph spreading-activation) stage.
52/// Opening/building the graph index is `O(corpus)` on a cold repo, so — like
53/// semantic ranking — we bound it and skip the (purely additive) section on
54/// overrun while the detached worker warms the index. `LEAN_CTX_COMPOSE_GRAPH_BUDGET_MS`.
55const DEFAULT_GRAPH_BUDGET_MS: u64 = 1500;
56
57fn graph_budget() -> Duration {
58    let ms = std::env::var("LEAN_CTX_COMPOSE_GRAPH_BUDGET_MS")
59        .ok()
60        .and_then(|v| v.parse::<u64>().ok())
61        .filter(|&v| v > 0)
62        .unwrap_or(DEFAULT_GRAPH_BUDGET_MS);
63    Duration::from_millis(ms)
64}
65
66/// Per-hop activation decay and hop count for spreading activation. Small decay
67/// keeps activation local (structurally near the seeds); 3 hops covers
68/// import→callee→sibling chains without diffusing across the whole graph.
69const SPREAD_DECAY: f64 = 0.6;
70const SPREAD_HOPS: usize = 3;
71/// How many associative neighbours to surface.
72const SPREAD_TOP_K: usize = 8;
73
74/// Build the associative-relevance block: spreading activation seeded at the
75/// files the task keywords resolve to, propagated over the union of the static
76/// import/call graph and the *learned* Hebbian co-access graph. Returns an empty
77/// string when no graph/seeds are available. Runs entirely in the worker thread
78/// so [`associative_block_budgeted`] can bound it.
79fn build_associative_block(project_root: &str, keywords: &[String]) -> String {
80    let Some(open) = graph_provider::open_or_build(project_root) else {
81        return String::new();
82    };
83    let gp = &open.provider;
84
85    // Seeds: distinct files the keywords resolve to via symbol lookup.
86    let mut seed_files: Vec<String> = Vec::new();
87    for kw in keywords {
88        for sym in gp.find_symbols(kw, None, None) {
89            if !seed_files.contains(&sym.file) {
90                seed_files.push(sym.file);
91            }
92        }
93    }
94    if seed_files.is_empty() {
95        return String::new();
96    }
97
98    // Hebbian update: files relevant to the same task "fire together", so record
99    // their co-access (strengthens future associative recall). Persisted.
100    crate::core::cooccurrence::record_access(project_root, &seed_files);
101
102    // Adjacency = static structural edges ∪ learned co-access edges. Edges are
103    // made bidirectional so activation spreads both up and down the graph.
104    let mut adjacency: HashMap<String, Vec<(String, f64)>> = HashMap::new();
105    let mut add_edge = |a: &str, b: &str, w: f64| {
106        adjacency
107            .entry(a.to_string())
108            .or_default()
109            .push((b.to_string(), w));
110        adjacency
111            .entry(b.to_string())
112            .or_default()
113            .push((a.to_string(), w));
114    };
115    for e in gp.edges() {
116        add_edge(&e.from, &e.to, if e.weight > 0.0 { e.weight } else { 1.0 });
117    }
118    let coaccess = crate::core::cooccurrence::load(project_root);
119    for sf in &seed_files {
120        for (nbr, w) in coaccess.related(sf, 16) {
121            add_edge(sf, &nbr, w);
122        }
123    }
124
125    let seeds: HashMap<String, f64> = seed_files.iter().map(|f| (f.clone(), 1.0)).collect();
126    let ranked = crate::core::spreading_activation::related_ranked(
127        &seeds,
128        &adjacency,
129        SPREAD_DECAY,
130        SPREAD_HOPS,
131        SPREAD_TOP_K,
132    );
133    if ranked.is_empty() {
134        return String::new();
135    }
136
137    let mut s = String::from("\n## Related (associative: import/call graph + learned co-access)\n");
138    for (file, activation) in ranked {
139        // Forward-slash normalize so Windows backslash paths are never escape-
140        // mangled by client render layers (issue #324).
141        let file = crate::core::protocol::display_path(&file);
142        s.push_str(&format!("- {file} (activation {activation:.2})\n"));
143    }
144    s
145}
146
147/// Run [`build_associative_block`] under [`graph_budget`]. The Hebbian record is
148/// a side effect of the worker, so it persists even when we time out and drop
149/// the (optional) section.
150fn associative_block_budgeted(project_root: &str, keywords: &[String]) -> String {
151    if keywords.is_empty() {
152        return String::new();
153    }
154    let (tx, rx) = mpsc::channel::<String>();
155    let root = project_root.to_string();
156    let kws = keywords.to_vec();
157    std::thread::spawn(move || {
158        let block = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
159            build_associative_block(&root, &kws)
160        }))
161        .unwrap_or_else(|_| {
162            tracing::warn!("[ctx_compose: associative block panicked; omitting section]");
163            String::new()
164        });
165        let _ = tx.send(block);
166    });
167    rx.recv_timeout(graph_budget()).unwrap_or_default()
168}
169
170/// Words that carry no retrieval signal — dropped from keyword extraction.
171const STOPWORDS: &[&str] = &[
172    "the",
173    "and",
174    "for",
175    "with",
176    "that",
177    "this",
178    "from",
179    "into",
180    "how",
181    "where",
182    "what",
183    "does",
184    "are",
185    "was",
186    "use",
187    "used",
188    "uses",
189    "add",
190    "all",
191    "any",
192    "can",
193    "get",
194    "set",
195    "via",
196    "out",
197    "its",
198    "his",
199    "her",
200    "you",
201    "your",
202    "our",
203    "find",
204    "show",
205    "list",
206    "make",
207    "when",
208    "then",
209    "has",
210    "have",
211    "had",
212    "not",
213    "but",
214    "see",
215    "function",
216    "method",
217    "class",
218    "code",
219    "file",
220    "files",
221    "implement",
222    "implementation",
223];
224
225/// Extract up to `max` distinct identifier-ish keywords from a task, preserving
226/// original case (symbol lookups are case-sensitive) and first-seen order.
227fn extract_keywords(task: &str, max: usize) -> Vec<String> {
228    let mut seen = std::collections::HashSet::new();
229    let mut out = Vec::new();
230    for raw in task.split(|c: char| !(c.is_alphanumeric() || c == '_')) {
231        if raw.len() < 3 {
232            continue;
233        }
234        if STOPWORDS.contains(&raw.to_ascii_lowercase().as_str()) {
235            continue;
236        }
237        if seen.insert(raw.to_string()) {
238            out.push(raw.to_string());
239            if out.len() >= max {
240                break;
241            }
242        }
243    }
244    out
245}
246
247/// Run the semantic ranking stage under a wall-time budget. Returns the ranked
248/// block on time, or a short "deferred" note if the (cold) build overruns —
249/// in which case the detached worker keeps running to warm the resident cache.
250fn ranked_files_budgeted(task: &str, project_root: &str, crp_mode: CrpMode) -> String {
251    let shared_cache = crate::tools::ctx_semantic_search::get_thread_cache();
252    let (tx, rx) = mpsc::channel::<String>();
253    let task_owned = task.to_string();
254    let root_owned = project_root.to_string();
255
256    std::thread::spawn(move || {
257        if let Some(cache) = shared_cache {
258            crate::tools::ctx_semantic_search::set_thread_cache(cache);
259        }
260        let ranked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
261            crate::tools::ctx_semantic_search::handle(
262                &task_owned,
263                &root_owned,
264                8,
265                crp_mode,
266                None,
267                None,
268                None,
269                Some(false),
270                Some(false),
271            )
272        }))
273        .unwrap_or_else(|_| {
274            tracing::warn!("[ctx_compose: semantic ranking panicked; omitting section]");
275            String::new()
276        });
277        // Receiver may be gone (we timed out); dropping the result is fine —
278        // the cache warming already happened as a side effect of the build.
279        let _ = tx.send(ranked);
280    });
281
282    match rx.recv_timeout(semantic_budget()) {
283        Ok(ranked) => ranked.trim().to_string(),
284        Err(_) => deferred_ranking_note(project_root),
285    }
286}
287
288/// Honest, state-aware note when semantic ranking overruns its wall-time budget.
289///
290/// The old message always promised ranking would be "instant on the next call".
291/// That is a lie when the index build *failed* or the index is too large to
292/// persist — in those cases every call rebuilds and the promise never comes
293/// true (issue #249: "keeps saying it's warming up … but it never happens").
294/// We now read the real orchestrator state and tell the agent exactly what is
295/// happening and what to do about it.
296fn deferred_ranking_note(project_root: &str) -> String {
297    let exact = "the exact matches below are authoritative for this call";
298    let s = crate::core::index_orchestrator::bm25_summary(project_root);
299    match s.state {
300        "failed" => {
301            let why = s
302                .last_error
303                .or(s.note)
304                .unwrap_or_else(|| "unknown error".to_string());
305            format!(
306                "(semantic ranking unavailable — index build FAILED: {why}. {exact}. \
307                 Inspect with `ctx_index status` / `lean-ctx doctor`, then `lean-ctx reindex`)"
308            )
309        }
310        "building" => {
311            let secs = s.elapsed_ms.map_or(0, |ms| ms / 1000);
312            format!(
313                "(deferred — semantic index is building ({secs}s elapsed); {exact}, \
314                 and ranking becomes available once the build finishes)"
315            )
316        }
317        // ready/idle: this call's cold build just overran the budget. If the
318        // index could not be persisted (too large), surface that — otherwise it
319        // silently rebuilds on every cold start and never gets faster.
320        _ => match s.note {
321            Some(note) if note.contains("NOT persisted") => {
322                format!("(semantic ranking deferred — {note} {exact}.)")
323            }
324            _ => format!(
325                "(deferred — semantic index is warming; {exact}, \
326                 and ranking will be fast on the next call once the index is cached)"
327            ),
328        },
329    }
330}
331
332/// Compose a single rich response for `task`.
333pub fn handle(task: &str, project_root: &str, crp_mode: CrpMode) -> (String, usize) {
334    let task = task.trim();
335    if task.is_empty() {
336        return ("ERROR: task is required".to_string(), 0);
337    }
338
339    let keywords = extract_keywords(task, 6);
340    let allow_secret = crate::core::roles::active_role().io.allow_secret_paths;
341
342    let mut out = String::new();
343    out.push_str(&format!("TASK: {task}\n"));
344    if keywords.is_empty() {
345        out.push_str("KEYWORDS: (none extracted — using full task for ranking)\n");
346    } else {
347        out.push_str(&format!("KEYWORDS: {}\n", keywords.join(", ")));
348    }
349
350    // 1. Semantically ranked files for the whole task — budgeted so a cold
351    //    BM25 build can never stall the agent loop (hardening H1). The worker
352    //    inherits the resident cache, so a build that overruns the budget still
353    //    warms the cache for the next call rather than being wasted.
354    out.push_str("\n## Ranked files (semantic)\n");
355    out.push_str(&ranked_files_budgeted(task, project_root, crp_mode));
356    out.push('\n');
357
358    // 2. Exact match locations for the primary keyword (index-backed search).
359    if let Some(primary) = keywords.first() {
360        let (grep, _g) = crate::tools::ctx_search::handle(
361            primary,
362            project_root,
363            None,
364            10,
365            crp_mode,
366            true,
367            allow_secret,
368        );
369        out.push_str(&format!("\n## Exact matches: '{primary}'\n"));
370        out.push_str(grep.trim());
371        out.push('\n');
372    }
373
374    // 3. Inline the symbol bodies that best cover the task keywords. Rather
375    //    than just the first match, select the non-redundant *set* of symbols
376    //    with maximal keyword coverage under a token budget via submodular
377    //    greedy (1−1/e optimal). Two keywords resolving to the same symbol, or
378    //    a symbol whose body adds no new keyword, are naturally pruned.
379    use crate::core::context_packing::{greedy_max_coverage, CoverageItem};
380    let mut snippets: Vec<String> = Vec::new();
381    let mut items: Vec<CoverageItem> = Vec::new();
382    for kw in &keywords {
383        if let Some((rendered, toks)) =
384            crate::tools::ctx_symbol::best_symbol_snippet(kw, project_root)
385        {
386            // The snippet always covers its triggering keyword, plus any other
387            // task keyword its body textually surfaces (a more central symbol).
388            let mut terms: std::collections::HashSet<String> =
389                std::collections::HashSet::from([kw.clone()]);
390            for other in &keywords {
391                if other != kw && rendered.contains(other.as_str()) {
392                    terms.insert(other.clone());
393                }
394            }
395            items.push(CoverageItem {
396                terms,
397                cost: toks.max(1),
398            });
399            snippets.push(rendered);
400        }
401    }
402    if !items.is_empty() {
403        let chosen = greedy_max_coverage(&items, symbol_budget_tokens(), |_| 1.0);
404        let mut seen = std::collections::HashSet::new();
405        let mut header_written = false;
406        for idx in chosen {
407            let rendered = snippets[idx].trim();
408            if rendered.is_empty() || !seen.insert(rendered.to_string()) {
409                continue;
410            }
411            if !header_written {
412                out.push_str("\n## Top symbols (bodies)\n");
413                header_written = true;
414            }
415            out.push_str(rendered);
416            out.push('\n');
417        }
418    }
419
420    // 4. Associative neighbours via spreading activation over the import/call
421    //    graph unified with the learned Hebbian co-access graph (budgeted,
422    //    additive — surfaces structurally-close files lexical search misses).
423    out.push_str(&associative_block_budgeted(project_root, &keywords));
424
425    let sent = count_tokens(&out);
426    (out, sent)
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn extract_keywords_drops_stopwords_and_short_tokens() {
435        let kw = extract_keywords("How does the BM25Index cache work for ctx_search?", 6);
436        assert!(kw.contains(&"BM25Index".to_string()));
437        assert!(kw.contains(&"cache".to_string()));
438        assert!(kw.contains(&"ctx_search".to_string()));
439        assert!(!kw.iter().any(|k| k == "the" || k == "How" || k == "for"));
440    }
441
442    #[test]
443    fn extract_keywords_dedups_and_caps() {
444        let kw = extract_keywords("alpha alpha beta gamma delta epsilon zeta eta", 3);
445        assert_eq!(kw.len(), 3);
446        assert_eq!(kw[0], "alpha");
447    }
448
449    #[test]
450    fn empty_task_is_rejected() {
451        let (out, tok) = handle("   ", "/tmp", CrpMode::Off);
452        assert!(out.starts_with("ERROR"));
453        assert_eq!(tok, 0);
454    }
455
456    #[test]
457    fn deferred_note_for_idle_index_is_optimistic_but_honest() {
458        // Unknown project → orchestrator state is idle. The note must NOT promise
459        // "instant on the next call" (the dishonest wording from #249); it should
460        // explain the index is warming and will be fast once cached.
461        let tmp = tempfile::tempdir().unwrap();
462        let note = deferred_ranking_note(tmp.path().to_string_lossy().as_ref());
463        assert!(
464            note.contains("warming") || note.contains("building"),
465            "note: {note}"
466        );
467        assert!(
468            note.contains("authoritative"),
469            "note must reassure that exact matches are authoritative: {note}"
470        );
471        assert!(
472            !note.contains("instant on the next call"),
473            "must not repeat the dishonest 'instant next call' promise: {note}"
474        );
475    }
476}