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::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
22pub const PARSE_ERROR_TOOL: &str = "__parse_error__";
29
30pub struct ReActAgent {
36 llm: Arc<dyn BaseChatModel<Error = ProviderError> + Send + Sync>,
38
39 tools: Vec<Arc<dyn BaseTool>>,
41
42 parser: ReActOutputParser,
44
45 system_prompt: Option<String>,
47
48 last_token_usage: std::sync::Mutex<Option<TokenUsage>>,
50}
51
52impl ReActAgent {
53 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 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 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 fn get_tool_names(&self) -> Vec<&str> {
105 self.tools.iter().map(|t| t.name()).collect()
106 }
107
108 fn build_prompt(
115 &self,
116 input: &str,
117 intermediate_steps: &[AgentStep],
118 history: Option<&str>,
119 ) -> String {
120 let tools_description = self.format_tools();
122 let tool_names = self.get_tool_names();
123
124 let scratchpad = format_scratchpad(intermediate_steps);
126
127 let mut prompt = build_react_prompt(&tools_description, &tool_names, input, &scratchpad);
129
130 if let Some(h) = history {
132 if !h.is_empty() {
133 prompt = format!("之前的对话历史:\n{}\n\n{}", h, prompt);
134 }
135 }
136
137 if let Some(sys) = &self.system_prompt {
139 prompt = format!("{}\n\n{}", sys, prompt);
140 }
141
142 prompt
143 }
144
145 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 async fn plan(
202 &self,
203 intermediate_steps: &[AgentStep],
204 inputs: &HashMap<String, String>,
205 config: Option<&RunnableConfig>,
206 ) -> Result<AgentOutput, AgentError> {
207 let input = inputs
209 .get("input")
210 .ok_or_else(|| AgentError::Other("Missing input parameter 'input'".to_string()))?;
211
212 let history = inputs.get("history").map(|s| s.as_str());
214
215 let prompt_text = self.build_prompt(input, intermediate_steps, history);
217
218 let messages = vec![Message::human(prompt_text)];
220
221 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 if let Ok(mut guard) = self.last_token_usage.lock() {
234 *guard = result.token_usage.clone();
235 }
236
237 self.parse_with_repair(&result.content, intermediate_steps)
239 }
240
241 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 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 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 fn get_allowed_tools(&self) -> Option<Vec<&str>> {
310 Some(self.get_tool_names())
311 }
312
313 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 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 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 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 #[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}