//! 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::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 = 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: &PathBuf, 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.clone()) .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; } } } 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); } } fn mark_done(flag: &Arc>) { if let Ok(mut f) = flag.lock() { *f = false; } }