Files
zesdex/apps/interfaces/tui/src/turn.rs
T

196 lines
6.8 KiB
Rust
Raw Normal View History

//! Agent turn engine — runs LLM + tool execution on a background thread.
//!
//! Flow: push user message → spawn OS thread → loop: call blocking LLM
//! client → execute tool calls → push TurnEvents → repeat until done.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::collections::VecDeque;
use tracing::{debug, info, warn};
use zesdex_domain::core::tool_call::sanitize_tool_arguments;
use zesdex_domain::core::ChatMessage;
use zesdex_infrastructure::llm::provider::LlmClient;
use zesdex_infrastructure::tools::{all_tools, tool_defs, ToolCtx};
use zesdex_infrastructure::TurnEvent;
use crate::state::AppStateRest;
/// Spawn an agent turn on a background OS thread.
pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
if let Ok(mut in_flight) = state.turn_in_flight_flag.lock() {
if *in_flight {
return;
}
*in_flight = true;
}
let turn_events = state.turn_events.clone();
let in_flight = state.turn_in_flight_flag.clone();
let abort = state.abort_flag.clone();
let session_dir = state.session_dir.clone();
let workspace_roots = state.workspace_roots.clone();
let mut messages: Vec<ChatMessage> = state
.session_runtime
.as_ref()
.map(|rt| rt.messages.clone())
.unwrap_or_default();
messages.push(ChatMessage::user(text));
if let Some(ref mut rt) = state.session_runtime {
rt.messages = messages.clone();
}
info!("spawning agent turn with {} messages", messages.len());
std::thread::spawn(move || {
run_turn(&mut messages, &session_dir, &workspace_roots, &turn_events, &in_flight, &abort);
});
}
/// The core agent turn — LLM call → tool execution → repeat.
fn run_turn(
messages: &mut Vec<ChatMessage>,
session_dir: &Path,
workspace_roots: &[PathBuf],
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
in_flight: &Arc<Mutex<bool>>,
abort: &Arc<AtomicBool>,
) {
let client = LlmClient::new(
String::new(), // API key resolved internally from env
"deepseek-v4-flash-free".to_string(),
Some("https://opencode.ai/zen/v1".to_string()),
);
let tools = all_tools();
let defs = tool_defs(&tools);
// Build tool description list for the system prompt
let tool_desc: Vec<String> = tools.iter().map(|t| {
let params = t.parameters();
let required = params.get("required").and_then(|r| r.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", "))
.unwrap_or_default();
format!("- {}: {} (required params: {})", t.name(), t.description(), required)
}).collect();
let tool_desc_text = tool_desc.join("\n");
// Prepend system message with tool descriptions
let sys_msg = ChatMessage::system(format!(
"You are Zesdex, an AI coding agent with access to the following tools:\n\n{}\n\n\
When using tools, always provide ALL required parameters in your tool call. \
If a tool returns an error, fix the issue before retrying. \
Respond conversationally and helpfully.",
tool_desc_text
));
messages.insert(0, sys_msg);
let tool_ctx = ToolCtx::builder()
.session_dir(session_dir.to_path_buf())
.workspaces(workspace_roots.to_vec())
.build();
for iteration in 0..50 {
if abort.load(Ordering::SeqCst) {
abort.store(false, Ordering::SeqCst);
push_event(turn_events, TurnEvent::SystemNote {
kind: "info".into(),
message: "Turn aborted by user".into(),
});
break;
}
debug!("agent turn iteration {iteration}");
// Blocking LLM call (reqwest::blocking::Client is sync)
let result = client.chat_with_tools_non_streaming(
messages,
Some(defs.clone()),
Some(4096),
Some(0.7),
None, // no atomic abort flag for the sync API
);
match result {
Ok((assistant_msg, usage)) => {
let content = assistant_msg.content.clone().unwrap_or_default();
let tool_calls = assistant_msg.tool_calls.clone().unwrap_or_default();
if let Some((tokens_in, tokens_out)) = usage {
push_event(turn_events, TurnEvent::Usage {
tokens_in,
tokens_out,
});
}
if !content.is_empty() {
push_event(turn_events, TurnEvent::AssistantMessage(assistant_msg.clone()));
}
if tool_calls.is_empty() {
messages.push(ChatMessage::assistant(Some(content)));
break;
}
messages.push(assistant_msg);
for tc in &tool_calls {
let name = &tc.function.name;
let args = sanitize_tool_arguments(&tc.function.arguments);
debug!("executing tool: {name}");
let output = if let Some(tool) = tools.iter().find(|t| t.name() == name) {
match tool.run(&tool_ctx, &args) {
Ok(o) => o,
Err(e) => format!("Error: {e}"),
}
} else {
format!("Unknown tool: {name}")
};
let is_error = output.starts_with("Error:");
push_event(turn_events, TurnEvent::ToolResult {
tool_call_id: tc.id.clone(),
tool_name: name.clone(),
output: output.clone(),
is_error,
path: None,
});
messages.push(ChatMessage::tool(tc.id.clone(), output.clone()));
}
}
Err(e) => {
warn!("LLM call failed: {e}");
push_event(turn_events, TurnEvent::Error(format!("LLM error: {e}")));
break;
}
}
}
// Propagate accumulated messages back to session_runtime so the next
// turn starts with the full history (assistant replies + tool results).
// TurnEvent::Compacted already exists on the enum and is handled in
// action.rs to write back to state.session_runtime.messages.
push_event(turn_events, TurnEvent::Compacted(messages.clone()));
push_event(turn_events, TurnEvent::Done);
mark_done(in_flight);
}
fn push_event(queue: &Arc<Mutex<VecDeque<TurnEvent>>>, event: TurnEvent) {
if let Ok(mut q) = queue.lock() {
q.push_back(event);
}
}
fn mark_done(flag: &Arc<Mutex<bool>>) {
if let Ok(mut f) = flag.lock() {
*f = false;
}
}