From e64f9ad61a34b6baa7e4c6048eba3907f6288942 Mon Sep 17 00:00:00 2001 From: patrick-mns Date: Fri, 31 Jul 2026 16:18:18 -0300 Subject: [PATCH 1/8] feat(ui): add button to add another folder to workspace Adds a "+" icon button next to the folder selector in GitContext so users can add a folder to the current workspace without leaving the selector context. --- frontend/src/components/GitContext.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/GitContext.tsx b/frontend/src/components/GitContext.tsx index ddd7de1..9d35eef 100644 --- a/frontend/src/components/GitContext.tsx +++ b/frontend/src/components/GitContext.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useRef } from 'react'; import { gitContextStyles } from '@/utils/theme-styles'; -import { CaretUpDown, FolderOpen, GitBranch, Check } from '@phosphor-icons/react'; +import { CaretUpDown, FolderOpen, GitBranch, Check, Plus } from '@phosphor-icons/react'; import { ipc } from '@/ipc'; import { useStore } from '@/store'; import { theme } from '@/theme'; @@ -132,6 +132,15 @@ export default function GitContext({ onPickWorkspace, refreshTick = 0 }: GitCont )} + + {gitInfo && gitInfo.branch !== 'no git' && ( From a324ccdb824b4dfd8c7f7e9086d50ed5cd01d3a4 Mon Sep 17 00:00:00 2001 From: patrick-mns Date: Fri, 31 Jul 2026 16:18:27 -0300 Subject: [PATCH 2/8] fix(llm): inject workspace folders into system prompt, fallback stale model roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model had no awareness of the workspace's folders or which one was active, only tools did. Adds a "## Workspace" section to the system prompt listing every folder and marking the active one. Also fixes model role selectors (chat/summarize/vision) showing UNAVAILABLE forever when the persisted model name belonged to a provider that's no longer active — get_model_roles now falls back to the first catalog model that fits the role and persists the swap. --- src/backend/prompt.rs | 25 +++++++++++++++++++ src/commands/chat.rs | 27 ++++++++++++++++++++- src/commands/settings.rs | 52 +++++++++++++++++++++++++++++++++++----- 3 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/backend/prompt.rs b/src/backend/prompt.rs index 7d66786..00c7b23 100755 --- a/src/backend/prompt.rs +++ b/src/backend/prompt.rs @@ -156,6 +156,31 @@ When a tool fails (returns an error): prompt } +/// Build the "## Workspace" section listing every folder in the current +/// workspace and marking which one is active (the root tools currently +/// operate on). Returns `None` when there's no workspace or only one folder +/// with nothing to disambiguate. +pub fn workspace_context_section( + folders: &[std::path::PathBuf], + active_root: &std::path::Path, +) -> Option { + if folders.is_empty() { + return None; + } + let mut section = String::from("\n\n## Workspace\nThis workspace has the following folder(s):\n"); + for f in folders { + let marker = if f == active_root { " (active — tools operate here)" } else { "" }; + section.push_str(&format!("- {}{}\n", f.display(), marker)); + } + if folders.len() > 1 { + section.push_str( + "The user can switch the active folder from the UI; ask them to switch if a task \ +targets a different folder than the one currently active.", + ); + } + Some(section) +} + /// Injected after a tool fails repeatedly (but before giving up) to force the /// model to diagnose the root cause and change approach instead of retrying /// the same failing call — a lightweight Reflexion-style self-correction. diff --git a/src/commands/chat.rs b/src/commands/chat.rs index 609359b..ca9e696 100755 --- a/src/commands/chat.rs +++ b/src/commands/chat.rs @@ -146,7 +146,20 @@ pub async fn send_message( .unwrap_or_default(); let provider = llm::provider_for_model(&model); - let system = crate::backend::prompt::system_prompt(); + let mut system = crate::backend::prompt::system_prompt(); + { + let workspace_root = state.workspace_root.lock().unwrap().clone(); + let folders = state + .current_workspace + .lock() + .unwrap() + .as_ref() + .map(|w| w.folders.clone()) + .unwrap_or_default(); + if let Some(section) = crate::backend::prompt::workspace_context_section(&folders, &workspace_root) { + system.push_str(§ion); + } + } let mut full = vec![Message::system(&system)]; full.extend(messages); @@ -219,6 +232,18 @@ pub async fn start_chat_stream(app: AppHandle, content: String) -> Result String { } /// Current model assignment for every role, in display order. +/// If `model` isn't served by any active provider anymore (renamed, provider +/// disabled/removed), swap it for the first catalog entry that fits the role +/// (vision role requires `vision: true`) and persist the swap, so a stale +/// model name from a previous provider config doesn't linger as UNAVAILABLE +/// forever. Returns the (possibly replaced) model and its provider label. +fn resolve_role_model( + state: &State<'_, AppState>, + role: &str, + model: String, +) -> (String, String) { + let provider = role_provider(&model); + if !provider.is_empty() || model.is_empty() { + return (model, provider); + } + let catalog = llm::catalog(); + let fallback = catalog.into_iter().find(|m| role != "vision" || m.vision); + match fallback { + Some(m) => { + match role { + "chat" => { + config::save_last_model(&m.name); + state.set_chat_model(m.name.clone()); + } + "summarize" => { + config::save_last_summarize_model(&m.name); + state.set_summarize_model(m.name.clone()); + } + "vision" => { + config::save_vision_model(&m.name); + state.set_vision_model(m.name.clone()); + } + _ => {} + } + (m.name, m.provider_label) + } + None => (model, provider), + } +} + #[tauri::command] pub async fn get_model_roles(state: State<'_, AppState>) -> Result, String> { - let chat = state.chat_model(); - let summarize = state.summarize_model(); - let vision = state.vision_model(); + let (chat, chat_provider) = resolve_role_model(&state, "chat", state.chat_model()); + let (summarize, summarize_provider) = + resolve_role_model(&state, "summarize", state.summarize_model()); + let (vision, vision_provider) = resolve_role_model(&state, "vision", state.vision_model()); Ok(vec![ ModelRole { role: "chat".into(), - provider: role_provider(&chat), + provider: chat_provider, model: chat, }, ModelRole { role: "summarize".into(), - provider: role_provider(&summarize), + provider: summarize_provider, model: summarize, }, ModelRole { role: "vision".into(), - provider: role_provider(&vision), + provider: vision_provider, model: vision, }, ]) From 0e40be1e3254f2c5d219bea7d54cef3194361211 Mon Sep 17 00:00:00 2001 From: patrick-mns Date: Sat, 1 Aug 2026 13:39:10 -0300 Subject: [PATCH 3/8] fix(agent): make agent loop resilient to transient/empty LLM responses DeepSeek and other reasoning models via OpenAI-compatible endpoints were producing empty turns (reasoning ate the whole max_tokens budget) and the loop bailed on the first stream error with no retry. - Raise max_tokens cap 8192 -> 16384 so reasoning content doesn't starve the answer. - Retry an empty model turn up to 3x instead of once. - Retry transient stream-open/mid-stream network errors (rate limits, connection resets) up to 2x with backoff before failing the whole turn, as long as nothing has streamed yet. - Raise MAX_TOOL_ROUNDS 50 -> 100 for long legitimate multi-step turns. Co-Authored-By: Claude Sonnet 5 --- src/backend/openai_compat.rs | 2 +- src/commands/agent.rs | 105 +++++++++++++++++++++++++++-------- 2 files changed, 83 insertions(+), 24 deletions(-) diff --git a/src/backend/openai_compat.rs b/src/backend/openai_compat.rs index 23ee452..899260a 100644 --- a/src/backend/openai_compat.rs +++ b/src/backend/openai_compat.rs @@ -29,7 +29,7 @@ fn ctx_cache() -> &'static Mutex> { /// reasoning model's `reasoning_content` pass eats the whole budget and /// leaves nothing for the actual `content`, ending the turn empty. Sending an /// explicit, generous cap gives the model room to think AND answer. -const MAX_COMPLETION_TOKENS_CAP: usize = 8_192; +const MAX_COMPLETION_TOKENS_CAP: usize = 16_384; /// `max_tokens` to send: a quarter of the model's known context window, /// capped by [`MAX_COMPLETION_TOKENS_CAP`] and floored so tiny-context models diff --git a/src/commands/agent.rs b/src/commands/agent.rs index b595353..98b1d50 100644 --- a/src/commands/agent.rs +++ b/src/commands/agent.rs @@ -17,7 +17,7 @@ use tauri::{AppHandle, Emitter, Manager}; /// emitting no tool call. This only bounds a pathological model that never /// stops calling tools. Set high enough that legitimate multi-step tasks never /// hit it. -const MAX_TOOL_ROUNDS: usize = 50; +const MAX_TOOL_ROUNDS: usize = 100; /// Consecutive tool failures that force the loop to stop and report. const MAX_CONSECUTIVE_ERRORS: u32 = 3; /// Consecutive failures after which a Reflexion nudge is injected (before the @@ -27,6 +27,11 @@ const REFLEXION_AFTER_ERRORS: u32 = 2; /// loop. Cheaper and more reliable than a low round cap for catching the model /// repeating itself. const MAX_IDENTICAL_CALLS: u32 = 3; +/// Transient network/gateway failures (stream failed to open, or dropped +/// mid-flight) to retry before giving up on the whole turn. Flaky +/// OpenAI-compatible gateways (rate limits, connection resets) are common +/// enough that failing the entire turn on the first hiccup is too brittle. +const MAX_STREAM_ERROR_RETRIES: u8 = 2; /// Max characters of a single tool result fed back into context. Large reads / /// command output are truncated (head + tail) so one call can't blow the /// window. Tuned for small local models. @@ -68,11 +73,19 @@ pub fn run_agent_loop( // never be satisfied there — suppress the tool-nudge retry. let needs_tool = needs_tool && mode != AgentMode::Chat; let mut did_any_tool = false; + // True as long as every tool called this turn was loop bookkeeping + // (schedule_wakeup/stop_loop). Those already say what happened in their + // own tool result — a /loop tick that's otherwise silent shouldn't be + // forced through request_summary/ensure_reply's "couldn't generate a + // response" fallback just because it had nothing else to say. + let mut only_loop_control_tools = true; let mut retried_for_tool = false; // One retry when the model returns a completely empty turn, before we fall // back to a canned reply — an empty completion is usually transient. - let mut retried_empty = false; + let mut empty_retries = 0u8; + const MAX_EMPTY_RETRIES: u8 = 3; let mut consecutive_errors: u32 = 0; + let mut stream_error_retries: u8 = 0; // Stagnation guard: signature of the previous tool call and how many times // it has repeated back-to-back. let mut last_call_sig: Option = None; @@ -208,7 +221,7 @@ pub fn run_agent_loop( } }; - for _ in 0..MAX_TOOL_ROUNDS { + 'rounds: for _ in 0..MAX_TOOL_ROUNDS { // User hit Stop between rounds — persist what we have and bail. if cancel.load(Ordering::SeqCst) { finish(&app, &history, &thinking_acc, &tool_summaries, "", true); @@ -220,6 +233,15 @@ pub fn run_agent_loop( let mut stream = match provider.start_stream(&model, &history, &tools_advert) { Ok(s) => s, Err(e) => { + // Transient gateway hiccup (rate limit, connection reset): retry + // a couple of times before giving up on the whole turn. + if stream_error_retries < MAX_STREAM_ERROR_RETRIES { + stream_error_retries += 1; + std::thread::sleep(std::time::Duration::from_millis( + 500 * stream_error_retries as u64, + )); + continue; + } // Persist anything earlier rounds accumulated before surfacing // the error, so a failure to open the stream doesn't drop the // turn's thinking/tool trace on reload. @@ -331,6 +353,21 @@ pub fn run_agent_loop( } } Err(e) => { + // Mid-stream drop with nothing produced yet is the same + // transient case as a failed stream open — retry rather than + // failing the turn. Once any content/tool call has streamed, + // retrying would replay or duplicate it, so only retry a + // clean, empty-handed failure. + if content_acc.is_empty() + && tool_calls.is_empty() + && stream_error_retries < MAX_STREAM_ERROR_RETRIES + { + stream_error_retries += 1; + std::thread::sleep(std::time::Duration::from_millis( + 500 * stream_error_retries as u64, + )); + continue 'rounds; + } // A mid-stream failure (e.g. the network dropped) must not // throw away what already streamed — persist it, then report. history.push(Message::assistant(content_acc.clone())); @@ -351,6 +388,11 @@ pub fn run_agent_loop( } } + // A round that streamed successfully clears the transient-failure + // budget, so a flaky gateway gets fresh retries for the next hiccup + // instead of exhausting them across an otherwise-healthy long turn. + stream_error_retries = 0; + // ---- model called one or more tools: execute all, then re-prompt ---- if !tool_calls.is_empty() { // Stagnation guard: a model stuck re-issuing the exact same call(s) @@ -386,6 +428,15 @@ pub fn run_agent_loop( return; } + if tool_calls.iter().any(|c| { + !matches!( + tools::normalize_tool_name(&c.name), + "schedule_wakeup" | "stop_loop" + ) + }) { + only_loop_control_tools = false; + } + let (summaries, any_error) = run_tool_calls( &app, provider.as_ref(), @@ -480,9 +531,9 @@ pub fn run_agent_loop( // handled above): the model returned nothing at all. Nudge it once to // answer before giving up — an empty completion is usually a transient // glitch rather than the model deciding it's done. - if content.is_empty() && !did_any_tool && !retried_empty { + if content.is_empty() && !did_any_tool && empty_retries < MAX_EMPTY_RETRIES { history.push(Message::system(prompt::EMPTY_RESPONSE_RETRY)); - retried_empty = true; + empty_retries += 1; continue; } @@ -497,26 +548,34 @@ pub fn run_agent_loop( // summary (when tools ran) and, failing that, fall back to a visible // reply so the user always gets something back. let final_content = if content.is_empty() { - let summary = if did_any_tool { - request_summary( - &app, - provider.as_ref(), - &model, - &mut history, - &session_id_ref, - ) - } else { + if did_any_tool && only_loop_control_tools { + // The only thing this turn did was schedule_wakeup/stop_loop — + // its own tool result already narrates what happened, so an + // otherwise-silent /loop tick isn't a failure worth nagging the + // model about or showing a scary fallback for. String::new() - }; - let reply = ensure_reply(&app, &session_id_ref, summary); - // The turn's own assistant message above was pushed empty (that's - // literally what the model said); patch it to whatever we end up - // showing so the persisted transcript matches the live stream - // instead of leaving a blank turn behind for the next round. - if let Some(msg) = history.get_mut(assistant_msg_idx) { - msg.content = reply.clone(); + } else { + let summary = if did_any_tool { + request_summary( + &app, + provider.as_ref(), + &model, + &mut history, + &session_id_ref, + ) + } else { + String::new() + }; + let reply = ensure_reply(&app, &session_id_ref, summary); + // The turn's own assistant message above was pushed empty (that's + // literally what the model said); patch it to whatever we end up + // showing so the persisted transcript matches the live stream + // instead of leaving a blank turn behind for the next round. + if let Some(msg) = history.get_mut(assistant_msg_idx) { + msg.content = reply.clone(); + } + reply } - reply } else { content }; From aab68d076f7e9220ee3b686bdbcd9ee4ac5c5e73 Mon Sep 17 00:00:00 2001 From: patrick-mns Date: Sat, 1 Aug 2026 13:41:23 -0300 Subject: [PATCH 4/8] style: run cargo fmt Fixes CI formatting check broken since a324ccd. --- src/backend/prompt.rs | 9 +++++++-- src/commands/chat.rs | 8 ++++++-- src/commands/settings.rs | 6 +----- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/backend/prompt.rs b/src/backend/prompt.rs index 00c7b23..3e75838 100755 --- a/src/backend/prompt.rs +++ b/src/backend/prompt.rs @@ -167,9 +167,14 @@ pub fn workspace_context_section( if folders.is_empty() { return None; } - let mut section = String::from("\n\n## Workspace\nThis workspace has the following folder(s):\n"); + let mut section = + String::from("\n\n## Workspace\nThis workspace has the following folder(s):\n"); for f in folders { - let marker = if f == active_root { " (active — tools operate here)" } else { "" }; + let marker = if f == active_root { + " (active — tools operate here)" + } else { + "" + }; section.push_str(&format!("- {}{}\n", f.display(), marker)); } if folders.len() > 1 { diff --git a/src/commands/chat.rs b/src/commands/chat.rs index ca9e696..ca4895d 100755 --- a/src/commands/chat.rs +++ b/src/commands/chat.rs @@ -156,7 +156,9 @@ pub async fn send_message( .as_ref() .map(|w| w.folders.clone()) .unwrap_or_default(); - if let Some(section) = crate::backend::prompt::workspace_context_section(&folders, &workspace_root) { + if let Some(section) = + crate::backend::prompt::workspace_context_section(&folders, &workspace_root) + { system.push_str(§ion); } } @@ -240,7 +242,9 @@ pub async fn start_chat_stream(app: AppHandle, content: String) -> Result String { /// (vision role requires `vision: true`) and persist the swap, so a stale /// model name from a previous provider config doesn't linger as UNAVAILABLE /// forever. Returns the (possibly replaced) model and its provider label. -fn resolve_role_model( - state: &State<'_, AppState>, - role: &str, - model: String, -) -> (String, String) { +fn resolve_role_model(state: &State<'_, AppState>, role: &str, model: String) -> (String, String) { let provider = role_provider(&model); if !provider.is_empty() || model.is_empty() { return (model, provider); From a065979e63fb47354f539990081a0b7396198484 Mon Sep 17 00:00:00 2001 From: patrick-mns Date: Sat, 1 Aug 2026 14:01:26 -0300 Subject: [PATCH 5/8] fix(agent): escalate token budget instead of blindly retrying on truncation The empty-turn retry added earlier wasn't enough: when a reasoning model truncates deterministically (finish_reason == "length", spends its whole budget on with zero answer), repeating the identical request just repeats the same runaway reasoning and truncates again every time. - Provider::start_stream now takes an optional max_tokens override; both OpenAI-compatible and Ollama backends honor it (Ollama also widens num_ctx to fit). - OpenAI-compatible streams now surface finish_reason via a new StreamEvent::FinishReason. - The agent loop detects finish_reason == "length" and, on retry, doubles the completion budget (16384 -> 32768 -> 65536) with its own 3-attempt budget and a nudge telling the model to stop deliberating and answer, instead of sharing the generic empty-reply retry path. Co-Authored-By: Claude Sonnet 5 --- src/backend/llm.rs | 16 ++++++- src/backend/ollama.rs | 31 ++++++++++-- src/backend/openai_compat.rs | 18 ++++++- src/backend/prompt.rs | 10 ++++ src/commands/agent.rs | 91 ++++++++++++++++++++++++------------ 5 files changed, 130 insertions(+), 36 deletions(-) diff --git a/src/backend/llm.rs b/src/backend/llm.rs index c9afcbd..a75eeb2 100755 --- a/src/backend/llm.rs +++ b/src/backend/llm.rs @@ -206,6 +206,13 @@ pub enum StreamEvent { /// Raw HTTP response payload received from the provider (the concatenated /// SSE/NDJSON stream), emitted once when the turn finishes. ResponseRaw(String), + /// The provider's `finish_reason` for this turn (`"length"`, `"stop"`, + /// `"tool_calls"`, …), when the wire format reports one. `"length"` + /// specifically means the completion was cut off by the token budget — + /// distinct from the model simply choosing to say nothing, so callers can + /// react by growing the budget instead of just nudging and repeating the + /// same request. + FinishReason(String), Done, } @@ -270,12 +277,18 @@ pub trait Provider: Send + Sync { /// Begin a streamed turn with full history. `tools_json` is the JSON array /// of tool definitions to advertise this turn (already filtered by the /// caller for the active mode); an empty string or `"[]"` omits tools - /// entirely so the model can only reply with text. + /// entirely so the model can only reply with text. `max_tokens_override`, + /// when set, replaces the provider's normal computed completion budget — + /// used to escalate the budget on retry after a turn was cut off + /// (`finish_reason == "length"`) with no content, since repeating the + /// identical request tends to repeat the same runaway reasoning and + /// truncate again. fn start_stream( &self, model: &str, history: &[Message], tools_json: &str, + max_tokens_override: Option, ) -> BackendResult>; /// Serializes one or more tool calls made in the SAME assistant turn @@ -438,6 +451,7 @@ mod tests { _: &str, _: &[Message], _: &str, + _: Option, ) -> BackendResult> { unimplemented!() } diff --git a/src/backend/ollama.rs b/src/backend/ollama.rs index 5ea77dc..d2768ef 100755 --- a/src/backend/ollama.rs +++ b/src/backend/ollama.rs @@ -95,8 +95,14 @@ impl crate::backend::llm::Provider for OllamaProvider { model: &str, history: &[Message], tools_json: &str, + max_tokens_override: Option, ) -> BackendResult> { - Ok(Box::new(ChatStream::start(model, history, tools_json)?)) + Ok(Box::new(ChatStream::start( + model, + history, + tools_json, + max_tokens_override, + )?)) } fn tool_calls_history_json(&self, calls: &[ToolCall]) -> String { tool_calls_to_json(calls) @@ -121,9 +127,14 @@ impl crate::backend::llm::ChatStream for ChatStream { } impl ChatStream { - pub fn start(model: &str, history: &[Message], tools_json: &str) -> BackendResult { + pub fn start( + model: &str, + history: &[Message], + tools_json: &str, + max_tokens_override: Option, + ) -> BackendResult { let messages_json = messages_to_json(history); - let num_ctx = request_num_ctx(model); + let mut num_ctx = request_num_ctx(model); let think = model_supports_thinking(model); // `tools_json` is already mode-filtered by the caller; an empty array // omits tools so the model can only reply with text. @@ -132,7 +143,19 @@ impl ChatStream { } else { tools_json }; - let num_predict = request_num_predict(num_ctx); + // A caller-supplied override (escalating after a truncated turn) wins + // over the snappy default budget. `num_predict` can't exceed + // `num_ctx`, so widen the window to fit it too — capped at the + // model's real max so we don't request more than it actually has. + let num_predict = match max_tokens_override { + Some(t) => { + let real_max = model_context_length(model); + let predict = t.min(real_max.saturating_sub(1_024)).max(512); + num_ctx = num_ctx.max(predict + 1_024).min(real_max); + predict + } + None => request_num_predict(num_ctx), + }; let body = format!( "{{\"model\":{},\"messages\":[{}],\"tools\":{},\"stream\":true,\"think\":{},\"options\":{{\"temperature\":0,\"num_ctx\":{},\"num_predict\":{}}}}}", json_string(model), diff --git a/src/backend/openai_compat.rs b/src/backend/openai_compat.rs index 899260a..d4c4f38 100644 --- a/src/backend/openai_compat.rs +++ b/src/backend/openai_compat.rs @@ -243,12 +243,19 @@ impl Provider for OpenAiCompatProvider { model: &str, history: &[Message], tools_json: &str, + max_tokens_override: Option, ) -> BackendResult> { + // A caller-supplied override (escalating after a truncated turn) wins + // over the normal context-fraction budget, but is still sanity-capped + // so a runaway retry loop can't request an absurd completion size. + let max_tokens = max_tokens_override + .map(|t| t.min(131_072)) + .unwrap_or_else(|| request_max_tokens(self.context_length(model))); let mut body = serde_json::json!({ "model": model, "messages": to_openai_messages(history), "stream": true, - "max_tokens": request_max_tokens(self.context_length(model)), + "max_tokens": max_tokens, }); // OpenRouter-specific extensions: reasoning streaming + usage in the @@ -330,7 +337,14 @@ impl Provider for OpenAiCompatProvider { })); } - let delta = &json["choices"][0]["delta"]; + let choice = &json["choices"][0]; + if let Some(r) = choice["finish_reason"].as_str() { + if !r.is_empty() { + let _ = tx.send(StreamEvent::FinishReason(r.to_string())); + } + } + + let delta = &choice["delta"]; if let Some(c) = delta["content"].as_str() { if !c.is_empty() { diff --git a/src/backend/prompt.rs b/src/backend/prompt.rs index 3e75838..0f1d713 100755 --- a/src/backend/prompt.rs +++ b/src/backend/prompt.rs @@ -211,6 +211,16 @@ summary of exactly what you did for the user. Be specific about files created or pub const EMPTY_RESPONSE_RETRY: &str = "Your last response was empty. Please answer the user's \ last message directly now."; +/// Injected instead of [`EMPTY_RESPONSE_RETRY`] when the empty turn's +/// `finish_reason` was `"length"` — the model spent its entire token budget on +/// internal reasoning and was cut off before producing any answer. Repeating +/// the same request tends to repeat the same runaway reasoning, so this nudge +/// explicitly tells the model to stop deliberating and answer immediately. +pub const EMPTY_RESPONSE_RETRY_TRUNCATED: &str = "Your last response was cut off before you \ +produced any answer — you spent the entire response budget on internal reasoning. Stop \ +deliberating. Reply now with a short, direct answer (a few sentences), skipping further \ +step-by-step analysis."; + /// Appended to the system prompt in Chat mode, where no tools are available. pub const CHAT_MODE: &str = "You are in CHAT mode: read-only. You may use the read-only tools \ available this turn (read files, search, read the knowledge graph, fetch URLs, look at images \ diff --git a/src/commands/agent.rs b/src/commands/agent.rs index 98b1d50..d5cd98d 100644 --- a/src/commands/agent.rs +++ b/src/commands/agent.rs @@ -84,6 +84,10 @@ pub fn run_agent_loop( // back to a canned reply — an empty completion is usually transient. let mut empty_retries = 0u8; const MAX_EMPTY_RETRIES: u8 = 3; + // Separate, independent budget for the deterministic "cut off mid-thought" + // failure (finish_reason == "length"): see the truncation check below. + let mut truncated_retries = 0u8; + const MAX_TRUNCATED_RETRIES: u8 = 3; let mut consecutive_errors: u32 = 0; let mut stream_error_retries: u8 = 0; // Stagnation guard: signature of the previous tool call and how many times @@ -230,32 +234,47 @@ pub fn run_agent_loop( // Keep the working context within budget before the next model turn. compact_history(&mut history); // ---- stream one model turn ---- - let mut stream = match provider.start_stream(&model, &history, &tools_advert) { - Ok(s) => s, - Err(e) => { - // Transient gateway hiccup (rate limit, connection reset): retry - // a couple of times before giving up on the whole turn. - if stream_error_retries < MAX_STREAM_ERROR_RETRIES { - stream_error_retries += 1; - std::thread::sleep(std::time::Duration::from_millis( - 500 * stream_error_retries as u64, - )); - continue; - } - // Persist anything earlier rounds accumulated before surfacing - // the error, so a failure to open the stream doesn't drop the - // turn's thinking/tool trace on reload. - finish(&app, &history, &thinking_acc, &tool_summaries, "", false); - let _ = app.emit( - "stream_error", - serde_json::json!({ "session_id": session_id_ref, "error": e }), - ); - return; - } + // After a turn truncated by the token budget (finish_reason == + // "length", empty content), double the completion budget on each + // retry instead of repeating the identical request — see the + // truncation check below for why that request is doomed to truncate + // again otherwise. + let max_tokens_override = if truncated_retries > 0 { + Some(16_384usize << truncated_retries) + } else { + None }; + let mut stream = + match provider.start_stream(&model, &history, &tools_advert, max_tokens_override) { + Ok(s) => s, + Err(e) => { + // Transient gateway hiccup (rate limit, connection reset): retry + // a couple of times before giving up on the whole turn. + if stream_error_retries < MAX_STREAM_ERROR_RETRIES { + stream_error_retries += 1; + std::thread::sleep(std::time::Duration::from_millis( + 500 * stream_error_retries as u64, + )); + continue; + } + // Persist anything earlier rounds accumulated before surfacing + // the error, so a failure to open the stream doesn't drop the + // turn's thinking/tool trace on reload. + finish(&app, &history, &thinking_acc, &tool_summaries, "", false); + let _ = app.emit( + "stream_error", + serde_json::json!({ "session_id": session_id_ref, "error": e }), + ); + return; + } + }; let mut content_acc = String::new(); let mut tool_calls: Vec = Vec::new(); let mut turn_done = false; + // Set when the provider reports this turn was cut off by the token + // budget rather than the model choosing to stop — distinguishes a + // deterministic "ran out of room" failure from a generic empty reply. + let mut finish_reason: Option = None; let stream_start = std::time::Instant::now(); let mut last_event = stream_start; while !turn_done { @@ -348,6 +367,7 @@ pub fn run_agent_loop( StreamEvent::ResponseRaw(r) => { *resp_raw_acc.lock().unwrap() = r; } + StreamEvent::FinishReason(r) => finish_reason = Some(r), StreamEvent::Done => turn_done = true, } } @@ -528,13 +548,26 @@ pub fn run_agent_loop( } // Empty turn with no tool call and nothing pending (needs_tool was - // handled above): the model returned nothing at all. Nudge it once to - // answer before giving up — an empty completion is usually a transient - // glitch rather than the model deciding it's done. - if content.is_empty() && !did_any_tool && empty_retries < MAX_EMPTY_RETRIES { - history.push(Message::system(prompt::EMPTY_RESPONSE_RETRY)); - empty_retries += 1; - continue; + // handled above): the model returned nothing at all. + // + // Truncation (finish_reason == "length") is a *deterministic* failure — + // the model burned its whole budget on reasoning and never got to an + // answer. Repeating the identical request just repeats the same + // runaway reasoning, so it gets its own retry budget and a nudge that + // explicitly tells the model to stop deliberating, instead of sharing + // the generic "try again" retry meant for transient empty replies. + if content.is_empty() && !did_any_tool { + let truncated = finish_reason.as_deref() == Some("length"); + if truncated && truncated_retries < MAX_TRUNCATED_RETRIES { + history.push(Message::system(prompt::EMPTY_RESPONSE_RETRY_TRUNCATED)); + truncated_retries += 1; + continue; + } + if !truncated && empty_retries < MAX_EMPTY_RETRIES { + history.push(Message::system(prompt::EMPTY_RESPONSE_RETRY)); + empty_retries += 1; + continue; + } } let assistant_msg_idx = history.len(); From 348b33582bf0e62d086a5c54cef85defff48768b Mon Sep 17 00:00:00 2001 From: patrick-mns Date: Sat, 1 Aug 2026 14:24:44 -0300 Subject: [PATCH 6/8] fix(agent): make the empty-reply fallback name the actual cause The generic "I wasn't able to generate a response. Please try again or rephrase your request." gave no signal about what actually happened, so users had no way to tell a flaky connection from a model stuck truncating on reasoning. ensure_reply now takes an explicit fallback message; the agent loop tracks whether the empty-reply retries were exhausted specifically due to truncation (finish_reason == "length") and picks a fallback that says so plus what to try next, instead of a one-size-fits-all apology. Co-Authored-By: Claude Sonnet 5 --- src/commands/agent.rs | 45 +++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/src/commands/agent.rs b/src/commands/agent.rs index d5cd98d..1bdefe8 100644 --- a/src/commands/agent.rs +++ b/src/commands/agent.rs @@ -88,6 +88,10 @@ pub fn run_agent_loop( // failure (finish_reason == "length"): see the truncation check below. let mut truncated_retries = 0u8; const MAX_TRUNCATED_RETRIES: u8 = 3; + // Set when the empty-reply retry budget above is exhausted specifically + // because of truncation, so the final fallback message can name the + // actual cause instead of a generic apology. + let mut exhausted_truncated = false; let mut consecutive_errors: u32 = 0; let mut stream_error_retries: u8 = 0; // Stagnation guard: signature of the previous tool call and how many times @@ -442,7 +446,7 @@ pub fn run_agent_loop( &history, &thinking_acc, &tool_summaries, - &ensure_reply(&app, &session_id_ref, summary), + &ensure_reply(&app, &session_id_ref, summary, EMPTY_REPLY_FALLBACK), true, ); return; @@ -493,7 +497,7 @@ pub fn run_agent_loop( &history, &thinking_acc, &tool_summaries, - &ensure_reply(&app, &session_id_ref, summary), + &ensure_reply(&app, &session_id_ref, summary, EMPTY_REPLY_FALLBACK), true, ); return; @@ -536,7 +540,7 @@ pub fn run_agent_loop( &history, &thinking_acc, &tool_summaries, - &ensure_reply(&app, &session_id_ref, summary), + &ensure_reply(&app, &session_id_ref, summary, EMPTY_REPLY_FALLBACK), true, ); return; @@ -568,6 +572,9 @@ pub fn run_agent_loop( empty_retries += 1; continue; } + // Every retry is exhausted: remember why, so the fallback shown + // to the user names the actual cause instead of a generic apology. + exhausted_truncated = truncated; } let assistant_msg_idx = history.len(); @@ -599,7 +606,12 @@ pub fn run_agent_loop( } else { String::new() }; - let reply = ensure_reply(&app, &session_id_ref, summary); + let fallback = if exhausted_truncated { + EMPTY_REPLY_FALLBACK_TRUNCATED + } else { + EMPTY_REPLY_FALLBACK + }; + let reply = ensure_reply(&app, &session_id_ref, summary, fallback); // The turn's own assistant message above was pushed empty (that's // literally what the model said); patch it to whatever we end up // showing so the persisted transcript matches the live stream @@ -636,6 +648,7 @@ pub fn run_agent_loop( &mut history, &session_id_ref, ), + EMPTY_REPLY_FALLBACK, ); finish( &app, @@ -683,20 +696,32 @@ fn force_stop_summary( } /// Shown to the user when the model produced no response at all and every -/// attempt to coax one out failed. Better an honest note than a blank turn. +/// attempt to coax one out failed, for reasons other than a known truncation +/// (see [`EMPTY_REPLY_FALLBACK_TRUNCATED`]) — a generic honest note beats a +/// blank turn. const EMPTY_REPLY_FALLBACK: &str = - "I wasn't able to generate a response. Please try again or rephrase your request."; + "The model didn't return a response after a few attempts. This can happen with a flaky \ +provider connection — try again, or switch models if it keeps happening."; + +/// Shown instead of [`EMPTY_REPLY_FALLBACK`] when every retry was cut off by +/// the token budget (`finish_reason == "length"`) with no answer at all — +/// names the actual cause instead of a generic "please retry", since retrying +/// the same way is unlikely to help. +const EMPTY_REPLY_FALLBACK_TRUNCATED: &str = + "The model kept running out of its response budget on internal reasoning and never reached \ +an answer, even after retrying with a larger budget. Try a shorter or simpler request, switch to \ +a different model, or check the provider's output token limit."; /// Guarantee the turn ends with something visible: if `content` is empty, emit -/// the fallback as stream content (so the live view shows it, matching what +/// `fallback` as stream content (so the live view shows it, matching what /// `finish` will persist) and return it. Otherwise pass `content` through. -fn ensure_reply(app: &AppHandle, session_id: &str, content: String) -> String { +fn ensure_reply(app: &AppHandle, session_id: &str, content: String, fallback: &str) -> String { if content.trim().is_empty() { let _ = app.emit( "stream_content", - serde_json::json!({ "session_id": session_id, "delta": EMPTY_REPLY_FALLBACK }), + serde_json::json!({ "session_id": session_id, "delta": fallback }), ); - EMPTY_REPLY_FALLBACK.to_string() + fallback.to_string() } else { content } From 2c153d39489d367cf048c08d27ce8b9b89ab5258 Mon Sep 17 00:00:00 2001 From: patrick-mns Date: Sat, 1 Aug 2026 14:39:55 -0300 Subject: [PATCH 7/8] fix(agent): detect dropped connections disguised as empty completions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-caused via the boost workspace's sessions.db: the failing turn's raw response was 50+ chunks of pure reasoning_content with finish_reason null throughout — the gateway connection closed mid-thought without ever sending [DONE] or a finish_reason, and the SSE reader treated that silently as a normal end-of-stream, indistinguishable from the model choosing to say nothing. openai_compat.rs now tracks whether the stream actually terminated properly; if it didn't and nothing was produced, it flags FinishReason("dropped") instead of just going quiet. The agent loop treats that the same as the other stream-error cases (retry with backoff) instead of running it through the model-facing empty-reply nudge, since there's nothing to nudge — the model never got to finish. Co-Authored-By: Claude Sonnet 5 --- src/backend/openai_compat.rs | 20 ++++++++++++++++++++ src/commands/agent.rs | 18 ++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/backend/openai_compat.rs b/src/backend/openai_compat.rs index d4c4f38..78ac8d8 100644 --- a/src/backend/openai_compat.rs +++ b/src/backend/openai_compat.rs @@ -300,6 +300,14 @@ impl Provider for OpenAiCompatProvider { let reader = std::io::BufReader::new(resp.into_reader()); let mut tool_slots: Vec<(String, String, Option)> = Vec::new(); let mut raw_resp = String::new(); + // Whether the stream ended the way a well-behaved SSE response + // should: an explicit `[DONE]` marker or a `finish_reason` on the + // last chunk. If neither ever arrives — the read errors out or the + // connection just closes mid-thought — that's a dropped + // connection, not the model deciding to say nothing, even though + // it looks identical to the caller (a clean `Done` with empty + // content) unless we flag it. + let mut saw_terminator = false; for line in reader.lines() { let Ok(line) = line else { break }; @@ -310,6 +318,7 @@ impl Provider for OpenAiCompatProvider { }; let data = data.trim(); if data == "[DONE]" { + saw_terminator = true; break; } let Ok(json) = serde_json::from_str::(data) else { @@ -340,6 +349,7 @@ impl Provider for OpenAiCompatProvider { let choice = &json["choices"][0]; if let Some(r) = choice["finish_reason"].as_str() { if !r.is_empty() { + saw_terminator = true; let _ = tx.send(StreamEvent::FinishReason(r.to_string())); } } @@ -384,6 +394,7 @@ impl Provider for OpenAiCompatProvider { } // Flush any accumulated tool calls. + let had_tool_call = tool_slots.iter().any(|(name, ..)| !name.is_empty()); for (name, arguments, id) in &tool_slots { if !name.is_empty() { let _ = tx.send(StreamEvent::ToolCall(ToolCall { @@ -394,6 +405,15 @@ impl Provider for OpenAiCompatProvider { } } + // The connection closed (or errored) without ever telling us why — + // no `[DONE]`, no `finish_reason`. If nothing useful came out of + // it either, this was a dropped connection wearing a normal + // completion's clothes; flag it so the caller retries like a + // network error instead of treating it as the model's real answer. + if !saw_terminator && !had_tool_call { + let _ = tx.send(StreamEvent::FinishReason("dropped".to_string())); + } + let _ = tx.send(StreamEvent::ResponseRaw(raw_resp)); let _ = tx.send(StreamEvent::Done); }); diff --git a/src/commands/agent.rs b/src/commands/agent.rs index 1bdefe8..169da60 100644 --- a/src/commands/agent.rs +++ b/src/commands/agent.rs @@ -412,6 +412,24 @@ pub fn run_agent_loop( } } + // The connection closed without ever saying why (no `[DONE]`, no + // `finish_reason`) and produced nothing — a dropped connection + // wearing a normal completion's clothes (see `openai_compat.rs`). + // Retry it exactly like the stream-open/mid-stream error cases below: + // a fresh attempt, not a nudge, since there's nothing to nudge — + // the model never actually finished. + if finish_reason.as_deref() == Some("dropped") + && content_acc.is_empty() + && tool_calls.is_empty() + && stream_error_retries < MAX_STREAM_ERROR_RETRIES + { + stream_error_retries += 1; + std::thread::sleep(std::time::Duration::from_millis( + 500 * stream_error_retries as u64, + )); + continue 'rounds; + } + // A round that streamed successfully clears the transient-failure // budget, so a flaky gateway gets fresh retries for the next hiccup // instead of exhausting them across an otherwise-healthy long turn. From ad1bb5bd65366fb478b988a733652237ab0eeb93 Mon Sep 17 00:00:00 2001 From: patrick-mns Date: Mon, 3 Aug 2026 22:49:49 -0300 Subject: [PATCH 8/8] feat: add /loop session driver with registry, runner and loop-control tools --- frontend/src/ipc/index.ts | 11 ++- frontend/src/types.ts | 16 ++++ frontend/src/utils/chatHelpers.ts | 2 + frontend/src/utils/theme-styles.ts | 1 + frontend/src/views/Chat.tsx | 58 +++++++++++- src/backend/loop_registry.rs | 141 +++++++++++++++++++++++++++++ src/backend/loop_runner.rs | 114 +++++++++++++++++++++++ src/backend/mod.rs | 2 + src/backend/prompt.rs | 29 ++++++ src/backend/tools/loop_ctl.rs | 32 +++++++ src/backend/tools/mod.rs | 9 +- src/commands/agentloop.rs | 47 ++++++++++ src/commands/chat.rs | 103 +++++++++++++++++++++ src/commands/mod.rs | 1 + src/commands/sessions.rs | 1 + src/lib.rs | 3 + 16 files changed, 565 insertions(+), 5 deletions(-) create mode 100644 src/backend/loop_registry.rs create mode 100644 src/backend/loop_runner.rs create mode 100644 src/backend/tools/loop_ctl.rs create mode 100644 src/commands/agentloop.rs diff --git a/frontend/src/ipc/index.ts b/frontend/src/ipc/index.ts index a2d45e4..62302ee 100755 --- a/frontend/src/ipc/index.ts +++ b/frontend/src/ipc/index.ts @@ -3,7 +3,7 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { open } from '@tauri-apps/plugin-dialog'; import type { AskUser, BgTaskExited, BgTaskInfo, ChatMessage, CompactResult, ContextWindow, DirEntry, - EditReviewRequest, FileContent, FileHit, GitContext, McpServerStatus, McpToolInfo, + EditReviewRequest, FileContent, FileHit, GitContext, LoopStatus, LoopStatusEvent, McpServerStatus, McpToolInfo, ModelOption, ModelRole, NodeCode, NodeSummarized, Opener, ProviderInfo, ProviderInput, ProviderStatus, PtyExit, PtyOutput, SessionInfo, SessionModels, SessionTitle, @@ -35,6 +35,15 @@ export const ipc = { onMcpOauthUrl: (cb: (p: { server_name: string; auth_url: string }) => void) => on<{ server_name: string; auth_url: string }>('mcp_oauth_url', cb), stopChatStream: (sessionId?: string) => invoke('stop_chat_stream', { sessionId }), + + // Self-pacing background loop for a session (/loop): after each turn the + // model calls the `schedule_wakeup` tool to keep it going, or it stops on + // its own. `prompt` overrides the default "continue from history" kickoff. + startLoop: (sessionId?: string, prompt?: string, forever?: boolean) => + invoke('start_loop', { sessionId, prompt, forever }), + stopLoop: (sessionId?: string) => invoke('stop_loop', { sessionId }), + getLoopStatus: (sessionId?: string) => invoke('get_loop_status', { sessionId }), + onLoopStatus: (cb: (p: LoopStatusEvent) => void) => on('loop_status', cb), answerQuestion: (answer: string, sessionId?: string) => invoke('answer_question', { answer, sessionId }), getHistory: (sessionId?: string) => invoke('get_history', { sessionId }), clearHistory: (sessionId?: string) => invoke('clear_history', { sessionId }), diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5854fb0..a896aa9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -301,6 +301,22 @@ export interface StreamUsage { cost: number; } +/** Result of `get_loop_status`, and the payload of the `loop_status` event + * (which also carries `session_id`). */ +export interface LoopStatus { + active: boolean; + /** "running" | "waiting" | "stopped" */ + status: string; + pending_delay_secs?: number | null; + pending_reason?: string | null; + /** Started with /loop --forever: keeps going even if the model doesn't ask to continue. */ + forever: boolean; +} + +export interface LoopStatusEvent extends LoopStatus { + session_id: string; +} + export interface AskUser { session_id: string; args: string; diff --git a/frontend/src/utils/chatHelpers.ts b/frontend/src/utils/chatHelpers.ts index f1f9c7c..60ea157 100644 --- a/frontend/src/utils/chatHelpers.ts +++ b/frontend/src/utils/chatHelpers.ts @@ -53,6 +53,7 @@ export interface CommandContext { workspace: () => void | Promise; scan: () => void | Promise; summarize: (concurrency?: number) => void | Promise; + loop: (prompt?: string, forever?: boolean) => void | Promise; } export interface SlashCommand { @@ -67,6 +68,7 @@ export const COMMANDS: SlashCommand[] = [ { cmd: '/workspace', desc: 'Switch workspace folder', run: (ctx) => ctx.workspace() }, { cmd: '/scan', desc: 'Rescan workspace into the graph', run: (ctx) => ctx.scan() }, { cmd: '/summarize', desc: 'Summarize stale & unsummarized nodes (e.g. /summarize 8)', run: (ctx) => ctx.summarize() }, + { cmd: '/loop', desc: 'Keep the session running, self-paced (/loop watch the build, /loop --forever monitor logs, /loop stop)', run: (ctx) => ctx.loop() }, ]; export const COL_W = 760; diff --git a/frontend/src/utils/theme-styles.ts b/frontend/src/utils/theme-styles.ts index 66879e3..9dd6ec4 100644 --- a/frontend/src/utils/theme-styles.ts +++ b/frontend/src/utils/theme-styles.ts @@ -1035,6 +1035,7 @@ export const chatStyles: Record = { activityText: { color: theme.dim, fontSize: 13, fontStyle: 'italic' }, composerWrap: { position: 'relative', display: 'flex', justifyContent: 'center', padding: '0 0 28px', background: theme.bg }, composerFade: { position: 'absolute', left: 0, right: 0, top: -32, height: 32, pointerEvents: 'none', background: `linear-gradient(to bottom, transparent, ${theme.bg})` }, + loopPill: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '5px 10px', borderRadius: 'var(--radius-md)', background: theme.card, border: `1px solid ${theme.border}`, color: theme.textSoft, fontSize: 12 }, card: { background: theme.card, borderRadius: 16, padding: '14px 16px' }, textarea: { width: '100%', background: 'transparent', border: 'none', outline: 'none', color: theme.text, fontSize: 14, resize: 'none', fontFamily: 'inherit', lineHeight: 1.5, maxHeight: 200 }, attachRow: { display: 'flex', marginBottom: 8 }, diff --git a/frontend/src/views/Chat.tsx b/frontend/src/views/Chat.tsx index 3f6283b..eb05d2d 100755 --- a/frontend/src/views/Chat.tsx +++ b/frontend/src/views/Chat.tsx @@ -16,7 +16,7 @@ import { useWorkspace } from '@/hooks/useWorkspace'; import { COMMANDS, type Attachment, type CommandContext, type ChatMessageView, type RenderedItem, type SlashCommand } from '@/utils/chatHelpers'; import { MIN_SCAN_MS } from '@/utils/treemapHelpers'; import { chatStyles as styles } from '@/utils/theme-styles'; -import type { EditReviewRequest, FileHit, SkillSummary, ToolConfirmRequest, Usage } from '@/types'; +import type { EditReviewRequest, FileHit, LoopStatus, SkillSummary, ToolConfirmRequest, Usage } from '@/types'; import type { StreamPart, StreamState } from '@/components/StreamStatus'; import type { Question } from '@/components/QuestionCard'; @@ -94,6 +94,8 @@ export default function Chat() { const streamsRef = useRef>({}); const [streamsBySession, setStreamsBySession] = useState>({}); const streaming = streamsBySession[viewingSession] ?? null; + const [loopStatusBySession, setLoopStatusBySession] = useState>({}); + const loopStatus = loopStatusBySession[viewingSession]; // ── Fetch history on session change ──────────────────────────────────────── useEffect(() => { @@ -158,11 +160,24 @@ export default function Chat() { ipc.onStreamUsage(({ session_id, ...u }) => { if (streamsRef.current[session_id]) streamsRef.current[session_id].usage = u; }); + ipc.onLoopStatus(({ session_id, ...status }) => { + setLoopStatusBySession((prev) => ({ ...prev, [session_id]: status })); + }); }, []); function pushTo(sessionId: string, key: 'content' | 'thinking' | 'tools', chunk: string) { - const cur = streamsRef.current[sessionId]; - if (!cur) return; + let cur = streamsRef.current[sessionId]; + if (!cur) { + // No buffer yet means this turn wasn't kicked off by send() — e.g. a + // /loop tick fired from the background driver thread with no frontend + // call in between. Create one now so the stream still renders live, + // instead of silently dropping every chunk. + cur = { thinking: '', parts: [], startedAt: Date.now() }; + streamsRef.current[sessionId] = cur; + setStreamsBySession((prev) => ({ ...prev, [sessionId]: cur! })); + useStore.getState().setLoading(true); + useStore.getState().setStreamingSession(sessionId); + } // Once the user hits stop, drop any chunks still arriving while the backend // unwinds, so content visibly stops immediately. if (cur.canceled) return; @@ -324,6 +339,23 @@ export default function Chat() { return; } + // /loop [prompt] and /loop stop shortcuts + if (/^\/loop\s+stop$/i.test(content)) { + setInput(''); + if (taRef.current) taRef.current.style.height = 'auto'; + await ipc.stopLoop(activeSession).catch(console.error); + return; + } + const loopMatch = content.match(/^\/loop(?:\s+(--forever|-f))?(?:\s+([\s\S]+))?$/i); + if (loopMatch) { + setInput(''); + if (taRef.current) taRef.current.style.height = 'auto'; + addMessage(activeSession, { role: 'user', content }); + setGitRefreshTick((t) => t + 1); + await ipc.startLoop(activeSession, loopMatch[2]?.trim() || undefined, !!loopMatch[1]).catch(console.error); + return; + } + // #skill mentions auto-enable the skill before the stream starts, so the // backend injects its body into this message's system prompt. // Only #tokens at the start of the text or after whitespace count — a URL @@ -521,6 +553,10 @@ export default function Chat() { }, workspace: () => pickWorkspace(viewingSession), summarize: async (concurrency?: number) => { await ipc.summarizeAll(concurrency).catch(console.error); }, + loop: async (prompt?: string, forever?: boolean) => { + addMessage(viewingSession, { role: 'user', content: prompt ? `/loop ${prompt}` : '/loop' }); + await ipc.startLoop(viewingSession, prompt, forever).catch(console.error); + }, scan: async () => { const t0 = performance.now(); setScanning(true); @@ -631,6 +667,22 @@ export default function Chat() { onCancel={() => ipc.stopSummarize().catch(console.error)} /> )} + {loopStatus?.active && ( +
+ + {loopStatus.forever ? '∞ ' : ''} + {loopStatus.status === 'waiting' + ? `Loop: next in ${loopStatus.pending_delay_secs ?? '?'}s${loopStatus.pending_reason ? ` — ${loopStatus.pending_reason}` : ''}` + : 'Loop: running…'} + + +
+ )} pickWorkspace(viewingSession)} refreshTick={gitRefreshTick} /> , + /// Bumped on every `start`; lets a stale sleeping thread from a + /// superseded loop detect it's no longer the active one and no-op instead + /// of firing a second, duplicate loop for the same session. + generation: u64, + pending_wakeup: Option<(u64, String)>, + /// Human-readable status for the UI: "running" | "waiting" | "stopped". + status: &'static str, + /// `/loop --forever`: keep going even if the model doesn't call + /// `schedule_wakeup` — only an explicit `stop_loop` call or manual stop + /// ends it. See `loop_runner::run`. + forever: bool, +} + +fn registry() -> &'static Mutex> { + static REGISTRY: OnceLock>> = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Snapshot for the frontend / `get_loop_status`. +#[derive(Clone, serde::Serialize)] +pub struct LoopStatus { + pub active: bool, + pub status: String, + pub pending_delay_secs: Option, + pub pending_reason: Option, + pub forever: bool, +} + +/// Register a new loop for `session_id`, replacing (and implicitly +/// cancelling) any previous one. Returns the cancel flag and generation the +/// driver thread must use. +pub fn start(session_id: &str, forever: bool) -> (Arc, u64) { + let mut reg = registry().lock().unwrap(); + let generation = reg.get(session_id).map(|e| e.generation + 1).unwrap_or(0); + // Cancel whatever was running before so its thread unwinds on its next check. + if let Some(prev) = reg.get(session_id) { + prev.cancel.store(true, Ordering::SeqCst); + } + let cancel = Arc::new(AtomicBool::new(false)); + reg.insert( + session_id.to_string(), + LoopEntry { + cancel: cancel.clone(), + generation, + pending_wakeup: None, + status: "running", + forever, + }, + ); + (cancel, generation) +} + +/// Whether `session_id`'s loop (current generation) is a `--forever` loop. +pub fn is_forever(session_id: &str, generation: u64) -> bool { + let reg = registry().lock().unwrap(); + reg.get(session_id) + .filter(|e| e.generation == generation) + .map(|e| e.forever) + .unwrap_or(false) +} + +/// Called by the `schedule_wakeup` tool. No-op if there's no active loop for +/// this session (e.g. the model called it outside of a `/loop`). +pub fn set_pending_wakeup(session_id: &str, delay_secs: u64, reason: String) -> bool { + let mut reg = registry().lock().unwrap(); + match reg.get_mut(session_id) { + Some(e) => { + e.pending_wakeup = Some((delay_secs, reason)); + true + } + None => false, + } +} + +/// Take (and clear) the pending wakeup request for the current generation of +/// `session_id`'s loop. Returns `None` both when nothing was scheduled and +/// when the entry has since moved to a newer generation (superseded). +pub fn take_pending_wakeup(session_id: &str, generation: u64) -> Option<(u64, String)> { + let mut reg = registry().lock().unwrap(); + let entry = reg.get_mut(session_id)?; + if entry.generation != generation { + return None; + } + entry.pending_wakeup.take() +} + +pub fn set_status(session_id: &str, generation: u64, status: &'static str) { + let mut reg = registry().lock().unwrap(); + if let Some(e) = reg.get_mut(session_id) { + if e.generation == generation { + e.status = status; + } + } +} + +/// Stops the loop (if any) for `session_id` — called on explicit user cancel, +/// the `stop_loop` tool, session deletion, or the driver thread exiting. +pub fn stop(session_id: &str) { + let mut reg = registry().lock().unwrap(); + if let Some(e) = reg.get_mut(session_id) { + e.cancel.store(true, Ordering::SeqCst); + e.status = "stopped"; + } +} + +pub fn status(session_id: &str) -> LoopStatus { + let reg = registry().lock().unwrap(); + match reg.get(session_id) { + Some(e) if e.status != "stopped" => LoopStatus { + active: true, + status: e.status.to_string(), + pending_delay_secs: e.pending_wakeup.as_ref().map(|(d, _)| *d), + pending_reason: e.pending_wakeup.as_ref().map(|(_, r)| r.clone()), + forever: e.forever, + }, + _ => LoopStatus { + active: false, + status: "stopped".to_string(), + pending_delay_secs: None, + pending_reason: None, + forever: false, + }, + } +} diff --git a/src/backend/loop_runner.rs b/src/backend/loop_runner.rs new file mode 100644 index 0000000..053a2af --- /dev/null +++ b/src/backend/loop_runner.rs @@ -0,0 +1,114 @@ +//! Driver thread for a `/loop` session: runs one agent turn, then either +//! sleeps for the delay the model requested (via the `schedule_wakeup` tool) +//! and fires again, or stops if the model didn't ask to continue. + +use super::loop_registry; +use std::sync::atomic::Ordering; +use std::time::Duration; +use tauri::{AppHandle, Emitter}; + +/// Delay used for a `--forever` loop's auto-continue when the model didn't +/// call `schedule_wakeup` on its own. Same default a well-behaved model +/// would pick for "nothing urgent, check back later". +const FOREVER_DEFAULT_DELAY_SECS: u64 = 300; + +fn emit_status(app: &AppHandle, session_id: &str, status: &loop_registry::LoopStatus) { + let _ = app.emit( + "loop_status", + serde_json::json!({ + "session_id": session_id, + "active": status.active, + "status": status.status, + "pending_delay_secs": status.pending_delay_secs, + "pending_reason": status.pending_reason, + }), + ); +} + +/// Entry point spawned by `start_loop`. `generation` pins this thread to the +/// loop instance created by `loop_registry::start` — if the user starts a new +/// loop for the same session before this one ends, its `take_pending_wakeup` +/// calls start returning `None` and it exits quietly instead of double-firing. +pub fn run( + app: AppHandle, + session_id: String, + cancel: std::sync::Arc, + generation: u64, + initial_prompt: Option, +) { + let mut content = initial_prompt.unwrap_or_else(|| { + "[/loop start] Begin the loop: figure out what needs to happen based on the \ +recent conversation, do the next slice of work, then call `schedule_wakeup` to keep going \ +or stop_loop when there's nothing left to do." + .to_string() + }); + + loop { + if cancel.load(Ordering::SeqCst) { + break; + } + + let forever = loop_registry::is_forever(&session_id, generation); + crate::commands::chat::run_turn_blocking( + app.clone(), + session_id.clone(), + content.clone(), + true, + forever, + ); + + if cancel.load(Ordering::SeqCst) { + break; + } + + let wakeup = loop_registry::take_pending_wakeup(&session_id, generation).or_else(|| { + // --forever: the model didn't ask to continue, but the loop only + // ends on an explicit stop (stop_loop tool or manual Stop), so + // keep it alive on a default cadence instead of ending here. + loop_registry::is_forever(&session_id, generation).then(|| { + ( + FOREVER_DEFAULT_DELAY_SECS, + "forever loop: no wakeup requested, continuing on the default cadence" + .to_string(), + ) + }) + }); + + match wakeup { + Some((delay_secs, reason)) => { + loop_registry::set_status(&session_id, generation, "waiting"); + emit_status( + &app, + &session_id, + &loop_registry::LoopStatus { + active: true, + status: "waiting".to_string(), + pending_delay_secs: Some(delay_secs), + pending_reason: Some(reason), + forever: loop_registry::is_forever(&session_id, generation), + }, + ); + + let mut waited = 0u64; + let mut canceled = false; + while waited < delay_secs { + if cancel.load(Ordering::SeqCst) { + canceled = true; + break; + } + std::thread::sleep(Duration::from_secs(1)); + waited += 1; + } + if canceled { + break; + } + loop_registry::set_status(&session_id, generation, "running"); + content = crate::backend::prompt::LOOP_TICK.to_string(); + } + None => break, + } + } + + loop_registry::stop(&session_id); + emit_status(&app, &session_id, &loop_registry::status(&session_id)); +} diff --git a/src/backend/mod.rs b/src/backend/mod.rs index d63f261..76ef62c 100755 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -7,6 +7,8 @@ pub mod hierarchy; pub mod knowledge; pub mod llm; pub mod locks; +pub mod loop_registry; +pub mod loop_runner; pub mod mcp; pub mod ollama; pub mod openai_compat; diff --git a/src/backend/prompt.rs b/src/backend/prompt.rs index 0f1d713..d7a39ae 100755 --- a/src/backend/prompt.rs +++ b/src/backend/prompt.rs @@ -221,6 +221,35 @@ produced any answer — you spent the entire response budget on internal reasoni deliberating. Reply now with a short, direct answer (a few sentences), skipping further \ step-by-step analysis."; +/// Appended to the system prompt for a `/loop` turn — the user asked this +/// session to keep running autonomously at a pace the model itself decides, +/// mirroring Claude Code's dynamic `/loop`. +pub const LOOP_MODE: &str = "\n\n## You are in a /loop\n\ +The user started a self-pacing loop: after you finish this turn, the session will automatically \ +continue — but only if YOU call `schedule_wakeup(delay_seconds, reason)` before ending your turn. \ +- If there's more to do (waiting on something, iterating, monitoring), call `schedule_wakeup` with a \ +delay that matches what you're actually waiting for — a slow build deserves minutes, not seconds. \ +- If the task is done, or you're stuck and can't make progress, do NOT call `schedule_wakeup` (or call \ +`stop_loop` to make it explicit) and say why in your response — the loop ends after this turn either way. \ +- Never fabricate results of a scheduled action; the next iteration is a fresh turn, not something you \ +can predict now."; + +/// Appended after [`LOOP_MODE`] for a `/loop --forever` session: the loop is +/// not allowed to end itself, only the user (or an explicit `stop_loop` call +/// when something is actually broken) can stop it. +pub const LOOP_FOREVER: &str = " This loop was started with --forever: it does NOT end just because \ +this slice of work looks done. Always call `schedule_wakeup` before ending your turn — pick whatever \ +delay makes sense (short if you're mid-task, long if you're just watching for something to happen). \ +Only call `stop_loop` if the task has become impossible to continue (not just \"looks finished\"). If \ +you forget to call `schedule_wakeup`, the loop will auto-continue anyway on a default cadence, so \ +prefer to call it yourself with a sensible delay."; + +/// Injected as the (synthetic) user turn each time a `/loop` wakes back up, +/// standing in for the original message so the model re-reads its own recent +/// history and decides what to do next. +pub const LOOP_TICK: &str = "[/loop tick] Continue the loop: check what's changed or what's next, \ +do the next slice of work, then call `schedule_wakeup` again to keep going or stop if you're done."; + /// Appended to the system prompt in Chat mode, where no tools are available. pub const CHAT_MODE: &str = "You are in CHAT mode: read-only. You may use the read-only tools \ available this turn (read files, search, read the knowledge graph, fetch URLs, look at images \ diff --git a/src/backend/tools/loop_ctl.rs b/src/backend/tools/loop_ctl.rs new file mode 100644 index 0000000..ff9cffe --- /dev/null +++ b/src/backend/tools/loop_ctl.rs @@ -0,0 +1,32 @@ +//! Control tools for `/loop` sessions. Neither tool touches disk — they only +//! signal `backend::loop_registry`, which the loop's driver thread +//! (`backend::loop_runner`) reads once the current turn finishes. Calling +//! either outside of an active `/loop` is a harmless no-op, reported back to +//! the model so it doesn't think it scheduled something that didn't happen. + +use super::{ToolContext, ToolResult}; +use crate::backend::loop_registry; + +pub fn run_schedule_wakeup(arguments: &str, context: &ToolContext) -> Result { + let delay_secs = super::get_int_field(arguments, "delay_seconds") + .ok_or_else(|| "tool call missing `delay_seconds`".to_string())? + .clamp(10, 3600) as u64; + let reason = super::get_string_field(arguments, "reason") + .unwrap_or_else(|| "continuing the loop".to_string()); + + let scheduled = + loop_registry::set_pending_wakeup(&context.session_id, delay_secs, reason.clone()); + let content = if scheduled { + format!("Next loop iteration scheduled in {delay_secs}s. Reason: {reason}") + } else { + "No active /loop for this session — nothing scheduled. This tool only has an effect during a /loop.".to_string() + }; + Ok(ToolResult { content }) +} + +pub fn run_stop_loop(_arguments: &str, context: &ToolContext) -> Result { + loop_registry::stop(&context.session_id); + Ok(ToolResult { + content: "Loop stopped.".to_string(), + }) +} diff --git a/src/backend/tools/mod.rs b/src/backend/tools/mod.rs index 8d497da..2f38164 100755 --- a/src/backend/tools/mod.rs +++ b/src/backend/tools/mod.rs @@ -4,6 +4,7 @@ mod context_node; mod fetch; pub mod file; mod graph; +mod loop_ctl; mod search; mod terminal; mod vision; @@ -143,6 +144,8 @@ pub fn normalize_tool_name(name: &str) -> &str { "file", "ask_user", "bg", + "schedule_wakeup", + "stop_loop", ]; if KNOWN.contains(&name) { return name; @@ -184,6 +187,8 @@ pub fn run(name: &str, arguments: &str, context: &ToolContext) -> Result graph::run_focus(arguments, context), "vision" => vision::run(arguments, context), "bg" => bg::run(arguments, context), + "schedule_wakeup" => loop_ctl::run_schedule_wakeup(arguments, context), + "stop_loop" => loop_ctl::run_stop_loop(arguments, context), // ask_user is handled specially in the worker (intercepts before calling run) "ask_user" => Err("ask_user tool error: should have been intercepted by worker".into()), other => Err(format!("unknown tool `{other}`")), @@ -339,7 +344,9 @@ pub fn tools_json() -> &'static str { {"type":"function","function":{"name":"graph","description":"Read the knowledge graph. With no arguments, returns a compact tree of the whole project: hierarchy, active state, kind, one-line summary and approximate token weight per node. Pass a `symbol` to find where that function/class is referenced. Pass a `filter` to get a flat list of just the matching nodes. Use this to see the project as a whole and decide what to focus on.","parameters":{"type":"object","properties":{"symbol":{"type":"string","description":"Optional: a function/class name to find references for instead of the full overview"},"filter":{"type":"string","description":"Optional: return only matching nodes as a flat list. One of: summarized (has a summary), unsummarized (no summary yet), active, inactive, or a kind (file, function, class, concept, dir, note)."}}}}}, {"type":"function","function":{"name":"graph_focus","description":"Activate or deactivate a whole part of the knowledge graph (a node and everything under it) to focus context on what matters for the current task.","parameters":{"type":"object","properties":{"selector":{"type":"string","description":"Node label, path prefix (src/backend) or symbol name"},"active":{"type":"string","description":"\"true\" to activate, \"false\" to deactivate"}},"required":["selector"]}}}, {"type":"function","function":{"name":"bg","description":"Inspect background processes started by the terminal tool (background:true, or a foreground command that outran its timeout). Use it to poll a dev server's logs, check if it's still running, or stop it.","parameters":{"type":"object","properties":{"action":{"type":"string","description":"list (all tasks), logs (new output for a pid), or stop (SIGTERM a pid)","enum":["list","logs","stop"]},"pid":{"type":"integer","description":"Required for logs/stop: the process id returned when the task was started."},"wait_ms":{"type":"integer","description":"For logs: block up to this many ms until a URL appears or the process exits (max 60000). Default 0 = return immediately."}},"required":["action"]}}}, - {"type":"function","function":{"name":"vision","description":"Look at an image file and get a text description back, using the user's Vision-role model. Use this whenever the user references an image (png, jpg, gif, svg, webp, bmp, ico, tiff) or you need to understand a screenshot/diagram/photo. Pass an optional `prompt` to ask something specific about the image.","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Path to the image file (absolute or relative to the workspace)."},"prompt":{"type":"string","description":"Optional question or instruction about the image (default: describe it in detail)."}},"required":["path"]}}} + {"type":"function","function":{"name":"vision","description":"Look at an image file and get a text description back, using the user's Vision-role model. Use this whenever the user references an image (png, jpg, gif, svg, webp, bmp, ico, tiff) or you need to understand a screenshot/diagram/photo. Pass an optional `prompt` to ask something specific about the image.","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Path to the image file (absolute or relative to the workspace)."},"prompt":{"type":"string","description":"Optional question or instruction about the image (default: describe it in detail)."}},"required":["path"]}}}, + {"type":"function","function":{"name":"schedule_wakeup","description":"ONLY relevant when the user started a /loop for this session. Call this at the end of a turn to keep the loop going: it schedules the next loop iteration after a delay you choose. If you don't call it, the loop ends after this turn — so call it whenever there's more to do, and skip it (or call stop_loop) once the task is actually finished.","parameters":{"type":"object","properties":{"delay_seconds":{"type":"integer","description":"Seconds until the next iteration (10-3600). Pick based on how fast the thing you're watching/working on actually changes — don't just always use the same number."},"reason":{"type":"string","description":"One short sentence: what you're waiting for or about to do next."}},"required":["delay_seconds","reason"]}}}, + {"type":"function","function":{"name":"stop_loop","description":"ONLY relevant when the user started a /loop for this session. Call this to end the loop early because the task is complete or can't make further progress. Equivalent to simply not calling schedule_wakeup, but makes the reason explicit in your response.","parameters":{"type":"object","properties":{}}}} ] "#.trim() } diff --git a/src/commands/agentloop.rs b/src/commands/agentloop.rs new file mode 100644 index 0000000..a8b79f9 --- /dev/null +++ b/src/commands/agentloop.rs @@ -0,0 +1,47 @@ +//! Tauri commands for `/loop`: start/stop a self-pacing background loop for a +//! session, and read its current status. Actual driving happens in +//! `backend::loop_runner` on a plain OS thread — see there for the mechanics. + +use crate::backend::loop_registry; +use crate::AppState; +use tauri::{AppHandle, State}; + +#[tauri::command] +pub async fn start_loop( + app: AppHandle, + state: State<'_, AppState>, + session_id: Option, + prompt: Option, + forever: Option, +) -> Result<(), String> { + let session_id = session_id.unwrap_or_else(|| state.current_session.lock().unwrap().clone()); + if session_id.is_empty() { + return Err("no active session to loop".to_string()); + } + let (cancel, generation) = loop_registry::start(&session_id, forever.unwrap_or(false)); + let app2 = app.clone(); + let sid = session_id.clone(); + std::thread::spawn(move || { + crate::backend::loop_runner::run(app2, sid, cancel, generation, prompt); + }); + Ok(()) +} + +#[tauri::command] +pub async fn stop_loop( + state: State<'_, AppState>, + session_id: Option, +) -> Result<(), String> { + let session_id = session_id.unwrap_or_else(|| state.current_session.lock().unwrap().clone()); + loop_registry::stop(&session_id); + Ok(()) +} + +#[tauri::command] +pub async fn get_loop_status( + state: State<'_, AppState>, + session_id: Option, +) -> Result { + let session_id = session_id.unwrap_or_else(|| state.current_session.lock().unwrap().clone()); + Ok(loop_registry::status(&session_id)) +} diff --git a/src/commands/chat.rs b/src/commands/chat.rs index ca4895d..d4f813e 100755 --- a/src/commands/chat.rs +++ b/src/commands/chat.rs @@ -293,6 +293,109 @@ pub async fn start_chat_stream(app: AppHandle, content: String) -> Result(); + let workspace_root = state.workspace_root.lock().unwrap().clone(); + let model = state.session_chat_model(&session_id); + let mode = state.session_agent_mode(&session_id); + + { + let store = state.sessions.lock().unwrap(); + let _ = store.append_event(&session_id, "user", None, &content); + } + { + let mut histories = state.session_histories.lock().unwrap(); + histories + .entry(session_id.clone()) + .or_default() + .push(Message::user(&content)); + } + let messages = state + .session_histories + .lock() + .unwrap() + .get(&session_id) + .cloned() + .unwrap_or_default(); + + let graph_json = { + let locks = crate::backend::locks::locked_filter(&workspace_root); + let graph = state.graph.lock().unwrap(); + graph.serialize_for_model(&locks) + }; + + let mut system = crate::backend::prompt::system_prompt(); + { + let folders = state + .current_workspace + .lock() + .unwrap() + .as_ref() + .map(|w| w.folders.clone()) + .unwrap_or_default(); + if let Some(section) = + crate::backend::prompt::workspace_context_section(&folders, &workspace_root) + { + system.push_str(§ion); + } + } + if is_loop { + system.push_str(crate::backend::prompt::LOOP_MODE); + if loop_forever { + system.push_str(crate::backend::prompt::LOOP_FOREVER); + } + } + if mode == crate::backend::review::AgentMode::Chat { + system.push_str("\n\n"); + system.push_str(crate::backend::prompt::CHAT_MODE); + } + let mut history = vec![Message::system(&system)]; + history.extend(messages); + (model, workspace_root, graph_json, history, mode) + }; + + let cancel = { + let state = app.state::(); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + state + .session_cancels + .lock() + .unwrap() + .insert(session_id.clone(), cancel.clone()); + cancel + }; + + let provider = llm::provider_for_model(&model); + let needs_tool = tools::is_file_or_workspace_request(&content); + + // Blocking on purpose: the loop driver thread waits for this turn to fully + // finish (including tool calls) before deciding whether/when to re-fire. + super::agent::run_agent_loop( + app, + provider, + model, + workspace_root, + graph_json, + session_id, + history, + cancel, + needs_tool, + mode, + ); +} + #[tauri::command] pub async fn stop_chat_stream( state: State<'_, AppState>, diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 81a1c8c..baa3a87 100755 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -18,6 +18,7 @@ pub(crate) fn take_pending( } pub mod agent; +pub mod agentloop; pub mod bg; pub mod chat; pub mod graph; diff --git a/src/commands/sessions.rs b/src/commands/sessions.rs index 4e7b367..aa1909a 100755 --- a/src/commands/sessions.rs +++ b/src/commands/sessions.rs @@ -287,6 +287,7 @@ pub async fn delete_session( // Clean up in-memory state for the deleted session. state.session_histories.lock().unwrap().remove(&id); state.session_cancels.lock().unwrap().remove(&id); + crate::backend::loop_registry::stop(&id); let mut current = state.current_session.lock().unwrap(); if *current == id { diff --git a/src/lib.rs b/src/lib.rs index a4e523a..168818f 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -325,6 +325,9 @@ pub fn run() { commands::chat::reset_system_prompt, commands::chat::save_attachment, commands::chat::compact_chat, + commands::agentloop::start_loop, + commands::agentloop::stop_loop, + commands::agentloop::get_loop_status, commands::graph::get_graph, commands::graph::set_node_locked, commands::graph::scan_workspace,