Skip to main content

vtcode_core/tools/source_inspection/
exposure.rs

1use 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    // Fingerprint the actual returned source, not decorative output metadata.
14    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    /// Revoke evidence whose exact source output or authored patch is no longer
73    /// present. This must receive the normalized, locally cleared request view.
74    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    /// Revoke only evidence affected by a retry projection. Unsent earlier
111    /// server continuation context remains available and is not reconstructed.
112    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    /// Reconcile against the final request projection; never modifies history.
141    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        // Comparison validates completeness and positioning, but is never itself
223        // exposure. Capture the revision now, before another request can edit it.
224        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}