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(tool_name, combined_reasoning, risk_level))
76    }
77
78    /// Suggest justification based on tool name and context
79    /// Used as fallback when decision context doesn't have explicit reasoning
80    pub fn suggest_default_justification(tool_name: &str, risk_level: &RiskLevel) -> Option<ToolJustification> {
81        let (reason, outcome) = match tool_name {
82            "run_command" | "execute" => (
83                "Execute command to perform necessary system operation or build/test task.",
84                Some("Will capture command output for analysis and decision-making."),
85            ),
86            "write_file" | "create_file" | "edit_file" => (
87                "Modify code or configuration to implement necessary changes.",
88                Some("Will create/update file with the generated content."),
89            ),
90            crate::config::constants::tools::GREP_FILE | "find_files" | crate::config::constants::tools::LIST_FILES => {
91                (
92                    "Search or list files to understand codebase structure.",
93                    Some("Will return matching files and their contents for analysis."),
94                )
95            }
96            "delete_file" | "remove_file" => (
97                "Remove unnecessary or generated files as part of cleanup.",
98                Some("Will delete the specified file(s)."),
99            ),
100            "apply_patch" => (
101                "Apply code changes to fix issues or implement features.",
102                Some("Will apply the patch and verify the changes."),
103            ),
104            _ => return None,
105        };
106
107        let just = ToolJustification::new(tool_name, reason, risk_level);
108        Some(outcome.map_or(just.clone(), |outcome_str| just.with_outcome(outcome_str)))
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::core::decision_tracker::{Action, DecisionContext, DecisionOutcome};
116    use hashbrown::HashMap;
117    use std::time::{SystemTime, UNIX_EPOCH};
118
119    fn create_test_decision(reasoning: &str) -> Decision {
120        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
121
122        Decision {
123            id: "test-1".to_string(),
124            timestamp: now,
125            context: DecisionContext {
126                conversation_turn: 1,
127                user_input: Some("analyze code".to_string()),
128                previous_actions: vec![],
129                available_tools: vec!["read_file".to_string()],
130                current_state: HashMap::new(),
131            },
132            reasoning: reasoning.to_string(),
133            action: Action::ToolCall {
134                name: "read_file".to_string(),
135                args: serde_json::json!({"path": "src/main.rs"}),
136                expected_outcome: "Get file contents".to_string(),
137            },
138            outcome: Some(DecisionOutcome::Success {
139                result: "File read successfully".to_string(),
140                metrics: HashMap::new(),
141            }),
142            confidence_score: Some(0.95),
143        }
144    }
145
146    #[test]
147    fn test_extract_from_decision_low_risk() {
148        let decision = create_test_decision("Read the source file");
149        let just = JustificationExtractor::extract_from_decision(&decision, "read_file", &RiskLevel::Low);
150
151        assert!(just.is_none()); // Low risk shouldn't generate justification
152    }
153
154    #[test]
155    fn test_extract_from_decision_high_risk() {
156        let decision = create_test_decision("Need to understand code structure deeply");
157        let just = JustificationExtractor::extract_from_decision(&decision, "run_command", &RiskLevel::High);
158
159        assert!(just.is_some());
160        let just = just.unwrap();
161        assert_eq!(just.tool_name, "run_command");
162        assert_eq!(just.reason, "Need to understand code structure deeply");
163        assert_eq!(just.risk_level, "High");
164    }
165
166    #[test]
167    fn test_extract_from_decision_empty_reasoning() {
168        let decision = create_test_decision("");
169        let just = JustificationExtractor::extract_from_decision(&decision, "run_command", &RiskLevel::High);
170
171        assert!(just.is_none()); // Empty reasoning should return None
172    }
173
174    #[test]
175    fn test_suggest_default_justification() {
176        let just = JustificationExtractor::suggest_default_justification("run_command", &RiskLevel::High);
177
178        assert!(just.is_some());
179        let just = just.unwrap();
180        assert!(just.reason.contains("Execute command"));
181        assert!(just.expected_outcome.is_some());
182    }
183
184    #[test]
185    fn test_suggest_default_for_write_file() {
186        let just = JustificationExtractor::suggest_default_justification("write_file", &RiskLevel::Medium);
187
188        assert!(just.is_some());
189        let just = just.unwrap();
190        assert!(just.reason.contains("Modify code"));
191    }
192
193    #[test]
194    fn test_suggest_default_for_unknown_tool() {
195        let just = JustificationExtractor::suggest_default_justification("unknown_tool", &RiskLevel::High);
196
197        assert!(just.is_none()); // Unknown tools should return None
198    }
199}