Skip to main content

vtcode_core/tools/registry/
justification_extractor.rs

1use super::justification::ToolJustification;
2use super::risk_scorer::RiskLevel;
3/// Justification Extraction from Agent Context
4///
5/// Extracts agent reasoning from decision ledger to create tool justifications
6/// for high-risk tool approvals.
7use crate::core::decision_tracker::{Decision, DecisionTracker};
8
9/// Extractor for tool justifications from agent decision context
10pub struct JustificationExtractor;
11
12impl JustificationExtractor {
13    /// Extract a justification from the latest decision context
14    ///
15    /// Returns a ToolJustification with the agent's reasoning if available,
16    /// or None if no relevant reasoning is found.
17    pub fn extract_from_decision(
18        decision: &Decision,
19        tool_name: &str,
20        risk_level: &RiskLevel,
21    ) -> Option<ToolJustification> {
22        // Only create justification for medium/high/critical risk tools
23        match risk_level {
24            RiskLevel::Low => None,
25            _ => {
26                // Use the decision's reasoning as the justification
27                if decision.reasoning.is_empty() {
28                    return None;
29                }
30
31                let just = ToolJustification::new(tool_name, &decision.reasoning, risk_level);
32
33                // Add expected outcome if available from the decision action
34                Some(just)
35            }
36        }
37    }
38
39    /// Extract justification from the most recent decision in the tracker
40    pub fn extract_latest_from_tracker(
41        tracker: &DecisionTracker,
42        tool_name: &str,
43        risk_level: &RiskLevel,
44    ) -> Option<ToolJustification> {
45        tracker
46            .latest_decision()
47            .and_then(|decision| Self::extract_from_decision(decision, tool_name, risk_level))
48    }
49
50    /// Create a brief justification from multiple recent decisions
51    /// Useful for multi-step operations
52    pub fn extract_from_recent_decisions(
53        tracker: &DecisionTracker,
54        tool_name: &str,
55        risk_level: &RiskLevel,
56        depth: usize,
57    ) -> Option<ToolJustification> {
58        let decisions = tracker.recent_decisions(depth);
59        if decisions.is_empty() {
60            return None;
61        }
62
63        // Combine reasoning from recent decisions
64        let combined_reasoning = decisions
65            .iter()
66            .filter(|d| !d.reasoning.is_empty())
67            .map(|d| d.reasoning.as_str())
68            .collect::<Vec<_>>()
69            .join(" ");
70
71        if combined_reasoning.is_empty() {
72            return None;
73        }
74
75        Some(ToolJustification::new(
76            tool_name,
77            combined_reasoning,
78            risk_level,
79        ))
80    }
81
82    /// Suggest justification based on tool name and context
83    /// Used as fallback when decision context doesn't have explicit reasoning
84    pub fn suggest_default_justification(
85        tool_name: &str,
86        risk_level: &RiskLevel,
87    ) -> Option<ToolJustification> {
88        let (reason, outcome) = match tool_name {
89            "run_command" | "execute" => (
90                "Execute command to perform necessary system operation or build/test task.",
91                Some("Will capture command output for analysis and decision-making."),
92            ),
93            "write_file" | "create_file" | "edit_file" => (
94                "Modify code or configuration to implement necessary changes.",
95                Some("Will create/update file with the generated content."),
96            ),
97            crate::config::constants::tools::GREP_FILE
98            | "find_files"
99            | crate::config::constants::tools::LIST_FILES => (
100                "Search or list files to understand codebase structure.",
101                Some("Will return matching files and their contents for analysis."),
102            ),
103            "delete_file" | "remove_file" => (
104                "Remove unnecessary or generated files as part of cleanup.",
105                Some("Will delete the specified file(s)."),
106            ),
107            "apply_patch" => (
108                "Apply code changes to fix issues or implement features.",
109                Some("Will apply the patch and verify the changes."),
110            ),
111            _ => return None,
112        };
113
114        let just = ToolJustification::new(tool_name, reason, risk_level);
115        Some(outcome.map_or(just.clone(), |outcome_str| just.with_outcome(outcome_str)))
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::core::decision_tracker::{Action, DecisionContext, DecisionOutcome};
123    use hashbrown::HashMap;
124    use std::time::{SystemTime, UNIX_EPOCH};
125
126    fn create_test_decision(reasoning: &str) -> Decision {
127        let now = SystemTime::now()
128            .duration_since(UNIX_EPOCH)
129            .unwrap()
130            .as_secs();
131
132        Decision {
133            id: "test-1".to_string(),
134            timestamp: now,
135            context: DecisionContext {
136                conversation_turn: 1,
137                user_input: Some("analyze code".to_string()),
138                previous_actions: vec![],
139                available_tools: vec!["read_file".to_string()],
140                current_state: HashMap::new(),
141            },
142            reasoning: reasoning.to_string(),
143            action: Action::ToolCall {
144                name: "read_file".to_string(),
145                args: serde_json::json!({"path": "src/main.rs"}),
146                expected_outcome: "Get file contents".to_string(),
147            },
148            outcome: Some(DecisionOutcome::Success {
149                result: "File read successfully".to_string(),
150                metrics: HashMap::new(),
151            }),
152            confidence_score: Some(0.95),
153        }
154    }
155
156    #[test]
157    fn test_extract_from_decision_low_risk() {
158        let decision = create_test_decision("Read the source file");
159        let just =
160            JustificationExtractor::extract_from_decision(&decision, "read_file", &RiskLevel::Low);
161
162        assert!(just.is_none()); // Low risk shouldn't generate justification
163    }
164
165    #[test]
166    fn test_extract_from_decision_high_risk() {
167        let decision = create_test_decision("Need to understand code structure deeply");
168        let just = JustificationExtractor::extract_from_decision(
169            &decision,
170            "run_command",
171            &RiskLevel::High,
172        );
173
174        assert!(just.is_some());
175        let just = just.unwrap();
176        assert_eq!(just.tool_name, "run_command");
177        assert_eq!(just.reason, "Need to understand code structure deeply");
178        assert_eq!(just.risk_level, "High");
179    }
180
181    #[test]
182    fn test_extract_from_decision_empty_reasoning() {
183        let decision = create_test_decision("");
184        let just = JustificationExtractor::extract_from_decision(
185            &decision,
186            "run_command",
187            &RiskLevel::High,
188        );
189
190        assert!(just.is_none()); // Empty reasoning should return None
191    }
192
193    #[test]
194    fn test_suggest_default_justification() {
195        let just =
196            JustificationExtractor::suggest_default_justification("run_command", &RiskLevel::High);
197
198        assert!(just.is_some());
199        let just = just.unwrap();
200        assert!(just.reason.contains("Execute command"));
201        assert!(just.expected_outcome.is_some());
202    }
203
204    #[test]
205    fn test_suggest_default_for_write_file() {
206        let just =
207            JustificationExtractor::suggest_default_justification("write_file", &RiskLevel::Medium);
208
209        assert!(just.is_some());
210        let just = just.unwrap();
211        assert!(just.reason.contains("Modify code"));
212    }
213
214    #[test]
215    fn test_suggest_default_for_unknown_tool() {
216        let just =
217            JustificationExtractor::suggest_default_justification("unknown_tool", &RiskLevel::High);
218
219        assert!(just.is_none()); // Unknown tools should return None
220    }
221}