2026-07-20 10:55:09 +07:00
|
|
|
//! 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.
|
|
|
|
|
|
2026-07-20 12:02:48 +07:00
|
|
|
use std::path::{Path, PathBuf};
|
2026-07-20 10:55:09 +07:00
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
|
|
|
|
|
|
use tracing::{debug, info, warn};
|
|
|
|
|
|
2026-07-20 11:29:33 +07:00
|
|
|
use zesdex_domain::core::tool_call::sanitize_tool_arguments;
|
2026-07-20 10:55:09 +07:00
|
|
|
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) {
|
2026-07-20 13:52:20 +07:00
|
|
|
// 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
|
2026-07-20 10:55:09 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
2026-07-20 14:30:36 +07:00
|
|
|
// Resolve LLM provider configuration from settings
|
|
|
|
|
let provider_name = &state.settings.provider;
|
|
|
|
|
let provider_cfg = state.app_config.providers.get(provider_name).cloned();
|
|
|
|
|
|
|
|
|
|
let mut api_key = String::new();
|
|
|
|
|
if let Some(key) = state.settings.api_keys.get(provider_name) {
|
|
|
|
|
api_key = key.clone();
|
|
|
|
|
} else if let Some(ref cfg) = provider_cfg {
|
|
|
|
|
if let Some(ref default_key) = cfg.default_api_key {
|
|
|
|
|
api_key = default_key.clone();
|
|
|
|
|
}
|
|
|
|
|
if api_key.is_empty() {
|
|
|
|
|
if let Some(ref env_name) = cfg.api_key_env {
|
|
|
|
|
if let Ok(val) = std::env::var(env_name) {
|
|
|
|
|
api_key = val;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let model = state.settings.model.clone();
|
|
|
|
|
let api_base = provider_cfg.map(|cfg| cfg.api_base.clone());
|
|
|
|
|
|
2026-07-20 10:55:09 +07:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 14:30:36 +07:00
|
|
|
info!("spawning agent turn with {} messages (model: {})", messages.len(), model);
|
2026-07-20 10:55:09 +07:00
|
|
|
|
|
|
|
|
std::thread::spawn(move || {
|
2026-07-20 14:30:36 +07:00
|
|
|
run_turn(
|
|
|
|
|
&mut messages,
|
|
|
|
|
&session_dir,
|
|
|
|
|
&workspace_roots,
|
|
|
|
|
&turn_events,
|
|
|
|
|
&in_flight,
|
|
|
|
|
&abort,
|
|
|
|
|
api_key,
|
|
|
|
|
model,
|
|
|
|
|
api_base,
|
|
|
|
|
);
|
2026-07-20 10:55:09 +07:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The core agent turn — LLM call → tool execution → repeat.
|
|
|
|
|
fn run_turn(
|
|
|
|
|
messages: &mut Vec<ChatMessage>,
|
2026-07-20 12:02:48 +07:00
|
|
|
session_dir: &Path,
|
2026-07-20 10:55:09 +07:00
|
|
|
workspace_roots: &[PathBuf],
|
|
|
|
|
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
|
2026-07-20 13:52:20 +07:00
|
|
|
in_flight: &Arc<AtomicBool>,
|
2026-07-20 10:55:09 +07:00
|
|
|
abort: &Arc<AtomicBool>,
|
2026-07-20 14:30:36 +07:00
|
|
|
api_key: String,
|
|
|
|
|
model: String,
|
|
|
|
|
api_base: Option<String>,
|
2026-07-20 10:55:09 +07:00
|
|
|
) {
|
2026-07-20 14:30:36 +07:00
|
|
|
let client = LlmClient::new(api_key, model, api_base);
|
2026-07-20 10:55:09 +07:00
|
|
|
|
|
|
|
|
let tools = all_tools();
|
|
|
|
|
let defs = tool_defs(&tools);
|
|
|
|
|
|
2026-07-20 11:29:33 +07:00
|
|
|
// 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);
|
|
|
|
|
|
2026-07-20 10:55:09 +07:00
|
|
|
let tool_ctx = ToolCtx::builder()
|
2026-07-20 12:02:48 +07:00
|
|
|
.session_dir(session_dir.to_path_buf())
|
2026-07-20 10:55:09 +07:00
|
|
|
.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;
|
2026-07-20 11:29:33 +07:00
|
|
|
let args = sanitize_tool_arguments(&tc.function.arguments);
|
2026-07-20 10:55:09 +07:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 12:26:08 +07:00
|
|
|
// 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()));
|
2026-07-20 10:55:09 +07:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 13:52:20 +07:00
|
|
|
/// Mark the turn as done using lock-free atomic store.
|
|
|
|
|
fn mark_done(flag: &Arc<AtomicBool>) {
|
|
|
|
|
flag.store(false, Ordering::SeqCst);
|
2026-07-20 10:55:09 +07:00
|
|
|
}
|