Skip to main content

skilllite_agent/
evolution.rs

1//! Evolution integration: implements EvolutionLlm for agent's LlmClient.
2//!
3//! Re-exports skilllite-evolution and provides the adapter to use the agent's
4//! LLM client for evolution operations.
5
6use anyhow::Result;
7
8use skilllite_evolution::feedback::{DecisionInput, FeedbackSignal as EvolutionFeedbackSignal};
9use skilllite_evolution::{strip_think_blocks, EvolutionLlm, EvolutionMessage};
10
11use super::llm::LlmClient;
12use super::types::{ChatMessage, ExecutionFeedback, FeedbackSignal};
13
14/// Adapter that makes LlmClient implement EvolutionLlm.
15pub struct EvolutionLlmAdapter<'a> {
16    pub llm: &'a LlmClient,
17}
18
19#[async_trait::async_trait]
20impl EvolutionLlm for EvolutionLlmAdapter<'_> {
21    async fn complete(
22        &self,
23        messages: &[EvolutionMessage],
24        model: &str,
25        temperature: f64,
26    ) -> Result<String> {
27        let chat_messages: Vec<ChatMessage> = messages
28            .iter()
29            .map(|m| ChatMessage {
30                role: m.role.clone(),
31                content: m.content.clone(),
32                tool_calls: None,
33                tool_call_id: None,
34                name: None,
35            })
36            .collect();
37
38        let response = self
39            .llm
40            .chat_completion(model, &chat_messages, None, Some(temperature))
41            .await?;
42
43        let msg = response.choices.first().map(|c| &c.message);
44        let content = msg.and_then(|m| m.content.as_deref()).unwrap_or("").trim();
45        let has_reasoning_field = msg.and_then(|m| m.reasoning_content.as_ref()).is_some();
46
47        if has_reasoning_field {
48            // API already separated reasoning from content — use content as-is
49            Ok(content.to_string())
50        } else {
51            // Fallback: strip <think>/<thinking>/<reasoning> tags from text
52            Ok(strip_think_blocks(content).to_string())
53        }
54    }
55}
56
57/// Convert agent's ExecutionFeedback to evolution's DecisionInput.
58pub fn execution_feedback_to_decision_input(feedback: &ExecutionFeedback) -> DecisionInput {
59    DecisionInput {
60        total_tools: feedback.total_tools,
61        failed_tools: feedback.failed_tools,
62        replans: feedback.replans,
63        elapsed_ms: feedback.elapsed_ms,
64        task_completed: feedback.task_completed,
65        task_description: feedback.task_description.clone(),
66        rules_used: feedback.rules_used.clone(),
67        tools_detail: feedback
68            .tools_detail
69            .iter()
70            .map(|t| skilllite_evolution::feedback::ToolExecDetail {
71                tool: t.tool.clone(),
72                success: t.success,
73            })
74            .collect(),
75    }
76}
77
78/// Convert agent's FeedbackSignal to evolution's.
79pub fn to_evolution_feedback(signal: FeedbackSignal) -> EvolutionFeedbackSignal {
80    match signal {
81        FeedbackSignal::ExplicitPositive => EvolutionFeedbackSignal::ExplicitPositive,
82        FeedbackSignal::ExplicitNegative => EvolutionFeedbackSignal::ExplicitNegative,
83        FeedbackSignal::Neutral => EvolutionFeedbackSignal::Neutral,
84    }
85}
86
87// Re-export evolution crate for use by chat_session and other modules.
88pub use skilllite_evolution::feedback;
89pub use skilllite_evolution::seed;
90pub use skilllite_evolution::{
91    check_auto_rollback, format_evolution_changes, on_shutdown, query_changes_by_txn,
92    run_evolution, EvolutionMode,
93};
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::types::{ExecutionFeedback, ToolExecDetail};
99
100    #[test]
101    fn test_execution_feedback_to_decision_input_preserves_rules_used() {
102        let feedback = ExecutionFeedback {
103            total_tools: 2,
104            failed_tools: 0,
105            replans: 1,
106            iterations: 3,
107            elapsed_ms: 1200,
108            context_overflow_retries: 0,
109            task_completed: true,
110            task_description: Some("test task".to_string()),
111            rules_used: vec!["rule.alpha".to_string(), "rule.beta".to_string()],
112            tools_detail: vec![ToolExecDetail {
113                tool: "read_file".to_string(),
114                success: true,
115            }],
116        };
117
118        let input = execution_feedback_to_decision_input(&feedback);
119        assert_eq!(
120            input.rules_used,
121            vec!["rule.alpha".to_string(), "rule.beta".to_string()]
122        );
123    }
124
125    #[test]
126    fn test_execution_feedback_to_decision_input_preserves_tools_detail() {
127        let feedback = ExecutionFeedback {
128            total_tools: 2,
129            failed_tools: 1,
130            replans: 1,
131            iterations: 3,
132            elapsed_ms: 1200,
133            context_overflow_retries: 0,
134            task_completed: false,
135            task_description: Some("another test task".to_string()),
136            rules_used: vec!["rule.gamma".to_string()],
137            tools_detail: vec![
138                ToolExecDetail {
139                    tool: "list_directory".to_string(),
140                    success: true,
141                },
142                ToolExecDetail {
143                    tool: "write_file".to_string(),
144                    success: false,
145                },
146            ],
147        };
148
149        let input = execution_feedback_to_decision_input(&feedback);
150        assert_eq!(input.tools_detail.len(), 2);
151        assert_eq!(input.tools_detail[0].tool, "list_directory".to_string());
152        assert!(input.tools_detail[0].success);
153        assert_eq!(input.tools_detail[1].tool, "write_file".to_string());
154        assert!(!input.tools_detail[1].success);
155    }
156}