//! 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) { // compare_exchange: only mark in-flight if not already running if state.turn_in_flight_flag .compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed) .is_err() { return; // already running } 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 = 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, session_dir: &Path, workspace_roots: &[PathBuf], turn_events: &Arc>>, in_flight: &Arc, abort: &Arc, ) { 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 = 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::>().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>>, event: TurnEvent) { if let Ok(mut q) = queue.lock() { q.push_back(event); } } /// Mark the turn as done using lock-free atomic store. fn mark_done(flag: &Arc) { flag.store(false, Ordering::SeqCst); }