lean_ctx/tools/registered/
ctx_compose.rs1use crate::core::ocla::cache_types::{CacheKeyBuilder, ComposedContextKey};
2use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5
6use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_str};
7use crate::tool_defs::tool_def;
8
9pub struct CtxComposeTool;
10
11impl McpTool for CtxComposeTool {
12 fn name(&self) -> &'static str {
13 "ctx_compose"
14 }
15
16 fn tool_def(&self) -> Tool {
17 tool_def(
18 "ctx_compose",
19 "PRIMARY TOOL — call FIRST for understanding code (before editing/debugging/'how does X work').\n\
20 Returns ranked files with relevant symbol source inline grouped by file.\n\
21 Combines BM25 lexical+semantic+associative retrieval+submodular optimization.\n\
22 ANTIPATTERN: Do NOT chain search→read→symbol — one compose replaces the whole chain.\n\
23 ANTIPATTERN: Do NOT Read files whose source compose already returned — it IS the source.\n\
24 WORKFLOW: Fire parallel ctx_read or ctx_compose for different areas.",
25 json!({
26 "type": "object",
27 "properties": {
28 "task": { "type": "string", "description": "Short English task/question or symbol names" },
29 "path": { "type": "string", "description": "Project root" }
30 },
31 "required": ["task"]
32 }),
33 )
34 }
35
36 fn handle(
37 &self,
38 args: &Map<String, Value>,
39 ctx: &ToolContext,
40 ) -> Result<ToolOutput, ErrorData> {
41 let task = get_str(args, "task")
42 .ok_or_else(|| ErrorData::invalid_params("task is required", None))?;
43 let path = if let Some(p) = ctx.resolved_path("path") {
44 p.to_string()
45 } else if let Some(err) = ctx.path_error("path") {
46 return Err(ErrorData::invalid_params(format!("path: {err}"), None));
47 } else {
48 ctx.project_root.clone()
49 };
50
51 if let Some(ref cache) = ctx.bm25_cache {
53 crate::tools::ctx_semantic_search::set_thread_cache(cache.clone());
54 }
55
56 let cache_enabled = crate::core::config::Config::load()
57 .cache
58 .compose_cache_enabled;
59 let cached = cache_enabled
60 .then(|| crate::core::ocla::compose_cache::global().check(&task, &path))
61 .flatten();
62 let (text, sent) = if let Some(text) = cached {
63 let sent = crate::core::tokens::count_tokens(&text);
64 (text, sent)
65 } else {
66 let compose_builder = ComposedContextKey {
68 task: task.clone(),
69 path: path.clone(),
70 source_digests: Vec::new(),
71 };
72 let ck = compose_builder.cache_key();
73 let cv = compose_builder.validator();
74 if let Some(entry) = crate::core::ocla::cache_delivery::check(&ck, &cv, "ctx_compose") {
75 let stub = crate::core::ocla::cache_delivery::stub(&entry, "compose");
76 let sent = crate::core::tokens::count_tokens(&stub);
77 (stub, sent)
78 } else {
79 let (text, sent) = tokio::task::block_in_place(|| {
80 crate::tools::ctx_compose::handle(&task, &path, ctx.crp_mode)
81 });
82 if cache_enabled && !text.starts_with("ERROR") {
83 crate::core::ocla::compose_cache::global().record(&task, &path, text.clone());
84 crate::core::ocla::cache_delivery::record(
85 ck,
86 crate::core::ocla::cache_types::DeliveryKind::ComposedContext,
87 cv,
88 Some(path.clone()),
89 &text,
90 "ctx_compose",
91 );
92 }
93 (text, sent)
94 }
95 };
96
97 if text.starts_with("ERROR") {
98 return Err(ErrorData::invalid_params(text, None));
99 }
100
101 Ok(ToolOutput {
102 text,
103 original_tokens: sent,
104 saved_tokens: 0,
105 mode: Some("compose".to_string()),
106 path: Some(path),
107 changed: false,
108 shell_outcome: None,
109 content_blocks: None,
110 })
111 }
112}