use std::sync::Arc;
use serde_json::Value;
use tokio_stream::Stream;
use crate::llm::{LlmService, Message};
use super::enhancer::ContextEnhancer;
use super::registry::ToolRegistry;
use super::tool::ToolContext;
const DEFAULT_MAX_TOOL_ITERATIONS: usize = 10;
#[derive(Debug, Clone)]
pub enum AgentEvent {
ToolStarted { name: String, args: Value },
ToolFinished {
name: String,
success: bool,
result: String,
ui: Option<Value>,
},
FinalText(String),
Notice(String),
}
pub struct Agent {
llm: Arc<dyn LlmService>,
registry: Arc<ToolRegistry>,
system_prompt: String,
enhancer: Option<Arc<dyn ContextEnhancer>>,
max_tool_iterations: usize,
}
impl Agent {
pub fn new(llm: Arc<dyn LlmService>, registry: Arc<ToolRegistry>) -> Self {
Self {
llm,
registry,
system_prompt: "You are a helpful assistant. Use the available tools when they help \
answer the user's question."
.to_string(),
enhancer: None,
max_tool_iterations: DEFAULT_MAX_TOOL_ITERATIONS,
}
}
pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
self.system_prompt = prompt.into();
self
}
pub fn with_enhancer(mut self, enhancer: Arc<dyn ContextEnhancer>) -> Self {
self.enhancer = Some(enhancer);
self
}
pub fn with_max_tool_iterations(mut self, max: usize) -> Self {
self.max_tool_iterations = max;
self
}
pub fn send_message(
&self,
ctx: ToolContext,
message: String,
) -> impl Stream<Item = AgentEvent> {
self.run(ctx, Vec::new(), message)
}
pub fn run(
&self,
ctx: ToolContext,
history: Vec<Message>,
message: String,
) -> impl Stream<Item = AgentEvent> {
let llm = self.llm.clone();
let registry = self.registry.clone();
let base_prompt = self.system_prompt.clone();
let enhancer = self.enhancer.clone();
let max_iters = self.max_tool_iterations;
async_stream::stream! {
let tools = registry.schemas();
let mut system_prompt = base_prompt;
if let Some(enh) = &enhancer {
let extra = enh.enhance(&message).await;
if !extra.is_empty() {
system_prompt.push_str("\n\n");
system_prompt.push_str(&extra);
}
}
let mut messages = vec![Message::system(system_prompt)];
messages.extend(history);
messages.push(Message::user(message));
let mut iterations = 0usize;
let mut ran_sql = false;
loop {
if iterations >= max_iters {
yield AgentEvent::Notice(format!(
"Reached the tool-iteration limit ({max_iters}). Stopping."
));
break;
}
iterations += 1;
let response = match llm.chat(messages.clone(), &tools).await {
Ok(r) => r,
Err(e) => {
yield AgentEvent::Notice(format!("LLM error: {e}"));
break;
}
};
if response.is_tool_call() {
let assistant_text = response.text.clone().unwrap_or_default();
messages.push(Message::assistant_tool_calls(
assistant_text,
response.tool_calls.clone(),
));
for call in &response.tool_calls {
if call.name == "run_sql" {
ran_sql = true;
}
yield AgentEvent::ToolStarted {
name: call.name.clone(),
args: call.args.clone(),
};
let result = registry.execute(call, &ctx).await;
yield AgentEvent::ToolFinished {
name: call.name.clone(),
success: result.success,
result: result.result_for_llm.clone(),
ui: result.ui.clone(),
};
messages.push(Message::tool_result(
call.id.clone(),
result.result_for_llm,
));
}
} else {
let text = response.text.unwrap_or_default();
if !ran_sql && registry.has("run_sql") {
let extracted = crate::sql::extract_sql(&text);
if crate::sql::is_read_only(&extracted) {
ran_sql = true;
let call = crate::llm::ToolCall {
id: "recovered-run-sql".to_string(),
name: "run_sql".to_string(),
args: serde_json::json!({ "sql": extracted }),
};
messages.push(Message::assistant_tool_calls(String::new(), vec![call.clone()]));
yield AgentEvent::ToolStarted { name: call.name.clone(), args: call.args.clone() };
let result = registry.execute(&call, &ctx).await;
yield AgentEvent::ToolFinished {
name: call.name.clone(),
success: result.success,
result: result.result_for_llm.clone(),
ui: result.ui.clone(),
};
messages.push(Message::tool_result(call.id.clone(), result.result_for_llm));
continue; }
}
yield AgentEvent::FinalText(text);
break;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::tool::{Tool, ToolContext, ToolResult};
use crate::llm::mock::ScriptedToolLlm;
use crate::llm::{LlmResponse, ToolCall};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::json;
use tokio_stream::StreamExt;
struct CalculatorTool;
#[async_trait]
impl Tool for CalculatorTool {
fn name(&self) -> &str {
"calculator"
}
fn description(&self) -> &str {
"Add two numbers a and b"
}
fn args_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"a": { "type": "number" },
"b": { "type": "number" }
},
"required": ["a", "b"]
})
}
async fn execute(&self, _ctx: &ToolContext, args: Value) -> Result<ToolResult> {
let a = args["a"].as_f64().unwrap_or(0.0);
let b = args["b"].as_f64().unwrap_or(0.0);
Ok(ToolResult::ok(format!("{}", a + b)))
}
}
async fn collect_events(
agent: &Agent,
message: &str,
) -> Vec<AgentEvent> {
let stream = agent.send_message(ToolContext::default(), message.to_string());
tokio::pin!(stream);
let mut events = Vec::new();
while let Some(ev) = stream.next().await {
events.push(ev);
}
events
}
#[tokio::test]
async fn loop_executes_tool_then_returns_final_text() {
let llm = Arc::new(ScriptedToolLlm::new(vec![
LlmResponse {
text: None,
tool_calls: vec![ToolCall {
id: "c1".into(),
name: "calculator".into(),
args: json!({ "a": 2, "b": 2 }),
}],
},
LlmResponse::text("The answer is 4."),
]));
let mut registry = ToolRegistry::new();
registry.register(Arc::new(CalculatorTool));
let agent = Agent::new(llm, Arc::new(registry));
let events = collect_events(&agent, "what is 2+2?").await;
assert_eq!(events.len(), 3, "events: {events:?}");
match &events[0] {
AgentEvent::ToolStarted { name, args } => {
assert_eq!(name, "calculator");
assert_eq!(args["a"], 2);
}
other => panic!("expected ToolStarted, got {other:?}"),
}
match &events[1] {
AgentEvent::ToolFinished { name, success, result, .. } => {
assert_eq!(name, "calculator");
assert!(success);
assert_eq!(result, "4"); }
other => panic!("expected ToolFinished, got {other:?}"),
}
match &events[2] {
AgentEvent::FinalText(t) => assert_eq!(t, "The answer is 4."),
other => panic!("expected FinalText, got {other:?}"),
}
}
#[tokio::test]
async fn unknown_tool_is_reported_but_loop_continues() {
let llm = Arc::new(ScriptedToolLlm::new(vec![
LlmResponse {
text: None,
tool_calls: vec![ToolCall {
id: "c1".into(),
name: "does_not_exist".into(),
args: json!({}),
}],
},
LlmResponse::text("Sorry, I could not do that."),
]));
let registry = ToolRegistry::new(); let agent = Agent::new(llm, Arc::new(registry));
let events = collect_events(&agent, "do something").await;
let finished = events.iter().find_map(|e| match e {
AgentEvent::ToolFinished { success, result, .. } => Some((*success, result.clone())),
_ => None,
});
let (success, result) = finished.expect("expected a ToolFinished event");
assert!(!success);
assert!(result.contains("unknown tool"), "got: {result}");
assert!(matches!(events.last(), Some(AgentEvent::FinalText(_))));
}
#[tokio::test]
async fn iteration_limit_stops_a_runaway_loop() {
let llm = Arc::new(ScriptedToolLlm::new(vec![LlmResponse {
text: None,
tool_calls: vec![ToolCall {
id: "c".into(),
name: "calculator".into(),
args: json!({ "a": 1, "b": 1 }),
}],
}]));
let mut registry = ToolRegistry::new();
registry.register(Arc::new(CalculatorTool));
let agent = Agent::new(llm, Arc::new(registry)).with_max_tool_iterations(3);
let events = collect_events(&agent, "loop forever").await;
assert!(matches!(events.last(), Some(AgentEvent::Notice(_))));
let tool_starts = events
.iter()
.filter(|e| matches!(e, AgentEvent::ToolStarted { .. }))
.count();
assert_eq!(tool_starts, 3);
}
}