+ )}
pickWorkspace(viewingSession)} refreshTick={gitRefreshTick} />
,
) -> 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/loop_registry.rs b/src/backend/loop_registry.rs
new file mode 100644
index 0000000..b8e80b2
--- /dev/null
+++ b/src/backend/loop_registry.rs
@@ -0,0 +1,141 @@
+//! Process-global registry of active `/loop` sessions, keyed by session id —
+//! same shape as `tools::bg`'s registry, since both need state reachable from
+//! a tool call (which only carries a `session_id`, no `AppHandle`) and from a
+//! background driver thread.
+//!
+//! A loop is self-paced: after each turn the model is expected to call the
+//! `schedule_wakeup` tool to say when it wants to run again. The driver
+//! thread (`backend::loop_runner`) reads that request after the turn ends. If
+//! the model doesn't call it, the loop ends on its own — same "no reschedule,
+//! no continuation" rule as Claude Code's dynamic `/loop`.
+
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex, OnceLock};
+
+struct LoopEntry {
+ cancel: Arc,
+ /// 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/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 23ee452..78ac8d8 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
@@ -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
@@ -293,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 };
@@ -303,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 {
@@ -330,7 +346,15 @@ 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() {
+ saw_terminator = true;
+ let _ = tx.send(StreamEvent::FinishReason(r.to_string()));
+ }
+ }
+
+ let delta = &choice["delta"];
if let Some(c) = delta["content"].as_str() {
if !c.is_empty() {
@@ -370,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 {
@@ -380,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/backend/prompt.rs b/src/backend/prompt.rs
index 7d66786..d7a39ae 100755
--- a/src/backend/prompt.rs
+++ b/src/backend/prompt.rs
@@ -156,6 +156,36 @@ 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.
@@ -181,6 +211,45 @@ 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 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/agent.rs b/src/commands/agent.rs
index b595353..169da60 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,27 @@ 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;
+ // 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;
+ // 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
// it has repeated back-to-back.
let mut last_call_sig: Option = None;
@@ -208,7 +229,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);
@@ -217,23 +238,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) => {
- // 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 {
@@ -326,11 +371,27 @@ 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,
}
}
}
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 +412,29 @@ 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.
+ 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)
@@ -380,12 +464,21 @@ 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;
}
+ 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(),
@@ -422,7 +515,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;
@@ -465,7 +558,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;
@@ -477,13 +570,29 @@ 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 && !retried_empty {
- history.push(Message::system(prompt::EMPTY_RESPONSE_RETRY));
- retried_empty = true;
- 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;
+ }
+ // 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();
@@ -497,26 +606,39 @@ 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 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
+ // 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
};
@@ -544,6 +666,7 @@ pub fn run_agent_loop(
&mut history,
&session_id_ref,
),
+ EMPTY_REPLY_FALLBACK,
);
finish(
&app,
@@ -591,20 +714,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
}
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 609359b..d4f813e 100755
--- a/src/commands/chat.rs
+++ b/src/commands/chat.rs
@@ -146,7 +146,22 @@ 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 +234,20 @@ pub async fn start_chat_stream(app: AppHandle, content: String) -> Result 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/commands/settings.rs b/src/commands/settings.rs
index 5fa1bae..dfd4dcf 100755
--- a/src/commands/settings.rs
+++ b/src/commands/settings.rs
@@ -156,25 +156,61 @@ fn role_provider(model: &str) -> 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,
},
])
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,