297 lines
9.8 KiB
Rust
297 lines
9.8 KiB
Rust
//! Agent main loop — orchestrates LLM calls and tool execution.
|
|
//!
|
|
//! Port of `agent_loop.c` from the C project:
|
|
//! 1. Append the user message to the chat store.
|
|
//! 2. Build `[system] + history` and call the LLM with the tool catalog.
|
|
//! 3. Store the assistant reply (with any tool_calls).
|
|
//! 4. Execute each tool call, storing the results as `tool` messages.
|
|
//! 5. Repeat until there are no tool calls, the iteration cap is hit,
|
|
//! or the user cancels.
|
|
//!
|
|
//! The loop runs on its own OS thread. The UI polls
|
|
//! `sovereign://agents/status` + `sovereign://agents/messages`.
|
|
|
|
use once_cell::sync::Lazy;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Mutex;
|
|
|
|
use crate::agent_chat_store;
|
|
use crate::agent_llm;
|
|
use crate::agent_tools;
|
|
|
|
/// Loop state, serialized as the strings the chat UI expects.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AgentLoopState {
|
|
Idle,
|
|
Thinking,
|
|
ToolCall,
|
|
Complete,
|
|
Error,
|
|
Cancelled,
|
|
}
|
|
|
|
impl AgentLoopState {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
AgentLoopState::Idle => "idle",
|
|
AgentLoopState::Thinking => "thinking",
|
|
AgentLoopState::ToolCall => "tool_call",
|
|
AgentLoopState::Complete => "complete",
|
|
AgentLoopState::Error => "error",
|
|
AgentLoopState::Cancelled => "cancelled",
|
|
}
|
|
}
|
|
|
|
fn is_running(&self) -> bool {
|
|
matches!(self, AgentLoopState::Thinking | AgentLoopState::ToolCall)
|
|
}
|
|
}
|
|
|
|
/// Status snapshot returned by `sovereign://agents/status`.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AgentLoopStatus {
|
|
pub state: AgentLoopState,
|
|
pub iteration: i32,
|
|
pub current_tool: Option<String>,
|
|
pub last_message: Option<String>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
impl AgentLoopStatus {
|
|
pub fn to_json(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"state": self.state.as_str(),
|
|
"iteration": self.iteration,
|
|
"current_tool": self.current_tool,
|
|
"last_message": self.last_message,
|
|
"error": self.error,
|
|
})
|
|
}
|
|
}
|
|
|
|
static G_STATUS: Lazy<Mutex<AgentLoopStatus>> = Lazy::new(|| {
|
|
Mutex::new(AgentLoopStatus {
|
|
state: AgentLoopState::Idle,
|
|
iteration: 0,
|
|
current_tool: None,
|
|
last_message: None,
|
|
error: None,
|
|
})
|
|
});
|
|
static G_CANCEL: AtomicBool = AtomicBool::new(false);
|
|
|
|
/// Max characters of a single tool result fed back to the LLM (keeps a
|
|
/// huge page dump from blowing the context window).
|
|
const MAX_TOOL_RESULT_CHARS: usize = 60_000;
|
|
|
|
fn set_status(state: AgentLoopState, iteration: i32, tool: Option<String>) {
|
|
let mut s = G_STATUS.lock().unwrap();
|
|
s.state = state;
|
|
s.iteration = iteration;
|
|
s.current_tool = if state == AgentLoopState::ToolCall { tool } else { None };
|
|
if state != AgentLoopState::Error {
|
|
s.error = None;
|
|
}
|
|
}
|
|
|
|
fn set_error(msg: &str) {
|
|
eprintln!("[agent-loop] error: {}", msg);
|
|
let mut s = G_STATUS.lock().unwrap();
|
|
s.state = AgentLoopState::Error;
|
|
s.current_tool = None;
|
|
s.error = Some(msg.to_string());
|
|
}
|
|
|
|
/// Current status snapshot.
|
|
pub fn agent_loop_get_status() -> AgentLoopStatus {
|
|
G_STATUS.lock().unwrap().clone()
|
|
}
|
|
|
|
/// Whether the loop is currently running.
|
|
pub fn agent_loop_is_running() -> bool {
|
|
G_STATUS.lock().unwrap().state.is_running()
|
|
}
|
|
|
|
/// Request cancellation (takes effect between LLM calls / tool calls).
|
|
pub fn agent_loop_cancel() {
|
|
if agent_loop_is_running() {
|
|
G_CANCEL.store(true, Ordering::SeqCst);
|
|
// Reflect the cancel immediately so the UI's stop button resets;
|
|
// the worker exits at its next checkpoint.
|
|
set_status(AgentLoopState::Cancelled, agent_loop_get_status().iteration, None);
|
|
}
|
|
}
|
|
|
|
fn truncate(s: &str, max: usize) -> String {
|
|
if s.chars().count() <= max {
|
|
s.to_string()
|
|
} else {
|
|
let head: String = s.chars().take(max).collect();
|
|
format!("{}\n…[truncated {} chars]", head, s.chars().count() - max)
|
|
}
|
|
}
|
|
|
|
/// OpenAI function-tool definitions for the whole catalog.
|
|
fn tool_definitions() -> Vec<serde_json::Value> {
|
|
let mut tools = agent_tools::agent_tools_get_all();
|
|
tools.sort_by(|a, b| a.name.cmp(&b.name));
|
|
tools
|
|
.iter()
|
|
.map(|t| {
|
|
serde_json::json!({
|
|
"type": "function",
|
|
"function": {
|
|
"name": t.name,
|
|
"description": t.description,
|
|
"parameters": t.input_schema,
|
|
}
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Start the agent loop for `user_message` on a background thread.
|
|
/// Returns an error if it is already running.
|
|
pub fn agent_loop_run(user_message: &str) -> Result<(), String> {
|
|
{
|
|
let mut s = G_STATUS.lock().unwrap();
|
|
if s.state.is_running() {
|
|
return Err("Agent is already running".to_string());
|
|
}
|
|
s.state = AgentLoopState::Thinking;
|
|
s.iteration = 0;
|
|
s.current_tool = None;
|
|
s.error = None;
|
|
s.last_message = None;
|
|
}
|
|
G_CANCEL.store(false, Ordering::SeqCst);
|
|
|
|
// Store the user message synchronously so it shows up immediately.
|
|
// If it can't be stored, don't start: the LLM would never see it.
|
|
if let Err(e) = agent_chat_store::agent_chat_store_add_user_message(user_message) {
|
|
set_error(&e);
|
|
return Err(e);
|
|
}
|
|
|
|
std::thread::Builder::new()
|
|
.name("agent-loop".into())
|
|
.spawn(run_loop)
|
|
.map_err(|e| {
|
|
set_error(&format!("Failed to spawn agent thread: {}", e));
|
|
"Failed to start agent loop".to_string()
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
fn cancelled(iter: i32) -> bool {
|
|
if G_CANCEL.load(Ordering::SeqCst) {
|
|
set_status(AgentLoopState::Cancelled, iter, None);
|
|
println!("[agent-loop] cancelled at iteration {}", iter);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
fn run_loop() {
|
|
run_loop_inner();
|
|
// Persist the conversation (derives a title from the first user
|
|
// message if it has none, and syncs to Nostr when a signer is
|
|
// available). The chat page also auto-saves, but only if its poll
|
|
// happened to observe the running state — a fast reply, or a message
|
|
// sent via the URL bar's `; ` shortcut, would otherwise never be saved.
|
|
let sid = agent_chat_store::agent_chat_store_get_session();
|
|
let _ = crate::agent_conversations::agent_conversations_save(Some(&sid), None);
|
|
}
|
|
|
|
fn run_loop_inner() {
|
|
let cfg = agent_llm::agent_config_get();
|
|
let base_url = cfg.base_url();
|
|
let api_key = cfg.api_key();
|
|
let model = cfg.model.clone();
|
|
let max_iter = cfg.effective_max_iterations();
|
|
let system_prompt = crate::agent_skills::agent_skills_build_system_prompt();
|
|
let tools = tool_definitions();
|
|
|
|
println!(
|
|
"[agent-loop] base_url={} model={} api_key={} max_iter={}",
|
|
base_url,
|
|
model,
|
|
if api_key.is_empty() { "(empty)" } else { "(set)" },
|
|
max_iter
|
|
);
|
|
|
|
for iter in 0..max_iter {
|
|
if cancelled(iter) {
|
|
return;
|
|
}
|
|
set_status(AgentLoopState::Thinking, iter, None);
|
|
|
|
let mut messages = vec![serde_json::json!({"role": "system", "content": system_prompt})];
|
|
messages.extend(agent_chat_store::agent_chat_store_get_messages());
|
|
|
|
let reply = match agent_llm::agent_llm_chat(&base_url, &api_key, &model, &messages, &tools) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
set_error(&e);
|
|
return;
|
|
}
|
|
};
|
|
if cancelled(iter) {
|
|
return;
|
|
}
|
|
|
|
let content = reply["content"].as_str().unwrap_or("").to_string();
|
|
let tool_calls: Vec<serde_json::Value> = reply["tool_calls"].as_array().cloned().unwrap_or_default();
|
|
if let Err(e) = agent_chat_store::agent_chat_store_add_assistant_message(&content, reply.get("tool_calls")) {
|
|
set_error(&e);
|
|
return;
|
|
}
|
|
if !content.is_empty() {
|
|
G_STATUS.lock().unwrap().last_message = Some(content.clone());
|
|
}
|
|
|
|
if tool_calls.is_empty() {
|
|
set_status(AgentLoopState::Complete, iter, None);
|
|
println!("[agent-loop] complete after {} iteration(s)", iter + 1);
|
|
return;
|
|
}
|
|
|
|
for call in &tool_calls {
|
|
if cancelled(iter) {
|
|
// Keep the transcript valid for the next request: every
|
|
// tool_call must have a matching tool message.
|
|
let id = call["id"].as_str().unwrap_or("");
|
|
let _ = agent_chat_store::agent_chat_store_add_tool_result(id, "{\"error\":\"cancelled by user\"}");
|
|
continue;
|
|
}
|
|
let id = call["id"].as_str().unwrap_or("").to_string();
|
|
let name = call["function"]["name"].as_str().unwrap_or("").to_string();
|
|
let args_str = call["function"]["arguments"].as_str().unwrap_or("{}");
|
|
let args: serde_json::Value = if args_str.trim().is_empty() {
|
|
serde_json::json!({})
|
|
} else {
|
|
serde_json::from_str(args_str).unwrap_or_else(|_| serde_json::json!({}))
|
|
};
|
|
|
|
set_status(AgentLoopState::ToolCall, iter, Some(name.clone()));
|
|
println!("[agent-loop] iter {}: tool {} {}", iter, name, truncate(args_str, 200));
|
|
|
|
let result = match agent_tools::agent_tools_execute(&name, &args) {
|
|
Ok(v) => serde_json::to_string(&v).unwrap_or_default(),
|
|
Err(e) => serde_json::json!({"error": e}).to_string(),
|
|
};
|
|
if let Err(e) = agent_chat_store::agent_chat_store_add_tool_result(&id, &truncate(&result, MAX_TOOL_RESULT_CHARS)) {
|
|
set_error(&e);
|
|
return;
|
|
}
|
|
}
|
|
if G_CANCEL.load(Ordering::SeqCst) {
|
|
set_status(AgentLoopState::Cancelled, iter, None);
|
|
return;
|
|
}
|
|
}
|
|
|
|
set_error(&format!("Reached the iteration limit ({})", max_iter));
|
|
}
|