1use 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
22const 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}