ceylon_llm/
react.rs

1use crate::client::{LLMClient, LLMConfig, LLMResponse, UniversalLLMClient};
2use crate::types::Message;
3use ceylon_core::action::ToolInvoker;
4use ceylon_core::agent::AgentContext;
5use ceylon_core::error::Result;
6use ceylon_core::memory::Memory;
7use serde::{Deserialize, Serialize};
8use std::sync::Arc;
9use tokio::sync::Mutex;
10
11/// Configuration for ReAct (Reason + Act) mode.
12///
13/// ReAct enables agents to perform autonomous reasoning and action execution
14/// in an interleaved loop, alternating between thoughts and actions.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ReActConfig {
17    /// Whether ReAct mode is enabled
18    pub enabled: bool,
19
20    /// Maximum number of reasoning iterations
21    pub max_iterations: usize,
22
23    /// Prefix for thought traces (default: "Thought:")
24    pub thought_prefix: String,
25
26    /// Prefix for actions (default: "Action:")
27    pub action_prefix: String,
28
29    /// Prefix for observations (default: "Observation:")
30    pub observation_prefix: String,
31
32    /// Name of the finish action (default: "finish")
33    pub finish_action: String,
34
35    /// Stop sequences for LLM generation
36    pub stop_sequences: Vec<String>,
37}
38
39impl Default for ReActConfig {
40    fn default() -> Self {
41        Self {
42            enabled: false,
43            max_iterations: 10,
44            thought_prefix: "Thought:".to_string(),
45            action_prefix: "Action:".to_string(),
46            observation_prefix: "Observation:".to_string(),
47            finish_action: "finish".to_string(),
48            stop_sequences: vec!["Observation:".to_string()],
49        }
50    }
51}
52
53impl ReActConfig {
54    /// Create a new ReActConfig with default settings
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// Enable ReAct mode
60    pub fn enabled(mut self) -> Self {
61        self.enabled = true;
62        self
63    }
64
65    /// Set maximum iterations
66    pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
67        self.max_iterations = max_iterations;
68        self
69    }
70
71    /// Set thought prefix
72    pub fn with_thought_prefix(mut self, prefix: impl Into<String>) -> Self {
73        self.thought_prefix = prefix.into();
74        self
75    }
76
77    /// Set action prefix
78    pub fn with_action_prefix(mut self, prefix: impl Into<String>) -> Self {
79        self.action_prefix = prefix.into();
80        self
81    }
82
83    /// Set observation prefix
84    pub fn with_observation_prefix(mut self, prefix: impl Into<String>) -> Self {
85        self.observation_prefix = prefix.into();
86        self
87    }
88}
89
90/// A single step in the ReAct reasoning process.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct ReActStep {
93    /// Iteration number (1-indexed)
94    pub iteration: usize,
95
96    /// The agent's reasoning/thought
97    pub thought: String,
98
99    /// The action name (if any)
100    pub action: Option<String>,
101
102    /// The action input (if any)
103    pub action_input: Option<String>,
104
105    /// The observation from executing the action
106    pub observation: Option<String>,
107}
108
109/// Result of a ReAct reasoning process.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ReActResult {
112    /// The final answer
113    pub answer: String,
114
115    /// All reasoning steps taken
116    pub steps: Vec<ReActStep>,
117
118    /// Number of iterations performed
119    pub iterations: usize,
120
121    /// Reason for finishing
122    pub finish_reason: FinishReason,
123}
124
125/// Reason why ReAct finished.
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
127pub enum FinishReason {
128    /// The finish action was called successfully
129    Success,
130
131    /// Maximum iterations reached
132    MaxIterations,
133
134    /// An error occurred
135    Error(String),
136}
137
138/// ReAct engine for executing the reasoning loop.
139pub struct ReActEngine {
140    config: ReActConfig,
141    tool_invoker: Option<Arc<Mutex<ToolInvoker>>>,
142}
143
144impl ReActEngine {
145    /// Create a new ReAct engine
146    pub fn new(config: ReActConfig, tool_invoker: Option<Arc<Mutex<ToolInvoker>>>) -> Self {
147        Self {
148            config,
149            tool_invoker,
150        }
151    }
152
153    /// Execute the ReAct loop
154    pub async fn execute(
155        &self,
156        query: String,
157        llm_client: &UniversalLLMClient,
158        _llm_config: &LLMConfig,
159        _memory: Option<&Arc<dyn Memory>>,
160        ctx: &mut AgentContext,
161    ) -> Result<ReActResult> {
162        let mut steps = Vec::new();
163        let mut conversation_history = self.build_initial_messages(&query);
164
165        for iteration in 1..=self.config.max_iterations {
166            // Build prompt for this iteration
167            let prompt = self.build_iteration_prompt(&query, &steps);
168
169            // Add to conversation
170            conversation_history.push(Message {
171                role: "user".to_string(),
172                content: prompt,
173            });
174
175            // Call LLM
176            let response: LLMResponse<String> = llm_client
177                .complete::<LLMResponse<String>, String>(&conversation_history, &[])
178                .await
179                .map_err(|e| ceylon_core::error::Error::MeshError(e))?;
180
181            // Parse the LLM output
182            let step = self.parse_step(iteration, &response.content)?;
183
184            // Check if this is the finish action
185            if let Some(ref action_name) = step.action {
186                if action_name == &self.config.finish_action {
187                    // Extract the final answer from action_input
188                    let answer = step
189                        .action_input
190                        .clone()
191                        .unwrap_or_else(|| step.thought.clone());
192
193                    steps.push(step);
194
195                    return Ok(ReActResult {
196                        answer,
197                        steps,
198                        iterations: iteration,
199                        finish_reason: FinishReason::Success,
200                    });
201                }
202            }
203
204            // Execute action if present
205            let mut step_with_observation = step.clone();
206
207            if let (Some(ref action_name), Some(ref action_input)) =
208                (&step.action, &step.action_input)
209            {
210                let observation = self.execute_action(action_name, action_input, ctx).await?;
211                step_with_observation.observation = Some(observation.clone());
212
213                // Add observation to conversation
214                conversation_history.push(Message {
215                    role: "assistant".to_string(),
216                    content: response.content.clone(),
217                });
218
219                conversation_history.push(Message {
220                    role: "user".to_string(),
221                    content: format!("{} {}", self.config.observation_prefix, observation),
222                });
223            } else {
224                // No action, just add the thought
225                conversation_history.push(Message {
226                    role: "assistant".to_string(),
227                    content: response.content,
228                });
229            }
230
231            steps.push(step_with_observation);
232        }
233
234        // Max iterations reached
235        let last_thought = steps.last().map(|s| s.thought.clone()).unwrap_or_default();
236
237        Ok(ReActResult {
238            answer: last_thought,
239            steps,
240            iterations: self.config.max_iterations,
241            finish_reason: FinishReason::MaxIterations,
242        })
243    }
244
245    /// Build initial system messages  
246    fn build_initial_messages(&self, _query: &str) -> Vec<Message> {
247        let system_prompt = self.build_react_system_prompt();
248
249        vec![Message {
250            role: "system".to_string(),
251            content: system_prompt,
252        }]
253    }
254
255    /// Build the ReAct system prompt
256    fn build_react_system_prompt(&self) -> String {
257        format!(
258            "You are a ReAct (Reason + Act) agent. You solve problems by alternating between reasoning and taking actions.\n\n\
259            On each turn, you must:\n\
260            1. Output your reasoning prefixed with '{}'\n\
261            2. Decide on an action prefixed with '{}'\n\
262            3. Wait for an observation prefixed with '{}'\n\n\
263            Format:\n\
264            {} [your reasoning about the problem]\n\
265            {} action_name[action_input]\n\n\
266            Example:\n\
267            {} I need to find information about X\n\
268            {} search[X]\n\n\
269            When you have the final answer, use:\n\
270            {} {}[your final answer]\n\n\
271            Available actions will be provided with each query.",
272            self.config.thought_prefix,
273            self.config.action_prefix,
274            self.config.observation_prefix,
275            self.config.thought_prefix,
276            self.config.action_prefix,
277            self.config.thought_prefix,
278            self.config.action_prefix,
279            self.config.action_prefix,
280            self.config.finish_action
281        )
282    }
283
284    /// Build prompt for current iteration
285    fn build_iteration_prompt(&self, query: &str, steps: &[ReActStep]) -> String {
286        if steps.is_empty() {
287            // First iteration
288            format!("Question: {}\n\nBegin reasoning:", query)
289        } else {
290            // Continue from previous step
291            "Continue reasoning:".to_string()
292        }
293    }
294
295    /// Parse LLM output into a ReActStep
296    fn parse_step(&self, iteration: usize, output: &str) -> Result<ReActStep> {
297        let mut thought = String::new();
298        let mut action: Option<String> = None;
299        let mut action_input: Option<String> = None;
300
301        // Parse line by line
302        for line in output.lines() {
303            let line = line.trim();
304
305            if line.starts_with(&self.config.thought_prefix) {
306                thought = line
307                    .strip_prefix(&self.config.thought_prefix)
308                    .unwrap_or("")
309                    .trim()
310                    .to_string();
311            } else if line.starts_with(&self.config.action_prefix) {
312                // Parse Action: action_name[input]
313                let action_str = line
314                    .strip_prefix(&self.config.action_prefix)
315                    .unwrap_or("")
316                    .trim();
317
318                if let Some(bracket_pos) = action_str.find('[') {
319                    let name = action_str[..bracket_pos].trim().to_string();
320                    let input = action_str[bracket_pos + 1..]
321                        .trim_end_matches(']')
322                        .trim()
323                        .to_string();
324
325                    action = Some(name);
326                    action_input = Some(input);
327                } else {
328                    // No brackets, entire string is action name
329                    action = Some(action_str.to_string());
330                }
331            }
332        }
333
334        Ok(ReActStep {
335            iteration,
336            thought,
337            action,
338            action_input,
339            observation: None,
340        })
341    }
342
343    /// Execute an action using the tool invoker
344    async fn execute_action(
345        &self,
346        action_name: &str,
347        action_input: &str,
348        ctx: &mut AgentContext,
349    ) -> Result<String> {
350        if let Some(ref tool_invoker) = self.tool_invoker {
351            let invoker = tool_invoker.lock().await;
352
353            // Convert string input to JSON value
354            let input_value: serde_json::Value = serde_json::from_str(action_input)
355                .unwrap_or_else(|_| serde_json::json!({"input": action_input}));
356
357            let result = invoker.invoke(action_name, ctx, input_value).await?;
358
359            Ok(result.to_string())
360        } else {
361            Ok(format!(
362                "No tool invoker available to execute action: {}",
363                action_name
364            ))
365        }
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn test_react_config_default() {
375        let config = ReActConfig::default();
376        assert_eq!(config.enabled, false);
377        assert_eq!(config.max_iterations, 10);
378        assert_eq!(config.thought_prefix, "Thought:");
379        assert_eq!(config.action_prefix, "Action:");
380    }
381
382    #[test]
383    fn test_react_config_builder() {
384        let config = ReActConfig::new()
385            .enabled()
386            .with_max_iterations(15)
387            .with_thought_prefix("Think:");
388
389        assert_eq!(config.enabled, true);
390        assert_eq!(config.max_iterations, 15);
391        assert_eq!(config.thought_prefix, "Think:");
392    }
393
394    #[test]
395    fn test_parse_step() {
396        let config = ReActConfig::default();
397        let engine = ReActEngine::new(config, None);
398
399        let output = "Thought: I need to search for information\nAction: search[test query]";
400        let step = engine.parse_step(1, output).unwrap();
401
402        assert_eq!(step.iteration, 1);
403        assert_eq!(step.thought, "I need to search for information");
404        assert_eq!(step.action, Some("search".to_string()));
405        assert_eq!(step.action_input, Some("test query".to_string()));
406    }
407}