vtcode_core/tools/registry/
execution_history.rs1use std::collections::VecDeque;
7use std::env;
8use std::path::PathBuf;
9use std::sync::{Arc, RwLock};
10use std::time::{Duration, SystemTime};
11
12use serde_json::{Value, json};
13
14use crate::config::constants::{defaults, tools};
15use crate::tools::tool_intent;
16
17use super::execution_kernel::PATH_ALIAS_KEYS;
18mod loop_detection;
19mod replay;
20mod telemetry;
21use loop_detection::DEFAULT_LOOP_DETECT_WINDOW;
22pub use telemetry::ToolTaskTelemetrySnapshot;
23
24#[derive(Debug, Clone)]
26pub struct LoopDetectionResult {
27 pub detected: bool,
29 pub repeat_count: usize,
31 pub tool_name: String,
33}
34
35#[derive(Debug, Clone)]
37pub struct HarnessContextSnapshot {
38 pub session_id: String,
39 pub task_id: Option<String>,
40}
41
42impl HarnessContextSnapshot {
43 pub fn new(session_id: String, task_id: Option<String>) -> Self {
45 Self { session_id, task_id }
46 }
47
48 pub fn to_json(&self) -> Value {
50 json!({
51 "session_id": self.session_id,
52 "task_id": self.task_id,
53 })
54 }
55}
56
57#[derive(Debug, Clone)]
59pub struct ToolExecutionRecord {
60 pub tool_name: String,
61 pub requested_name: String,
62 pub is_mcp: bool,
63 pub mcp_provider: Option<String>,
64 pub args: Value,
65 pub result: Result<Value, String>,
66 pub timestamp: SystemTime,
67 pub success: bool,
68 pub context: HarnessContextSnapshot,
69 pub timeout_category: Option<String>,
70 pub base_timeout_ms: Option<u64>,
71 pub adaptive_timeout_ms: Option<u64>,
72 pub effective_timeout_ms: Option<u64>,
73 pub circuit_breaker: bool,
74 pub attempt: u32,
75 pub retry_after_ms: Option<u64>,
76 pub circuit_breaker_state: Option<String>,
77}
78
79impl ToolExecutionRecord {
80 #[expect(
82 clippy::too_many_arguments,
83 reason = "Intentional compatibility, platform, test, or API-shape suppression."
84 )]
85 #[cold]
86 pub fn failure(
87 tool_name: String,
88 requested_name: String,
89 is_mcp: bool,
90 mcp_provider: Option<String>,
91 args: Value,
92 error_msg: String,
93 context: HarnessContextSnapshot,
94 timeout_category: Option<String>,
95 base_timeout_ms: Option<u64>,
96 adaptive_timeout_ms: Option<u64>,
97 effective_timeout_ms: Option<u64>,
98 circuit_breaker: bool,
99 ) -> Self {
100 Self::new_execution_record(
101 tool_name,
102 requested_name,
103 is_mcp,
104 mcp_provider,
105 args,
106 Err(error_msg),
107 context,
108 timeout_category,
109 base_timeout_ms,
110 adaptive_timeout_ms,
111 effective_timeout_ms,
112 circuit_breaker,
113 )
114 }
115
116 #[expect(
118 clippy::too_many_arguments,
119 reason = "Intentional compatibility, platform, test, or API-shape suppression."
120 )]
121 #[inline]
122 pub fn success(
123 tool_name: String,
124 requested_name: String,
125 is_mcp: bool,
126 mcp_provider: Option<String>,
127 args: Value,
128 result: Value,
129 context: HarnessContextSnapshot,
130 timeout_category: Option<String>,
131 base_timeout_ms: Option<u64>,
132 adaptive_timeout_ms: Option<u64>,
133 effective_timeout_ms: Option<u64>,
134 circuit_breaker: bool,
135 ) -> Self {
136 Self::new_execution_record(
137 tool_name,
138 requested_name,
139 is_mcp,
140 mcp_provider,
141 args,
142 Ok(result),
143 context,
144 timeout_category,
145 base_timeout_ms,
146 adaptive_timeout_ms,
147 effective_timeout_ms,
148 circuit_breaker,
149 )
150 }
151
152 #[expect(
155 clippy::too_many_arguments,
156 reason = "Intentional compatibility, platform, test, or API-shape suppression."
157 )]
158 fn new_execution_record(
159 tool_name: String,
160 requested_name: String,
161 is_mcp: bool,
162 mcp_provider: Option<String>,
163 args: Value,
164 result: Result<Value, String>,
165 context: HarnessContextSnapshot,
166 timeout_category: Option<String>,
167 base_timeout_ms: Option<u64>,
168 adaptive_timeout_ms: Option<u64>,
169 effective_timeout_ms: Option<u64>,
170 circuit_breaker: bool,
171 ) -> Self {
172 let success = result.is_ok();
173 Self {
174 tool_name,
175 requested_name,
176 is_mcp,
177 mcp_provider,
178 args,
179 result,
180 timestamp: SystemTime::now(),
181 success,
182 context,
183 timeout_category,
184 base_timeout_ms,
185 adaptive_timeout_ms,
186 effective_timeout_ms,
187 circuit_breaker,
188 attempt: 1,
189 retry_after_ms: None,
190 circuit_breaker_state: None,
191 }
192 }
193
194 #[inline]
195 pub fn with_attempt(mut self, attempt: u32) -> Self {
196 self.attempt = attempt.max(1);
197 self
198 }
199
200 #[inline]
201 pub fn with_retry_after(mut self, retry_after: Option<Duration>) -> Self {
202 self.retry_after_ms = retry_after.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64);
203 self
204 }
205
206 #[inline]
207 pub fn with_circuit_breaker_state(mut self, state: impl Into<String>) -> Self {
208 self.circuit_breaker_state = Some(state.into());
209 self
210 }
211}
212
213fn normalize_tool_name_for_match(name: &str) -> String {
214 let normalized = name.trim().to_ascii_lowercase().replace(' ', "_");
215 tool_intent::canonical_command_session_tool_name(&normalized)
216 .unwrap_or(&normalized)
217 .to_string()
218}
219
220fn is_read_file_tool_name(name: &str) -> bool {
221 let normalized = normalize_tool_name_for_match(name);
222 normalized == tools::READ_FILE || normalized.ends_with(".read_file")
223}
224
225fn is_file_operation_tool_name(name: &str) -> bool {
226 let normalized = normalize_tool_name_for_match(name);
227 normalized == tools::UNIFIED_FILE || normalized.ends_with(".file_operation")
228}
229
230fn tool_name_matches(name: &str, expected: &str) -> bool {
231 let normalized = normalize_tool_name_for_match(name);
232 normalized == expected || normalized.ends_with(&format!(".{expected}"))
233}
234
235fn is_read_style_tool_call(tool_name: &str, args: &Value) -> bool {
236 if tool_name_matches(tool_name, tools::READ_FILE) {
237 return true;
238 }
239 if is_file_operation_tool_name(tool_name) {
240 return tool_intent::file_operation_action_is(args, "read");
241 }
242 false
243}
244
245#[derive(Clone)]
247pub struct ToolExecutionHistory {
248 records: Arc<RwLock<VecDeque<ToolExecutionRecord>>>,
249 workspace_root: Arc<PathBuf>,
250 max_records: usize,
251 detect_window: Arc<std::sync::atomic::AtomicUsize>,
252 identical_limit: Arc<std::sync::atomic::AtomicUsize>,
253 rate_limit_per_minute: Arc<std::sync::atomic::AtomicUsize>,
254 tracker_loop_start: Arc<RwLock<Option<SystemTime>>>,
255}
256
257impl ToolExecutionHistory {
258 pub fn new(max_records: usize) -> Self {
260 Self::with_workspace_root(max_records, env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
261 }
262
263 pub(crate) fn with_workspace_root(max_records: usize, workspace_root: PathBuf) -> Self {
264 Self {
265 records: Arc::new(RwLock::new(VecDeque::with_capacity(max_records))),
266 workspace_root: Arc::new(workspace_root),
267 max_records,
268 tracker_loop_start: Arc::new(RwLock::new(None)),
269 detect_window: Arc::new(std::sync::atomic::AtomicUsize::new(DEFAULT_LOOP_DETECT_WINDOW)),
270 identical_limit: Arc::new(std::sync::atomic::AtomicUsize::new(defaults::DEFAULT_MAX_REPEATED_TOOL_CALLS)),
271 rate_limit_per_minute: Arc::new(std::sync::atomic::AtomicUsize::new(
272 crate::tools::rate_limit_config::tool_calls_per_minute_from_env().unwrap_or(0),
273 )),
274 }
275 }
276
277 pub fn add_record(&self, record: ToolExecutionRecord) {
279 let Ok(mut records) = self.records.write() else {
280 return;
281 };
282 records.push_back(record);
283 while records.len() > self.max_records {
284 records.pop_front();
285 }
286 }
287
288 pub(super) fn begin_tracker_request(&self) {
289 if let Ok(mut start) = self.tracker_loop_start.write() {
290 *start = Some(SystemTime::now());
291 }
292 }
293
294 pub fn set_loop_detection_limits(&self, detect_window: usize, identical_limit: usize) {
296 self.detect_window
297 .store(detect_window.max(1), std::sync::atomic::Ordering::Relaxed);
298 self.identical_limit
299 .store(identical_limit, std::sync::atomic::Ordering::Relaxed);
300 }
301
302 pub fn set_rate_limit_per_minute(&self, limit: Option<usize>) {
304 self.rate_limit_per_minute
305 .store(limit.filter(|v| *v > 0).unwrap_or(0), std::sync::atomic::Ordering::Relaxed);
306 }
307
308 pub fn get_recent_records(&self, count: usize) -> Vec<ToolExecutionRecord> {
310 self.with_records(|records| {
311 let records_len = records.len();
312 let start = records_len.saturating_sub(count);
313 records.iter().skip(start).cloned().collect()
314 })
315 }
316
317 pub fn get_recent_failures(&self, count: usize) -> Vec<ToolExecutionRecord> {
319 self.with_records(|records| {
320 let mut failures: Vec<ToolExecutionRecord> =
321 records.iter().rev().filter(|r| !r.success).take(count).cloned().collect();
322 failures.reverse();
323 failures
324 })
325 }
326
327 fn with_records<T>(&self, f: impl FnOnce(&VecDeque<ToolExecutionRecord>) -> T) -> T {
330 let guard = self.records.read().unwrap_or_else(|e| e.into_inner());
331 f(&guard)
332 }
333
334 pub fn clear(&self) {
336 if let Ok(mut records) = self.records.write() {
337 records.clear();
338 }
339 }
340
341 pub fn len(&self) -> usize {
343 self.records.read().unwrap_or_else(|e| e.into_inner()).len()
344 }
345
346 pub fn is_empty(&self) -> bool {
348 self.len() == 0
349 }
350}
351
352impl Default for ToolExecutionHistory {
353 fn default() -> Self {
354 Self::new(100)
355 }
356}
357
358#[cfg(test)]
359mod tests;