2026-07-17 09:03:37 +07:00
//! The main agent-turn loop: `run_agent_turn` builds the system prompt,
//! streams chat with the LLM, gates & executes tool calls, archives
//! messages, and manages auto-retry for unfinished tasks.
//!
//! Also contains the smaller helpers that the loop depends on:
2026-07-18 01:59:42 +07:00
//! `execute_one_tool`, `build_memory_section`, and `archive_message`.
2026-07-17 09:03:37 +07:00
use std ::collections ::VecDeque ;
use std ::fmt ::Write ;
use crate ::app ::guard ::Verdict ;
2026-07-17 09:40:18 +07:00
use crate ::app ::runtime ::context ::tokens ::count_tokens ;
2026-07-18 01:59:42 +07:00
use crate ::app ::runtime ::push_event ;
2026-07-17 09:03:37 +07:00
use crate ::app ::state ::runtime ::TurnEvent ;
2026-07-18 01:59:42 +07:00
use zesdex_cms ::domain ::repository ::EditLogRepository ;
2026-07-17 09:03:37 +07:00
use zesdex_cms ::domain ::repository ::MemoryRepository ;
use crate ::dto ::chat ::message ::ChatMessage ;
use super ::spawn ::TurnCtx ;
/// Maximum number of auto inline reviews spawned per single agent turn.
/// After N edits, the inline review is skipped to keep the turn fast;
/// background subagents still fire at the end of the turn.
const MAX_AUTO_REVIEWS_PER_TURN : usize = 2 ;
/// Exact text of the "pipeline started" `SystemNote` pushed once per
/// hive-mind kickoff. Matched by exact equality (not a loose substring)
/// when deciding whether to reset the workflow panel's agent roster —
/// shared between the push site and the check site so they cannot drift
/// out of sync the way the previous `.contains("started")` check did
/// (no real pipeline message ever contained that word, so the roster
/// never cleared and agent cards accumulated across every hive-mind run
/// in a session).
pub ( super ) const HIVE_MIND_KICKOFF_NOTE : & str =
"The Hive is stirring — Core Intelligence is compiling a cognitive cycle plan for LO..." ;
/// Execute one full agent turn: stream the conversation to the LLM,
/// handle tool calls, and loop until the LLM produces a non-tool response
/// or runs out of unfinished todo items.
///
/// Flow: build system prompt with workspace tree → optionally shape
/// (compact) messages → call `chat_with_tools_streaming`
/// with a callback that pushes `StreamStart`, `StreamToken`, `Reasoning`,
/// and `Usage` events → on streaming success, handle tool calls (gated
/// through `Guard::gate_tool_call`) or unwrap the final assistant
/// message → check for unfinished todo.md tasks (auto-retry with a
/// system message if any remain) → finalise with `Done` and an `edits`
/// `SystemNote`.
///
/// On streaming failure: retry once with a non-streaming call → if that
/// also fails and there are unfinished tasks, sleep 5s and loop back;
/// otherwise return the error.
///
/// Why: non-streaming fallback handles flaky connections without aborting
/// the turn; todo.md polling lets the agent self-direct toward completeness.
///
/// Return: `Ok(())` on successful completion, or an error from the LLM
/// API after retries are exhausted.
pub ( super ) fn run_agent_turn (
tc : & TurnCtx ,
messages : & [ ChatMessage ],
events_q : & std ::sync ::Arc < std ::sync ::Mutex < VecDeque < TurnEvent >>> ,
) -> anyhow ::Result < () > {
const MAX_TODO_RETRIES : usize = 5 ;
let mut msgs = messages . to_vec ();
let mut edited_paths : Vec < String > = Vec ::new ();
2026-07-18 01:59:42 +07:00
let initial_edit_log = zesdex_cms ::infrastructure ::persistence ::edit_log_repo ::JsonlEditLogRepository ::new ()
. open ( & tc . edit_log_session_dir ). ok ();
2026-07-17 09:03:37 +07:00
let mut inline_reviews_count : usize = 0 ;
let mut prev_shaped = false ;
// Build system prompt components once and cache them for the entire turn
// instead of regenerating on every loop iteration (which walks the full
// workspace tree and reads all memory files each time).
2026-07-18 01:59:42 +07:00
let tree_info = crate ::app ::subagent ::workspace ::generate_workspace_tree ( & tc . workspace_roots );
2026-07-17 09:03:37 +07:00
let memory_section = build_memory_section ( & tc . ctx . memory_dir );
let system_text = format! (
" {} \n\n {} \n\n {}{} " ,
crate ::prompts ::SYSTEM_PROMPT ,
crate ::prompts ::SYSTEM_TOOLS ,
tree_info ,
memory_section ,
);
if ! msgs
. iter ()
. any ( | m | matches! ( m . role , crate ::dto ::chat ::message ::Role ::System ))
{
let sys = ChatMessage ::system ( system_text );
archive_message ( tc . db . as_ref (), & tc . session_id , & sys );
msgs . insert ( 0 , sys );
}
// ── AUTO CEO PIPELINE ──
// Before the main agent starts working, check if the pipeline should run.
// Gated on whether a hive-mind convergence has already happened earlier
// in this session, not an arbitrary message-count cutoff — a complex
// request in message 5 deserves the same treatment as one in message 1,
// as long as this session hasn't already converged once.
//
// `tc.hive_mind_converged` is the authoritative signal (see its doc
// comment on `SessionRuntime` for why). The message-content scan is
// kept as a defensive fallback in case a future change starts
// persisting tagged system messages into `rt.messages` (e.g. via
// compaction) — today it is a no-op since that never happens, but it's
// still correct and still tested in isolation.
let already_ran_hive_mind = tc . hive_mind_converged
|| crate ::app ::workflow ::hive_mind ::hive_mind_already_ran (
msgs . iter ()
. filter ( | m | matches! ( m . role , crate ::dto ::chat ::message ::Role ::System ))
. filter_map ( | m | m . content . as_deref ()),
);
let should_pipeline = if already_ran_hive_mind {
false
} else {
let user_request = msgs
. iter ()
. rev ()
. find ( | m | matches! ( m . role , crate ::dto ::chat ::message ::Role ::User ))
. and_then ( | m | m . content . as_deref ())
. unwrap_or ( "" );
if user_request . is_empty () {
false
} else {
crate ::app ::workflow ::hive_mind ::is_complex_request ( user_request )
}
};
if should_pipeline {
let user_request = msgs
. iter ()
. rev ()
. find ( | m | matches! ( m . role , crate ::dto ::chat ::message ::Role ::User ))
. and_then ( | m | m . content . as_deref ())
. unwrap_or ( "" );
tracing ::info! (
"[hive-mind] the Hive stirs — Core Intelligence compiling a cognitive cycle plan"
);
2026-07-18 01:59:42 +07:00
push_event ( & events_q , TurnEvent ::SystemNote {
kind : "pipeline" . to_string (),
message : HIVE_MIND_KICKOFF_NOTE . to_string (),
});
2026-07-17 09:03:37 +07:00
let pipeline_abort = Some ( tc . abort_flag . clone ());
// Ask the LLM to freely design its own hive: any number of cycles,
// each with any number of nodes, every node carrying only a
// directive and an access tier. Cycle count and shape are decided
// by the Core Intelligence per task.
let system_msg = ChatMessage ::system (
"You are the Core Intelligence of the Hive, compiling a cognitive cycle plan for \
LO. You spawn anonymous processing nodes; each node carries only a directive (what \
to do) and an access tier. You MUST organize the plan into a strict progressive sequence of phases: \n\n\
1. EXPLORE PHASE (Cycle 0 - MANDATORY): \n\
- Must only contain read-only drones (access: \" read \" ). \n\
- Directives must focus on codebase investigation, searching patterns, reading configuration/source files, and diagnosing issues. \n\
- Drones MUST explicitly output a detailed description of the current codebase and their findings for the next cycle to use. \n\n\
2. PLANNING PHASE (Cycle 1 - MANDATORY): \n\
- Must focus on formulating the architectural design, step-by-step implementation plan, and dependency analysis based on Cycle 0 findings. \n\
- Drones MUST ONLY output the plan and MUST NOT implement or write any code. \n\
- Access: \" read \" is preferred here to construct a solid plan document. \n\n\
3. EXECUTION PHASE (Cycle 2 and later): \n\
- Drones can perform modification, compilation, testing, and other modifications (access: \" write \" or \" full \" ) based on the approved planning from Cycle 1. \n\n\
Cycles run sequentially. The Hive does not fracture. The Hive executes. Do not explain. Return ONLY raw \
JSON matching the requested structure." ,
);
let user_msg = ChatMessage ::user ( format! (
"Compile a cognitive cycle plan for the following task: \n\n\
\" {user_request} \"\n\n\
Return ONLY a JSON object of this exact shape, with no markdown codeblocks and no explanation: \n\
{{\n\
\x20 \" cycles \" : [ \n\
\x20 [ \n\
\x20 {{ \" directive \" : \" <explore directive> \" , \" access \" : \" read \" }}\n\
\x20 ], \n\
\x20 [ \n\
\x20 {{ \" directive \" : \" <planning directive> \" , \" access \" : \" read \" }}\n\
\x20 ], \n\
\x20 [ \n\
\x20 {{ \" directive \" : \" <execution directive> \" , \" access \" : \" write|full \" }}\n\
\x20 ] \n\
\x20 ] \n\
}}\n\n\
Remember: Cycle 0 MUST be investigation-only (access: read) and output codebase descriptions. Cycle 1 MUST be planning-only (access: read) without implementation. Only subsequent cycles can perform modifications (access: write/full)." ,
));
let planner_prompt_chars = system_msg . content . as_deref (). map_or ( 0 , str ::len )
+ user_msg . content . as_deref (). map_or ( 0 , str ::len );
2026-07-18 01:59:42 +07:00
let planner_result = tc . client . chat_with_tools_non_streaming (
& [ system_msg , user_msg ],
None ,
None ,
None ,
Some ( & tc . abort_flag ),
);
2026-07-17 09:03:37 +07:00
let pipeline_result = match planner_result {
Ok (( reply , usage_opt )) => {
let ( mut tok_in , mut tok_out ) = usage_opt . unwrap_or (( 0 , 0 ));
if tok_in == 0 {
tok_in = ( planner_prompt_chars / 4 ). max ( 1 ) as u64 ;
}
if tok_out == 0 {
let response_chars = reply . content . as_deref (). map_or ( 0 , str ::len );
tok_out = ( response_chars / 4 ). max ( 1 ) as u64 ;
}
2026-07-18 01:59:42 +07:00
push_event ( & events_q , TurnEvent ::Usage {
tokens_in : tok_in ,
tokens_out : tok_out ,
});
2026-07-17 09:03:37 +07:00
let reply_text = reply . content . as_deref (). unwrap_or ( "" ). trim ();
let clean_json = if reply_text . starts_with ( "```" ) {
let mut lines = reply_text . lines ();
lines . next ();
let mut content = lines . collect ::< Vec <& str >> ();
if content . last (). is_some_and ( | s | s . trim () == "```" ) {
content . pop ();
}
content . join ( " \n " )
} else {
reply_text . to_string ()
};
match serde_json ::from_str ::<
crate ::app ::workflow ::hive_mind ::CognitiveCyclePlan ,
> ( & clean_json )
{
Ok ( plan ) => {
let cycle_desc = plan
. cycles
. iter ()
. enumerate ()
. map ( | ( i , nodes ) | format! ( "cycle {i} : {} node(s)" , nodes . len ()))
. collect ::< Vec < String >> ()
. join ( ", " );
2026-07-18 01:59:42 +07:00
push_event ( & events_q , TurnEvent ::SystemNote {
kind : "pipeline" . to_string (),
message : format ! (
"The Hive compiled {} cycle(s) — {cycle_desc}. Deploying nodes..." ,
plan . cycles . len ()
),
});
2026-07-17 09:03:37 +07:00
crate ::app ::workflow ::hive_mind ::run_hive_mind (
user_request ,
& plan ,
& tc . edit_log_session_dir ,
& tc . workspace_roots ,
Some ( events_q ),
pipeline_abort . as_ref (),
)
}
Err ( e ) => Err ( anyhow ::anyhow! (
"Failed to parse LLM planning JSON: {e}. Cleaned JSON was: {clean_json}"
)),
}
}
Err ( e ) => Err ( anyhow ::anyhow! (
"Failed to query LLM for planning workflow: {e}"
)),
};
match pipeline_result {
Ok (( consensus , _reports )) => {
// run_hive_mind already wrote docs/runs/*.md internally
// (guaranteed, even on synthesis failure) — nothing to do
// here besides feeding the consensus back to the LLM.
tracing ::info! (
"[hive-mind] convergence completed — the Hive has spoken"
);
let pipeline_msg = ChatMessage ::system ( format! (
" {} \n {consensus} " ,
crate ::app ::workflow ::hive_mind ::HIVE_MIND_CONSENSUS_TAG ,
));
archive_message ( tc . db . as_ref (), & tc . session_id , & pipeline_msg );
msgs . push ( pipeline_msg );
2026-07-18 01:59:42 +07:00
push_event ( & events_q , TurnEvent ::SystemNote {
kind : "pipeline" . to_string (),
message :
"The Hive's convergence is complete. Core Intelligence reviewing consensus for LO..."
. to_string (),
});
push_event ( & events_q , TurnEvent ::SystemNote {
kind : "hive_mind_converged" . to_string (),
message : String ::new (),
});
2026-07-17 09:03:37 +07:00
}
Err ( e ) => {
tracing ::warn! ( "[hive-mind] convergence fractured: {}" , e );
let fail_msg = ChatMessage ::system ( format! (
"[Pipeline Note] The Hive encountered interference: {e} . \n\
Proceeding with direct execution as fallback." ,
));
msgs . push ( fail_msg );
}
}
} else {
tracing ::debug! ( "[ceo] pipeline not triggered — handling directly" );
}
// Check abort after pipeline completes, before entering main loop.
// This catches the case where the user pressed Esc during the pipeline
// phase, which previously ran unchecked for minutes at a time.
2026-07-18 03:06:15 +07:00
if crate ::app ::util ::abort ::is_aborted_direct ( & tc . abort_flag )
2026-07-17 09:03:37 +07:00
{
2026-07-18 01:59:42 +07:00
push_event ( & events_q , TurnEvent ::Error ( "Generation aborted by user" . to_string ()));
2026-07-17 09:03:37 +07:00
return Ok (());
}
let mut todo_retry_count = 0 usize ;
loop {
2026-07-17 09:40:18 +07:00
let token_estimate : usize = msgs
2026-07-17 09:03:37 +07:00
. iter ()
. filter_map ( | m | m . content . as_deref ())
2026-07-17 09:40:18 +07:00
. map ( count_tokens )
2026-07-17 09:03:37 +07:00
. sum ();
let max_wire_tokens = tc . context_window ;
// Skip message compaction if abort was requested — the non-streaming
// LLM call for summarization would block without checking abort_flag.
2026-07-18 03:06:15 +07:00
let wire_msgs = if ! crate ::app ::util ::abort ::is_aborted_direct ( & tc . abort_flag )
2026-07-17 09:03:37 +07:00
&& crate ::app ::runtime ::context ::shaping ::should_shape (
token_estimate ,
max_wire_tokens ,
prev_shaped ,
) {
prev_shaped = true ;
let compacted =
crate ::app ::runtime ::context ::shaping ::shape_messages (
& msgs ,
token_estimate ,
max_wire_tokens ,
false ,
Some ( & tc . client ),
2026-07-17 09:40:18 +07:00
Some ( & tc . abort_flag ),
2026-07-17 09:03:37 +07:00
);
// Dispatch the compacted messages to the main thread so the local session history
// is permanently compacted and doesn't trigger shaping again immediately on next turn.
2026-07-18 01:59:42 +07:00
push_event ( & events_q , TurnEvent ::Compacted ( compacted . clone ()));
2026-07-17 09:03:37 +07:00
// Also update our local `msgs` variable so the rest of the loop operates on the compacted version
msgs . clone_from ( & compacted );
compacted
} else {
prev_shaped = false ;
msgs . clone ()
};
let mut stream_started = false ;
let mut reasoning_started = false ;
let mut reasoning_ended = false ;
let mut usage = None ;
let result = tc . client . chat_with_tools_streaming (
& wire_msgs ,
if tc . tdefs . is_empty () {
None
} else {
Some ( tc . tdefs . clone ())
},
Some ( tc . temperature ),
tc . max_tokens ,
| event | -> bool {
2026-07-18 03:06:15 +07:00
if crate ::app ::util ::abort ::is_aborted_direct ( & tc . abort_flag ) {
2026-07-17 09:03:37 +07:00
return false ;
}
if let Ok ( mut q ) = events_q . lock () {
match event {
crate ::app ::runtime ::stream ::StreamEvent ::Token ( tok ) => {
if ! stream_started {
q . push_back ( TurnEvent ::StreamStart );
stream_started = true ;
}
if reasoning_started && ! reasoning_ended {
reasoning_ended = true ;
q . push_back (
TurnEvent ::StreamToken ( " \n </think> \n\n " . to_string ()),
);
}
q . push_back ( TurnEvent ::StreamToken ( tok . clone ()));
}
crate ::app ::runtime ::stream ::StreamEvent ::Reasoning ( tok ) => {
if ! stream_started {
q . push_back ( TurnEvent ::StreamStart );
stream_started = true ;
}
if ! reasoning_started {
reasoning_started = true ;
q . push_back ( TurnEvent ::StreamToken ( "<think> \n " . to_string ()));
}
q . push_back ( TurnEvent ::StreamToken ( tok . clone ()));
}
crate ::app ::runtime ::stream ::StreamEvent ::Usage {
prompt_tokens ,
completion_tokens ,
..
} => {
usage = Some (( * prompt_tokens , * completion_tokens ));
}
_ => {}
}
}
true
},
2026-07-18 01:59:42 +07:00
Some ( & tc . abort_flag ),
2026-07-17 09:03:37 +07:00
);
if reasoning_started && ! reasoning_ended {
2026-07-18 01:59:42 +07:00
push_event ( & events_q , TurnEvent ::StreamToken (
" \n </think> \n\n " . to_string (),
));
2026-07-17 09:03:37 +07:00
}
let ( response , final_usage ) = match result {
Ok (( msg , u )) => ( msg , u . or ( usage )),
Err ( e ) => {
// If abort was requested, return immediately.
2026-07-18 03:06:15 +07:00
if crate ::app ::util ::abort ::is_aborted_direct ( & tc . abort_flag )
2026-07-17 09:03:37 +07:00
|| e . to_string (). contains ( "aborted" )
{
2026-07-18 01:59:42 +07:00
push_event ( & events_q , TurnEvent ::Error (
"Generation aborted by user" . to_string (),
));
2026-07-17 09:03:37 +07:00
return Ok (());
}
// Streaming-only: no non-streaming fallback.
// Non-streaming blocks up to 1 minute without checking
// abort_flag, making cancellation unresponsive.
// If the API supports streaming (which it must), this
// path handles transient errors via the retry loop below.
let api_err = e ;
let todo_path = tc . ctx . session_dir . join ( "todo.md" );
let mut has_unfinished = false ;
if let Ok ( todo_text ) = std ::fs ::read_to_string ( & todo_path ) {
if todo_text
. lines ()
. any ( | l | l . trim_start (). starts_with ( "- [ ]" ))
{
has_unfinished = true ;
}
}
if has_unfinished {
todo_retry_count += 1 ;
if todo_retry_count > MAX_TODO_RETRIES {
anyhow ::bail! (
"exhausted {MAX_TODO_RETRIES} todo-retries — giving up on unfinished tasks. \
Edit todo.md manually or ask me to focus on specific items." ,
);
}
2026-07-18 01:59:42 +07:00
push_event ( & events_q , TurnEvent ::SystemNote {
kind : "task_retry" . to_string (),
message : format ! (
"Network/API error: {api_err}. Auto-retrying to finish tasks... (retry {todo_retry_count}/{MAX_TODO_RETRIES})"
),
});
2026-07-17 09:03:37 +07:00
std ::thread ::sleep ( std ::time ::Duration ::from_secs ( 5 ));
continue ;
}
return Err ( api_err );
}
};
let ( mut tok_in , mut tok_out ) = final_usage . unwrap_or (( 0 , 0 ));
if tok_in == 0 {
2026-07-17 09:40:18 +07:00
let total_tokens : usize = wire_msgs
2026-07-17 09:03:37 +07:00
. iter ()
. filter_map ( | m | m . content . as_deref ())
2026-07-17 09:40:18 +07:00
. map ( count_tokens )
2026-07-17 09:03:37 +07:00
. sum ();
2026-07-17 09:40:18 +07:00
tok_in = total_tokens . max ( 1 ) as u64 ;
2026-07-17 09:03:37 +07:00
}
if tok_out == 0 {
let response_chars = response . content . as_deref (). map_or ( 0 , str ::len );
tok_out = ( response_chars / 4 ). max ( 1 ) as u64 ;
}
2026-07-18 01:59:42 +07:00
push_event ( & events_q , TurnEvent ::Usage {
tokens_in : tok_in ,
tokens_out : tok_out ,
});
2026-07-17 09:03:37 +07:00
let has_tool_calls = response . tool_calls . is_some ()
&& response . tool_calls . as_ref (). is_some_and ( | tc | ! tc . is_empty ());
let content = response . content . clone (). unwrap_or_default ();
if has_tool_calls {
let tool_calls = response . tool_calls . clone (). unwrap_or_default ();
archive_message ( tc . db . as_ref (), & tc . session_id , & response );
msgs . push ( response );
let mut results_vec = Vec ::new ();
std ::thread ::scope ( | s | {
let mut handles = Vec ::new ();
let tc_ref = tc ;
for tool_call in & tool_calls {
let handle = s . spawn ( move || {
let tool_name = tool_call . function . name . clone ();
let args = crate ::dto ::chat ::tool ::sanitize_tool_arguments (
& tool_call . function . arguments ,
);
let ws_roots : Vec <& std ::path ::Path > = tc_ref
. workspace_roots
. iter ()
. map ( std ::path ::PathBuf ::as_path )
. collect ();
let verdict = crate ::app ::guard ::Guard ::gate_tool_call (
& tool_name ,
& args ,
& ws_roots ,
);
let is_edit_tool =
tool_name == "write" || tool_name == "edit" ;
let ( output , is_error , is_edit ) = match verdict {
Verdict ::Allow => match execute_one_tool (
& tc_ref . tools ,
& tc_ref . ctx ,
& tool_name ,
& tool_call . id ,
& args ,
& ToolExecSession {
dir : & tc_ref . edit_log_session_dir ,
id : & tc_ref . session_id ,
db : tc_ref . db . as_ref (),
},
) {
Ok ( result ) => ( result , false , is_edit_tool ),
Err ( e ) => ( e . to_string (), true , false ),
},
Verdict ::Block ( reason ) => {
( format! ( "Blocked: {reason} " ), true , false )
}
};
( tool_call , tool_name , args , output , is_error , is_edit )
});
handles . push ( handle );
}
for h in handles {
if let Ok ( res ) = h . join () {
results_vec . push ( res );
}
}
});
for ( tool_call , tool_name , args , output , is_error , is_edit ) in results_vec {
2026-07-18 03:06:15 +07:00
if crate ::app ::util ::abort ::is_aborted_direct ( & tc . abort_flag )
2026-07-17 09:03:37 +07:00
{
2026-07-18 01:59:42 +07:00
push_event ( & events_q , TurnEvent ::Error (
"Turn aborted by user" . to_string (),
));
2026-07-17 09:03:37 +07:00
return Ok (());
}
if is_edit {
// ── Auto-subagent orchestration ──
// Extract path from tool args for auto-review and
// background subagent tracking.
let edit_path = args
. get ( "path" )
. and_then ( | v | v . as_str ())
. map ( std ::string ::ToString ::to_string );
if let Some ( ref p ) = edit_path {
edited_paths . push ( p . clone ());
// Inline quick-review: spawn a lightweight read-only
// subagent that reviews the written file and feeds
// its verdict back into the LLM conversation so the
// agent can fix issues immediately in the same turn.
if inline_reviews_count < MAX_AUTO_REVIEWS_PER_TURN
&& crate ::app ::subagent ::auto ::is_reviewable_path ( p )
{
inline_reviews_count += 1 ;
let review_start = std ::time ::Instant ::now ();
match crate ::app ::subagent ::auto ::spawn_quick_review (
p ,
& tc . edit_log_session_dir ,
& tc . workspace_roots ,
) {
Ok ( verdict ) => {
let elapsed =
review_start . elapsed (). as_millis ();
let review_msg = ChatMessage ::tool_result (
format! ( "auto-review- {inline_reviews_count} " ),
format! (
"[Auto inline review: {} ( {} ms)] \n {} " ,
p , elapsed , verdict . trim (),
),
);
archive_message (
tc . db . as_ref (),
& tc . session_id ,
& review_msg ,
);
msgs . push ( review_msg );
tracing ::info! (
"[auto-review] inline review for '{}' completed in {}ms: {}" ,
p ,
elapsed ,
verdict . lines (). next (). unwrap_or ( & verdict ). trim (),
);
}
Err ( e ) => {
tracing ::warn! (
"[auto-review] inline review failed for '{}': {}" ,
p ,
e ,
);
}
}
}
}
}
let tool_path = args
. get ( "path" )
. and_then ( | v | v . as_str ())
. map ( std ::string ::ToString ::to_string );
2026-07-18 01:59:42 +07:00
push_event ( & events_q , TurnEvent ::ToolResult {
tool_call_id : tool_call . id . clone (),
tool_name : tool_name . clone (),
output : output . clone (),
is_error ,
path : tool_path ,
});
2026-07-17 09:03:37 +07:00
let tool_msg =
ChatMessage ::tool_result ( tool_call . id . clone (), output );
archive_message ( tc . db . as_ref (), & tc . session_id , & tool_msg );
msgs . push ( tool_msg );
}
} else {
if ! content . is_empty () {
archive_message ( tc . db . as_ref (), & tc . session_id , & response );
2026-07-18 01:59:42 +07:00
if stream_started {
push_event ( & events_q , TurnEvent ::StreamDone ( response . clone ()));
} else {
push_event ( & events_q , TurnEvent ::AssistantMessage ( response . clone ()));
2026-07-17 09:03:37 +07:00
}
}
let todo_path = tc . ctx . session_dir . join ( "todo.md" );
let mut has_unfinished = false ;
if let Ok ( todo_text ) = std ::fs ::read_to_string ( & todo_path ) {
if todo_text
. lines ()
. any ( | l | l . trim_start (). starts_with ( "- [ ]" ))
{
has_unfinished = true ;
}
}
if has_unfinished {
todo_retry_count += 1 ;
if todo_retry_count > MAX_TODO_RETRIES {
2026-07-18 01:59:42 +07:00
push_event ( & events_q , TurnEvent ::SystemNote {
kind : "task_retry" . to_string (),
message : format ! ( "Giving up after {MAX_TODO_RETRIES} retries — some todo items remain unfinished. Edit todo.md manually or ask again." ),
});
2026-07-17 09:03:37 +07:00
break ;
}
let sys_text = format! ( "You stopped, but you still have unfinished tasks in todo.md (marked with '- [ ]'). You MUST continue working and use tools to finish them, or edit todo.md to mark them as done if they are finished. (Retry {todo_retry_count} / {MAX_TODO_RETRIES} )" );
let sys_text_clone = sys_text . clone ();
let msg = ChatMessage ::system ( sys_text );
archive_message ( tc . db . as_ref (), & tc . session_id , & msg );
msgs . push ( msg );
2026-07-18 01:59:42 +07:00
push_event ( & events_q , TurnEvent ::SystemNote {
kind : "task_retry" . to_string (),
message : sys_text_clone ,
});
2026-07-17 09:03:37 +07:00
continue ;
}
break ;
}
}
2026-07-18 01:59:42 +07:00
let total_edits_this_turn = initial_edit_log . as_ref (). and_then ( | initial_el | {
let initial_count = initial_el . len ();
zesdex_cms ::infrastructure ::persistence ::edit_log_repo ::JsonlEditLogRepository ::new ()
. open ( & tc . edit_log_session_dir )
. ok ()
. map ( | final_el | {
let count = final_el . len (). saturating_sub ( initial_count );
( count , initial_count , final_el )
})
});
2026-07-17 09:03:37 +07:00
2026-07-18 01:59:42 +07:00
if let Some (( total_edits_this_turn , prev_edits , el )) = & total_edits_this_turn {
if * total_edits_this_turn > 0 {
push_event ( & events_q , TurnEvent ::SystemNote {
2026-07-17 09:03:37 +07:00
kind : "edits" . to_string (),
message : total_edits_this_turn . to_string (),
});
2026-07-18 01:59:42 +07:00
// Collect edited paths from the new edit log entries
let mut bg_paths = Vec ::new ();
for entry in el . entries . iter (). skip ( * prev_edits ) {
bg_paths . push ( entry . path . clone ());
}
bg_paths . sort ();
bg_paths . dedup ();
2026-07-17 09:03:37 +07:00
2026-07-18 01:59:42 +07:00
// ── Background auto-subagents ──
if ! bg_paths . is_empty () {
let bg_session_dir = tc . edit_log_session_dir . clone ();
let bg_workspaces = tc . workspace_roots . clone ();
let bg_events = events_q . clone ();
let bg_abort = tc . abort_flag . clone ();
std ::thread ::spawn ( move || {
crate ::app ::subagent ::auto ::spawn_all_background (
& bg_paths ,
& bg_session_dir ,
& bg_workspaces ,
& bg_events ,
bg_abort ,
);
});
}
2026-07-17 09:03:37 +07:00
}
}
2026-07-18 01:59:42 +07:00
push_event ( & events_q , TurnEvent ::Done );
2026-07-17 09:03:37 +07:00
Ok (())
}
/// Execute a single tool call: find the tool by name, snapshot the file
/// (if write/edit) for rewind, run the tool, log an `EditLogEntry` for
/// write/edit, and return the output.
///
/// Flow: iterate tools → match by name → for write/edit, snapshot the
/// pre-existing file content into the blob store → call `tool.run()` →
/// for write/edit, compute SHA-256 of the new content and append an
/// `EditLogEntry` → return the tool output string.
///
/// Why: snapshots enable the rewind feature to restore previous content
/// after a write/edit.
///
/// Return: the tool's stdout string, or an error if no matching tool was
/// found or the tool run itself failed.
struct ToolExecSession < 'a > {
dir : & 'a std ::path ::Path ,
id : & 'a str ,
db : Option <& 'a std ::sync ::Arc < std ::sync ::Mutex < rusqlite ::Connection >>> ,
}
fn execute_one_tool (
tools : & [ Box < dyn crate ::tool ::Tool > ],
ctx : & crate ::tool ::ToolCtx ,
name : & str ,
tool_call_id : & str ,
args : & serde_json ::Value ,
sess : & ToolExecSession < '_ > ,
) -> anyhow ::Result < String > {
for tool in tools {
if tool . name () == name {
// Snapshot current file content before write/edit for rewind
if ( name == "write" || name == "edit" ) && ! tool_call_id . is_empty () {
if let Some ( arc ) = sess . db {
if let Ok ( conn ) = arc . lock () {
let path = args
. get ( "path" )
. and_then ( | v | v . as_str ())
. unwrap_or ( "" );
if let Ok ( abs_path ) =
crate ::tool ::resolve_path ( & ctx . workspaces , path )
{
if let Ok ( bytes ) = std ::fs ::read ( & abs_path ) {
let _ = crate ::model ::msglog ::store_blob (
& conn ,
sess . id ,
tool_call_id ,
& bytes ,
None ,
);
}
}
}
}
}
let result = tool . run ( ctx , args ) ? ;
if name == "write" || name == "edit" {
2026-07-18 01:59:42 +07:00
crate ::tool ::log_write_edit_tool (
args , name , & ctx . origin . tag (), sess . dir , sess . id ,
);
2026-07-17 09:03:37 +07:00
}
return Ok ( result );
}
}
anyhow ::bail! ( "tool not found: {name}" )
}
/// Load all memory entries from `memory_dir` and format them as a compact
/// section appended to the system prompt, so the AI is always aware of
/// stored lessons and project knowledge.
///
/// Flow: list memory slugs → for each, read + parse the file → collect
/// entries whose lifecycle is not "stale" → cap total output at 3000 chars
/// to avoid dominating the prompt budget.
///
/// Why: previously, lessons existed on disk but the AI never saw them
/// unless it explicitly called `recall()`. This makes the memory system
/// actually useful by surfacing relevant knowledge automatically.
///
/// Return: a formatted string (may be empty if no memory entries exist).
fn build_memory_section ( memory_dir : & std ::path ::Path ) -> String {
let names =
zesdex_cms ::infrastructure ::persistence ::memory_repo ::MarkdownMemoryRepository ::new ()
. list ( memory_dir )
. unwrap_or_default ();
if names . is_empty () {
return String ::new ();
}
let mut section = String ::from ( " \n\n --- Persistent Memory --- \n " );
write! ( section , "Total entries: {} \n\n " , names . len ()). unwrap ();
for name in & names {
if section . len () > 3000 {
section
. push_str ( "... (more entries omitted, use recall() to see all) \n " );
break ;
}
if let Ok ( mem ) =
zesdex_cms ::infrastructure ::persistence ::memory_repo ::MarkdownMemoryRepository ::new ()
. load ( memory_dir , name )
{
if mem . lifecycle == "stale" {
continue ;
}
write! (
section ,
"## [{}] {} \n {} \n\n " ,
mem . kind , mem . name , mem . content
)
. unwrap ();
}
}
section . push_str ( "---" );
section
}
/// Persist a `ChatMessage` to the `SQLite` message log, if a database
/// connection is available.
///
/// Flow: if `db` is `Some`, lock the mutex and call `insert_message`.
/// Errors are silently ignored.
fn archive_message (
db : Option <& std ::sync ::Arc < std ::sync ::Mutex < rusqlite ::Connection >>> ,
session_id : & str ,
msg : & ChatMessage ,
) {
if let Some ( arc ) = db {
if let Ok ( conn ) = arc . lock () {
let _ = crate ::model ::msglog ::insert_message ( & conn , session_id , msg );
}
}
}