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::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    /// Create a new successful execution record.
117    #[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    /// Shared initializer: both constructors differ only in the `result`
153    /// outcome, so this pins the single field list.
154    #[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/// Thread-safe execution history for recording tool executions.
246#[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    /// Create a new execution history with a maximum record count.
259    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    /// Add a record to the history.
278    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    /// Set loop detection parameters.
295    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    /// Set the rate limit for tool executions per minute.
303    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    /// Get the most recent records.
309    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    /// Get recent failures in chronological order.
318    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    /// Run `f` against a shared read of the records; poisoned locks recover
328    /// via `into_inner` without losing the history.
329    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    /// Clear all records.
335    pub fn clear(&self) {
336        if let Ok(mut records) = self.records.write() {
337            records.clear();
338        }
339    }
340
341    /// Total number of execution records currently stored.
342    pub fn len(&self) -> usize {
343        self.records.read().unwrap_or_else(|e| e.into_inner()).len()
344    }
345
346    /// Whether no execution records are currently stored.
347    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;