Skip to main content

vtcode_core/core/
decision_tracker.rs

1use crate::utils::current_timestamp;
2use hashbrown::HashMap;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6/// Represents a single decision made by the agent
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Decision {
9    pub id: String,
10    pub timestamp: u64,
11    pub context: DecisionContext,
12    pub reasoning: String,
13    pub action: Action,
14    pub outcome: Option<DecisionOutcome>,
15    pub confidence_score: Option<f64>,
16}
17
18/// Context information that led to a decision
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct DecisionContext {
21    pub conversation_turn: usize,
22    pub user_input: Option<String>,
23    pub previous_actions: Vec<String>,
24    pub available_tools: Vec<String>,
25    pub current_state: HashMap<String, Value>,
26}
27
28/// Action taken as a result of the decision
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub enum Action {
31    ToolCall {
32        name: String,
33        args: Value,
34        expected_outcome: String,
35    },
36    Response {
37        content: String,
38        response_type: ResponseType,
39    },
40
41    ErrorRecovery {
42        error_type: String,
43        recovery_strategy: String,
44    },
45}
46
47/// Type of response given to user
48#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
49pub enum ResponseType {
50    Text,
51    ToolExecution,
52    ErrorHandling,
53    ContextSummary,
54}
55
56/// Outcome of a decision
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub enum DecisionOutcome {
59    Success {
60        result: String,
61        metrics: HashMap<String, Value>,
62    },
63    Failure {
64        error: String,
65        recovery_attempts: usize,
66        context_preserved: bool,
67    },
68    Partial {
69        result: String,
70        issues: Vec<String>,
71    },
72}
73
74/// Decision tracker for maintaining transparency
75pub struct DecisionTracker {
76    decisions: Vec<Decision>,
77    current_context: DecisionContext,
78    session_start: u64,
79}
80
81impl Default for DecisionTracker {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl DecisionTracker {
88    pub fn new() -> Self {
89        let now = current_timestamp();
90
91        Self {
92            decisions: Vec::new(),
93            current_context: DecisionContext {
94                conversation_turn: 0,
95                user_input: None,
96                previous_actions: Vec::new(),
97                available_tools: Vec::new(),
98                current_state: HashMap::new(),
99            },
100            session_start: now,
101        }
102    }
103
104    /// Start tracking a new conversation turn
105    #[inline]
106    pub fn start_turn(&mut self, turn_number: usize, user_input: Option<String>) {
107        self.current_context.conversation_turn = turn_number;
108        self.current_context.user_input = user_input;
109    }
110
111    /// Update the current context with available tools
112    #[inline]
113    pub fn update_available_tools(&mut self, tools: Vec<String>) {
114        self.current_context.available_tools = tools;
115    }
116
117    /// Update the current state
118    #[inline]
119    pub fn update_state(&mut self, key: &str, value: Value) {
120        self.current_context.current_state.insert(key.into(), value);
121    }
122
123    /// Record a decision
124    /// Note: Takes ownership of action to avoid cloning when possible
125    pub fn record_decision(
126        &mut self,
127        reasoning: String,
128        action: Action,
129        confidence_score: Option<f64>,
130    ) -> String {
131        let decision_id = format!("decision_{}_{}", self.session_start, self.decisions.len());
132
133        // Generate action summary before moving action into decision
134        let action_summary: String = match &action {
135            Action::ToolCall { name, .. } => format!("tool_call:{name}"),
136            Action::Response { response_type, .. } => format!("response:{response_type:?}"),
137            Action::ErrorRecovery { .. } => "error_recovery".into(),
138        };
139
140        let decision = Decision {
141            id: decision_id.clone(),
142            timestamp: current_timestamp(),
143            context: self.current_context.clone(),
144            reasoning,
145            action, // Move instead of clone
146            outcome: None,
147            confidence_score,
148        };
149
150        self.decisions.push(decision);
151
152        // Update previous actions for next decision
153        self.current_context.previous_actions.push(action_summary);
154
155        decision_id
156    }
157
158    /// Record the outcome of a decision
159    #[inline]
160    pub fn record_outcome(&mut self, decision_id: &str, outcome: DecisionOutcome) {
161        if let Some(decision) = self.decisions.iter_mut().find(|d| d.id == decision_id) {
162            decision.outcome = Some(outcome);
163        }
164    }
165
166    /// Get all decisions for transparency reporting
167    #[inline]
168    pub fn get_decisions(&self) -> &[Decision] {
169        &self.decisions
170    }
171
172    /// Generate a transparency report
173    pub fn generate_transparency_report(&self) -> TransparencyReport {
174        let total_decisions = self.decisions.len();
175        let successful_decisions = self
176            .decisions
177            .iter()
178            .filter(|d| matches!(d.outcome, Some(DecisionOutcome::Success { .. })))
179            .count();
180        let failed_decisions = self
181            .decisions
182            .iter()
183            .filter(|d| matches!(d.outcome, Some(DecisionOutcome::Failure { .. })))
184            .count();
185
186        let tool_calls = self
187            .decisions
188            .iter()
189            .filter(|d| matches!(d.action, Action::ToolCall { .. }))
190            .count();
191
192        let (sum, count) = self
193            .decisions
194            .iter()
195            .filter_map(|d| d.confidence_score)
196            .fold((0.0, 0usize), |(s, c), v| (s + v, c + 1));
197
198        let avg_confidence = if count == 0 {
199            None
200        } else {
201            Some(sum / count as f64)
202        };
203
204        TransparencyReport {
205            session_duration: current_timestamp().saturating_sub(self.session_start),
206            total_decisions,
207            successful_decisions,
208            failed_decisions,
209            tool_calls,
210            avg_confidence,
211            recent_decisions: self.decisions.iter().rev().take(5).cloned().collect(),
212        }
213    }
214
215    /// Get decision context for error recovery
216    pub fn get_decision_context(&self, decision_id: &str) -> Option<&DecisionContext> {
217        self.decisions
218            .iter()
219            .find(|d| d.id == decision_id)
220            .map(|d| &d.context)
221    }
222
223    pub fn get_current_context(&self) -> &DecisionContext {
224        &self.current_context
225    }
226
227    /// Get the latest decision made
228    pub fn latest_decision(&self) -> Option<&Decision> {
229        self.decisions.last()
230    }
231
232    /// Get the N most recent decisions
233    pub fn recent_decisions(&self, count: usize) -> Vec<&Decision> {
234        self.decisions.iter().rev().take(count).collect()
235    }
236
237    /// Convenience: record a user goal/intention for this turn
238    pub fn record_goal(&mut self, content: String) -> String {
239        self.record_decision(
240            "User goal provided".to_owned(),
241            Action::Response {
242                content,
243                response_type: ResponseType::ContextSummary,
244            },
245            None,
246        )
247    }
248
249    /// Render a compact Decision Ledger for injection into the system prompt
250    pub fn render_ledger_brief(&self, max_entries: usize) -> String {
251        let mut out = String::new();
252        out.push_str("Decision Ledger (most recent first)\n");
253        let take_n = max_entries.max(1);
254        for d in self.decisions.iter().rev().take(take_n) {
255            let ts = d.timestamp;
256            let turn = d.context.conversation_turn;
257            let line = match &d.action {
258                Action::ToolCall { name, args, .. } => {
259                    let arg_preview = match args {
260                        Value::String(s) => s.clone(),
261                        _ => {
262                            let s = args.to_string();
263                            vtcode_commons::formatting::truncate_byte_budget(&s, 120, "…")
264                        }
265                    };
266                    format!(
267                        "- [turn {}] tool:{} args={} (t={})",
268                        turn, name, arg_preview, ts
269                    )
270                }
271                Action::Response {
272                    response_type,
273                    content,
274                } => {
275                    let preview =
276                        vtcode_commons::formatting::truncate_byte_budget(content, 120, "…");
277                    format!(
278                        "- [turn {}] response:{:?} {} (t={})",
279                        turn, response_type, preview, ts
280                    )
281                }
282                Action::ErrorRecovery {
283                    error_type,
284                    recovery_strategy,
285                } => {
286                    format!(
287                        "- [turn {}] recovery {} via {} (t={})",
288                        turn, error_type, recovery_strategy, ts
289                    )
290                }
291            };
292            out.push_str(&line);
293            out.push('\n');
294        }
295        if out.is_empty() {
296            "(no decisions yet)".to_string()
297        } else {
298            out
299        }
300    }
301
302    /// Prune decisions older than the specified duration (in seconds)
303    /// Returns the number of decisions removed
304    pub fn prune_old_decisions(&mut self, max_age_secs: u64) -> usize {
305        let now = current_timestamp();
306        let cutoff = now.saturating_sub(max_age_secs);
307        let before_len = self.decisions.len();
308
309        self.decisions.retain(|d| d.timestamp >= cutoff);
310
311        before_len.saturating_sub(self.decisions.len())
312    }
313
314    /// Prune decisions to keep only the most recent N entries
315    /// Returns the number of decisions removed
316    pub fn prune_to_count(&mut self, max_count: usize) -> usize {
317        if self.decisions.len() <= max_count {
318            return 0;
319        }
320
321        let excess = self.decisions.len() - max_count;
322        self.decisions.drain(0..excess);
323        excess
324    }
325
326    /// Auto-prune based on sensible defaults: keep last 500 decisions or last 30 minutes
327    /// Call this periodically (e.g., at turn start) to prevent unbounded growth
328    pub fn auto_prune(&mut self) -> usize {
329        const MAX_DECISIONS: usize = 500;
330        const MAX_AGE_SECS: u64 = 30 * 60; // 30 minutes
331
332        let by_age = self.prune_old_decisions(MAX_AGE_SECS);
333        let by_count = self.prune_to_count(MAX_DECISIONS);
334
335        by_age + by_count
336    }
337
338    /// Get the current decision count
339    #[inline]
340    pub fn decision_count(&self) -> usize {
341        self.decisions.len()
342    }
343}
344
345/// Transparency report for the current session
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct TransparencyReport {
348    pub session_duration: u64,
349    pub total_decisions: usize,
350    pub successful_decisions: usize,
351    pub failed_decisions: usize,
352    pub tool_calls: usize,
353    pub avg_confidence: Option<f64>,
354    pub recent_decisions: Vec<Decision>,
355}