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 {
101 tool_name,
102 requested_name,
103 is_mcp,
104 mcp_provider,
105 args,
106 result: Err(error_msg),
107 timestamp: SystemTime::now(),
108 success: false,
109 context,
110 timeout_category,
111 base_timeout_ms,
112 adaptive_timeout_ms,
113 effective_timeout_ms,
114 circuit_breaker,
115 attempt: 1,
116 retry_after_ms: None,
117 circuit_breaker_state: None,
118 }
119 }
120
121 #[expect(
123 clippy::too_many_arguments,
124 reason = "Intentional compatibility, platform, test, or API-shape suppression."
125 )]
126 #[inline]
127 pub fn success(
128 tool_name: String,
129 requested_name: String,
130 is_mcp: bool,
131 mcp_provider: Option<String>,
132 args: Value,
133 result: Value,
134 context: HarnessContextSnapshot,
135 timeout_category: Option<String>,
136 base_timeout_ms: Option<u64>,
137 adaptive_timeout_ms: Option<u64>,
138 effective_timeout_ms: Option<u64>,
139 circuit_breaker: bool,
140 ) -> Self {
141 Self {
142 tool_name,
143 requested_name,
144 is_mcp,
145 mcp_provider,
146 args,
147 result: Ok(result),
148 timestamp: SystemTime::now(),
149 success: true,
150 context,
151 timeout_category,
152 base_timeout_ms,
153 adaptive_timeout_ms,
154 effective_timeout_ms,
155 circuit_breaker,
156 attempt: 1,
157 retry_after_ms: None,
158 circuit_breaker_state: None,
159 }
160 }
161
162 #[inline]
163 pub fn with_attempt(mut self, attempt: u32) -> Self {
164 self.attempt = attempt.max(1);
165 self
166 }
167
168 #[inline]
169 pub fn with_retry_after(mut self, retry_after: Option<Duration>) -> Self {
170 self.retry_after_ms = retry_after.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64);
171 self
172 }
173
174 #[inline]
175 pub fn with_circuit_breaker_state(mut self, state: impl Into<String>) -> Self {
176 self.circuit_breaker_state = Some(state.into());
177 self
178 }
179}
180
181fn normalize_tool_name_for_match(name: &str) -> String {
182 let normalized = name.trim().to_ascii_lowercase().replace(' ', "_");
183 tool_intent::canonical_command_session_tool_name(&normalized)
184 .unwrap_or(&normalized)
185 .to_string()
186}
187
188fn is_read_file_tool_name(name: &str) -> bool {
189 let normalized = normalize_tool_name_for_match(name);
190 normalized == tools::READ_FILE || normalized.ends_with(".read_file")
191}
192
193fn is_file_operation_tool_name(name: &str) -> bool {
194 let normalized = normalize_tool_name_for_match(name);
195 normalized == tools::UNIFIED_FILE || normalized.ends_with(".file_operation")
196}
197
198fn tool_name_matches(name: &str, expected: &str) -> bool {
199 let normalized = normalize_tool_name_for_match(name);
200 normalized == expected || normalized.ends_with(&format!(".{expected}"))
201}
202
203fn is_read_style_tool_call(tool_name: &str, args: &Value) -> bool {
204 if tool_name_matches(tool_name, tools::READ_FILE) {
205 return true;
206 }
207 if is_file_operation_tool_name(tool_name) {
208 return tool_intent::file_operation_action_is(args, "read");
209 }
210 false
211}
212
213#[derive(Clone)]
215pub struct ToolExecutionHistory {
216 records: Arc<RwLock<VecDeque<ToolExecutionRecord>>>,
217 workspace_root: Arc<PathBuf>,
218 max_records: usize,
219 detect_window: Arc<std::sync::atomic::AtomicUsize>,
220 identical_limit: Arc<std::sync::atomic::AtomicUsize>,
221 rate_limit_per_minute: Arc<std::sync::atomic::AtomicUsize>,
222}
223
224impl ToolExecutionHistory {
225 pub fn new(max_records: usize) -> Self {
227 Self::with_workspace_root(max_records, env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
228 }
229
230 pub(crate) fn with_workspace_root(max_records: usize, workspace_root: PathBuf) -> Self {
231 Self {
232 records: Arc::new(RwLock::new(VecDeque::with_capacity(max_records))),
233 workspace_root: Arc::new(workspace_root),
234 max_records,
235 detect_window: Arc::new(std::sync::atomic::AtomicUsize::new(DEFAULT_LOOP_DETECT_WINDOW)),
236 identical_limit: Arc::new(std::sync::atomic::AtomicUsize::new(defaults::DEFAULT_MAX_REPEATED_TOOL_CALLS)),
237 rate_limit_per_minute: Arc::new(std::sync::atomic::AtomicUsize::new(
238 crate::tools::rate_limit_config::tool_calls_per_minute_from_env().unwrap_or(0),
239 )),
240 }
241 }
242
243 pub fn add_record(&self, record: ToolExecutionRecord) {
245 let Ok(mut records) = self.records.write() else {
246 return;
247 };
248 records.push_back(record);
249 while records.len() > self.max_records {
250 records.pop_front();
251 }
252 }
253
254 pub fn set_loop_detection_limits(&self, detect_window: usize, identical_limit: usize) {
256 self.detect_window
257 .store(detect_window.max(1), std::sync::atomic::Ordering::Relaxed);
258 self.identical_limit
259 .store(identical_limit, std::sync::atomic::Ordering::Relaxed);
260 }
261
262 pub fn set_rate_limit_per_minute(&self, limit: Option<usize>) {
264 self.rate_limit_per_minute
265 .store(limit.filter(|v| *v > 0).unwrap_or(0), std::sync::atomic::Ordering::Relaxed);
266 }
267
268 pub fn get_recent_records(&self, count: usize) -> Vec<ToolExecutionRecord> {
270 let Ok(records) = self.records.read() else {
271 return Vec::new();
272 };
273 let records_len = records.len();
274 let start = records_len.saturating_sub(count);
275 records.iter().skip(start).cloned().collect()
276 }
277
278 pub fn get_recent_failures(&self, count: usize) -> Vec<ToolExecutionRecord> {
280 let Ok(records) = self.records.read() else {
281 return Vec::new();
282 };
283 let mut failures: Vec<ToolExecutionRecord> =
284 records.iter().rev().filter(|r| !r.success).take(count).cloned().collect();
285 failures.reverse();
286 failures
287 }
288
289 pub fn clear(&self) {
291 if let Ok(mut records) = self.records.write() {
292 records.clear();
293 }
294 }
295
296 pub fn len(&self) -> usize {
298 self.records.read().unwrap_or_else(|e| e.into_inner()).len()
299 }
300
301 pub fn is_empty(&self) -> bool {
303 self.len() == 0
304 }
305}
306
307impl Default for ToolExecutionHistory {
308 fn default() -> Self {
309 Self::new(100)
310 }
311}
312
313#[cfg(test)]
314mod tests;