vtcode_core/tools/
source_inspection.rs1mod exposure;
6pub(crate) mod mutations;
7mod shell;
8pub use shell::TrustedCommandExemption;
9
10use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
11use std::ops::Range;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use anyhow::{Context, Result};
16use parking_lot::Mutex;
17use serde_json::{Value, json};
18use vtcode_commons::utils::calculate_sha256;
19use vtcode_config::core::agent::InspectionMode;
20
21const MAX_FILES: usize = 128;
22const MAX_CREDIT_LINES: usize = 16_384;
23const MAX_SUPPORTS: usize = 128;
24type Credit = Arc<BTreeSet<String>>;
25
26#[derive(Default)]
27struct FileEvidence {
28 revision: String,
29 line_count: usize,
30 lines: HashMap<usize, Credit>,
31 empty: Option<Credit>,
32}
33
34struct Support {
35 signature: String,
36 call_id: Option<String>,
37}
38
39#[derive(Default)]
40struct Ledger {
41 mode: InspectionMode,
42 files: HashMap<PathBuf, FileEvidence>,
43 order: VecDeque<PathBuf>,
44 active: HashSet<String>,
45 exemptions: HashSet<String>,
46 supports: HashMap<String, Support>,
47 previous_calls: HashSet<String>,
48}
49
50#[derive(Clone, Default)]
52pub struct SourceInspection(Arc<Mutex<Ledger>>);
53
54#[derive(Debug, thiserror::Error)]
56#[error("source inspection required: {details}")]
57pub(crate) struct InspectionRequired {
58 pub(crate) details: Value,
59}
60
61fn revision(content: &str) -> String {
62 calculate_sha256(content.as_bytes())
63}
64
65pub(super) fn call_key(name: &str, args: &Value) -> String {
66 use crate::config::constants::tools;
67 let mut name = crate::tools::names::canonical_tool_name(name);
68 if name == tools::UNIFIED_FILE {
69 name = match crate::tools::tool_intent::file_operation_action(args) {
70 Some("read") => tools::READ_FILE,
71 Some("write") => tools::WRITE_FILE,
72 Some("edit") => tools::EDIT_FILE,
73 Some("patch") => tools::APPLY_PATCH,
74 Some("create") => tools::CREATE_FILE,
75 Some("move") => tools::MOVE_FILE,
76 Some("copy") => tools::COPY_FILE,
77 _ => name,
78 };
79 }
80 let mut args = if name == tools::APPLY_PATCH {
81 crate::tools::apply_patch::decode_apply_patch_input(args)
82 .ok()
83 .flatten()
84 .map(|input| json!({"input": input.text}))
85 .unwrap_or_else(|| args.clone())
86 } else if crate::tools::tool_intent::is_command_run_tool_call(name, args) {
87 name = tools::EXEC_COMMAND;
88 crate::tools::command_args::normalize_shell_args(args).unwrap_or_else(|_| args.clone())
89 } else {
90 args.clone()
91 };
92 if name == tools::CREATE_FILE {
93 name = tools::WRITE_FILE;
94 if let Some(object) = args.as_object_mut() {
95 object.insert("mode".into(), json!("fail_if_exists"));
96 }
97 }
98 if let Some(object) = args.as_object_mut() {
99 object.remove("action");
100 object.remove("max_output_tokens");
101 object.remove(crate::tools::file_ops::PATCH_READ_CACHE_NONCE);
102 object.remove(crate::tools::edited_file_monitor::FILE_CONFLICT_OVERRIDE_ARG);
103 }
104 revision(&format!("{}:{args}", crate::tools::names::canonical_tool_name(name)))
105}
106
107impl SourceInspection {
108 pub fn set_mode(&self, mode: InspectionMode) {
109 self.0.lock().mode = mode;
110 }
111
112 pub fn mode(&self) -> InspectionMode {
113 self.0.lock().mode
114 }
115
116 pub(crate) fn require(&self, path: &Path, content: &str, ranges: &[Range<usize>]) -> Result<()> {
117 if self.mode() == InspectionMode::Off {
118 return Ok(());
119 }
120 let digest = revision(content);
121 let mut ledger = self.0.lock();
122 let stale = ledger.files.get(path).is_some_and(|file| file.revision != digest);
123 if stale {
124 ledger.files.remove(path);
125 ledger.order.retain(|entry| entry != path);
126 }
127 let file = ledger.files.get(path);
128 let valid = |credit: &Credit| credit.iter().all(|support| ledger.active.contains(support));
129 let missing = ranges
130 .iter()
131 .find_map(|range| (range.clone()).find(|line| !file.and_then(|f| f.lines.get(line)).is_some_and(valid)));
132 let empty_missing = content.is_empty() && !file.and_then(|f| f.empty.as_ref()).is_some_and(valid);
133 if missing.is_none() && !empty_missing {
134 return Ok(());
135 }
136 let start = missing.unwrap_or(0) + 1;
137 let reason = if stale {
138 "stale_revision"
139 } else {
140 "source_not_retained_or_not_exposed"
141 };
142 let details = json!({
143 "failure_kind": "source_inspection_required", "path": path,
144 "reason": reason, "missing_ranges": [{"start_line": start, "end_line": start}],
145 "next_read_args": {"path": path, "offset": start, "limit": 200, "max_tokens": 6000, "condense": false},
146 "message": "Read the missing source, then retry a localized patch. Verification does not replace inspection."
147 });
148 if ledger.mode == InspectionMode::Warn {
149 tracing::warn!(%details, "Source inspection warning");
150 return Ok(());
151 }
152 Err(InspectionRequired { details }.into())
153 }
154
155 pub(crate) fn require_full(&self, path: &Path, content: &str) -> Result<()> {
156 self.require(path, content, std::slice::from_ref(&(0..content.lines().count())))
157 }
158
159 pub(crate) async fn require_existing_file(&self, path: &Path) -> Result<()> {
162 if self.mode() == InspectionMode::Off {
163 return Ok(());
164 }
165 match tokio::fs::read_to_string(path).await {
166 Ok(content) => self.require_full(path, &content),
167 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
168 Err(error) if self.mode() == InspectionMode::Warn => {
169 tracing::warn!(path = %path.display(), %error, "Source inspection unavailable");
170 Ok(())
171 }
172 Err(error) => Err(error).with_context(|| format!("Failed to inspect existing source {}", path.display())),
173 }
174 }
175
176 fn register_support(&self, signature: String) -> String {
177 let mut ledger = self.0.lock();
178 if ledger.supports.len() >= MAX_FILES * MAX_SUPPORTS {
179 ledger.supports.clear();
180 ledger.files.clear();
181 ledger.order.clear();
182 ledger.active.clear();
183 }
184 let key = uuid::Uuid::new_v4().to_string();
185 ledger.supports.insert(key.clone(), Support { signature, call_id: None });
186 key
187 }
188
189 fn insert_lines(&self, path: PathBuf, content: &str, lines: Vec<usize>, support: String, empty: bool) {
190 let mut ledger = self.0.lock();
191 if ledger.files.len() >= MAX_FILES
192 && !ledger.files.contains_key(&path)
193 && let Some(oldest) = ledger.order.pop_front()
194 {
195 ledger.files.remove(&oldest);
196 }
197 if !ledger.files.contains_key(&path) {
198 ledger.order.retain(|entry| entry != &path);
199 ledger.order.push_back(path.clone());
200 }
201 let digest = revision(content);
202 let file = ledger.files.entry(path).or_default();
203 if file.revision != digest {
204 *file = FileEvidence {
205 revision: digest,
206 line_count: content.lines().count(),
207 ..FileEvidence::default()
208 };
209 }
210 let credit = Arc::new(BTreeSet::from([support]));
211 if empty {
212 file.empty = Some(Arc::clone(&credit));
213 }
214 for line in lines {
215 if file.lines.len() >= MAX_CREDIT_LINES {
216 break;
217 }
218 file.lines.insert(line, Arc::clone(&credit));
219 }
220 }
221}
222
223#[cfg(test)]
224mod tests;