vtcode_core/tools/source_inspection/
exposure.rs1use super::*;
2use crate::config::constants::tools;
3use crate::llm::provider::{Message, MessageRole};
4use crate::tools::registry::ToolRegistry;
5
6fn output_text(value: &Value) -> Option<&str> {
7 ["content", "output", "stdout"]
8 .iter()
9 .find_map(|key| value.get(key).and_then(Value::as_str))
10}
11
12fn read_support(name: &str, args: &Value, output: &Value) -> Option<String> {
13 let digest = if name == tools::CODE_SEARCH {
15 revision(&output.get("results")?.to_string())
16 } else {
17 revision(output_text(output)?)
18 };
19 Some(format!("{}:{digest}", call_key(name, args)))
20}
21
22struct RetainedSignatures {
23 by_call_id: HashMap<String, Vec<String>>,
24 call_ids: HashSet<String>,
25}
26
27struct SourceReadTarget {
28 path: PathBuf,
29 raw_start: Option<usize>,
30}
31
32fn retained_signatures(messages: &[Message]) -> RetainedSignatures {
33 let calls: HashMap<_, _> = messages
34 .iter()
35 .filter(|message| message.role == MessageRole::Assistant)
36 .flat_map(|message| message.tool_calls.iter().flatten())
37 .map(|call| (call.id.as_str(), call))
38 .collect();
39 let mut signatures = HashMap::new();
40 let mut current_calls = HashSet::new();
41 for message in messages.iter().filter(|message| message.role == MessageRole::Tool) {
42 let Some(call) = message.tool_call_id.as_deref().and_then(|id| calls.get(id)) else {
43 continue;
44 };
45 current_calls.insert(call.id.clone());
46 let Some(function) = call.function.as_ref() else {
47 continue;
48 };
49 let text = message.content.as_text();
50 let Ok(output) = serde_json::from_str::<Value>(&text) else {
51 continue;
52 };
53 if output.get("cleared").and_then(Value::as_str) == Some("tool_result")
54 || output.get("error").is_some()
55 || output.get("success").and_then(Value::as_bool) == Some(false)
56 || output.get("conflict_detected").and_then(Value::as_bool) == Some(true)
57 {
58 continue;
59 }
60 let args = serde_json::from_str::<Value>(&function.arguments)
61 .unwrap_or_else(|_| Value::String(function.arguments.clone()));
62 let mut call_signatures = vec![call_key(&function.name, &args)];
63 if let Some(support) = read_support(&function.name, &args, &output) {
64 call_signatures.push(support);
65 }
66 signatures.insert(call.id.clone(), call_signatures);
67 }
68 RetainedSignatures { by_call_id: signatures, call_ids: current_calls }
69}
70
71impl SourceInspection {
72 pub fn reconcile(&self, messages: &[Message]) {
75 let RetainedSignatures { by_call_id: signatures, call_ids: current_calls } = retained_signatures(messages);
76 let mut ledger = self.0.lock();
77 let previous_calls = ledger.previous_calls.clone();
78 let mut active = HashSet::new();
79 for (key, support) in &mut ledger.supports {
80 if support.call_id.is_none() {
81 support.call_id = signatures
82 .iter()
83 .find(|(id, values)| !previous_calls.contains(*id) && values.contains(&support.signature))
84 .map(|(id, _)| id.clone());
85 }
86 if support
87 .call_id
88 .as_ref()
89 .and_then(|id| signatures.get(id))
90 .is_some_and(|values| values.contains(&support.signature))
91 {
92 active.insert(key.clone());
93 }
94 }
95 ledger.previous_calls = current_calls;
96 ledger.active = active;
97 let active = ledger.active.clone();
98 for file in ledger.files.values_mut() {
99 file.lines
100 .retain(|_, credit| credit.iter().all(|support| active.contains(support)));
101 if file
102 .empty
103 .as_ref()
104 .is_some_and(|credit| !credit.iter().all(|support| active.contains(support)))
105 {
106 file.empty = None;
107 }
108 }
109 }
110 pub fn reconcile_projection_change(&self, before: &[Message], after: &[Message]) {
113 let affected = retained_signatures(before).call_ids;
114 let signatures = retained_signatures(after).by_call_id;
115 let mut ledger = self.0.lock();
116 let mut active = ledger.active.clone();
117 active.retain(|key| {
118 ledger.supports.get(key).is_some_and(|support| {
119 support.call_id.as_ref().is_some_and(|id| {
120 !affected.contains(id)
121 || signatures.get(id).is_some_and(|values| values.contains(&support.signature))
122 })
123 })
124 });
125 ledger.active = active.clone();
126 for file in ledger.files.values_mut() {
127 file.lines.retain(|_, credit| credit.iter().all(|key| active.contains(key)));
128 if file
129 .empty
130 .as_ref()
131 .is_some_and(|credit| !credit.iter().all(|key| active.contains(key)))
132 {
133 file.empty = None;
134 }
135 }
136 }
137}
138
139impl ToolRegistry {
140 pub fn reconcile_source_inspection(&self, messages: &[Message]) {
142 self.edited_file_monitor_ref().source_inspection().reconcile(messages);
143 }
144
145 pub fn reconcile_source_projection_change(&self, before: &[Message], after: &[Message]) {
146 self.edited_file_monitor_ref()
147 .source_inspection()
148 .reconcile_projection_change(before, after);
149 }
150
151 pub fn set_inspection_mode(&self, mode: InspectionMode) {
152 self.edited_file_monitor_ref().source_inspection().set_mode(mode);
153 }
154
155 pub fn inspection_mode(&self) -> InspectionMode {
156 self.edited_file_monitor_ref().source_inspection().mode()
157 }
158
159 pub(crate) async fn observe_source_output(&self, name: &str, args: &Value, output: &Value) {
160 let inspection = self.edited_file_monitor_ref().source_inspection();
161 if inspection.mode() == InspectionMode::Off || output.get("error").is_some() {
162 return;
163 }
164 if name == tools::CODE_SEARCH {
165 let Some(support) = read_support(name, args, output) else {
166 return;
167 };
168 if let Some(results) = output.get("results").and_then(Value::as_array) {
169 for result in results {
170 let (Some(path), Some(line), Some(snippet)) = (
171 result.get("path").and_then(Value::as_str),
172 result.get("line").and_then(Value::as_u64),
173 result.get("snippet").and_then(Value::as_str),
174 ) else {
175 continue;
176 };
177 let Ok(path) = self.file_ops_tool().normalize_user_path(path).await else {
178 continue;
179 };
180 let Ok(content) = tokio::fs::read_to_string(&path).await else {
181 continue;
182 };
183 let source_lines: Vec<_> = content.lines().collect();
184 let Some(start) = usize::try_from(line).ok().and_then(|line| line.checked_sub(1)) else {
185 continue;
186 };
187 let exposed = snippet
188 .lines()
189 .enumerate()
190 .filter_map(|(offset, text)| {
191 let index = start + offset;
192 (source_lines.get(index).copied() == Some(text)).then_some(index)
193 })
194 .collect();
195 let support = inspection.register_support(support.clone());
196 inspection.insert_lines(path, &content, exposed, support, false);
197 }
198 }
199 return;
200 }
201 let Some(text) = output_text(output) else { return };
202 let Some(support) = read_support(name, args, output) else {
203 return;
204 };
205 let file_read = name == tools::READ_FILE
206 || (name == tools::UNIFIED_FILE && crate::tools::tool_intent::file_operation_action(args) == Some("read"));
207 let target = if file_read {
208 output.get("path").and_then(Value::as_str).map(|path| SourceReadTarget {
209 path: PathBuf::from(path),
210 raw_start: crate::tools::file_ops::source_line_offset(args),
211 })
212 } else {
213 self.verbatim_shell_read(name, args)
214 };
215 let Some(SourceReadTarget { path, raw_start }) = target else {
216 return;
217 };
218 let Some(path_text) = path.to_str() else { return };
219 let Ok(path) = self.file_ops_tool().normalize_user_path(path_text).await else {
220 return;
221 };
222 let Ok(content) = tokio::fs::read_to_string(&path).await else {
225 return;
226 };
227 let source_lines: Vec<_> = content.lines().collect();
228 let lines = if let Some(start) = raw_start {
229 text.lines()
230 .enumerate()
231 .map_while(|(index, line)| {
232 let index = start + index;
233 (source_lines.get(index).copied() == Some(line)).then_some(index)
234 })
235 .collect()
236 } else if text == content
237 || crate::tools::file_ops::restore_exact_text_content(text, content.len() as u64).as_deref()
238 == Some(&content)
239 {
240 (0..source_lines.len()).collect()
241 } else {
242 text.lines()
243 .filter_map(|line| {
244 let (number, body) = line.split_once(": ")?;
245 let index = number.parse::<usize>().ok()?.checked_sub(1)?;
246 (source_lines.get(index).copied() == Some(body)).then_some(index)
247 })
248 .collect()
249 };
250 let empty = content.is_empty() && text.is_empty() && file_read;
251 let support = inspection.register_support(support);
252 inspection.insert_lines(path, &content, lines, support, empty);
253 }
254
255 fn verbatim_shell_read(&self, name: &str, args: &Value) -> Option<SourceReadTarget> {
256 if !crate::tools::tool_intent::is_command_run_tool_call(name, args) {
257 return None;
258 }
259 let words = crate::tools::command_args::command_words(args).ok()??;
260 let (path, start) = match words.as_slice() {
261 [command, path] if command == "cat" && !path.starts_with('-') => (path, 0),
262 [command, option, script, path] if command == "sed" && option == "-n" => {
263 let range = script.strip_suffix('p')?;
264 let (start, end) = range.split_once(',').unwrap_or((range, range));
265 let start = start.parse::<usize>().ok()?.checked_sub(1)?;
266 let end = end.parse::<usize>().ok()?;
267 if end <= start {
268 return None;
269 }
270 (path, start)
271 }
272 _ => return None,
273 };
274 let cwd = self
275 .pty_manager()
276 .working_dir_candidate(crate::tools::command_args::working_dir_text(args))
277 .ok()?;
278 Some(SourceReadTarget { path: cwd.join(path), raw_start: Some(start) })
279 }
280}