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