Skip to main content

winx_code_agent/tools/
code_map.rs

1//! Implementation of the `CodeMap` tool: tree-sitter code navigation.
2//!
3//! One tool with two operations, consolidating what used to be the separate
4//! `Outline` and `FindReferences` tools (to keep the MCP surface small). It is a
5//! thin dispatcher: it builds the corresponding internal request and delegates to
6//! the unchanged `outline` / `references` implementations, so all the tree-sitter
7//! and ranking logic is reused as-is.
8
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use serde_json::{json, Value};
13use tokio::sync::Mutex;
14use tracing::instrument;
15
16use crate::errors::{Result, WinxError};
17use crate::state::bash_state::BashState;
18use crate::types::{CodeMap, CodeMapOperation, FindReferences, Outline};
19use crate::utils::agent_temp::{AgentTempInfo, DerivedCodeMapPermit};
20use crate::utils::path::{expand_user, resolve_in_workspace};
21
22/// Reserve enough room for the non-canonical notice and budget counters added
23/// after the syntax-navigation implementation builds its bounded payload.
24const DERIVED_SCOPE_METADATA_RESERVE: usize = 512;
25
26#[derive(Clone, Copy, Debug)]
27enum SourceScope {
28    Canonical,
29    Derived(DerivedCodeMapPermit),
30}
31
32impl SourceScope {
33    const fn payload_max_bytes(self) -> usize {
34        match self {
35            Self::Canonical => crate::tools::outline::CODE_MAP_PAYLOAD_MAX_BYTES,
36            Self::Derived(_) => {
37                crate::utils::agent_temp::MAX_DERIVED_CODE_MAP_PAYLOAD_BYTES
38                    - DERIVED_SCOPE_METADATA_RESERVE
39            }
40        }
41    }
42}
43
44#[instrument(level = "info", skip(bash_state_arc, code_map))]
45pub async fn handle_tool_call(
46    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
47    code_map: CodeMap,
48) -> Result<(String, serde_json::Value)> {
49    if code_map.operation == CodeMapOperation::References && code_map.name.trim().is_empty() {
50        return Err(WinxError::ArgumentParseError(
51            "CodeMap operation 'references' requires a non-empty 'name' (the symbol to find)."
52                .to_string(),
53        ));
54    }
55    let scope = reserve_source_scope(bash_state_arc, &code_map.path).await?;
56    let payload_max_bytes = scope.payload_max_bytes();
57    let result = match code_map.operation {
58        CodeMapOperation::Outline => {
59            let outline = Outline {
60                path: code_map.path,
61                max_results: code_map.max_results,
62                query: code_map.query,
63                cursor: code_map.cursor,
64                thread_id: code_map.thread_id,
65            };
66            crate::tools::outline::handle_tool_call_with_budget(
67                bash_state_arc,
68                outline,
69                payload_max_bytes,
70            )
71            .await
72        }
73        CodeMapOperation::References => {
74            let find = FindReferences {
75                name: code_map.name,
76                path: code_map.path,
77                max_results: code_map.max_results,
78                thread_id: code_map.thread_id,
79            };
80            crate::tools::references::handle_tool_call_with_budget(
81                bash_state_arc,
82                find,
83                payload_max_bytes,
84            )
85            .await
86        }
87    }?;
88    decorate_source_scope(result, scope)
89}
90
91async fn reserve_source_scope(
92    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
93    raw_path: &str,
94) -> Result<SourceScope> {
95    let mut guard = bash_state_arc.lock().await;
96    let state = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
97    let expanded = expand_user(raw_path);
98    let requested = if Path::new(&expanded).is_absolute() {
99        PathBuf::from(&expanded)
100    } else {
101        state.cwd.join(&expanded)
102    };
103    let resolved =
104        resolve_in_workspace(raw_path, &state.cwd, &state.workspace_root).map_err(|error| {
105            WinxError::PathSecurityError {
106                path: PathBuf::from(raw_path),
107                message: error.to_string(),
108            }
109        })?;
110    if !crate::utils::agent_temp::validate_code_map_target(
111        &state.workspace_root,
112        &state.current_thread_id,
113        &requested,
114        &resolved,
115    )? {
116        return Ok(SourceScope::Canonical);
117    }
118
119    let info =
120        crate::utils::agent_temp::session_info(&state.workspace_root, &state.current_thread_id);
121    ensure_existing_helper_file(&resolved, &info)?;
122    let permit = crate::utils::agent_temp::reserve_derived_code_map(
123        &mut state.derived_code_map_usage,
124        &resolved,
125        &info,
126    )?;
127    Ok(SourceScope::Derived(permit))
128}
129
130fn ensure_existing_helper_file(path: &Path, info: &AgentTempInfo) -> Result<()> {
131    if path.is_file() {
132        return Ok(());
133    }
134    let message = if path.is_dir() {
135        "CodeMap over a temporary directory is not allowed; map one existing, reusable helper \
136         file explicitly"
137    } else {
138        "temporary helpers must exist before CodeMap is called; reuse an existing stable helper \
139         instead of racing creation/deletion or guessing a new carrier name"
140    };
141    Err(WinxError::TemporaryArtifactPolicy {
142        path: path.to_path_buf(),
143        temporary_artifact_dir: info.directory.clone(),
144        message: message.to_string(),
145    })
146}
147
148fn decorate_source_scope(
149    (mut text, mut structured): (String, Value),
150    scope: SourceScope,
151) -> Result<(String, Value)> {
152    let fields = structured.as_object_mut().ok_or_else(|| {
153        WinxError::SerializationError("CodeMap structured result must be an object".to_string())
154    })?;
155    match scope {
156        SourceScope::Canonical => {
157            fields.insert("source_kind".to_string(), Value::String("canonical_source".to_string()));
158            fields.insert("canonical".to_string(), Value::Bool(true));
159        }
160        SourceScope::Derived(permit) => {
161            fields.insert("source_kind".to_string(), Value::String("derived_helper".to_string()));
162            fields.insert("canonical".to_string(), Value::Bool(false));
163            fields.insert(
164                "temporary_helper_budget".to_string(),
165                json!({
166                    "calls_used": permit.calls_used,
167                    "calls_limit": permit.calls_limit,
168                    "unique_files_used": permit.unique_files_used,
169                    "unique_files_limit": permit.unique_files_limit,
170                }),
171            );
172            text = format!(
173                "Non-canonical temporary helper; reuse this file and do not create another \
174                 carrier for the same task (helper maps: {}/{}, unique files: {}/{}).\n{text}",
175                permit.calls_used,
176                permit.calls_limit,
177                permit.unique_files_used,
178                permit.unique_files_limit,
179            );
180        }
181    }
182    refresh_payload_bytes(&text, &mut structured)?;
183    Ok((text, structured))
184}
185
186fn refresh_payload_bytes(text: &str, structured: &mut Value) -> Result<()> {
187    if structured.get("payload_bytes").is_none() {
188        return Ok(());
189    }
190    for _ in 0..3 {
191        let serialized = serde_json::to_vec(structured)
192            .map_err(|error| WinxError::SerializationError(error.to_string()))?;
193        let measured = text.len().saturating_add(serialized.len());
194        if structured["payload_bytes"].as_u64() == u64::try_from(measured).ok() {
195            break;
196        }
197        structured["payload_bytes"] = json!(measured);
198    }
199    Ok(())
200}
201
202#[cfg(test)]
203mod tests {
204    #![allow(clippy::unwrap_used)]
205
206    use std::fmt::Write as _;
207
208    use tempfile::TempDir;
209
210    use super::*;
211
212    fn state_in(dir: &TempDir, thread_id: &str) -> Arc<Mutex<Option<BashState>>> {
213        let mut state = BashState::new();
214        let root = dir.path().canonicalize().map(crate::utils::path::simplified).unwrap();
215        state.cwd = root.clone();
216        state.workspace_root = root;
217        state.current_thread_id = thread_id.to_string();
218        state.initialized = true;
219        Arc::new(Mutex::new(Some(state)))
220    }
221
222    fn outline(path: impl Into<String>) -> CodeMap {
223        CodeMap {
224            operation: CodeMapOperation::Outline,
225            path: path.into(),
226            name: String::new(),
227            max_results: 0,
228            query: String::new(),
229            cursor: String::new(),
230            thread_id: "active".to_string(),
231        }
232    }
233
234    #[tokio::test]
235    async fn canonical_and_derived_results_are_explicitly_distinguished() {
236        let workspace = TempDir::new().unwrap();
237        std::fs::write(workspace.path().join("main.py"), "def canonical():\n    pass\n").unwrap();
238        let helper = crate::utils::agent_temp::session_info(workspace.path(), "active")
239            .directory
240            .join("review.py");
241        std::fs::create_dir_all(helper.parent().unwrap()).unwrap();
242        std::fs::write(&helper, "def derived():\n    pass\n").unwrap();
243        let state = state_in(&workspace, "active");
244
245        let (_, canonical) = handle_tool_call(&state, outline("main.py")).await.unwrap();
246        assert_eq!(canonical["source_kind"], "canonical_source");
247        assert_eq!(canonical["canonical"], true);
248        assert!(canonical.get("temporary_helper_budget").is_none());
249
250        let relative = helper
251            .strip_prefix(
252                workspace.path().canonicalize().map(crate::utils::path::simplified).unwrap(),
253            )
254            .unwrap();
255        let (text, derived) =
256            handle_tool_call(&state, outline(relative.to_string_lossy())).await.unwrap();
257        assert!(text.starts_with("Non-canonical temporary helper"), "{text}");
258        assert_eq!(derived["source_kind"], "derived_helper");
259        assert_eq!(derived["canonical"], false);
260        assert_eq!(derived["temporary_helper_budget"]["calls_used"], 1);
261        assert_eq!(derived["temporary_helper_budget"]["unique_files_used"], 1);
262    }
263
264    #[tokio::test]
265    async fn derived_helper_response_has_the_smaller_end_to_end_budget() {
266        let workspace = TempDir::new().unwrap();
267        let helper = crate::utils::agent_temp::session_info(workspace.path(), "active")
268            .directory
269            .join("dense.py");
270        std::fs::create_dir_all(helper.parent().unwrap()).unwrap();
271        let mut source = String::new();
272        for index in 0..600 {
273            let _ = writeln!(source, "def derived_{index}():\n    pass");
274        }
275        std::fs::write(&helper, source).unwrap();
276        let state = state_in(&workspace, "active");
277        let relative = helper
278            .strip_prefix(
279                workspace.path().canonicalize().map(crate::utils::path::simplified).unwrap(),
280            )
281            .unwrap();
282
283        let (text, structured) =
284            handle_tool_call(&state, outline(relative.to_string_lossy())).await.unwrap();
285        let total = text.len() + serde_json::to_vec(&structured).unwrap().len();
286        assert!(
287            total <= crate::utils::agent_temp::MAX_DERIVED_CODE_MAP_PAYLOAD_BYTES,
288            "derived payload used {total} bytes: {structured}"
289        );
290        assert_eq!(structured["truncated"], true);
291    }
292
293    #[tokio::test]
294    async fn helper_directory_and_unique_carrier_churn_are_rejected() {
295        let workspace = TempDir::new().unwrap();
296        std::fs::write(workspace.path().join("main.py"), "def canonical():\n    pass\n").unwrap();
297        let info = crate::utils::agent_temp::session_info(workspace.path(), "active");
298        std::fs::create_dir_all(&info.directory).unwrap();
299        let state = state_in(&workspace, "active");
300        let directory = info
301            .directory
302            .strip_prefix(
303                workspace.path().canonicalize().map(crate::utils::path::simplified).unwrap(),
304            )
305            .unwrap();
306        let directory_error =
307            handle_tool_call(&state, outline(directory.to_string_lossy())).await.unwrap_err();
308        assert!(directory_error.to_string().contains("one existing"), "{directory_error}");
309
310        for index in 0..crate::utils::agent_temp::MAX_DERIVED_CODE_MAP_UNIQUE_FILES {
311            let helper = info.directory.join(format!("carrier-{index}.py"));
312            std::fs::write(&helper, format!("def helper_{index}():\n    pass\n")).unwrap();
313            let relative = helper
314                .strip_prefix(
315                    workspace.path().canonicalize().map(crate::utils::path::simplified).unwrap(),
316                )
317                .unwrap();
318            handle_tool_call(&state, outline(relative.to_string_lossy())).await.unwrap();
319        }
320        let excess = info.directory.join("carrier-excess.py");
321        std::fs::write(&excess, "def excess():\n    pass\n").unwrap();
322        let relative = excess
323            .strip_prefix(
324                workspace.path().canonicalize().map(crate::utils::path::simplified).unwrap(),
325            )
326            .unwrap();
327        let error =
328            handle_tool_call(&state, outline(relative.to_string_lossy())).await.unwrap_err();
329        assert!(matches!(error, WinxError::DerivedCodeMapBudget { .. }));
330
331        let (_, canonical) = handle_tool_call(&state, outline("main.py")).await.unwrap();
332        assert_eq!(canonical["source_kind"], "canonical_source");
333    }
334}