use super::parser::ReActOutputParser;
use super::prompt::{build_react_prompt, format_scratchpad};
use crate::{AgentError, AgentOutput, AgentStep, BaseAgent};
use async_trait::async_trait;
use futures_util::StreamExt;
use lc_core::language_models::{BaseChatModel, TokenUsage};
use lc_core::tools::BaseTool;
use lc_providers::ProviderError;
use lc_schema::Message;
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
pub struct ReActAgent {
llm: Arc<dyn BaseChatModel<Error = ProviderError> + Send + Sync>,
tools: Vec<Arc<dyn BaseTool>>,
parser: ReActOutputParser,
system_prompt: Option<String>,
last_token_usage: std::sync::Mutex<Option<TokenUsage>>,
}
impl ReActAgent {
pub fn new<L>(llm: L, tools: Vec<Arc<dyn BaseTool>>, system_prompt: Option<String>) -> Self
where
L: BaseChatModel + Send + Sync + 'static,
L::Error: Into<ProviderError>,
{
Self {
llm: lc_providers::wrap_chat_model(llm),
tools,
parser: ReActOutputParser::new(),
system_prompt,
last_token_usage: std::sync::Mutex::new(None),
}
}
pub fn from_arc(
llm: Arc<dyn BaseChatModel<Error = ProviderError> + Send + Sync>,
tools: Vec<Arc<dyn BaseTool>>,
system_prompt: Option<String>,
) -> Self {
Self {
llm,
tools,
parser: ReActOutputParser::new(),
system_prompt,
last_token_usage: std::sync::Mutex::new(None),
}
}
fn format_tools(&self) -> String {
self.tools
.iter()
.map(|tool| format!("{}: {}", tool.name(), tool.description()))
.collect::<Vec<_>>()
.join("\n")
}
fn get_tool_names(&self) -> Vec<&str> {
self.tools.iter().map(|t| t.name()).collect()
}
fn build_prompt(
&self,
input: &str,
intermediate_steps: &[AgentStep],
history: Option<&str>,
) -> String {
let tools_description = self.format_tools();
let tool_names = self.get_tool_names();
let scratchpad = format_scratchpad(intermediate_steps);
let mut prompt = build_react_prompt(&tools_description, &tool_names, input, &scratchpad);
if let Some(h) = history {
if !h.is_empty() {
prompt = format!("之前的对话历史:\n{}\n\n{}", h, prompt);
}
}
if let Some(sys) = &self.system_prompt {
prompt = format!("{}\n\n{}", sys, prompt);
}
prompt
}
}
#[async_trait]
impl BaseAgent for ReActAgent {
async fn plan(
&self,
intermediate_steps: &[AgentStep],
inputs: &HashMap<String, String>,
) -> Result<AgentOutput, AgentError> {
let input = inputs
.get("input")
.ok_or_else(|| AgentError::Other("Missing input parameter 'input'".to_string()))?;
let history = inputs.get("history").map(|s| s.as_str());
let prompt_text = self.build_prompt(input, intermediate_steps, history);
let messages = vec![Message::human(prompt_text)];
let result = crate::retry::retry_chat(
self.llm.as_ref(),
messages,
None,
&crate::retry::RetryConfig::default(),
)
.await
.map_err(|e| AgentError::Other(format!("LLM call failed: {}", e)))?;
if let Ok(mut guard) = self.last_token_usage.lock() {
*guard = result.token_usage.clone();
}
self.parser.parse(&result.content)
}
async fn plan_stream(
&self,
intermediate_steps: &[AgentStep],
inputs: &HashMap<String, String>,
on_token: &mut (dyn FnMut(String) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send),
) -> Result<AgentOutput, AgentError> {
let input = inputs
.get("input")
.ok_or_else(|| AgentError::Other("Missing input parameter 'input'".to_string()))?;
let history = inputs.get("history").map(|s| s.as_str());
let prompt_text = self.build_prompt(input, intermediate_steps, history);
let messages = vec![Message::human(prompt_text)];
let mut stream = match self.llm.stream_chat(messages, None).await {
Ok(s) => s,
Err(e) => {
log::warn!(
"stream_chat unavailable ({}), falling back to non-streaming plan",
e
);
let output = self.plan(intermediate_steps, inputs).await?;
if let AgentOutput::Finish(finish) = &output {
on_token(finish.output().unwrap_or("").to_string()).await;
}
return Ok(output);
}
};
let mut full = String::new();
let mut usage: Option<TokenUsage> = None;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| AgentError::Other(format!("LLM stream error: {}", e)))?;
on_token(chunk.text.clone()).await;
full.push_str(&chunk.text);
if chunk.token_usage.is_some() {
usage = chunk.token_usage;
}
}
if let Ok(mut guard) = self.last_token_usage.lock() {
*guard = usage;
}
self.parser.parse(&full)
}
fn get_allowed_tools(&self) -> Option<Vec<&str>> {
Some(self.get_tool_names())
}
fn last_token_usage(&self) -> Option<TokenUsage> {
self.last_token_usage.lock().ok().and_then(|g| g.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures_util::Stream;
use lc_core::language_models::{LLMResult, StreamChunk};
use lc_core::runnables::{Runnable, RunnableConfig};
use lc_core::BaseLanguageModel;
use lc_providers::{OpenAIChat, OpenAIConfig};
use lc_tools::Calculator;
use std::pin::Pin;
fn create_test_config() -> OpenAIConfig {
OpenAIConfig {
api_key: "sk-6eb65fcf5d17491ca10b984efe1f43e7".to_string(),
base_url:
"https://llm-8xo1b7o30z27y2xc.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
.to_string(),
model: "glm-5.2".to_string(),
temperature: Some(0.0),
max_tokens: Some(500),
top_p: None,
frequency_penalty: None,
presence_penalty: None,
streaming: false,
organization: None,
tools: None,
tool_choice: None,
}
}
#[test]
fn test_format_tools_description() {
let config = create_test_config();
let llm = OpenAIChat::new(config);
let tools: Vec<Arc<dyn BaseTool>> = vec![Arc::new(Calculator)];
let agent = ReActAgent::new(llm, tools, None);
let desc = agent.format_tools();
assert!(desc.contains("calculator"));
}
#[test]
fn test_get_tool_names() {
let config = create_test_config();
let llm = OpenAIChat::new(config);
let tools: Vec<Arc<dyn BaseTool>> = vec![Arc::new(Calculator)];
let agent = ReActAgent::new(llm, tools, None);
let names = agent.get_tool_names();
assert_eq!(names, vec!["calculator"]);
}
#[test]
fn test_build_prompt() {
let config = create_test_config();
let llm = OpenAIChat::new(config);
let tools: Vec<Arc<dyn BaseTool>> = vec![Arc::new(Calculator)];
let agent = ReActAgent::new(llm, tools, None);
let prompt = agent.build_prompt("计算 2 + 2", &[], None);
assert!(prompt.contains("计算 2 + 2"));
assert!(prompt.contains("calculator"));
assert!(prompt.contains("Question:"));
assert!(prompt.contains("Thought:"));
}
#[test]
fn test_build_prompt_with_history() {
let config = create_test_config();
let llm = OpenAIChat::new(config);
let tools: Vec<Arc<dyn BaseTool>> = vec![Arc::new(Calculator)];
let agent = ReActAgent::new(llm, tools, None);
let prompt = agent.build_prompt("计算 3 + 3", &[], Some("用户: 你好\n助手: 你好!"));
assert!(prompt.contains("之前的对话历史"));
assert!(prompt.contains("你好"));
}
#[test]
fn test_build_prompt_with_system_prompt() {
let config = create_test_config();
let llm = OpenAIChat::new(config);
let tools: Vec<Arc<dyn BaseTool>> = vec![Arc::new(Calculator)];
let agent = ReActAgent::new(llm, tools, Some("你是一个数学助手".to_string()));
let prompt = agent.build_prompt("计算 4 + 4", &[], None);
assert!(prompt.contains("你是一个数学助手"));
}
struct UsageStreamingLLM;
#[async_trait]
impl Runnable<Vec<Message>, LLMResult> for UsageStreamingLLM {
type Error = ProviderError;
async fn invoke(
&self,
_input: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error> {
Ok(LLMResult {
content: "Final Answer: 42".to_string(),
model: "mock".to_string(),
token_usage: None,
tool_calls: None,
thinking_content: None,
})
}
}
#[async_trait]
impl BaseLanguageModel<Vec<Message>, LLMResult> for UsageStreamingLLM {
fn model_name(&self) -> &str {
"mock"
}
fn get_num_tokens(&self, t: &str) -> usize {
t.len()
}
fn with_temperature(self, _: f32) -> Self {
self
}
fn with_max_tokens(self, _: usize) -> Self {
self
}
}
#[async_trait]
impl BaseChatModel for UsageStreamingLLM {
async fn chat(
&self,
messages: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error> {
self.invoke(messages, config).await
}
async fn stream_chat(
&self,
_messages: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
{
let chunks = [
Ok(StreamChunk::new("Final ")),
Ok(StreamChunk {
text: "Answer: 42".to_string(),
token_usage: Some(TokenUsage {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
}),
}),
];
Ok(Box::pin(futures_util::stream::iter(chunks)))
}
}
#[tokio::test]
async fn test_plan_stream_records_streaming_token_usage() {
let llm = UsageStreamingLLM;
let agent = ReActAgent::new(llm, vec![], None);
let mut inputs = HashMap::new();
inputs.insert("input".to_string(), "6 * 7".to_string());
let mut received = String::new();
let mut on_token = |text: String| {
received.push_str(&text);
Box::pin(async move {}) as Pin<Box<dyn Future<Output = ()> + Send>>
};
let output = agent
.plan_stream(&[], &inputs, &mut on_token)
.await
.expect("plan_stream should parse to Finish");
assert_eq!(received, "Final Answer: 42");
assert!(matches!(output, AgentOutput::Finish(_)));
let usage = agent.last_token_usage().expect("streaming usage recorded");
assert_eq!(usage.prompt_tokens, 10);
assert_eq!(usage.completion_tokens, 5);
assert_eq!(usage.total_tokens, 15);
}
}