1use 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
21pub const PARSE_ERROR_TOOL: &str = "__parse_error__";
28
29pub struct ReActAgent {
35 llm: Arc<dyn BaseChatModel<Error = ProviderError> + Send + Sync>,
37
38 tools: Vec<Arc<dyn BaseTool>>,
40
41 parser: ReActOutputParser,
43
44 system_prompt: Option<String>,
46
47 last_token_usage: std::sync::Mutex<Option<TokenUsage>>,
49}
50
51impl ReActAgent {
52 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 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 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 fn get_tool_names(&self) -> Vec<&str> {
104 self.tools.iter().map(|t| t.name()).collect()
105 }
106
107 fn build_prompt(
114 &self,
115 input: &str,
116 intermediate_steps: &[AgentStep],
117 history: Option<&str>,
118 ) -> String {
119 let tools_description = self.format_tools();
121 let tool_names = self.get_tool_names();
122
123 let scratchpad = format_scratchpad(intermediate_steps);
125
126 let mut prompt = build_react_prompt(&tools_description, &tool_names, input, &scratchpad);
128
129 if let Some(h) = history {
131 if !h.is_empty() {
132 prompt = format!("之前的对话历史:\n{}\n\n{}", h, prompt);
133 }
134 }
135
136 if let Some(sys) = &self.system_prompt {
138 prompt = format!("{}\n\n{}", sys, prompt);
139 }
140
141 prompt
142 }
143
144 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 async fn plan(
201 &self,
202 intermediate_steps: &[AgentStep],
203 inputs: &HashMap<String, String>,
204 ) -> Result<AgentOutput, AgentError> {
205 let input = inputs
207 .get("input")
208 .ok_or_else(|| AgentError::Other("Missing input parameter 'input'".to_string()))?;
209
210 let history = inputs.get("history").map(|s| s.as_str());
212
213 let prompt_text = self.build_prompt(input, intermediate_steps, history);
215
216 let messages = vec![Message::human(prompt_text)];
218
219 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 if let Ok(mut guard) = self.last_token_usage.lock() {
231 *guard = result.token_usage.clone();
232 }
233
234 self.parse_with_repair(&result.content, intermediate_steps)
236 }
237
238 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 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 fn get_allowed_tools(&self) -> Option<Vec<&str>> {
305 Some(self.get_tool_names())
306 }
307
308 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 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 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 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 #[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}