Skip to main content

vtcode_core/tools/registry/
execution_history.rs

1//! Tool execution history and records.
2//!
3//! This module provides thread-safe recording and querying of tool executions,
4//! including loop detection and rate limiting.
5
6use 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/// Result of loop detection analysis.
25#[derive(Debug, Clone)]
26pub struct LoopDetectionResult {
27    /// Whether a loop was detected.
28    pub detected: bool,
29    /// Number of identical consecutive calls found.
30    pub repeat_count: usize,
31    /// Name of the tool being checked.
32    pub tool_name: String,
33}
34
35/// Snapshot of harness context for execution records.
36#[derive(Debug, Clone)]
37pub struct HarnessContextSnapshot {
38    pub session_id: String,
39    pub task_id: Option<String>,
40}
41
42impl HarnessContextSnapshot {
43    /// Create a new harness context snapshot.
44    pub fn new(session_id: String, task_id: Option<String>) -> Self {
45        Self { session_id, task_id }
46    }
47
48    /// Serialize snapshot for middleware/telemetry consumers without cloning callers.
49    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/// Record of a single tool execution for diagnostics.
58#[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    /// Create a new failed execution record.
81    #[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    /// Create a new successful execution record.
122    #[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/// Thread-safe execution history for recording tool executions.
214#[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    /// Create a new execution history with a maximum record count.
226    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    /// Add a record to the history.
244    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    /// Set loop detection parameters.
255    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    /// Set the rate limit for tool executions per minute.
263    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    /// Get the most recent records.
269    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    /// Get recent failures in chronological order.
279    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    /// Clear all records.
290    pub fn clear(&self) {
291        if let Ok(mut records) = self.records.write() {
292            records.clear();
293        }
294    }
295
296    /// Total number of execution records currently stored.
297    pub fn len(&self) -> usize {
298        self.records.read().unwrap_or_else(|e| e.into_inner()).len()
299    }
300
301    /// Whether no execution records are currently stored.
302    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;