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 (4s, tuned for cold-start coverage #902) 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 = 4000;
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
247fn exact_match_keyword(keywords: &[String]) -> Option<&str> {
248    keywords
249        .iter()
250        .filter(|keyword| keyword.contains('_') || keyword.chars().skip(1).any(char::is_uppercase))
251        .max_by_key(|keyword| keyword.len())
252        .map(String::as_str)
253}
254
255/// Run the semantic ranking stage under a wall-time budget. Returns the ranked
256/// block on time, or a short "deferred" note if the (cold) build overruns —
257/// in which case the detached worker keeps running to warm the resident cache.
258fn ranked_files_budgeted(task: &str, project_root: &str, crp_mode: CrpMode) -> String {
259    let shared_cache = crate::tools::ctx_semantic_search::get_thread_cache();
260    let (tx, rx) = mpsc::channel::<String>();
261    let task_owned = task.to_string();
262    let root_owned = project_root.to_string();
263
264    std::thread::spawn(move || {
265        if let Some(cache) = shared_cache {
266            crate::tools::ctx_semantic_search::set_thread_cache(cache);
267        }
268        let ranked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
269            crate::tools::ctx_semantic_search::handle(
270                &task_owned,
271                &root_owned,
272                8,
273                crp_mode,
274                None,
275                None,
276                None,
277                Some(false),
278                Some(false),
279            )
280        }))
281        .unwrap_or_else(|_| {
282            tracing::warn!("[ctx_compose: semantic ranking panicked; omitting section]");
283            String::new()
284        });
285        // Receiver may be gone (we timed out); dropping the result is fine —
286        // the cache warming already happened as a side effect of the build.
287        let _ = tx.send(ranked);
288    });
289
290    match rx.recv_timeout(semantic_budget()) {
291        Ok(ranked) => ranked.trim().to_string(),
292        Err(_) => deferred_ranking_note(project_root),
293    }
294}
295
296/// Honest, state-aware note when semantic ranking overruns its wall-time budget.
297///
298/// The old message always promised ranking would be "instant on the next call".
299/// That is a lie when the index build *failed* or the index is too large to
300/// persist — in those cases every call rebuilds and the promise never comes
301/// true (issue #249: "keeps saying it's warming up … but it never happens").
302/// We now read the real orchestrator state and tell the agent exactly what is
303/// happening and what to do about it.
304fn deferred_ranking_note(project_root: &str) -> String {
305    let exact = "the exact matches below are authoritative for this call";
306    let s = crate::core::index_orchestrator::bm25_summary(project_root);
307    match s.state {
308        "failed" => {
309            let why = s
310                .last_error
311                .or(s.note)
312                .unwrap_or_else(|| "unknown error".to_string());
313            format!(
314                "(semantic ranking unavailable — index build FAILED: {why}. {exact}. \
315                 Inspect with `ctx_index status` / `lean-ctx doctor`, then `lean-ctx reindex`)"
316            )
317        }
318        "building" => {
319            let secs = s.elapsed_ms.map_or(0, |ms| ms / 1000);
320            format!(
321                "(deferred — semantic index is building ({secs}s elapsed); {exact}, \
322                 and ranking becomes available once the build finishes)"
323            )
324        }
325        // ready/idle: this call's cold build just overran the budget. If the
326        // index could not be persisted (too large), surface that — otherwise it
327        // silently rebuilds on every cold start and never gets faster.
328        _ => match s.note {
329            Some(note) if note.contains("NOT persisted") => {
330                format!("(semantic ranking deferred — {note} {exact}.)")
331            }
332            _ => format!(
333                "(deferred — semantic index is warming; {exact}, \
334                 and ranking will be fast on the next call once the index is cached)"
335            ),
336        },
337    }
338}
339
340/// Compose a single rich response for `task`.
341pub fn handle(task: &str, project_root: &str, crp_mode: CrpMode) -> (String, usize) {
342    let task = task.trim();
343    if task.is_empty() {
344        return ("ERROR: task is required".to_string(), 0);
345    }
346
347    let keywords = extract_keywords(task, 6);
348    let allow_secret = crate::core::roles::active_role().io.allow_secret_paths;
349
350    let mut out = String::new();
351    out.push_str(&format!("TASK: {task}\n"));
352    if keywords.is_empty() {
353        out.push_str("KEYWORDS: (none extracted — using full task for ranking)\n");
354    } else {
355        out.push_str(&format!("KEYWORDS: {}\n", keywords.join(", ")));
356    }
357
358    // 1. Semantically ranked files for the whole task — budgeted so a cold
359    //    BM25 build can never stall the agent loop (hardening H1). The worker
360    //    inherits the resident cache, so a build that overruns the budget still
361    //    warms the cache for the next call rather than being wasted.
362    out.push_str("\n## Ranked files (semantic)\n");
363    out.push_str(&ranked_files_budgeted(task, project_root, crp_mode));
364    out.push('\n');
365
366    // 2. Exact matches only for identifier-shaped terms. Broad prose words and
367    // acronyms create repository-wide README/Dockerfile noise and can exhaust
368    // the search budget before reaching source.
369    if let Some(primary) = exact_match_keyword(&keywords) {
370        let grep = crate::tools::ctx_search::handle(
371            primary,
372            project_root,
373            None,
374            10,
375            crp_mode,
376            true,
377            allow_secret,
378            false,
379        )
380        .text;
381        out.push_str(&format!("\n## Exact matches: '{primary}'\n"));
382        out.push_str(grep.trim());
383        out.push('\n');
384    }
385
386    // 3. Inline the symbol bodies that best cover the task keywords. Rather
387    //    than just the first match, select the non-redundant *set* of symbols
388    //    with maximal keyword coverage under a token budget via submodular
389    //    greedy (1−1/e optimal). Two keywords resolving to the same symbol, or
390    //    a symbol whose body adds no new keyword, are naturally pruned.
391    use crate::core::context_packing::{CoverageItem, greedy_max_coverage};
392    let mut snippets: Vec<String> = Vec::new();
393    let mut items: Vec<CoverageItem> = Vec::new();
394    for kw in &keywords {
395        if let Some((rendered, toks)) =
396            crate::tools::ctx_symbol::best_symbol_snippet_for_task(kw, task, project_root)
397        {
398            // The snippet always covers its triggering keyword, plus any other
399            // task keyword its body textually surfaces (a more central symbol).
400            let mut terms: std::collections::HashSet<String> =
401                std::collections::HashSet::from([kw.clone()]);
402            for other in &keywords {
403                if other != kw && rendered.contains(other.as_str()) {
404                    terms.insert(other.clone());
405                }
406            }
407            items.push(CoverageItem {
408                terms,
409                cost: toks.max(1),
410            });
411            snippets.push(rendered);
412        }
413    }
414    if !items.is_empty() {
415        let chosen = greedy_max_coverage(&items, symbol_budget_tokens(), |_| 1.0);
416        let mut seen = std::collections::HashSet::new();
417        let mut header_written = false;
418        for idx in chosen {
419            let rendered = snippets[idx].trim();
420            if rendered.is_empty() || !seen.insert(rendered.to_string()) {
421                continue;
422            }
423            if !header_written {
424                out.push_str("\n## Top symbols (bodies)\n");
425                header_written = true;
426            }
427            out.push_str(rendered);
428            out.push('\n');
429        }
430    }
431
432    // 4. Associative neighbours via spreading activation over the import/call
433    //    graph unified with the learned Hebbian co-access graph (budgeted,
434    //    additive — surfaces structurally-close files lexical search misses).
435    out.push_str(&associative_block_budgeted(project_root, &keywords));
436
437    let sent = count_tokens(&out);
438    (out, sent)
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn extract_keywords_drops_stopwords_and_short_tokens() {
447        let kw = extract_keywords("How does the BM25Index cache work for ctx_search?", 6);
448        assert!(kw.contains(&"BM25Index".to_string()));
449        assert!(kw.contains(&"cache".to_string()));
450        assert!(kw.contains(&"ctx_search".to_string()));
451        assert!(!kw.iter().any(|k| k == "the" || k == "How" || k == "for"));
452    }
453
454    #[test]
455    fn extract_keywords_dedups_and_caps() {
456        let kw = extract_keywords("alpha alpha beta gamma delta epsilon zeta eta", 3);
457        assert_eq!(kw.len(), 3);
458        assert_eq!(kw[0], "alpha");
459    }
460
461    #[test]
462    fn exact_matches_choose_specific_identifier_not_first_broad_keyword() {
463        let keywords = extract_keywords(
464            "OCPP charger GetMaxCurrent Current.Offered measurand CurrentGetter",
465            6,
466        );
467        assert_eq!(exact_match_keyword(&keywords), Some("GetMaxCurrent"));
468
469        let prose = extract_keywords("Fix semantic ranking exact matches", 6);
470        assert_eq!(exact_match_keyword(&prose), None);
471    }
472
473    #[test]
474    fn empty_task_is_rejected() {
475        let (out, tok) = handle("   ", "/tmp", CrpMode::Off);
476        assert!(out.starts_with("ERROR"));
477        assert_eq!(tok, 0);
478    }
479
480    #[test]
481    fn deferred_note_for_idle_index_is_optimistic_but_honest() {
482        // Unknown project → orchestrator state is idle. The note must NOT promise
483        // "instant on the next call" (the dishonest wording from #249); it should
484        // explain the index is warming and will be fast once cached.
485        let tmp = tempfile::tempdir().unwrap();
486        let note = deferred_ranking_note(tmp.path().to_string_lossy().as_ref());
487        assert!(
488            note.contains("warming") || note.contains("building"),
489            "note: {note}"
490        );
491        assert!(
492            note.contains("authoritative"),
493            "note must reassure that exact matches are authoritative: {note}"
494        );
495        assert!(
496            !note.contains("instant on the next call"),
497            "must not repeat the dishonest 'instant next call' promise: {note}"
498        );
499    }
500}