//! 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: //! `execute_one_tool`, `build_memory_section`, and `archive_message`. use std::collections::VecDeque; use std::fmt::Write; use crate::app::guard::Verdict; use crate::app::runtime::context::tokens::count_tokens; use crate::app::runtime::push_event; use crate::app::state::runtime::TurnEvent; use zesdex_cms::domain::repository::EditLogRepository; 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>>, ) -> anyhow::Result<()> { const MAX_TODO_RETRIES: usize = 5; let mut msgs = messages.to_vec(); let mut edited_paths: Vec = Vec::new(); let initial_edit_log = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new() .open(&tc.edit_log_session_dir).ok(); 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). let tree_info = crate::app::subagent::workspace::generate_workspace_tree(&tc.workspace_roots); 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" ); push_event(&events_q, TurnEvent::SystemNote { kind: "pipeline".to_string(), message: HIVE_MIND_KICKOFF_NOTE.to_string(), }); 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\": \"\", \"access\": \"read\" }}\n\ \x20 ],\n\ \x20 [\n\ \x20 {{ \"directive\": \"\", \"access\": \"read\" }}\n\ \x20 ],\n\ \x20 [\n\ \x20 {{ \"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); let planner_result = tc.client.chat_with_tools_non_streaming( &[system_msg, user_msg], None, None, None, Some(&tc.abort_flag), ); 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; } push_event(&events_q, TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out, }); 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::>(); 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::>() .join(", "); push_event(&events_q, TurnEvent::SystemNote { kind: "pipeline".to_string(), message: format!( "The Hive compiled {} cycle(s) — {cycle_desc}. Deploying nodes...", plan.cycles.len() ), }); 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); 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(), }); } 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. if crate::app::util::abort::is_aborted_direct(&tc.abort_flag) { push_event(&events_q, TurnEvent::Error("Generation aborted by user".to_string())); return Ok(()); } let mut todo_retry_count = 0usize; loop { let token_estimate: usize = msgs .iter() .filter_map(|m| m.content.as_deref()) .map(count_tokens) .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. let wire_msgs = if !crate::app::util::abort::is_aborted_direct(&tc.abort_flag) && 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), Some(&tc.abort_flag), ); // 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. push_event(&events_q, TurnEvent::Compacted(compacted.clone())); // 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 { if crate::app::util::abort::is_aborted_direct(&tc.abort_flag) { 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\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("\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 }, Some(&tc.abort_flag), ); if reasoning_started && !reasoning_ended { push_event(&events_q, TurnEvent::StreamToken( "\n\n\n".to_string(), )); } let (response, final_usage) = match result { Ok((msg, u)) => (msg, u.or(usage)), Err(e) => { // If abort was requested, return immediately. if crate::app::util::abort::is_aborted_direct(&tc.abort_flag) || e.to_string().contains("aborted") { push_event(&events_q, TurnEvent::Error( "Generation aborted by user".to_string(), )); 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.", ); } 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})" ), }); 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 { let total_tokens: usize = wire_msgs .iter() .filter_map(|m| m.content.as_deref()) .map(count_tokens) .sum(); tok_in = total_tokens.max(1) as u64; } 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; } push_event(&events_q, TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out, }); 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 { if crate::app::util::abort::is_aborted_direct(&tc.abort_flag) { push_event(&events_q, TurnEvent::Error( "Turn aborted by user".to_string(), )); 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); 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, }); 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); if stream_started { push_event(&events_q, TurnEvent::StreamDone(response.clone())); } else { push_event(&events_q, TurnEvent::AssistantMessage(response.clone())); } } 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 { 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."), }); 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); push_event(&events_q, TurnEvent::SystemNote { kind: "task_retry".to_string(), message: sys_text_clone, }); continue; } break; } } 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) }) }); 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 { kind: "edits".to_string(), message: total_edits_this_turn.to_string(), }); // 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(); // ── 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, ); }); } } } push_event(&events_q, TurnEvent::Done); 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>>, } fn execute_one_tool( tools: &[Box], ctx: &crate::tool::ToolCtx, name: &str, tool_call_id: &str, args: &serde_json::Value, sess: &ToolExecSession<'_>, ) -> anyhow::Result { 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" { crate::tool::log_write_edit_tool( args, name, &ctx.origin.tag(), sess.dir, sess.id, ); } 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>>, 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); } } }