feat(tui): implement agent turn engine for background processing and enhance input handling
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
//! 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::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: &PathBuf,
|
||||
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);
|
||||
|
||||
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 = tc.function.arguments.clone();
|
||||
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user