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 14:47:13 +07:00
// Prepend system message
let sys_msg = ChatMessage ::system (
"You are Zesdex, an AI coding assistant. You have access to various tools via native function calling to help the user. \
For any non-trivial tasks, you MUST prioritize creating a structured plan (using `plan_enter`) and a list of TODOs (using `todowrite`) BEFORE executing any other tools or modifying files. \
Use other tools when necessary. If a tool returns an error, fix the issue before retrying. \
Respond conversationally, concisely, and helpfully."
. to_string (),
);
2026-07-20 11:29:33 +07:00
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}" );
2026-07-20 14:51:16 +07:00
// Stream the LLM response
push_event ( turn_events , TurnEvent ::StreamStart );
let result = client . chat_with_tools_streaming (
2026-07-20 10:55:09 +07:00
messages ,
Some ( defs . clone ()),
Some ( 0.7 ),
2026-07-20 14:51:16 +07:00
Some ( 4096 ),
| event | {
if abort . load ( Ordering ::SeqCst ) {
return false ;
}
match event {
zesdex_domain ::core ::StreamEvent ::Token ( s ) | zesdex_domain ::core ::StreamEvent ::Reasoning ( s ) => {
push_event ( turn_events , TurnEvent ::StreamToken ( s . clone ()));
}
_ => {}
}
true
},
Some ( & abort ),
2026-07-20 10:55:09 +07:00
);
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 ();
2026-07-20 14:51:16 +07:00
push_event ( turn_events , TurnEvent ::StreamDone ( assistant_msg . clone ()));
2026-07-20 10:55:09 +07:00
if let Some (( tokens_in , tokens_out )) = usage {
push_event ( turn_events , TurnEvent ::Usage {
tokens_in ,
tokens_out ,
});
}
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
}