Skip to main content

lc_agents/react/
agent.rs

1// src/agents/react/agent.rs
2//! ReAct Agent implementation
3//!
4//! Based on the paper "ReAct: Synergizing Reasoning and Acting in Language Models".
5//! Supports any LLM provider that implements `BaseChatModel`.
6
7use super::parser::ReActOutputParser;
8use super::prompt::{build_react_prompt, format_scratchpad};
9use crate::{AgentAction, AgentError, AgentOutput, AgentStep, BaseAgent, ToolInput};
10use async_trait::async_trait;
11use futures_util::StreamExt;
12use lc_core::language_models::{BaseChatModel, TokenUsage};
13use lc_core::tools::BaseTool;
14use lc_providers::ProviderError;
15use lc_schema::Message;
16use std::collections::HashMap;
17use std::future::Future;
18use std::pin::Pin;
19use std::sync::Arc;
20
21/// 0.22.0 audit fix (H-A5): pseudo-tool the agent returns when the model output
22/// cannot be parsed. The executor never executes it — it feeds the embedded
23/// message back as an observation so the model can retry in the correct ReAct
24/// format (standard repair loop). If the model fails to parse again while the
25/// previous observation is already a parse-repair message, the agent hard-fails
26/// instead of looping forever.
27pub const PARSE_ERROR_TOOL: &str = "__parse_error__";
28
29/// ReAct Agent
30///
31/// An agent that uses the ReAct (Reasoning + Acting) pattern:
32/// it first thinks, then decides which tool to execute, and finally observes the
33/// result. Supports any LLM provider that implements `BaseChatModel`.
34pub struct ReActAgent {
35    /// LLM client
36    llm: Arc<dyn BaseChatModel<Error = ProviderError> + Send + Sync>,
37
38    /// Available tools
39    tools: Vec<Arc<dyn BaseTool>>,
40
41    /// Output parser
42    parser: ReActOutputParser,
43
44    /// Custom system prompt (optional)
45    system_prompt: Option<String>,
46
47    /// Token usage from the most recent `plan()` call (P1-5).
48    last_token_usage: std::sync::Mutex<Option<TokenUsage>>,
49}
50
51impl ReActAgent {
52    /// Creates a new ReAct Agent
53    ///
54    /// # Parameters
55    /// * `llm` - LLM client (any type implementing `BaseChatModel`)
56    /// * `tools` - available tools
57    /// * `system_prompt` - custom system prompt (optional)
58    ///
59    /// # Backward compatibility
60    /// Legacy code `ReActAgent::new(openai_chat, tools, None)` still works,
61    /// because `OpenAIChat: BaseChatModel` and `OpenAIError: Into<Error>`.
62    pub fn new<L>(llm: L, tools: Vec<Arc<dyn BaseTool>>, system_prompt: Option<String>) -> Self
63    where
64        L: BaseChatModel + Send + Sync + 'static,
65        L::Error: Into<ProviderError>,
66    {
67        Self {
68            llm: lc_providers::wrap_chat_model(llm),
69            tools,
70            parser: ReActOutputParser::new(),
71            system_prompt,
72            last_token_usage: std::sync::Mutex::new(None),
73        }
74    }
75
76    /// Creates an agent from an already-wrapped `Arc<dyn BaseChatModel>`
77    pub fn from_arc(
78        llm: Arc<dyn BaseChatModel<Error = ProviderError> + Send + Sync>,
79        tools: Vec<Arc<dyn BaseTool>>,
80        system_prompt: Option<String>,
81    ) -> Self {
82        Self {
83            llm,
84            tools,
85            parser: ReActOutputParser::new(),
86            system_prompt,
87            last_token_usage: std::sync::Mutex::new(None),
88        }
89    }
90
91    /// Formats the tool descriptions
92    ///
93    /// Formats the tool list into the format the ReAct prompt expects.
94    fn format_tools(&self) -> String {
95        self.tools
96            .iter()
97            .map(|tool| format!("{}: {}", tool.name(), tool.description()))
98            .collect::<Vec<_>>()
99            .join("\n")
100    }
101
102    /// Returns the list of tool names
103    fn get_tool_names(&self) -> Vec<&str> {
104        self.tools.iter().map(|t| t.name()).collect()
105    }
106
107    /// Builds the ReAct prompt
108    ///
109    /// # Parameters
110    /// * `input` - user question
111    /// * `intermediate_steps` - history of executed steps
112    /// * `history` - conversation history (optional)
113    fn build_prompt(
114        &self,
115        input: &str,
116        intermediate_steps: &[AgentStep],
117        history: Option<&str>,
118    ) -> String {
119        // Format the tool descriptions
120        let tools_description = self.format_tools();
121        let tool_names = self.get_tool_names();
122
123        // Format the thought history (scratchpad)
124        let scratchpad = format_scratchpad(intermediate_steps);
125
126        // Build the base prompt
127        let mut prompt = build_react_prompt(&tools_description, &tool_names, input, &scratchpad);
128
129        // Prepend the conversation history if present
130        if let Some(h) = history {
131            if !h.is_empty() {
132                prompt = format!("之前的对话历史:\n{}\n\n{}", h, prompt);
133            }
134        }
135
136        // Prepend the custom system prompt if present
137        if let Some(sys) = &self.system_prompt {
138            prompt = format!("{}\n\n{}", sys, prompt);
139        }
140
141        prompt
142    }
143
144    /// 0.22.0 audit fix (H-A5): a single parse failure must not hard-fail the run.
145    ///
146    /// On `OutputParsingError`, return an Action on the [`PARSE_ERROR_TOOL`]
147    /// pseudo-tool whose input is a repair instruction + the error. The executor
148    /// feeds it back as an observation and the model retries in the correct
149    /// format. Guard: if the previous observation is already a parse-repair
150    /// message (the model failed twice in a row), hard-fail to prevent an
151    /// infinite repair loop.
152    fn parse_with_repair(
153        &self,
154        text: &str,
155        intermediate_steps: &[AgentStep],
156    ) -> Result<AgentOutput, AgentError> {
157        match self.parser.parse(text) {
158            Ok(output) => Ok(output),
159            Err(e) => {
160                let already_repaired = intermediate_steps
161                    .last()
162                    .map(|s| s.action.tool == PARSE_ERROR_TOOL)
163                    .unwrap_or(false);
164                if already_repaired {
165                    return Err(e);
166                }
167                log::warn!(
168                    "ReAct output parse failed, feeding back a repair prompt: {}",
169                    e
170                );
171                Ok(AgentOutput::Action(AgentAction {
172                    tool: PARSE_ERROR_TOOL.to_string(),
173                    tool_input: ToolInput::String {
174                        value: format!(
175                            "Your previous output could not be parsed ({e}). \
176                             Re-emit your next step using EXACTLY one of these formats:\n\
177                             Thought: <reasoning>\nAction: <tool name>\n\
178                             Action Input: <tool input>\n\nor\n\n\
179                             Thought: <reasoning>\nFinal Answer: <final answer>"
180                        ),
181                    },
182                    log: "0.22.0 audit fix: parse repair".to_string(),
183                }))
184            }
185        }
186    }
187}
188
189#[async_trait]
190impl BaseAgent for ReActAgent {
191    /// Plans the next action
192    ///
193    /// # Parameters
194    /// * `intermediate_steps` - history of executed steps
195    /// * `inputs` - user input
196    ///
197    /// # Returns
198    /// * `AgentOutput::Action` - the action to execute
199    /// * `AgentOutput::Finish` - the final answer
200    async fn plan(
201        &self,
202        intermediate_steps: &[AgentStep],
203        inputs: &HashMap<String, String>,
204    ) -> Result<AgentOutput, AgentError> {
205        // Get the user input
206        let input = inputs
207            .get("input")
208            .ok_or_else(|| AgentError::Other("Missing input parameter 'input'".to_string()))?;
209
210        // Get the conversation history (if any)
211        let history = inputs.get("history").map(|s| s.as_str());
212
213        // Build the prompt
214        let prompt_text = self.build_prompt(input, intermediate_steps, history);
215
216        // Create the message
217        let messages = vec![Message::human(prompt_text)];
218
219        // Call the LLM
220        let result = crate::retry::retry_chat(
221            self.llm.as_ref(),
222            messages,
223            None,
224            &crate::retry::RetryConfig::default(),
225        )
226        .await
227        .map_err(|e| AgentError::Other(format!("LLM call failed: {}", e)))?;
228
229        // P1-5: record token usage for the executor's metrics.
230        if let Ok(mut guard) = self.last_token_usage.lock() {
231            *guard = result.token_usage.clone();
232        }
233
234        // Parse the output
235        self.parse_with_repair(&result.content, intermediate_steps)
236    }
237
238    /// Streaming plan (F3): forwards model output token by token, accumulating
239    /// the full text before parsing.
240    ///
241    /// `plan()` goes through non-streaming `chat` (with retry, records token
242    /// usage); this goes through `stream_chat`, forwarding each chunk via
243    /// `on_token` as a live `Text` event while accumulating the full text for
244    /// Action / Final Answer parsing.
245    ///
246    /// Trade-off: `stream_chat` chunks carry optional `token_usage`; after the
247    /// stream ends it is written to `last_token_usage` for the budget gate to
248    /// read. When the provider does not report usage (chunk.token_usage is
249    /// `None`), the streaming path's metrics usage is filled in by the
250    /// non-streaming `invoke` path. If `stream_chat` fails immediately (e.g. the
251    /// provider does not implement streaming), it falls back to non-streaming
252    /// `plan()` so the agent loop is not interrupted.
253    async fn plan_stream(
254        &self,
255        intermediate_steps: &[AgentStep],
256        inputs: &HashMap<String, String>,
257        on_token: &mut (dyn FnMut(String) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send),
258    ) -> Result<AgentOutput, AgentError> {
259        let input = inputs
260            .get("input")
261            .ok_or_else(|| AgentError::Other("Missing input parameter 'input'".to_string()))?;
262        let history = inputs.get("history").map(|s| s.as_str());
263        let prompt_text = self.build_prompt(input, intermediate_steps, history);
264        let messages = vec![Message::human(prompt_text)];
265
266        let mut stream = match self.llm.stream_chat(messages, None).await {
267            Ok(s) => s,
268            Err(e) => {
269                log::warn!(
270                    "stream_chat unavailable ({}), falling back to non-streaming plan",
271                    e
272                );
273                let output = self.plan(intermediate_steps, inputs).await?;
274                if let AgentOutput::Finish(finish) = &output {
275                    on_token(finish.output().unwrap_or("").to_string()).await;
276                }
277                return Ok(output);
278            }
279        };
280
281        // Token by token: forward the chunk live (cloned into an owned String),
282        // then append to the full text.
283        // Parsing only happens after the stream ends, so the Action / Final
284        // Answer decision for a single ReAct step is unaffected.
285        let mut full = String::new();
286        let mut usage: Option<TokenUsage> = None;
287        while let Some(chunk) = stream.next().await {
288            let chunk = chunk.map_err(|e| AgentError::Other(format!("LLM stream error: {}", e)))?;
289            if !chunk.text.is_empty() {
290                on_token(chunk.text.clone()).await;
291            }
292            full.push_str(&chunk.text);
293            if chunk.token_usage.is_some() {
294                usage = chunk.token_usage;
295            }
296        }
297        if let Ok(mut guard) = self.last_token_usage.lock() {
298            *guard = usage;
299        }
300        self.parse_with_repair(&full, intermediate_steps)
301    }
302
303    /// Returns the allowed tools list
304    fn get_allowed_tools(&self) -> Option<Vec<&str>> {
305        Some(self.get_tool_names())
306    }
307
308    /// Reports the token usage from the most recent `plan()` call (P1-5).
309    fn last_token_usage(&self) -> Option<TokenUsage> {
310        self.last_token_usage.lock().ok().and_then(|g| g.clone())
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use futures_util::Stream;
318    use lc_core::language_models::{LLMResult, StreamChunk};
319    use lc_core::runnables::{Runnable, RunnableConfig};
320    use lc_core::BaseLanguageModel;
321    use lc_providers::{OpenAIChat, OpenAIConfig};
322    use lc_tools::Calculator;
323    use std::pin::Pin;
324
325    /// Creates an OpenAI config for tests
326    fn create_test_config() -> OpenAIConfig {
327        OpenAIConfig {
328            api_key: "sk-6eb65fcf5d17491ca10b984efe1f43e7".to_string(),
329            base_url:
330                "https://llm-8xo1b7o30z27y2xc.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
331                    .to_string(),
332            model: "glm-5.2".to_string(),
333            temperature: Some(0.0),
334            max_tokens: Some(500),
335            top_p: None,
336            frequency_penalty: None,
337            presence_penalty: None,
338            streaming: false,
339            organization: None,
340            tools: None,
341            tool_choice: None,
342            response_format: None,
343        }
344    }
345
346    #[test]
347    fn test_format_tools_description() {
348        let config = create_test_config();
349        let llm = OpenAIChat::new(config);
350        let tools: Vec<Arc<dyn BaseTool>> = vec![Arc::new(Calculator)];
351        let agent = ReActAgent::new(llm, tools, None);
352
353        let desc = agent.format_tools();
354        assert!(desc.contains("calculator"));
355    }
356
357    #[test]
358    fn test_get_tool_names() {
359        let config = create_test_config();
360        let llm = OpenAIChat::new(config);
361        let tools: Vec<Arc<dyn BaseTool>> = vec![Arc::new(Calculator)];
362        let agent = ReActAgent::new(llm, tools, None);
363
364        let names = agent.get_tool_names();
365        assert_eq!(names, vec!["calculator"]);
366    }
367
368    #[test]
369    fn test_build_prompt() {
370        let config = create_test_config();
371        let llm = OpenAIChat::new(config);
372        let tools: Vec<Arc<dyn BaseTool>> = vec![Arc::new(Calculator)];
373        let agent = ReActAgent::new(llm, tools, None);
374
375        let prompt = agent.build_prompt("计算 2 + 2", &[], None);
376
377        assert!(prompt.contains("计算 2 + 2"));
378        assert!(prompt.contains("calculator"));
379        assert!(prompt.contains("Question:"));
380        assert!(prompt.contains("Thought:"));
381    }
382
383    #[test]
384    fn test_build_prompt_with_history() {
385        let config = create_test_config();
386        let llm = OpenAIChat::new(config);
387        let tools: Vec<Arc<dyn BaseTool>> = vec![Arc::new(Calculator)];
388        let agent = ReActAgent::new(llm, tools, None);
389
390        let prompt = agent.build_prompt("计算 3 + 3", &[], Some("用户: 你好\n助手: 你好!"));
391
392        assert!(prompt.contains("之前的对话历史"));
393        assert!(prompt.contains("你好"));
394    }
395
396    #[test]
397    fn test_build_prompt_with_system_prompt() {
398        let config = create_test_config();
399        let llm = OpenAIChat::new(config);
400        let tools: Vec<Arc<dyn BaseTool>> = vec![Arc::new(Calculator)];
401        let agent = ReActAgent::new(llm, tools, Some("你是一个数学助手".to_string()));
402
403        let prompt = agent.build_prompt("计算 4 + 4", &[], None);
404
405        assert!(prompt.contains("你是一个数学助手"));
406    }
407
408    /// S1 streaming mock: returns text chunk by chunk, with the last chunk
409    /// carrying `token_usage`. Used to verify that `plan_stream` writes the
410    /// streaming usage into `last_token_usage`, which the budget gate of
411    /// `AgentExecutor::stream` reads.
412    struct UsageStreamingLLM;
413
414    #[async_trait]
415    impl Runnable<Vec<Message>, LLMResult> for UsageStreamingLLM {
416        type Error = ProviderError;
417        async fn invoke(
418            &self,
419            _input: Vec<Message>,
420            _config: Option<RunnableConfig>,
421        ) -> Result<LLMResult, Self::Error> {
422            Ok(LLMResult {
423                content: "Final Answer: 42".to_string(),
424                model: "mock".to_string(),
425                token_usage: None,
426                tool_calls: None,
427                thinking_content: None,
428            })
429        }
430    }
431
432    #[async_trait]
433    impl BaseLanguageModel<Vec<Message>, LLMResult> for UsageStreamingLLM {
434        fn model_name(&self) -> &str {
435            "mock"
436        }
437        fn get_num_tokens(&self, t: &str) -> usize {
438            t.len()
439        }
440        fn with_temperature(self, _: f32) -> Self {
441            self
442        }
443        fn with_max_tokens(self, _: usize) -> Self {
444            self
445        }
446    }
447
448    #[async_trait]
449    impl BaseChatModel for UsageStreamingLLM {
450        async fn chat(
451            &self,
452            messages: Vec<Message>,
453            config: Option<RunnableConfig>,
454        ) -> Result<LLMResult, Self::Error> {
455            self.invoke(messages, config).await
456        }
457        async fn stream_chat(
458            &self,
459            _messages: Vec<Message>,
460            _config: Option<RunnableConfig>,
461        ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
462        {
463            // First chunk carries no usage, last chunk does — verifies "take the last non-None".
464            let chunks = [
465                Ok(StreamChunk::new("Final ")),
466                Ok(StreamChunk {
467                    text: "Answer: 42".to_string(),
468                    token_usage: Some(TokenUsage {
469                        prompt_tokens: 10,
470                        completion_tokens: 5,
471                        total_tokens: 15,
472                    }),
473                    tool_calls: None,
474                }),
475            ];
476            Ok(Box::pin(futures_util::stream::iter(chunks)))
477        }
478    }
479
480    /// S1: `plan_stream` forwards text chunk by chunk and writes the last
481    /// non-None token_usage into `last_token_usage` (the streaming path's
482    /// budget gate depends on it).
483    #[tokio::test]
484    async fn test_plan_stream_records_streaming_token_usage() {
485        let llm = UsageStreamingLLM;
486        let agent = ReActAgent::new(llm, vec![], None);
487
488        let mut inputs = HashMap::new();
489        inputs.insert("input".to_string(), "6 * 7".to_string());
490        let mut received = String::new();
491        let mut on_token = |text: String| {
492            received.push_str(&text);
493            Box::pin(async move {}) as Pin<Box<dyn Future<Output = ()> + Send>>
494        };
495
496        let output = agent
497            .plan_stream(&[], &inputs, &mut on_token)
498            .await
499            .expect("plan_stream should parse to Finish");
500
501        assert_eq!(received, "Final Answer: 42");
502        assert!(matches!(output, AgentOutput::Finish(_)));
503        let usage = agent.last_token_usage().expect("streaming usage recorded");
504        assert_eq!(usage.prompt_tokens, 10);
505        assert_eq!(usage.completion_tokens, 5);
506        assert_eq!(usage.total_tokens, 15);
507    }
508}