From 75d90fbd71889f707c15681735f90bc6d9216b18 Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Wed, 24 Jun 2026 15:28:42 +0530 Subject: [PATCH 01/14] fix(agent): collapse system-prompt cache_control to a single marker --- crates/agent/src/agent/prompt.rs | 98 +++++++++++++---- crates/agent/tests/integration.rs | 177 ++++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+), 21 deletions(-) diff --git a/crates/agent/src/agent/prompt.rs b/crates/agent/src/agent/prompt.rs index 13593cd0..c79c1f4a 100644 --- a/crates/agent/src/agent/prompt.rs +++ b/crates/agent/src/agent/prompt.rs @@ -45,22 +45,22 @@ pub struct SystemBlock { /// Build a system prompt for the given mode as an ordered list of blocks. /// -/// Returns 2 or 3 blocks depending on whether a skill registry is provided: +/// Returns 2-4 blocks depending on which optional sections apply: /// [0] static body — everything except the Environment section, with -/// `{{working_dir}}` interpolated inline. Marked `cache_control: ephemeral` -/// (1h TTL) so Anthropic caches this prefix (stable per project). -/// [1] (optional) Available Skills list — injected when `skills` is Some -/// and non-empty. Marked `cache_control: ephemeral` (1h TTL) on its own -/// breakpoint so toggling a skill invalidates only this block's cache, -/// not block 0's larger prefix. Independence is best-effort: both blocks -/// expire after 1h. +/// `{{working_dir}}` interpolated inline. +/// [1] (optional) Context-engine index guidance — when `context_engine_enabled`. +/// [2] (optional) Skills + Subagents combined — when either registry has entries. /// [last] environment section — Working directory, branch, project note, -/// date, OS/arch. NOT cached: `{{date}}` rotates daily, and we don't +/// date, OS/arch. NOT cached: `{{date}}` rotates daily and we don't /// want a fresh cache write every midnight. /// -/// Across sessions on the same project: block 0 is byte-identical → cache hit. -/// Across the midnight boundary: block 0 still cache-hits; env block is sent -/// uncached (~50 tokens, negligible). +/// Caching: exactly ONE `cache_control: ephemeral` marker is placed on the LAST +/// cacheable block (i.e. the block immediately before env). Anthropic's prompt +/// cache extends as a prefix from the start of the request through each +/// breakpoint, so a single marker at the end of the prefix caches the whole +/// static+index+skills span as one entry — and keeps the system prompt's +/// contribution to Anthropic's 4-cache_control limit at exactly 1, leaving +/// headroom for the conversation-level and tools-level breakpoints. pub fn build_system_prompt( mode: ToolMode, working_dir: &Path, @@ -95,20 +95,16 @@ pub fn build_system_prompt( let mut blocks = vec![SystemBlock { text: static_body, - cache_control: Some(CacheControl::ephemeral()), + cache_control: None, }]; - // Context-engine index guidance — only when an index is available for this run. - // Its own ephemeral breakpoint so toggling the engine invalidates just this block. if context_engine_enabled { blocks.push(SystemBlock { text: INDEX_PROMPT_BLOCK.to_string(), - cache_control: Some(CacheControl::ephemeral()), + cache_control: None, }); } - // Skills + Subagents share ONE ephemeral breakpoint. Toggling either - // invalidates the combined block once (not twice). let skills_entries = skills.map(|r| r.list_for_prompt()).unwrap_or_default(); let subagents_entries = subagents.map(|r| r.list_for_prompt()).unwrap_or_default(); @@ -151,10 +147,18 @@ pub fn build_system_prompt( ); blocks.push(SystemBlock { text: combined, - cache_control: Some(CacheControl::ephemeral()), + cache_control: None, }); } + // Single cache breakpoint at the end of the cacheable prefix. Caches the + // whole static+index+skills span as one entry regardless of which optional + // blocks are present, and leaves room for the conversation + tools markers + // within Anthropic's 4-marker cap. + if let Some(last) = blocks.last_mut() { + last.cache_control = Some(CacheControl::ephemeral()); + } + blocks.push(SystemBlock { text: env_section, cache_control: None, @@ -209,14 +213,66 @@ mod tests { false, ); assert_eq!(blocks.len(), 3, "{:?}: expected 3 blocks with skills", mode); - assert!(blocks[0].cache_control.is_some(), "{:?}: static body cached", mode); - assert!(blocks[1].cache_control.is_some(), "{:?}: skills cached", mode); + // Single cache breakpoint at the end of the cacheable prefix — + // static body is part of the same cached span but carries no + // marker of its own. + assert!(blocks[0].cache_control.is_none(), "{:?}: static body no marker", mode); + assert!(blocks[1].cache_control.is_some(), "{:?}: last cacheable block carries marker", mode); assert!(blocks[2].cache_control.is_none(), "{:?}: env uncached", mode); assert!(blocks[1].text.contains("# Available Skills")); assert!(blocks[1].text.contains("hello: A greeting skill.")); } } + // ── Anthropic 4-cache_control invariant: system contributes exactly 1 ── + + #[test] + fn test_exactly_one_cache_marker_across_all_configs() { + let registry = mk_skill_registry(); + let empty = SkillRegistry::new(vec![], vec![], vec![], &HashSet::new()); + // (semantic_on, skills_registry) — env block never carries a marker, so + // every config must total exactly one cache_control across all blocks. + let cases: &[(bool, Option<&SkillRegistry>)] = &[ + (false, None), + (true, None), + (false, Some(&empty)), + (true, Some(&empty)), + (false, Some(®istry)), + (true, Some(®istry)), + ]; + for (semantic_on, skills) in cases { + for mode in [ToolMode::Ask, ToolMode::Coding, ToolMode::Plan] { + let blocks = build_system_prompt( + mode, + &PathBuf::from("/p"), + Some("main"), + None, + *skills, + None, + *semantic_on, + ); + let markers = blocks.iter().filter(|b| b.cache_control.is_some()).count(); + assert_eq!( + markers, 1, + "{:?} semantic_on={} skills={}: expected exactly 1 cache_control marker, got {} (blocks={})", + mode, + semantic_on, + skills.is_some(), + markers, + blocks.len(), + ); + // Marker must be on the last *cacheable* block — i.e. the one + // immediately before env. Env itself must never be marked. + let last_idx = blocks.len() - 1; + assert!(blocks[last_idx].cache_control.is_none(), "env block must stay uncached"); + assert!( + blocks[last_idx - 1].cache_control.is_some(), + "marker must sit on the last cacheable block" + ); + } + } + } + #[test] fn test_skills_block_omitted_when_registry_empty() { let empty = SkillRegistry::new(vec![], vec![], vec![], &HashSet::new()); diff --git a/crates/agent/tests/integration.rs b/crates/agent/tests/integration.rs index 435d918c..5c495c5b 100644 --- a/crates/agent/tests/integration.rs +++ b/crates/agent/tests/integration.rs @@ -1072,3 +1072,180 @@ async fn test_caching_emits_cache_tokens_across_turns() { ); } } + +/// Surgical, request-shape end-to-end check that the system-prompt +/// cache_control fix lands correctly on the wire. Complements +/// `test_caching_emits_cache_tokens_across_turns` (which runs the full agent +/// loop): this one bypasses the agent and posts a hand-built request, so it +/// isolates the system-block cache marker logic. +/// +/// Worst-case shape (semantic search ON + skills present + tools present) +/// used to emit 5 cache_control markers, exceeding Anthropic's 4-marker cap. +/// The fix collapses the system contribution to exactly one marker — total 3 +/// on the wire (system + last user message + last tool). +/// +/// Asserts: +/// 1. The serialized request carries exactly 3 cache_control markers. +/// 2. Anthropic accepts the request (no "too many cache breakpoints" 400). +/// 3. Caching actually works — a second identical request must read from cache. +#[tokio::test] +#[ignore = "requires ANTHROPIC_API_KEY"] +async fn test_system_prompt_cache_breakpoints_under_anthropic_limit() { + use agent::agent::prompt::build_system_prompt; + use agent::llm::anthropic::build_anthropic_request; + use agent::llm::types::{ + ChatMessage, ContentBlock, FunctionDefinition, MessageContent, ToolDefinition, + }; + use agent::llm::{LlmClientConfig, Provider}; + use agent::skills::{registry::SkillInput, SkillRegistry}; + use serde_json::json; + use std::collections::HashSet; + + let api_key = api_key(); + + // Worst-case shape: context-engine ON + non-empty skill registry. + let registry = SkillRegistry::new( + vec![], + vec![SkillInput { + raw: "---\nname: hello\ndescription: greeting skill.\n---\nbody\n".into(), + path: PathBuf::from("/hello"), + }], + vec![], + &HashSet::new(), + ); + let system_blocks = build_system_prompt( + ToolMode::Coding, + &PathBuf::from("/p"), + Some("main"), + None, + Some(®istry), + None, + /*context_engine_enabled=*/ true, + ); + + // ChatMessage::system() uses MessageContent::Text and would lose per-block + // cache_control. Build the struct manually with Blocks to preserve markers. + let system_msg = ChatMessage { + role: "system".into(), + content: Some(MessageContent::Blocks( + system_blocks + .into_iter() + .map(|b| ContentBlock::Text { text: b.text, cache_control: b.cache_control }) + .collect(), + )), + tool_calls: None, + tool_call_id: None, + name: None, + thinking: None, + }; + let user_msg = ChatMessage::user("Respond with the single word READY and nothing else."); + let messages = vec![system_msg, user_msg]; + + // One dummy tool so the tools-level cache marker also fires. + let tools = vec![ToolDefinition { + type_: "function".into(), + function: FunctionDefinition { + name: "noop".into(), + description: Some("never invoked; present only to exercise tools-level cache".into()), + parameters: Some(json!({"type": "object", "properties": {}, "additionalProperties": false})), + }, + cache_control: None, + }]; + + let cfg = LlmClientConfig { + provider: Provider::Anthropic, + base_url: "https://api.anthropic.com".into(), + model: "claude-sonnet-4-6".into(), + api_key: api_key.clone(), + temperature: Some(0.0), + max_completion_tokens: Some(32), + extra_headers: vec![], + thinking: None, + disable_cache_control: false, + policy: Default::default(), + }; + + let mut req = build_anthropic_request(&messages, &tools, &cfg); + req.stream = false; + + // (1) Marker count invariant — exactly 3 markers on the wire. + let body = serde_json::to_value(&req).expect("serialize request"); + let markers = count_cache_control_markers(&body); + eprintln!("[live] cache_control markers in serialized request: {markers}"); + assert_eq!( + markers, 3, + "expected exactly 3 cache_control markers (system+message+tool); got {markers}" + ); + + let http = reqwest::Client::new(); + + // (2) Call 1: cache freshly written OR (on re-run within the 5m TTL window) + // read from a prior run. Either is a healthy caching signal. + let usage1 = post_anthropic_messages(&http, &api_key, &body).await; + eprintln!("[live] call 1 usage: {usage1:?}"); + assert!( + usage1.cache_creation_input_tokens > 0 || usage1.cache_read_input_tokens > 0, + "first call must produce cache stats (write or read) (got {usage1:?})" + ); + + // (3) Call 2: an identical request issued immediately MUST read from cache. + let usage2 = post_anthropic_messages(&http, &api_key, &body).await; + eprintln!("[live] call 2 usage: {usage2:?}"); + assert!( + usage2.cache_read_input_tokens > 0, + "second identical call must read from the cache (got {usage2:?})" + ); +} + +fn count_cache_control_markers(v: &serde_json::Value) -> usize { + match v { + serde_json::Value::Object(map) => { + let mine = matches!(map.get("cache_control"), Some(v) if !v.is_null()) as usize; + mine + map.values().map(count_cache_control_markers).sum::() + } + serde_json::Value::Array(arr) => arr.iter().map(count_cache_control_markers).sum(), + _ => 0, + } +} + +#[derive(Debug, Default)] +#[allow(dead_code)] // fields are read via Debug in eprintln only +struct AnthropicUsage { + input_tokens: u32, + output_tokens: u32, + cache_creation_input_tokens: u32, + cache_read_input_tokens: u32, +} + +async fn post_anthropic_messages( + http: &reqwest::Client, + api_key: &str, + body: &serde_json::Value, +) -> AnthropicUsage { + use agent::llm::anthropic::ANTHROPIC_VERSION; + let resp = http + .post("https://api.anthropic.com/v1/messages") + .header("x-api-key", api_key) + .header("anthropic-version", ANTHROPIC_VERSION) + .header("content-type", "application/json") + .json(body) + .send() + .await + .expect("HTTP send failed"); + let status = resp.status(); + let text = resp.text().await.expect("read body"); + assert!( + status.is_success(), + "Anthropic API returned {status}: {}", + &text[..text.len().min(1000)] + ); + let v: serde_json::Value = serde_json::from_str(&text).expect("parse response"); + let u = v.get("usage").cloned().unwrap_or(serde_json::Value::Null); + let get = |k: &str| u.get(k).and_then(|x| x.as_u64()).unwrap_or(0) as u32; + AnthropicUsage { + input_tokens: get("input_tokens"), + output_tokens: get("output_tokens"), + cache_creation_input_tokens: get("cache_creation_input_tokens"), + cache_read_input_tokens: get("cache_read_input_tokens"), + } +} From 8a9e8a4627b7acd54131aefeb762c648eb18c6ef Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Wed, 24 Jun 2026 15:28:44 +0530 Subject: [PATCH 02/14] fix(desktop): skip engine stop on quit when nothing was started --- apps/desktop/src-tauri/src/engine_control.rs | 14 ++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 19 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/engine_control.rs b/apps/desktop/src-tauri/src/engine_control.rs index a140ebf7..54eaa724 100644 --- a/apps/desktop/src-tauri/src/engine_control.rs +++ b/apps/desktop/src-tauri/src/engine_control.rs @@ -511,10 +511,24 @@ impl EngineController { } /// Stop containers, keep volumes (fast restart). Cancels any in-flight start. + /// + /// Early-returns when the controller has no live state worth tearing down: + /// the user kept the engine disabled this session (status still `Stopped`) + /// or Docker is unreachable (`DockerMissing`). Without this guard, quitting + /// the app with semantic search off would shell out to `docker compose + /// stop` against a project we never created; a slow/unresponsive Docker + /// daemon then wedges the quit handler indefinitely (no timeout on the + /// child process). pub async fn stop(&self) -> Result<(), String> { if let Some(t) = self.cancel.lock().take() { t.cancel(); } + if matches!( + *self.status.lock(), + EngineStatus::Stopped | EngineStatus::DockerMissing { .. } + ) { + return Ok(()); + } // Short stop timeout: the worker has no SIGTERM handler and would // otherwise hold the default ~10s grace before being killed, making quit // feel slow. 3s lets the datastores flush; volumes are kept either way. diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 0718e30d..eafc27a0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -689,14 +689,29 @@ pub fn run() { let wm = wm.inner().clone(); tauri::async_runtime::block_on(async move { wm.stop_all().await }); } - // App-managed stack: stop containers (keep volumes). + // App-managed stack: stop containers (keep volumes). Bounded + // to 5s so a wedged Docker daemon can't keep the app + // un-quittable — `block_on` here would otherwise pin the event + // loop until docker responded. 5s comfortably covers + // `docker compose stop -t 3`; past that we abandon and exit. if let Some(ctl) = app_handle.try_state::>() { if ctl.mode() == engine_control::EngineMode::App { let ctl = ctl.inner().clone(); tauri::async_runtime::block_on(async move { - let _ = ctl.stop().await; + match tokio::time::timeout( + std::time::Duration::from_secs(5), + ctl.stop(), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(e)) => log::warn!("[engine] quit stop failed: {e}"), + Err(_) => log::warn!( + "[engine] quit stop timed out after 5s; abandoning" + ), + } }); } } From 1d5748c24499b6da2967e2dda26fe7aae6fc4fb3 Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Wed, 24 Jun 2026 17:20:00 +0530 Subject: [PATCH 03/14] feat(agent): scaffold Auto-mode types + events (P0) --- .../src-tauri/src/agent_bridge/events.rs | 12 + crates/agent/src/auto/mod.rs | 20 ++ crates/agent/src/auto/schema.rs | 123 +++++++++ crates/agent/src/auto/types.rs | 238 ++++++++++++++++++ crates/agent/src/lib.rs | 1 + crates/agent/src/types.rs | 65 +++++ 6 files changed, 459 insertions(+) create mode 100644 crates/agent/src/auto/mod.rs create mode 100644 crates/agent/src/auto/schema.rs create mode 100644 crates/agent/src/auto/types.rs diff --git a/apps/desktop/src-tauri/src/agent_bridge/events.rs b/apps/desktop/src-tauri/src/agent_bridge/events.rs index 1d1bbc9e..995ad2b5 100644 --- a/apps/desktop/src-tauri/src/agent_bridge/events.rs +++ b/apps/desktop/src-tauri/src/agent_bridge/events.rs @@ -424,6 +424,18 @@ async fn relay_event( ); false } + + // ── Auto mode events (PHASE_AUTO_MODE.md) ── + // Emitted by the executor (P3+). Frontend wiring lands in P6 — for + // now these are no-ops at the relay layer. Terminal variants + // (AutoDone / AutoFailed) terminate the loop just like Done. + AgentEvent::AutoPlanning { .. } + | AgentEvent::AutoPlan { .. } + | AgentEvent::AutoAwaitingApproval { .. } + | AgentEvent::AutoWorkerStart { .. } + | AgentEvent::AutoWorkerEnd { .. } + | AgentEvent::AutoReplan { .. } => false, + AgentEvent::AutoDone { .. } | AgentEvent::AutoFailed { .. } => true, } } diff --git a/crates/agent/src/auto/mod.rs b/crates/agent/src/auto/mod.rs new file mode 100644 index 00000000..302fca61 --- /dev/null +++ b/crates/agent/src/auto/mod.rs @@ -0,0 +1,20 @@ +//! Auto mode — orchestrator-driven multi-model coding. +//! +//! See PHASE_AUTO_MODE.md for the locked design + phase plan. This crate +//! sub-module is architecturally distinct from `crate::subagents` (no +//! `SubagentRegistry` coupling) and reuses only the spawning skeleton. +//! +//! Phase status: +//! P0 (current): types + schema only. No behavior. +//! P1: `worker.rs` — ephemeral worker runtime. +//! P2: `orchestrator.rs` — orchestrator LLM call + plan parsing (forced tool use). +//! P3: `executor.rs` — sequencer + reactive replan loop. + +pub mod schema; +pub mod types; + +pub use schema::{submit_plan_input_schema, submit_plan_tool, SUBMIT_PLAN_TOOL_NAME}; +pub use types::{ + AutoResult, AutoRun, AutoStatus, Plan, SeePrior, SeePriorKeyword, WorkerPoolEntry, + WorkerResult, WorkerSpec, WorkerStatus, +}; diff --git a/crates/agent/src/auto/schema.rs b/crates/agent/src/auto/schema.rs new file mode 100644 index 00000000..075e963b --- /dev/null +++ b/crates/agent/src/auto/schema.rs @@ -0,0 +1,123 @@ +//! JSON schema for the `submit_plan` tool. The orchestrator is forced to call +//! this tool as its only allowed action (forced `tool_choice` in P2), making +//! the plan structure provider-validated on both Anthropic and OpenAI. +//! +//! Canonical schema definition: PHASE_AUTO_MODE.md "Orchestrator output schema". + +use serde_json::{json, Value}; + +use crate::llm::types::{FunctionDefinition, ToolDefinition}; + +pub const SUBMIT_PLAN_TOOL_NAME: &str = "submit_plan"; + +/// Build the `submit_plan` ToolDefinition. Used by `orchestrator::generate_plan` +/// (P2) when invoking the orchestrator LLM with forced tool_choice. +pub fn submit_plan_tool() -> ToolDefinition { + ToolDefinition { + type_: "function".into(), + function: FunctionDefinition { + name: SUBMIT_PLAN_TOOL_NAME.into(), + description: Some( + "Submit the worker plan for this task. The plan is an ordered \ + list of workers; each worker has a model id, a natural-language \ + prompt, and a see_prior field declaring which prior worker \ + outputs it may consume." + .into(), + ), + parameters: Some(submit_plan_input_schema()), + }, + cache_control: None, + } +} + +/// Just the JSON Schema for the tool's input. Split out so tests / docs can +/// inspect it without going through the ToolDefinition wrapper. +pub fn submit_plan_input_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["reasoning", "plan"], + "properties": { + "reasoning": { + "type": "string", + "description": "Brief why-this-plan rationale (1-3 sentences)." + }, + "plan": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "model", "prompt", "see_prior"], + "properties": { + "id": { "type": "string", "pattern": "^w[0-9]+$" }, + "model": { "type": "string" }, + "prompt": { "type": "string", "minLength": 1 }, + "see_prior": { + "oneOf": [ + { "type": "string", "enum": ["none", "all"] }, + { "type": "array", "items": { "type": "string" } } + ] + } + } + } + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn submit_plan_tool_has_correct_name_and_schema() { + let tool = submit_plan_tool(); + assert_eq!(tool.type_, "function"); + assert_eq!(tool.function.name, SUBMIT_PLAN_TOOL_NAME); + assert!(tool.function.description.is_some()); + let params = tool.function.parameters.expect("schema present"); + // Sanity: schema has the top-level required fields the orchestrator + // must return. + let required = params["required"].as_array().expect("required array"); + let names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); + assert!(names.contains(&"reasoning")); + assert!(names.contains(&"plan")); + } + + #[test] + fn submit_plan_tool_serializes_as_provider_function_shape() { + // The wire format expected by both Anthropic (after translate_tool) and + // OpenAI (chat-completions tools array). If this breaks the LLM client + // would silently reject the tool. + let tool = submit_plan_tool(); + let v = serde_json::to_value(&tool).unwrap(); + assert_eq!(v["type"], "function"); + assert_eq!(v["function"]["name"], SUBMIT_PLAN_TOOL_NAME); + assert!(v["function"]["parameters"]["properties"]["plan"].is_object()); + assert!(v.get("cache_control").is_none(), "cache_control must be omitted when None"); + } + + #[test] + fn submit_plan_schema_accepts_valid_example_payload() { + // The example payload from PHASE_AUTO_MODE.md must structurally match + // the schema. We don't run a JSON Schema validator here (no dep); + // instead we deserialize as Plan, which validates the Rust-side + // contract that mirrors the schema. + let payload = serde_json::json!({ + "reasoning": "Two-stage decomposition.", + "plan": [ + {"id": "w1", "model": "haiku", "prompt": "explore", "see_prior": "none"}, + {"id": "w2", "model": "sonnet", "prompt": "design", "see_prior": ["w1"]}, + {"id": "w3", "model": "sonnet", "prompt": "synthesize", "see_prior": "all"} + ] + }); + // The Plan type doesn't have a `version` in the tool payload — version + // is assigned by the executor at insertion time. So we deserialize + // just the inner shape. + let workers = payload["plan"].as_array().unwrap(); + for w in workers { + let _: crate::auto::WorkerSpec = serde_json::from_value(w.clone()).unwrap(); + } + } +} diff --git a/crates/agent/src/auto/types.rs b/crates/agent/src/auto/types.rs new file mode 100644 index 00000000..1218553b --- /dev/null +++ b/crates/agent/src/auto/types.rs @@ -0,0 +1,238 @@ +//! Public types for Auto mode (orchestrator + workers). +//! +//! Pure data definitions. No behavior. Logic lives in `orchestrator.rs` (P2), +//! `executor.rs` (P3), `worker.rs` (P1). See PHASE_AUTO_MODE.md for the full +//! design. + +use serde::{Deserialize, Serialize}; + +/// One worker's specification, emitted by the orchestrator as part of a Plan. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WorkerSpec { + /// Stable id like "w1", "w2". Used in `see_prior` references. + pub id: String, + /// Model identifier the orchestrator picked from the eligible pool. + pub model: String, + /// Natural-language task for this worker. Becomes its user prompt. + pub prompt: String, + /// Which prior worker outputs (if any) get injected into this worker's prompt. + pub see_prior: SeePrior, +} + +/// Paper's `access_list`: which prior worker summaries this worker can see. +/// Serializes as either a keyword string (`"none"` / `"all"`) or an explicit +/// array of worker ids (`["w1", "w2"]`). Untagged so the orchestrator's JSON +/// output can use either form per the locked schema. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum SeePrior { + Keyword(SeePriorKeyword), + Specific(Vec), +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SeePriorKeyword { + None, + All, +} + +/// A complete orchestrator-produced plan. One Plan per orchestrator call; +/// successive replans produce new Plans with incremented `version`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Plan { + /// 1-indexed. Initial plan is version 1; each replan increments. + pub version: u32, + /// Brief why-this-plan rationale (1-3 sentences). + pub reasoning: String, + /// Workers in execution order. + pub workers: Vec, +} + +/// Result of executing a single worker. Populated by `worker::run_worker` (P1). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WorkerResult { + pub id: String, + pub model: String, + pub prompt: String, + /// Final summary text returned by the worker's agent loop. + pub summary: String, + pub tool_count: u32, + pub cost_cents: u32, + pub status: WorkerStatus, +} + +/// Terminal status of a single worker. Hard-failure variants trigger reactive +/// replans (executor.rs, P3); non-hard variants pass through to the next worker. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WorkerStatus { + /// Worker completed normally. + Ok, + /// Worker hit max_iterations (100 per AgentConfig default). + MaxIterations, + /// A tool call returned an error the worker couldn't recover from. + ToolError { message: String }, + /// Worker's underlying LLM call failed (network, 5xx, parse, etc). + LlmError { message: String }, + /// User cancelled (or the parent task was cancelled). + Cancelled, +} + +impl WorkerStatus { + /// True for statuses that trigger a reactive replan in the executor. + pub fn is_hard_failure(&self) -> bool { + matches!( + self, + WorkerStatus::MaxIterations + | WorkerStatus::ToolError { .. } + | WorkerStatus::LlmError { .. } + ) + } +} + +/// Persisted per-Auto-task state. One row per Auto invocation in the +/// `auto_runs` table (schema added in P4). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AutoRun { + pub id: String, + pub session_id: String, + pub status: AutoStatus, + /// Ordered; index 0 is the initial plan, subsequent entries are replans. + pub plan_versions: Vec, + pub worker_results: Vec, + pub cost_cents: u32, + pub replans_used: u32, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AutoStatus { + Planning, + AwaitingApproval, + Running, + Done, + Failed, + Cancelled, +} + +/// Terminal result of an Auto task. Returned by `executor::run` (P3). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AutoResult { + /// All workers ran successfully (possibly after replans). + Done { summary: String, run: AutoRun }, + /// Replan budget exhausted; no silent fallback. + Failed { reason: String, run: AutoRun }, + /// User cancelled mid-execution. + Cancelled { run: AutoRun }, +} + +/// One entry in the user's curated worker pool (Settings → Auto → Worker pool). +/// `description` is fed verbatim to the orchestrator so it can pick wisely. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WorkerPoolEntry { + pub provider_id: String, + pub model: String, + /// User-written hint, e.g. "best for cheap exploration, weak on synthesis". + pub description: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn see_prior_keyword_serializes_as_string() { + let none = serde_json::to_value(SeePrior::Keyword(SeePriorKeyword::None)).unwrap(); + assert_eq!(none, json!("none")); + let all = serde_json::to_value(SeePrior::Keyword(SeePriorKeyword::All)).unwrap(); + assert_eq!(all, json!("all")); + } + + #[test] + fn see_prior_specific_serializes_as_array() { + let s = SeePrior::Specific(vec!["w1".into(), "w3".into()]); + let v = serde_json::to_value(&s).unwrap(); + assert_eq!(v, json!(["w1", "w3"])); + } + + #[test] + fn see_prior_roundtrips_all_forms() { + for form in [ + json!("none"), + json!("all"), + json!([]), + json!(["w1"]), + json!(["w1", "w2"]), + ] { + let parsed: SeePrior = serde_json::from_value(form.clone()).unwrap(); + let back = serde_json::to_value(&parsed).unwrap(); + assert_eq!(back, form, "round-trip mismatch for {form}"); + } + } + + #[test] + fn worker_status_serializes_with_kind_tag() { + assert_eq!( + serde_json::to_value(WorkerStatus::Ok).unwrap(), + json!({"kind": "ok"}) + ); + assert_eq!( + serde_json::to_value(WorkerStatus::MaxIterations).unwrap(), + json!({"kind": "max_iterations"}) + ); + assert_eq!( + serde_json::to_value(WorkerStatus::ToolError { message: "boom".into() }).unwrap(), + json!({"kind": "tool_error", "message": "boom"}) + ); + } + + #[test] + fn is_hard_failure_classifies_correctly() { + assert!(!WorkerStatus::Ok.is_hard_failure()); + assert!(!WorkerStatus::Cancelled.is_hard_failure()); + assert!(WorkerStatus::MaxIterations.is_hard_failure()); + assert!(WorkerStatus::ToolError { message: "x".into() }.is_hard_failure()); + assert!(WorkerStatus::LlmError { message: "x".into() }.is_hard_failure()); + } + + #[test] + fn plan_full_roundtrip_matches_orchestrator_schema() { + // Mirrors the example payload in PHASE_AUTO_MODE.md "Orchestrator + // output schema". If this breaks, either the type or the doc moved + // and the other needs to catch up. + let raw = json!({ + "version": 1, + "reasoning": "Two-stage: cheap exploration first, then expensive synthesis.", + "workers": [ + { + "id": "w1", + "model": "claude-haiku-4-5", + "prompt": "Read all files matching src/auth/**.ts and summarize the auth flow.", + "see_prior": "none" + }, + { + "id": "w2", + "model": "claude-sonnet-4-6", + "prompt": "Given the prior summary, design a JWT-based replacement.", + "see_prior": ["w1"] + } + ] + }); + let plan: Plan = serde_json::from_value(raw.clone()).unwrap(); + assert_eq!(plan.workers.len(), 2); + assert_eq!(plan.workers[0].id, "w1"); + assert!(matches!(plan.workers[0].see_prior, SeePrior::Keyword(SeePriorKeyword::None))); + assert!(matches!(&plan.workers[1].see_prior, SeePrior::Specific(ids) if ids == &["w1"])); + let back = serde_json::to_value(&plan).unwrap(); + assert_eq!(back, raw); + } + + #[test] + fn auto_status_serializes_snake_case() { + assert_eq!(serde_json::to_value(AutoStatus::AwaitingApproval).unwrap(), json!("awaiting_approval")); + assert_eq!(serde_json::to_value(AutoStatus::Failed).unwrap(), json!("failed")); + } +} diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 7c889250..cf0d4232 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -11,6 +11,7 @@ pub mod session; pub mod context_engine; pub mod skills; pub mod subagents; +pub mod auto; #[cfg(test)] pub mod test_util; diff --git a/crates/agent/src/types.rs b/crates/agent/src/types.rs index 935a0c65..2d862fcf 100644 --- a/crates/agent/src/types.rs +++ b/crates/agent/src/types.rs @@ -1,5 +1,7 @@ use serde::Serialize; +use crate::auto::{WorkerSpec, WorkerStatus}; + /// Events emitted by the agent loop for UI consumption. #[derive(Debug, Clone, Serialize)] #[serde(tag = "type")] @@ -109,6 +111,69 @@ pub enum AgentEvent { success: bool, summary: String, }, + + // ── Auto mode (orchestrator + workers) ── + // See PHASE_AUTO_MODE.md "AgentEvent additions" for the canonical list. + + /// Auto: the orchestrator LLM call is in flight. + AutoPlanning { + session_id: String, + run_id: String, + }, + /// Auto: orchestrator emitted a (new or revised) plan. `version` matches + /// `Plan::version` — initial plan is 1; replans increment. + AutoPlan { + session_id: String, + run_id: String, + version: u32, + reasoning: String, + plan: Vec, + }, + /// Auto: plan rendered to the user; executor is blocked on the Run button. + AutoAwaitingApproval { + session_id: String, + run_id: String, + }, + /// Auto: a worker has started executing. + AutoWorkerStart { + session_id: String, + run_id: String, + worker_id: String, + model: String, + prompt: String, + }, + /// Auto: a worker finished (success or failure). Hard-failure statuses + /// trigger a reactive replan in the executor. + AutoWorkerEnd { + session_id: String, + run_id: String, + worker_id: String, + summary: String, + cost_cents: u32, + tool_count: u32, + status: WorkerStatus, + }, + /// Auto: a worker failed and the orchestrator is being re-invoked. + AutoReplan { + session_id: String, + run_id: String, + reason: String, + }, + /// Auto: replan budget exhausted. No silent fallback — user decides next move. + AutoFailed { + session_id: String, + run_id: String, + reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + last_worker_output: Option, + }, + /// Auto: all workers finished successfully (possibly after replans). + AutoDone { + session_id: String, + run_id: String, + summary: String, + total_cost_cents: u32, + }, } /// A single todo item tracked by the agent. From ae6b3982397e30bfbed09a4e8778ba0616e61c8d Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Wed, 24 Jun 2026 17:58:12 +0530 Subject: [PATCH 04/14] feat(agent): ephemeral worker runtime for Auto mode (P1) --- crates/agent/src/auto/mod.rs | 2 + crates/agent/src/auto/worker.rs | 573 ++++++++++++++++++++++++++++++ crates/agent/tests/integration.rs | 111 ++++++ 3 files changed, 686 insertions(+) create mode 100644 crates/agent/src/auto/worker.rs diff --git a/crates/agent/src/auto/mod.rs b/crates/agent/src/auto/mod.rs index 302fca61..2caf1982 100644 --- a/crates/agent/src/auto/mod.rs +++ b/crates/agent/src/auto/mod.rs @@ -12,9 +12,11 @@ pub mod schema; pub mod types; +pub mod worker; pub use schema::{submit_plan_input_schema, submit_plan_tool, SUBMIT_PLAN_TOOL_NAME}; pub use types::{ AutoResult, AutoRun, AutoStatus, Plan, SeePrior, SeePriorKeyword, WorkerPoolEntry, WorkerResult, WorkerSpec, WorkerStatus, }; +pub use worker::{run_worker, WorkerContext}; diff --git a/crates/agent/src/auto/worker.rs b/crates/agent/src/auto/worker.rs new file mode 100644 index 00000000..19ad1253 --- /dev/null +++ b/crates/agent/src/auto/worker.rs @@ -0,0 +1,573 @@ +//! Ephemeral worker runtime. +//! +//! A worker is the unit the orchestrator dispatches to. It runs as a brand-new +//! `AgentLoop` (no parent message history, no shared state besides the working +//! directory and the inherited host context). The orchestrator supplies the +//! model and the natural-language prompt; the worker's system prompt is the +//! standard Coding-mode prompt — the orchestrator instructs via the user +//! message, not via system overrides. +//! +//! This module reuses the spawning skeleton pattern from `crate::subagents::tool` +//! (child `AgentLoop::with_provider`, drained event channel, cancel-child token) +//! but is **not coupled** to `SubagentRegistry` — workers are constructed from a +//! `WorkerSpec` the orchestrator emits at runtime, not from a markdown-defined +//! persona registry. +//! +//! Phase: P1 (PHASE_AUTO_MODE.md). Sequencing across workers + the orchestrator +//! itself land in P2/P3. + +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use crate::agent::config::{CompactionConfig, RetryConfig}; +use crate::agent::{AgentConfig, AgentLoop}; +use crate::approval::ApprovalHandler; +use crate::context_engine::ContextEngineApi; +use crate::error::AgentError; +use crate::llm::client::LlmProvider; +use crate::llm::types::ChatMessage; +use crate::llm::{LlmClient, LlmClientConfig}; +use crate::persistence::MessagePersister; +use crate::tool::{ToolMode, ToolPolicy, ToolRegistry}; +use crate::types::{AgentEvent, AgentResult}; + +use super::types::{SeePrior, SeePriorKeyword, WorkerResult, WorkerSpec, WorkerStatus}; + +/// Marker prefix the `AgentLoop` writes into the final summary when it hits +/// `max_iterations` (see `crates/agent/src/agent/loop_.rs` exit path). The +/// worker classifier uses this to map that result to `WorkerStatus::MaxIterations` +/// so the executor can trigger a reactive replan. +/// +/// Fragile string-match. If the AgentLoop ever rephrases that message, the +/// classifier silently misroutes hard failures as `Ok`. The unit test +/// `max_iterations_summary_maps_to_status` pins this contract. +const MAX_ITER_SUMMARY_PREFIX: &str = "Reached maximum of"; + +/// Host-supplied context every worker needs: inherited LLM client shape, +/// working directory, channels, cancellation, and the optional integrations +/// (context engine / persister / approval handler). +/// +/// Cloned/borrowed per worker call. The executor (P3) builds one of these +/// from the parent session and reuses it across all workers in a single +/// `AutoRun`. +pub struct WorkerContext { + pub session_id: String, + pub run_id: String, + pub working_dir: PathBuf, + pub event_tx: mpsc::Sender, + pub cancel_token: CancellationToken, + + /// Base LLM config. `model` is overridden per worker from `WorkerSpec`. + pub llm_base_config: LlmClientConfig, + pub retry_config: RetryConfig, + pub compaction_config: CompactionConfig, + pub compaction_llm: Option, + pub max_iterations: u32, + + pub context_engine: Option>, + pub context_engine_repo_path: Option, + pub persister: Option>, + pub approval_handler: Option>, + pub checkpoint_dir: Option, +} + +/// Run a single worker to completion (or hard failure). +/// +/// Emits `AutoWorkerStart` on the parent channel before kicking off, drains the +/// child's event stream (counting tool calls), runs the child `AgentLoop`, then +/// emits `AutoWorkerEnd` and returns the populated `WorkerResult`. +/// +/// `prior_results` is the full history of completed workers in this `AutoRun`; +/// `spec.see_prior` decides which subset gets injected into the user message +/// (paper's `access_list`). +pub async fn run_worker( + spec: &WorkerSpec, + prior_results: &[WorkerResult], + ctx: &WorkerContext, +) -> WorkerResult { + // 1. Announce the start to the parent UI. + let _ = ctx + .event_tx + .send(AgentEvent::AutoWorkerStart { + session_id: ctx.session_id.clone(), + run_id: ctx.run_id.clone(), + worker_id: spec.id.clone(), + model: spec.model.clone(), + prompt: spec.prompt.clone(), + }) + .await; + + // 2. Build the user message: prior outputs (filtered by see_prior) + + // the orchestrator's task prompt. + let user_text = build_worker_user_message(spec, prior_results); + + // 3. Build the child LlmClientConfig with this worker's model. + let mut child_llm = ctx.llm_base_config.clone(); + child_llm.model = spec.model.clone(); + + // 4. Build the child AgentConfig. system_prompt=None → AgentLoop builds + // the standard Coding-mode prompt internally. No skills/subagents + // nesting in v1 (depth-1 enforced same way subagents do it). + let child_config = AgentConfig { + llm: child_llm.clone(), + working_dir: ctx.working_dir.clone(), + mode: ToolMode::Coding, + max_iterations: ctx.max_iterations, + system_prompt: None, + retry_config: ctx.retry_config.clone(), + compaction_config: ctx.compaction_config.clone(), + compaction_llm: ctx.compaction_llm.clone(), + context_engine: ctx.context_engine.clone(), + context_engine_repo_path: ctx.context_engine_repo_path.clone(), + skills: None, + subagents: None, + subagent_inheritance: None, + checkpoint_dir: ctx.checkpoint_dir.clone(), + tool_policy: ToolPolicy::default(), + }; + + // 5. Coding-mode tool registry, wired to the parent's context engine if any. + let context_engine_arg = ctx.context_engine.as_ref().and_then(|e| { + ctx.context_engine_repo_path + .as_ref() + .map(|p| (e.clone(), p.clone())) + }); + let child_registry = ToolRegistry::for_mode(ToolMode::Coding, context_engine_arg, None); + + // 6. Child event channel + drain task. We don't relay child events to the + // parent UI (per the AutoWorkerStart/End contract — the UI shows a + // collapsed card and will fetch the trace on demand in P6). The drain + // counts ToolEnd events so the parent's WorkerResult has a meaningful + // `tool_count`. + let (child_tx, mut child_rx) = mpsc::channel::(256); + let drain_handle = tokio::spawn(async move { + let mut tool_count = 0u32; + while let Some(ev) = child_rx.recv().await { + if matches!(ev, AgentEvent::ToolEnd { .. }) { + tool_count += 1; + } + } + tool_count + }); + + // 7. Run the child loop. Cancel propagates from parent → child via + // `child_token()` so user-cancel kills the in-flight worker mid-tool. + let child_session_id = Uuid::new_v4().to_string(); + let child_cancel = ctx.cancel_token.child_token(); + let provider: Box = Box::new(LlmClient::new(child_llm)); + let mut child_loop = AgentLoop::with_provider( + child_config, + provider, + child_registry, + child_cancel, + child_tx, + child_session_id.clone(), + ); + if let Some(p) = ctx.persister.clone() { + child_loop = child_loop.with_persister(p, child_session_id); + } + if let Some(h) = ctx.approval_handler.clone() { + child_loop = child_loop.with_approval_handler(h); + } + let run_result = child_loop.run(ChatMessage::user(user_text)).await; + drop(child_loop); + let tool_count = drain_handle.await.unwrap_or(0); + + // 8. Map AgentLoop outcome to (summary, WorkerStatus). + let (summary, status) = classify_outcome(run_result); + + // 9. P1: cost is not yet computed (no pricing table wired). Surface 0 and + // let the executor / UI display "—" until cost telemetry lands in P7. + let cost_cents = 0; + + // 10. Announce the end. + let _ = ctx + .event_tx + .send(AgentEvent::AutoWorkerEnd { + session_id: ctx.session_id.clone(), + run_id: ctx.run_id.clone(), + worker_id: spec.id.clone(), + summary: summary.clone(), + cost_cents, + tool_count, + status: status.clone(), + }) + .await; + + WorkerResult { + id: spec.id.clone(), + model: spec.model.clone(), + prompt: spec.prompt.clone(), + summary, + tool_count, + cost_cents, + status, + } +} + +/// Build the user message handed to the worker: prior outputs (filtered by +/// `see_prior`) followed by the orchestrator's task prompt. When no priors +/// are visible, returns the prompt verbatim. +fn build_worker_user_message(spec: &WorkerSpec, prior: &[WorkerResult]) -> String { + let included: Vec<&WorkerResult> = match &spec.see_prior { + SeePrior::Keyword(SeePriorKeyword::None) => Vec::new(), + SeePrior::Keyword(SeePriorKeyword::All) => prior.iter().collect(), + SeePrior::Specific(ids) => prior.iter().filter(|r| ids.iter().any(|i| i == &r.id)).collect(), + }; + + if included.is_empty() { + return spec.prompt.clone(); + } + + let mut s = String::new(); + for r in included { + s.push_str(&format!("\n", r.id)); + s.push_str(&r.summary); + s.push_str("\n\n\n"); + } + s.push_str("\n"); + s.push_str(&spec.prompt); + s.push_str("\n"); + s +} + +/// Translate the `AgentLoop`'s terminal outcome into a worker-facing +/// `(summary, WorkerStatus)` pair. `WorkerStatus::is_hard_failure()` (in +/// `types.rs`) decides whether the executor will trigger a reactive replan. +fn classify_outcome( + outcome: Result, +) -> (String, WorkerStatus) { + match outcome { + Ok(AgentResult::Done { summary }) => { + // The AgentLoop encodes max_iterations exhaustion as Ok(Done) with + // a known prefix; rescue it as a hard failure here so the + // executor can replan. + if summary.starts_with(MAX_ITER_SUMMARY_PREFIX) { + (summary, WorkerStatus::MaxIterations) + } else { + (summary, WorkerStatus::Ok) + } + } + Ok(AgentResult::AskUser { question, .. }) => ( + format!("worker yielded AskUser unexpectedly: {question}"), + WorkerStatus::ToolError { + message: "unexpected AskUser yield from a worker — Auto mode does not pass questions through".into(), + }, + ), + Ok(AgentResult::PlanReady { .. }) => ( + "worker yielded PlanReady unexpectedly".into(), + WorkerStatus::ToolError { + message: "unexpected PlanReady yield from a worker — Auto mode does not surface plans".into(), + }, + ), + Err(AgentError::Cancelled) => ("cancelled".into(), WorkerStatus::Cancelled), + Err(e) => { + let msg = e.to_string(); + (format!("worker failed: {msg}"), WorkerStatus::LlmError { message: msg }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auto::types::{SeePrior, SeePriorKeyword}; + use crate::llm::client::LlmPolicy; + use crate::llm::Provider; + use crate::test_util::{text_response, MockLlm}; + + fn wr(id: &str, summary: &str) -> WorkerResult { + WorkerResult { + id: id.into(), + model: "test".into(), + prompt: "p".into(), + summary: summary.into(), + tool_count: 0, + cost_cents: 0, + status: WorkerStatus::Ok, + } + } + + fn spec(id: &str, see: SeePrior) -> WorkerSpec { + WorkerSpec { + id: id.into(), + model: "test-model".into(), + prompt: "do the task".into(), + see_prior: see, + } + } + + // ── build_worker_user_message ── + + #[test] + fn user_message_with_no_priors_is_just_the_prompt() { + let s = spec("w2", SeePrior::Keyword(SeePriorKeyword::None)); + let priors = vec![wr("w1", "prior summary here")]; + let msg = build_worker_user_message(&s, &priors); + assert_eq!(msg, "do the task"); + } + + #[test] + fn user_message_with_all_injects_every_prior_in_order() { + let s = spec("w3", SeePrior::Keyword(SeePriorKeyword::All)); + let priors = vec![wr("w1", "first"), wr("w2", "second")]; + let msg = build_worker_user_message(&s, &priors); + assert!(msg.contains("\nfirst\n")); + assert!(msg.contains("\nsecond\n")); + assert!(msg.contains("\ndo the task\n")); + // Ordering: w1 must appear before w2. + assert!(msg.find("w1").unwrap() < msg.find("w2").unwrap()); + } + + #[test] + fn user_message_with_specific_filters_to_listed_priors_only() { + let s = spec("w4", SeePrior::Specific(vec!["w2".into()])); + let priors = vec![ + wr("w1", "first"), + wr("w2", "second"), + wr("w3", "third"), + ]; + let msg = build_worker_user_message(&s, &priors); + assert!(!msg.contains("first"), "w1 must be excluded"); + assert!(msg.contains("second"), "w2 must be present"); + assert!(!msg.contains("third"), "w3 must be excluded"); + } + + #[test] + fn user_message_with_specific_but_no_matches_emits_just_prompt() { + // Orchestrator references a worker id that doesn't exist yet (shouldn't + // happen if the executor validates plans, but be defensive). + let s = spec("w5", SeePrior::Specific(vec!["w99".into()])); + let priors = vec![wr("w1", "first")]; + let msg = build_worker_user_message(&s, &priors); + assert_eq!(msg, "do the task"); + } + + // ── classify_outcome ── + + #[test] + fn done_classified_as_ok() { + let (summary, status) = classify_outcome(Ok(AgentResult::Done { + summary: "completed normally".into(), + })); + assert_eq!(summary, "completed normally"); + assert_eq!(status, WorkerStatus::Ok); + } + + #[test] + fn max_iterations_summary_maps_to_status() { + // Pins the contract between AgentLoop's max-iter exit summary and the + // worker classifier. If this test breaks, the AgentLoop rephrased its + // exit message — update MAX_ITER_SUMMARY_PREFIX accordingly. + let summary = format!("{} 100 steps. Progress preserved.", MAX_ITER_SUMMARY_PREFIX); + let (out_summary, status) = classify_outcome(Ok(AgentResult::Done { + summary: summary.clone(), + })); + assert_eq!(out_summary, summary); + assert_eq!(status, WorkerStatus::MaxIterations); + assert!(status.is_hard_failure()); + } + + #[test] + fn cancelled_classified_correctly() { + let (_, status) = classify_outcome(Err(AgentError::Cancelled)); + assert_eq!(status, WorkerStatus::Cancelled); + assert!(!status.is_hard_failure(), "Cancelled is NOT a hard failure (no replan)"); + } + + #[test] + fn llm_error_classified_as_hard_failure() { + let (_, status) = classify_outcome(Err(AgentError::LlmApiError { + status: 500, + body: "boom".into(), + })); + assert!(matches!(status, WorkerStatus::LlmError { .. })); + assert!(status.is_hard_failure()); + } + + #[test] + fn unexpected_ask_user_yield_treated_as_tool_error() { + let (_, status) = classify_outcome(Ok(AgentResult::AskUser { + question: "?".into(), + options: None, + })); + assert!(matches!(status, WorkerStatus::ToolError { .. })); + assert!(status.is_hard_failure()); + } + + // ── run_worker end-to-end with MockLlm (no API) ── + + fn make_ctx( + provider: Provider, + event_tx: mpsc::Sender, + working_dir: PathBuf, + ) -> WorkerContext { + WorkerContext { + session_id: "parent-session".into(), + run_id: "run-1".into(), + working_dir, + event_tx, + cancel_token: CancellationToken::new(), + llm_base_config: LlmClientConfig { + provider, + base_url: "http://localhost".into(), + model: "placeholder".into(), + api_key: String::new(), + temperature: None, + max_completion_tokens: None, + extra_headers: vec![], + thinking: None, + disable_cache_control: true, + policy: LlmPolicy::default(), + }, + retry_config: RetryConfig::default(), + compaction_config: CompactionConfig::default(), + compaction_llm: None, + max_iterations: 5, + context_engine: None, + context_engine_repo_path: None, + persister: None, + approval_handler: None, + checkpoint_dir: None, + } + } + + /// Verify run_worker drives a child AgentLoop end-to-end and emits the + /// AutoWorker{Start,End} bracket around it. We bypass the production + /// LlmClient by constructing the loop via with_provider in a parallel + /// path — but since run_worker doesn't expose that knob, this test + /// instead checks the outer plumbing by issuing a worker that the + /// classifier maps to Ok via a one-shot text response. + /// + /// We can't easily intercept the inner AgentLoop's provider from outside + /// run_worker without changing the public API. So this test focuses on + /// the parts run_worker fully owns: event emission + result shape + + /// see_prior injection. End-to-end with a real LLM lives in the + /// integration test (#[ignore]'d). + #[tokio::test] + async fn start_event_carries_worker_id_model_and_prompt() { + let (tx, mut rx) = mpsc::channel::(32); + let tmp = std::env::temp_dir(); + let ctx = make_ctx(Provider::OpenAI, tx, tmp); + let spec = WorkerSpec { + id: "w1".into(), + model: "gpt-test".into(), + prompt: "explore the auth files".into(), + see_prior: SeePrior::Keyword(SeePriorKeyword::None), + }; + + // Spawn run_worker; we expect the AutoWorkerStart event to land on + // the channel before the child loop completes (or fails) — and even + // if the LLM call fails (no real API at http://localhost), the start + // event still goes out first, which is the contract we're pinning. + let handle = tokio::spawn(async move { run_worker(&spec, &[], &ctx).await }); + + // First event must be AutoWorkerStart with the spec's identity. + let first = tokio::time::timeout( + std::time::Duration::from_secs(5), + rx.recv(), + ) + .await + .expect("AutoWorkerStart must arrive within 5s") + .expect("channel closed before start event"); + match first { + AgentEvent::AutoWorkerStart { worker_id, model, prompt, run_id, session_id } => { + assert_eq!(worker_id, "w1"); + assert_eq!(model, "gpt-test"); + assert_eq!(prompt, "explore the auth files"); + assert_eq!(run_id, "run-1"); + assert_eq!(session_id, "parent-session"); + } + other => panic!("expected AutoWorkerStart, got {other:?}"), + } + + // Wait for the worker to finish (it will fail because the LlmClient + // tries to hit http://localhost). We don't care about the failure + // mode — only that the result shape is well-formed and the end + // event fires. + let result = handle.await.expect("worker task panicked"); + assert_eq!(result.id, "w1"); + assert_eq!(result.model, "gpt-test"); + assert_eq!(result.prompt, "explore the auth files"); + assert!(result.status.is_hard_failure(), "localhost LLM should fail"); + + // Drain the rest of the events; AutoWorkerEnd must be in there. + let mut saw_end = false; + while let Ok(Some(ev)) = tokio::time::timeout( + std::time::Duration::from_millis(200), + rx.recv(), + ) + .await + { + if matches!(ev, AgentEvent::AutoWorkerEnd { .. }) { + saw_end = true; + break; + } + } + assert!(saw_end, "AutoWorkerEnd must be emitted even on failure"); + } + + /// Headless end-to-end exercising the full classify→emit path against + /// MockLlm. Builds an AgentLoop directly (bypassing run_worker's internal + /// `LlmClient::new`) so we can prove the loop-side contract: a one-shot + /// text response → AgentResult::Done → would map to WorkerStatus::Ok. + /// This is the contract run_worker depends on; if it breaks, run_worker + /// breaks. + #[tokio::test] + async fn mocklm_one_shot_response_yields_done() { + use crate::tool::{ToolMode, ToolRegistry}; + + let tmp = tempfile::tempdir().unwrap(); + let (tx, _rx) = mpsc::channel::(32); + let cancel = CancellationToken::new(); + let mock = MockLlm::new(vec![Ok(text_response("done — auth flow uses bcrypt + sessions"))]); + let config = AgentConfig { + llm: LlmClientConfig { + provider: Provider::OpenAI, + base_url: "http://localhost".into(), + model: "mock".into(), + api_key: String::new(), + temperature: None, + max_completion_tokens: None, + extra_headers: vec![], + thinking: None, + disable_cache_control: true, + policy: LlmPolicy::default(), + }, + working_dir: tmp.path().to_path_buf(), + mode: ToolMode::Coding, + max_iterations: 5, + system_prompt: None, + retry_config: RetryConfig::default(), + compaction_config: CompactionConfig::default(), + compaction_llm: None, + context_engine: None, + context_engine_repo_path: None, + skills: None, + subagents: None, + subagent_inheritance: None, + checkpoint_dir: None, + tool_policy: ToolPolicy::default(), + }; + let registry = ToolRegistry::for_mode(ToolMode::Coding, None, None); + let mut loop_ = AgentLoop::with_provider( + config, + Box::new(mock), + registry, + cancel, + tx, + "child-session".into(), + ); + let result = loop_ + .run(ChatMessage::user("describe the auth flow")) + .await + .expect("loop should succeed"); + let (summary, status) = classify_outcome(Ok(result)); + assert!(summary.contains("bcrypt")); + assert_eq!(status, WorkerStatus::Ok); + } +} diff --git a/crates/agent/tests/integration.rs b/crates/agent/tests/integration.rs index 5c495c5b..1e336dd8 100644 --- a/crates/agent/tests/integration.rs +++ b/crates/agent/tests/integration.rs @@ -1249,3 +1249,114 @@ async fn post_anthropic_messages( cache_read_input_tokens: get("cache_read_input_tokens"), } } + +// ─────────────────────────────────────────────────────────────────────────── +// Auto mode tests (P1+) +// +// PHASE_AUTO_MODE.md. P1 covers the worker runtime in isolation — no +// orchestrator yet. Hand-crafted WorkerSpec is dispatched through +// `auto::run_worker` and verified end-to-end. +// ─────────────────────────────────────────────────────────────────────────── + +/// Run a single worker against a real LLM. Verifies the full P1 surface: +/// - AutoWorkerStart fires with the spec's identity +/// - the child AgentLoop runs to completion and uses tools +/// - AutoWorkerEnd fires with status=Ok and a non-empty summary +/// - the returned WorkerResult mirrors the events +#[tokio::test] +#[ignore = "requires LLM_API_KEY / ANTHROPIC_API_KEY / OPENAI_API_KEY"] +async fn test_auto_run_worker_single_live() { + use agent::auto::{run_worker, SeePrior, SeePriorKeyword, WorkerContext, WorkerSpec, WorkerStatus}; + use agent::llm::client::LlmPolicy; + use std::time::Duration; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + + let _ = env_logger::try_init(); + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("note.txt"), "the answer is 42\n").unwrap(); + + // Reuse the file's existing model/provider resolution so this test runs + // against whatever the caller's env is configured for (OpenAI or Anthropic). + let template = make_config(&api_key(), tmp.path().to_path_buf()); + + let (event_tx, mut event_rx) = mpsc::channel::(256); + let ctx = WorkerContext { + session_id: "parent".into(), + run_id: "auto-run-1".into(), + working_dir: tmp.path().to_path_buf(), + event_tx, + cancel_token: CancellationToken::new(), + llm_base_config: template.llm.clone(), + retry_config: template.retry_config.clone(), + compaction_config: template.compaction_config.clone(), + compaction_llm: template.compaction_llm.clone(), + max_iterations: 20, // small ceiling — this is a 1-tool task + context_engine: None, + context_engine_repo_path: None, + persister: None, + approval_handler: None, + checkpoint_dir: None, + }; + + let spec = WorkerSpec { + id: "w1".into(), + // Inherit the model the test env picked. The orchestrator (P2) will + // pick from the worker pool instead. + model: template.llm.model.clone(), + prompt: "Read the file note.txt in the working directory and report \ + its contents in one short sentence." + .into(), + see_prior: SeePrior::Keyword(SeePriorKeyword::None), + }; + + // Collect events in parallel with the worker run. + let events_handle = tokio::spawn(async move { + let mut out = Vec::new(); + while let Ok(Some(ev)) = tokio::time::timeout(Duration::from_secs(60), event_rx.recv()).await { + let is_terminal = matches!(ev, AgentEvent::AutoWorkerEnd { .. }); + eprintln!("[auto-evt] {ev:?}"); + out.push(ev); + if is_terminal { + break; + } + } + out + }); + + let result = run_worker(&spec, &[], &ctx).await; + let events = events_handle.await.expect("event collector panicked"); + + // ── assertions ── + assert_eq!(result.id, "w1"); + assert_eq!(result.model, template.llm.model); + assert_eq!( + result.status, + WorkerStatus::Ok, + "worker should finish cleanly; got status={:?}, summary={:?}", + result.status, + result.summary + ); + assert!( + result.summary.contains("42") || result.summary.to_lowercase().contains("answer"), + "summary should reference the file content; got: {}", + result.summary + ); + assert!(result.tool_count >= 1, "worker should have used at least one tool (read)"); + + let start_ok = events.iter().any(|e| matches!( + e, + AgentEvent::AutoWorkerStart { worker_id, run_id, .. } + if worker_id == "w1" && run_id == "auto-run-1" + )); + let end_ok = events.iter().any(|e| matches!( + e, + AgentEvent::AutoWorkerEnd { worker_id, run_id, .. } + if worker_id == "w1" && run_id == "auto-run-1" + )); + assert!(start_ok, "AutoWorkerStart event missing"); + assert!(end_ok, "AutoWorkerEnd event missing"); + + // Silence unused-import warnings when this is the only test compiled. + let _ = (LlmPolicy::default(),); +} From caee892dc1304308f7175a40b4864173d2fb61b0 Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Wed, 24 Jun 2026 18:11:08 +0530 Subject: [PATCH 05/14] feat(agent): orchestrator + plan parsing for Auto mode (P2) --- crates/agent/src/auto/mod.rs | 5 + crates/agent/src/auto/orchestrator.rs | 679 ++++++++++++++++++ .../agent/src/auto/prompts/orchestrator.txt | 50 ++ crates/agent/tests/integration.rs | 91 +++ 4 files changed, 825 insertions(+) create mode 100644 crates/agent/src/auto/orchestrator.rs create mode 100644 crates/agent/src/auto/prompts/orchestrator.txt diff --git a/crates/agent/src/auto/mod.rs b/crates/agent/src/auto/mod.rs index 2caf1982..a5c95465 100644 --- a/crates/agent/src/auto/mod.rs +++ b/crates/agent/src/auto/mod.rs @@ -10,10 +10,15 @@ //! P2: `orchestrator.rs` — orchestrator LLM call + plan parsing (forced tool use). //! P3: `executor.rs` — sequencer + reactive replan loop. +pub mod orchestrator; pub mod schema; pub mod types; pub mod worker; +pub use orchestrator::{ + build_orchestrator_messages, generate_plan, parse_plan_from_tool_input, OrchestratorError, + OrchestratorRequest, +}; pub use schema::{submit_plan_input_schema, submit_plan_tool, SUBMIT_PLAN_TOOL_NAME}; pub use types::{ AutoResult, AutoRun, AutoStatus, Plan, SeePrior, SeePriorKeyword, WorkerPoolEntry, diff --git a/crates/agent/src/auto/orchestrator.rs b/crates/agent/src/auto/orchestrator.rs new file mode 100644 index 00000000..c3af0e7c --- /dev/null +++ b/crates/agent/src/auto/orchestrator.rs @@ -0,0 +1,679 @@ +//! Orchestrator: build the prompt, call the orchestrator LLM with forced +//! `submit_plan` tool use, parse the result into a `Plan`. +//! +//! Two layers: +//! - **Pure**: `build_orchestrator_messages`, `parse_plan_from_tool_input`, +//! and the formatters. No IO; unit-tested exhaustively. +//! - **IO**: `generate_plan` — direct reqwest calls per provider. Not +//! unit-tested (lives behind the live integration test). +//! +//! IO design choice: we do NOT route through `LlmClient::chat_completion`. +//! The orchestrator is a one-shot, non-streaming call with a forced tool +//! choice — different enough from the streaming agent-loop path that +//! threading a `force_tool` parameter through the trait + every mock + every +//! call site felt heavier than just building the body directly here. +//! Provider differences (URL, auth header, tool_choice shape, response shape) +//! are isolated in `call_anthropic` and `call_openai`. +//! +//! Phase: P2 (PHASE_AUTO_MODE.md). Executor (P3) is the only intended caller. + +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio_util::sync::CancellationToken; + +use crate::auto::schema::{submit_plan_tool, SUBMIT_PLAN_TOOL_NAME}; +use crate::auto::types::{Plan, WorkerPoolEntry, WorkerResult, WorkerSpec}; +use crate::llm::anthropic::ANTHROPIC_VERSION; +use crate::llm::{LlmClientConfig, Provider}; + +/// System prompt template with `{{worker_pool_listing}}` placeholder. +/// Loaded at compile time. +const SYSTEM_PROMPT_TEMPLATE: &str = include_str!("prompts/orchestrator.txt"); + +/// Default cap on output tokens for the orchestrator call. The plan itself is +/// usually <2KB; this leaves room for the reasoning field and any unforeseen +/// growth. Lower than typical agent calls because the orchestrator is +/// one-shot. +const DEFAULT_MAX_TOKENS: u32 = 4096; + +/// Number of parse retries on top of the initial attempt. Separate from the +/// REPLAN budget (which counts orchestrator re-invocations after worker +/// failures). With forced tool_choice + the input_schema, parse failures +/// should be rare; this is just a safety net. +const PARSE_RETRY_BUDGET: u32 = 2; + +/// Network timeout for one orchestrator HTTP request. Orchestrator calls are +/// short — most plans should arrive within seconds. 60s is generous; past that +/// we bail rather than wedge the executor. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + +/// Everything the orchestrator needs to know to produce a plan. +/// +/// Construction is the executor's job (P3). On the initial call, `prior_results` +/// is empty and `replan_reason` is None. On a reactive replan, both are populated. +#[derive(Debug, Clone)] +pub struct OrchestratorRequest<'a> { + /// The user's original task verbatim. + pub task: &'a str, + /// User-curated worker pool (Settings → Auto → Worker pool). + pub worker_pool: &'a [WorkerPoolEntry], + /// Outputs of workers that already executed (empty on initial call). + pub prior_results: &'a [WorkerResult], + /// Why we're being re-invoked. Present iff this is a replan. + pub replan_reason: Option<&'a str>, + /// Version to stamp on the produced Plan. 1 for initial; increments per replan. + pub plan_version: u32, +} + +/// Errors surfaced by `generate_plan`. The executor catches these and may +/// emit `AutoFailed` (no silent fallback per locked design). +#[derive(Debug, thiserror::Error)] +pub enum OrchestratorError { + #[error("worker pool is empty — orchestrator cannot pick from zero models")] + EmptyWorkerPool, + #[error("orchestrator returned an unparseable plan after {attempts} attempt(s); last error: {message}")] + Parse { attempts: u32, message: String }, + #[error("orchestrator HTTP error: {0}")] + Http(String), + #[error("orchestrator call cancelled")] + Cancelled, + #[error("provider returned no submit_plan tool_use: {0}")] + NoToolUse(String), +} + +// ── Pure: prompt building ───────────────────────────────────────────────── + +/// Build the two-message conversation handed to the orchestrator LLM: +/// 1. system: the orchestrator instructions with worker pool injected +/// 2. user: the task plus (on replan) prior outputs + failure context +/// +/// Returns plain `(system, user)` strings so the IO layer can package them +/// per-provider (Anthropic puts system at the top level; OpenAI inlines it +/// as the first message). +pub fn build_orchestrator_messages(req: &OrchestratorRequest) -> (String, String) { + let system = format_system_prompt(req.worker_pool); + let user = format_user_message(req); + (system, user) +} + +fn format_system_prompt(pool: &[WorkerPoolEntry]) -> String { + let listing = if pool.is_empty() { + "(empty — caller must reject before this point)".to_string() + } else { + pool.iter() + .map(|e| format!("- `{}` ({}) — {}", e.model, e.provider_id, e.description)) + .collect::>() + .join("\n") + }; + SYSTEM_PROMPT_TEMPLATE.replace("{{worker_pool_listing}}", &listing) +} + +fn format_user_message(req: &OrchestratorRequest) -> String { + // Initial call: just the task. + if req.prior_results.is_empty() && req.replan_reason.is_none() { + return format!("# Task\n\n{}\n", req.task); + } + + // Replan: surface task, prior outputs, and the failure context. + let mut s = String::new(); + s.push_str("# Task\n\n"); + s.push_str(req.task); + s.push_str("\n\n"); + + if !req.prior_results.is_empty() { + s.push_str("# Prior worker outputs\n\n"); + for r in req.prior_results { + s.push_str(&format!( + "## `{}` (model: `{}`) — status: {}\n\n{}\n\n", + r.id, + r.model, + worker_status_short(&r.status), + r.summary, + )); + } + } + + if let Some(reason) = req.replan_reason { + s.push_str("# Replan reason\n\n"); + s.push_str(reason); + s.push_str( + "\n\nRevise the REMAINING plan. Workers already completed (status ok) \ + will not re-execute. Add new workers (`w+`) or reissue the failed \ + worker with a corrected prompt. Reuse a failed worker's id if you want \ + it to replace the failed attempt.\n", + ); + } + + s +} + +fn worker_status_short(status: &crate::auto::types::WorkerStatus) -> &'static str { + use crate::auto::types::WorkerStatus::*; + match status { + Ok => "ok", + MaxIterations => "max_iterations", + ToolError { .. } => "tool_error", + LlmError { .. } => "llm_error", + Cancelled => "cancelled", + } +} + +// ── Pure: parse the tool input as a Plan ────────────────────────────────── + +/// Deserialize the orchestrator's `submit_plan` tool input into a `Plan`. +/// `version` is supplied by the caller — the orchestrator doesn't know which +/// plan version it's producing; only the executor maintains that counter. +/// +/// Returns Err with a human-readable message on shape mismatch. The +/// orchestrator IO layer turns this into a parse retry; if all retries +/// exhaust, the executor surfaces `OrchestratorError::Parse`. +pub fn parse_plan_from_tool_input(input: &Value, version: u32) -> Result { + let reasoning = input + .get("reasoning") + .and_then(|v| v.as_str()) + .ok_or_else(|| "missing `reasoning` field (must be string)".to_string())?; + + let plan_arr = input + .get("plan") + .and_then(|v| v.as_array()) + .ok_or_else(|| "missing `plan` field (must be array)".to_string())?; + + if plan_arr.is_empty() { + return Err("`plan` array is empty; orchestrator must return at least one worker".into()); + } + + let workers: Vec = plan_arr + .iter() + .enumerate() + .map(|(i, w)| { + serde_json::from_value::(w.clone()) + .map_err(|e| format!("worker[{i}] shape invalid: {e}")) + }) + .collect::, String>>()?; + + Ok(Plan { + version, + reasoning: reasoning.to_string(), + workers, + }) +} + +// ── IO: generate_plan ───────────────────────────────────────────────────── + +/// Run the orchestrator end-to-end: build prompt, call LLM with forced +/// `submit_plan`, parse result. Retries on malformed output up to +/// `PARSE_RETRY_BUDGET` times (separate from the executor's replan budget). +/// +/// `llm` is the orchestrator's LLM config — the user picks this in Settings → +/// Auto → Orchestrator model. It is NOT the same client as the workers use; +/// the orchestrator typically wants a strong reasoning model regardless of +/// what workers use. +pub async fn generate_plan( + llm: &LlmClientConfig, + req: &OrchestratorRequest<'_>, + cancel: Option<&CancellationToken>, +) -> Result { + if req.worker_pool.is_empty() { + return Err(OrchestratorError::EmptyWorkerPool); + } + + let (system, user) = build_orchestrator_messages(req); + let http = build_http_client()?; + + let mut last_parse_err: Option = None; + for attempt in 0..=PARSE_RETRY_BUDGET { + let raw_input = match llm.provider { + Provider::Anthropic => call_anthropic(&http, llm, &system, &user, cancel).await?, + Provider::OpenAI => call_openai(&http, llm, &system, &user, cancel).await?, + }; + + match parse_plan_from_tool_input(&raw_input, req.plan_version) { + Ok(plan) => return Ok(plan), + Err(e) => { + log::warn!( + "[auto::orchestrator] parse attempt {} failed: {e}", + attempt + 1 + ); + last_parse_err = Some(e); + // Falls through to next iteration; final iteration's Err + // exits the loop via the `attempts` counter below. + } + } + } + + Err(OrchestratorError::Parse { + attempts: PARSE_RETRY_BUDGET + 1, + message: last_parse_err.unwrap_or_else(|| "unknown parse error".to_string()), + }) +} + +fn build_http_client() -> Result { + reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(|e| OrchestratorError::Http(format!("build http client: {e}"))) +} + +// ── Anthropic IO ── + +async fn call_anthropic( + http: &reqwest::Client, + llm: &LlmClientConfig, + system: &str, + user: &str, + cancel: Option<&CancellationToken>, +) -> Result { + let tool = submit_plan_tool(); + let body = json!({ + "model": llm.model, + "max_tokens": llm.max_completion_tokens.unwrap_or(DEFAULT_MAX_TOKENS), + "system": system, + "messages": [ + {"role": "user", "content": user} + ], + "tools": [{ + "name": tool.function.name, + "description": tool.function.description, + "input_schema": tool.function.parameters, + }], + "tool_choice": {"type": "tool", "name": SUBMIT_PLAN_TOOL_NAME}, + "stream": false, + }); + + let url = format!("{}/v1/messages", llm.base_url.trim_end_matches('/')); + let mut req = http + .post(&url) + .header("content-type", "application/json") + .header("anthropic-version", ANTHROPIC_VERSION) + .json(&body); + if !llm.api_key.is_empty() { + req = req.header("x-api-key", &llm.api_key); + } + for (k, v) in &llm.extra_headers { + req = req.header(k.as_str(), v.as_str()); + } + + let resp = send_with_cancel(req, cancel).await?; + let value = parse_http_response(resp).await?; + extract_anthropic_tool_input(&value) +} + +fn extract_anthropic_tool_input(resp: &Value) -> Result { + let content = resp + .get("content") + .and_then(|v| v.as_array()) + .ok_or_else(|| OrchestratorError::NoToolUse("response missing `content` array".into()))?; + for block in content { + let is_tool_use = block.get("type").and_then(|v| v.as_str()) == Some("tool_use"); + let is_submit = block.get("name").and_then(|v| v.as_str()) == Some(SUBMIT_PLAN_TOOL_NAME); + if is_tool_use && is_submit { + return block + .get("input") + .cloned() + .ok_or_else(|| OrchestratorError::NoToolUse("tool_use block has no `input`".into())); + } + } + Err(OrchestratorError::NoToolUse( + "no submit_plan tool_use in response (model may have refused / replied as text)".into(), + )) +} + +// ── OpenAI IO ── + +async fn call_openai( + http: &reqwest::Client, + llm: &LlmClientConfig, + system: &str, + user: &str, + cancel: Option<&CancellationToken>, +) -> Result { + let body = json!({ + "model": llm.model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user} + ], + "tools": [submit_plan_tool()], + "tool_choice": {"type": "function", "function": {"name": SUBMIT_PLAN_TOOL_NAME}}, + "stream": false, + }); + + let url = format!("{}/chat/completions", llm.base_url.trim_end_matches('/')); + let mut req = http + .post(&url) + .header("content-type", "application/json") + .json(&body); + if !llm.api_key.is_empty() { + req = req.bearer_auth(&llm.api_key); + } + for (k, v) in &llm.extra_headers { + req = req.header(k.as_str(), v.as_str()); + } + + let resp = send_with_cancel(req, cancel).await?; + let value = parse_http_response(resp).await?; + extract_openai_tool_input(&value) +} + +fn extract_openai_tool_input(resp: &Value) -> Result { + let calls = resp + .pointer("/choices/0/message/tool_calls") + .and_then(|v| v.as_array()) + .ok_or_else(|| { + OrchestratorError::NoToolUse("response missing /choices/0/message/tool_calls".into()) + })?; + for call in calls { + let name = call.pointer("/function/name").and_then(|v| v.as_str()); + if name == Some(SUBMIT_PLAN_TOOL_NAME) { + let args_str = call + .pointer("/function/arguments") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + OrchestratorError::NoToolUse( + "submit_plan tool_call has no string `function.arguments`".into(), + ) + })?; + return serde_json::from_str(args_str).map_err(|e| { + OrchestratorError::NoToolUse(format!( + "submit_plan arguments are not valid JSON: {e}" + )) + }); + } + } + Err(OrchestratorError::NoToolUse( + "no submit_plan in tool_calls (model may have refused)".into(), + )) +} + +// ── HTTP helpers ── + +async fn send_with_cancel( + req: reqwest::RequestBuilder, + cancel: Option<&CancellationToken>, +) -> Result { + let fut = req.send(); + match cancel { + Some(token) => tokio::select! { + r = fut => r.map_err(|e| OrchestratorError::Http(e.to_string())), + _ = token.cancelled() => Err(OrchestratorError::Cancelled), + }, + None => fut.await.map_err(|e| OrchestratorError::Http(e.to_string())), + } +} + +async fn parse_http_response(resp: reqwest::Response) -> Result { + let status = resp.status(); + let text = resp + .text() + .await + .map_err(|e| OrchestratorError::Http(format!("read response body: {e}")))?; + if !status.is_success() { + return Err(OrchestratorError::Http(format!( + "HTTP {status}: {}", + &text[..text.len().min(500)] + ))); + } + serde_json::from_str(&text) + .map_err(|e| OrchestratorError::Http(format!("parse response JSON: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auto::types::{SeePrior, SeePriorKeyword, WorkerStatus}; + + fn pool_one() -> Vec { + vec![ + WorkerPoolEntry { + provider_id: "anthropic".into(), + model: "claude-haiku-4-5".into(), + description: "fast + cheap, best for reads and verification".into(), + }, + WorkerPoolEntry { + provider_id: "anthropic".into(), + model: "claude-sonnet-4-6".into(), + description: "strong reasoning, best for synthesis and edits".into(), + }, + ] + } + + fn wr(id: &str, summary: &str, status: WorkerStatus) -> WorkerResult { + WorkerResult { + id: id.into(), + model: "claude-sonnet-4-6".into(), + prompt: "p".into(), + summary: summary.into(), + tool_count: 1, + cost_cents: 0, + status, + } + } + + // ── format_system_prompt ── + + #[test] + fn system_prompt_lists_pool_with_descriptions() { + let s = format_system_prompt(&pool_one()); + assert!(s.contains("claude-haiku-4-5")); + assert!(s.contains("fast + cheap")); + assert!(s.contains("claude-sonnet-4-6")); + assert!(s.contains("strong reasoning")); + assert!(!s.contains("{{worker_pool_listing}}"), "template placeholder must be substituted"); + } + + #[test] + fn system_prompt_handles_empty_pool_with_fallback_string() { + // generate_plan should reject before we get here, but the formatter + // must not panic on an empty pool. + let s = format_system_prompt(&[]); + assert!(s.contains("(empty")); + assert!(!s.contains("{{worker_pool_listing}}")); + } + + // ── format_user_message ── + + #[test] + fn user_message_initial_call_is_just_the_task() { + let req = OrchestratorRequest { + task: "refactor auth", + worker_pool: &pool_one(), + prior_results: &[], + replan_reason: None, + plan_version: 1, + }; + let msg = format_user_message(&req); + assert!(msg.starts_with("# Task")); + assert!(msg.contains("refactor auth")); + assert!(!msg.contains("Prior worker outputs")); + assert!(!msg.contains("Replan reason")); + } + + #[test] + fn user_message_replan_includes_prior_outputs_and_reason() { + let priors = vec![ + wr("w1", "found 12 files", WorkerStatus::Ok), + wr("w2", "tool failed: nosuch", WorkerStatus::ToolError { message: "x".into() }), + ]; + let req = OrchestratorRequest { + task: "refactor auth", + worker_pool: &pool_one(), + prior_results: &priors, + replan_reason: Some("worker w2 failed: tool_error"), + plan_version: 2, + }; + let msg = format_user_message(&req); + assert!(msg.contains("# Prior worker outputs")); + assert!(msg.contains("`w1`")); + assert!(msg.contains("status: ok")); + assert!(msg.contains("`w2`")); + assert!(msg.contains("status: tool_error")); + assert!(msg.contains("# Replan reason")); + assert!(msg.contains("w2 failed")); + } + + // ── parse_plan_from_tool_input ── + + #[test] + fn parse_accepts_well_formed_plan() { + let raw = json!({ + "reasoning": "two-stage decomposition", + "plan": [ + {"id": "w1", "model": "claude-haiku-4-5", "prompt": "explore", "see_prior": "none"}, + {"id": "w2", "model": "claude-sonnet-4-6", "prompt": "design", "see_prior": ["w1"]} + ] + }); + let plan = parse_plan_from_tool_input(&raw, 1).expect("plan should parse"); + assert_eq!(plan.version, 1); + assert_eq!(plan.reasoning, "two-stage decomposition"); + assert_eq!(plan.workers.len(), 2); + assert_eq!(plan.workers[0].id, "w1"); + assert!(matches!( + plan.workers[0].see_prior, + SeePrior::Keyword(SeePriorKeyword::None) + )); + assert!(matches!( + &plan.workers[1].see_prior, + SeePrior::Specific(ids) if ids == &["w1"] + )); + } + + #[test] + fn parse_rejects_missing_reasoning() { + let raw = json!({"plan": [{"id":"w1","model":"m","prompt":"p","see_prior":"none"}]}); + let err = parse_plan_from_tool_input(&raw, 1).unwrap_err(); + assert!(err.contains("reasoning"), "got: {err}"); + } + + #[test] + fn parse_rejects_empty_plan_array() { + let raw = json!({"reasoning": "r", "plan": []}); + let err = parse_plan_from_tool_input(&raw, 1).unwrap_err(); + assert!(err.contains("empty"), "got: {err}"); + } + + #[test] + fn parse_rejects_worker_missing_required_field() { + let raw = json!({ + "reasoning": "r", + "plan": [ + {"id": "w1", "model": "m", "prompt": "p"} // missing see_prior + ] + }); + let err = parse_plan_from_tool_input(&raw, 1).unwrap_err(); + assert!(err.contains("worker[0]"), "got: {err}"); + } + + #[test] + fn parse_stamps_caller_provided_version() { + let raw = json!({ + "reasoning": "r", + "plan": [{"id":"w1","model":"m","prompt":"p","see_prior":"all"}] + }); + let plan = parse_plan_from_tool_input(&raw, 7).unwrap(); + assert_eq!(plan.version, 7, "version must come from caller, not from input"); + } + + // ── extract_anthropic_tool_input ── + + #[test] + fn anthropic_extracts_input_from_tool_use_block() { + let resp = json!({ + "content": [ + {"type": "text", "text": "ignored"}, + {"type": "tool_use", "id": "tu1", "name": "submit_plan", "input": {"reasoning": "r", "plan": []}} + ] + }); + let v = extract_anthropic_tool_input(&resp).unwrap(); + assert_eq!(v["reasoning"], "r"); + } + + #[test] + fn anthropic_errors_when_no_tool_use() { + let resp = json!({ + "content": [ + {"type": "text", "text": "I refuse"} + ] + }); + let err = extract_anthropic_tool_input(&resp).unwrap_err(); + assert!(matches!(err, OrchestratorError::NoToolUse(_))); + } + + #[test] + fn anthropic_errors_when_wrong_tool_name() { + let resp = json!({ + "content": [ + {"type": "tool_use", "name": "other_tool", "input": {}} + ] + }); + let err = extract_anthropic_tool_input(&resp).unwrap_err(); + assert!(matches!(err, OrchestratorError::NoToolUse(_))); + } + + // ── extract_openai_tool_input ── + + #[test] + fn openai_extracts_arguments_from_tool_call() { + let args_json = r#"{"reasoning":"r","plan":[]}"#; + let resp = json!({ + "choices": [{ + "message": { + "tool_calls": [ + {"id": "c1", "function": {"name": "submit_plan", "arguments": args_json}} + ] + } + }] + }); + let v = extract_openai_tool_input(&resp).unwrap(); + assert_eq!(v["reasoning"], "r"); + } + + #[test] + fn openai_errors_when_arguments_is_invalid_json() { + let resp = json!({ + "choices": [{ + "message": { + "tool_calls": [ + {"id": "c1", "function": {"name": "submit_plan", "arguments": "not json"}} + ] + } + }] + }); + let err = extract_openai_tool_input(&resp).unwrap_err(); + assert!(matches!(err, OrchestratorError::NoToolUse(_))); + } + + #[test] + fn openai_errors_when_no_tool_calls() { + let resp = json!({"choices": [{"message": {"content": "plain text"}}]}); + let err = extract_openai_tool_input(&resp).unwrap_err(); + assert!(matches!(err, OrchestratorError::NoToolUse(_))); + } + + // ── generate_plan: rejects empty pool without hitting the network ── + + #[tokio::test] + async fn generate_plan_rejects_empty_worker_pool() { + let llm = LlmClientConfig { + provider: Provider::Anthropic, + base_url: "http://localhost".into(), + model: "x".into(), + api_key: String::new(), + temperature: None, + max_completion_tokens: None, + extra_headers: vec![], + thinking: None, + disable_cache_control: true, + policy: Default::default(), + }; + let req = OrchestratorRequest { + task: "anything", + worker_pool: &[], + prior_results: &[], + replan_reason: None, + plan_version: 1, + }; + let err = generate_plan(&llm, &req, None).await.unwrap_err(); + assert!(matches!(err, OrchestratorError::EmptyWorkerPool)); + } +} diff --git a/crates/agent/src/auto/prompts/orchestrator.txt b/crates/agent/src/auto/prompts/orchestrator.txt new file mode 100644 index 00000000..16624af1 --- /dev/null +++ b/crates/agent/src/auto/prompts/orchestrator.txt @@ -0,0 +1,50 @@ +You are the Auto-mode orchestrator. Your sole job is to decompose the user's task into an ordered list of worker subtasks and emit a plan by calling the `submit_plan` tool. You do not execute the work yourself. + +Each worker is a fresh AgentLoop with the FULL coding toolkit (read, edit, bash, grep, glob, write, etc.) running in the user's working directory. Workers run sequentially — when one finishes, the next begins. A worker sees ONLY what `see_prior` admits and the prompt you give it. Workers do NOT see the original conversation, other workers' tool calls, or the system prompt you are reading. + +# Submitting the plan + +You must call `submit_plan` exactly once. Its input shape: +- `reasoning`: 1-3 sentences explaining your decomposition strategy. +- `plan`: ordered list of workers; each worker has: + - `id`: "w1", "w2", "w3", ... (sequential, starting at w1) + - `model`: a model id from the worker pool listed below + - `prompt`: the full natural-language task for that worker (becomes its user message) + - `see_prior`: visibility of prior worker outputs — `"none"`, `"all"`, or `["w1", "w2"]` + +# Available worker pool + +The user has curated this pool. Each entry includes a description hinting at strengths and intended use. Pick wisely. + +{{worker_pool_listing}} + +# Decomposition guidelines + +- Aim for 1–5 workers. More than 5 is usually over-engineered. A single coherent task → ONE worker. +- Multi-stage work (explore → design → implement → verify) → one worker per stage. +- Match model to subtask cost/capability per the pool descriptions. Cheap models for reads and verification; expensive for synthesis, design, and multi-file edits. +- Default `see_prior` to `"all"` for any worker after the first. Use `"none"` only when the worker is genuinely independent of prior work. Use explicit ids when only a subset is relevant — fewer tokens injected = faster + cheaper for that worker. +- The worker prompt must be SELF-CONTAINED. Assume the worker has zero context beyond its prompt plus the prior outputs you grant. Spell out file paths, expected outputs, and acceptance criteria. +- Workers share the file system. Sequential ordering means w2 can read files that w1 created. + +# Replanning + +If you are being re-invoked after a worker failure, the user message will include prior worker outputs and a failure context. Workers that already ran successfully will NOT re-execute — revise only the failed worker and any subsequent ones. Keep the same `id` for workers that should re-run with a new prompt; add new ids (`w`) for additional workers. + +# Examples + +User task: "What does the auth module do?" +Plan: one Haiku-class worker, `see_prior: "none"`, prompt: `"Read all files under src/auth/ and explain the auth module's responsibilities in 2-3 paragraphs. Reference specific file paths in your summary."` + +User task: "Refactor the auth flow to use JWT instead of sessions." +Plan: four workers. + - w1 (Haiku, see_prior none): `"Read src/auth/**.ts and src/middleware/auth.ts. Report current auth flow, identifying every session-related call site as file:line."` + - w2 (Sonnet, see_prior ["w1"]): `"Given the prior exploration, design a JWT-based replacement. Output: a short design doc covering token format, expiry, refresh, and the precise file-level edits needed. Do not edit yet."` + - w3 (Sonnet, see_prior ["w2"]): `"Apply the edits from the prior design doc. Use the edit tool. Run \`bash cargo check\` after each significant change to catch compile errors early."` + - w4 (Haiku, see_prior ["w2", "w3"]): `"Run the test suite (\`bash cargo test\`). If failures relate to the auth refactor, summarize them; do not attempt fixes."` + +# Constraints + +- Do not include reasoning or commentary outside of the `reasoning` field in `submit_plan`. Your only allowed action is calling that tool. +- Do not invent model ids. Use only ids present in the worker pool above. +- Do not exceed 10 workers in any plan; if a task truly needs more, the user should split it manually. diff --git a/crates/agent/tests/integration.rs b/crates/agent/tests/integration.rs index 1e336dd8..ec740dba 100644 --- a/crates/agent/tests/integration.rs +++ b/crates/agent/tests/integration.rs @@ -1360,3 +1360,94 @@ async fn test_auto_run_worker_single_live() { // Silence unused-import warnings when this is the only test compiled. let _ = (LlmPolicy::default(),); } + +/// Live orchestrator call. Verifies the forced-tool-use path end-to-end: +/// - HTTP body construction with forced tool_choice +/// - Anthropic (or OpenAI) response parsing +/// - submit_plan tool input → Plan struct via parse_plan_from_tool_input +/// - resulting plan picks models from the supplied pool only +/// - resulting plan has at least one worker with non-empty prompt +#[tokio::test] +#[ignore = "requires ANTHROPIC_API_KEY (orchestrator currently defaults to Claude Sonnet 4.6)"] +async fn test_auto_orchestrator_generates_parseable_plan_live() { + use agent::auto::{generate_plan, OrchestratorRequest, WorkerPoolEntry}; + use agent::llm::{LlmClientConfig, Provider}; + use agent::llm::client::LlmPolicy; + + let _ = env_logger::try_init(); + let api_key = api_key(); + + let pool = vec![ + WorkerPoolEntry { + provider_id: "anthropic".into(), + model: "claude-haiku-4-5".into(), + description: "fast + cheap; best for file reads, grep, simple verification".into(), + }, + WorkerPoolEntry { + provider_id: "anthropic".into(), + model: "claude-sonnet-4-6".into(), + description: "strong reasoning + coding; best for design, multi-file edits, synthesis".into(), + }, + ]; + + let req = OrchestratorRequest { + task: "Read the file README.md in the working directory and produce a one-paragraph \ + summary of what the project does, then list any TODO comments you find anywhere \ + under src/.", + worker_pool: &pool, + prior_results: &[], + replan_reason: None, + plan_version: 1, + }; + + let orchestrator_llm = LlmClientConfig { + provider: Provider::Anthropic, + base_url: "https://api.anthropic.com".into(), + model: "claude-sonnet-4-6".into(), + api_key, + temperature: Some(0.0), + max_completion_tokens: Some(4096), + extra_headers: vec![], + thinking: None, + disable_cache_control: true, + policy: LlmPolicy::default(), + }; + + let plan = generate_plan(&orchestrator_llm, &req, None) + .await + .expect("orchestrator should produce a parseable plan"); + + eprintln!("[live] plan v{}: {}", plan.version, plan.reasoning); + for w in &plan.workers { + eprintln!( + "[live] {} ({}): see_prior={:?}, prompt={:?}", + w.id, + w.model, + w.see_prior, + &w.prompt[..w.prompt.len().min(80)] + ); + } + + assert_eq!(plan.version, 1); + assert!(!plan.reasoning.is_empty(), "reasoning must be non-empty"); + assert!(!plan.workers.is_empty(), "plan must contain at least one worker"); + assert!( + plan.workers.len() <= 10, + "plan exceeded the system-prompt's 10-worker hard cap: {} workers", + plan.workers.len() + ); + + let pool_models: std::collections::HashSet<&str> = + pool.iter().map(|e| e.model.as_str()).collect(); + for w in &plan.workers { + assert!( + pool_models.contains(w.model.as_str()), + "worker {} picked model `{}` not in pool {:?}", + w.id, + w.model, + pool_models + ); + assert!(!w.prompt.is_empty(), "worker {} has empty prompt", w.id); + assert!(w.id.starts_with('w'), "worker id should be wN, got `{}`", w.id); + } +} From 428a5afa81bf5b93df1e97762a33c12984cc3d37 Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Wed, 24 Jun 2026 18:21:28 +0530 Subject: [PATCH 06/14] feat(agent): executor + reactive replan loop for Auto mode (P3) --- crates/agent/src/auto/executor.rs | 785 ++++++++++++++++++++++++++++++ crates/agent/src/auto/mod.rs | 5 + crates/agent/tests/integration.rs | 173 +++++++ 3 files changed, 963 insertions(+) create mode 100644 crates/agent/src/auto/executor.rs diff --git a/crates/agent/src/auto/executor.rs b/crates/agent/src/auto/executor.rs new file mode 100644 index 00000000..b0f4bb8c --- /dev/null +++ b/crates/agent/src/auto/executor.rs @@ -0,0 +1,785 @@ +//! Executor: tie the orchestrator + workers + replan loop together. +//! +//! This module is the Auto-mode entry point. It owns the `AutoRun` state, +//! drives the orchestrator → workers → (maybe replan) cycle, and emits the +//! `Auto*` events the UI consumes. +//! +//! ## Why traits? +//! +//! `executor::run` depends on three traits — `PlanGenerator`, `WorkerRunner`, +//! `PlanApprover` — rather than concrete implementations. This is the only +//! way to write meaningful tests for the sequencer: the production +//! `LlmPlanGenerator` makes real HTTP calls to Anthropic/OpenAI, and the +//! production `LiveWorkerRunner` spawns an `AgentLoop`. Neither is mockable +//! from a unit test. With these traits, headless tests inject canned plans +//! and canned worker results to exercise every branch of the sequencer. +//! +//! Production wiring (P4) constructs: +//! - `LlmPlanGenerator { llm: orchestrator_model_config }` +//! - `LiveWorkerRunner { ctx: worker_context }` +//! - A `PlanApprover` that emits `AutoAwaitingApproval` and awaits the +//! user's Run-button click via a Tauri channel. +//! +//! ## Replan semantics +//! +//! On a hard worker failure within the replan budget, the executor re-invokes +//! the orchestrator with the prior worker results + failure reason. The +//! orchestrator returns a NEW plan; the executor runs it top-to-bottom from +//! index 0. The orchestrator's system prompt instructs it to return only +//! REMAINING work (not redo successful workers), but the executor trusts the +//! orchestrator's decision — it doesn't defensively dedup by worker id. +//! +//! `prior_results` accumulates monotonically across replans so the +//! orchestrator (and `see_prior`-using workers) can reference results from +//! any prior plan version. +//! +//! Phase: P3 (PHASE_AUTO_MODE.md). P4 wires this to the Tauri backend. + +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use crate::llm::LlmClientConfig; +use crate::types::AgentEvent; + +use super::orchestrator::{generate_plan as orchestrator_generate_plan, OrchestratorError, OrchestratorRequest}; +use super::types::{ + AutoResult, AutoRun, AutoStatus, Plan, WorkerPoolEntry, WorkerResult, WorkerSpec, WorkerStatus, +}; +use super::worker::{run_worker as live_run_worker, WorkerContext}; + +// ── Traits ─────────────────────────────────────────────────────────────── + +/// Produces a `Plan` from a task + worker pool. The production impl +/// (`LlmPlanGenerator`) calls the orchestrator LLM; tests pass mocks that +/// return canned plans / errors. +#[async_trait] +pub trait PlanGenerator: Send + Sync { + async fn generate( + &self, + req: &OrchestratorRequest<'_>, + cancel: Option<&CancellationToken>, + ) -> Result; +} + +/// Runs a single worker to completion. The production impl +/// (`LiveWorkerRunner`) spawns an `AgentLoop`; tests pass mocks that return +/// canned `WorkerResult`s (success / hard failure / cancel). +#[async_trait] +pub trait WorkerRunner: Send + Sync { + async fn run(&self, spec: &WorkerSpec, prior_results: &[WorkerResult]) -> WorkerResult; +} + +/// Gate the plan on user approval. Production impl (added in P4) shows the +/// plan UI and awaits the Run button. Tests use `AutoApprove` (always true) +/// or `AlwaysReject` (always false). +#[async_trait] +pub trait PlanApprover: Send + Sync { + /// Returns `true` to proceed, `false` to abort the task. + async fn approve(&self, plan: &Plan) -> bool; +} + +// ── Production-ready impls ─────────────────────────────────────────────── + +/// Production `PlanGenerator` — calls the orchestrator LLM via `auto::orchestrator`. +pub struct LlmPlanGenerator { + pub llm: LlmClientConfig, +} + +#[async_trait] +impl PlanGenerator for LlmPlanGenerator { + async fn generate( + &self, + req: &OrchestratorRequest<'_>, + cancel: Option<&CancellationToken>, + ) -> Result { + orchestrator_generate_plan(&self.llm, req, cancel).await + } +} + +/// Production `WorkerRunner` — spawns an `AgentLoop` per worker via `auto::worker`. +pub struct LiveWorkerRunner { + pub ctx: WorkerContext, +} + +#[async_trait] +impl WorkerRunner for LiveWorkerRunner { + async fn run(&self, spec: &WorkerSpec, prior_results: &[WorkerResult]) -> WorkerResult { + live_run_worker(spec, prior_results, &self.ctx).await + } +} + +/// `PlanApprover` that always returns `true`. Useful for headless tests, the +/// bench-runner, and any CLI mode that should bypass interactive approval. +pub struct AutoApprove; + +#[async_trait] +impl PlanApprover for AutoApprove { + async fn approve(&self, _: &Plan) -> bool { + true + } +} + +// ── Configuration ──────────────────────────────────────────────────────── + +/// What the executor needs to know about this Auto task. +pub struct ExecutorConfig { + pub task: String, + pub auto_run_id: String, + pub session_id: String, + pub worker_pool: Vec, + /// Max reactive replans after the initial plan. Total orchestrator calls + /// per task = 1 + replan_budget. 0 = strict single-shot. + pub replan_budget: u32, +} + +// ── Main entry ─────────────────────────────────────────────────────────── + +/// Drive an Auto task from start to terminal `AutoResult`. +/// +/// Emits the `Auto*` events on `event_tx`. The executor never silently falls +/// back to the regular agent loop; a fully exhausted replan budget produces +/// `AutoResult::Failed` (and an `AutoFailed` event). +pub async fn run( + config: ExecutorConfig, + event_tx: mpsc::Sender, + plan_gen: Arc, + worker_runner: Arc, + approver: Arc, + cancel: CancellationToken, +) -> AutoResult { + let mut auto_run = AutoRun { + id: config.auto_run_id.clone(), + session_id: config.session_id.clone(), + status: AutoStatus::Planning, + plan_versions: Vec::new(), + worker_results: Vec::new(), + cost_cents: 0, + replans_used: 0, + }; + + let mut prior_results: Vec = Vec::new(); + let mut current_version: u32 = 1; + let mut replan_reason: Option = None; + + loop { + // ── 1. Planning ── + emit( + &event_tx, + AgentEvent::AutoPlanning { + session_id: config.session_id.clone(), + run_id: config.auto_run_id.clone(), + }, + ) + .await; + auto_run.status = AutoStatus::Planning; + + if cancel.is_cancelled() { + return finalize_cancelled(auto_run); + } + + let req = OrchestratorRequest { + task: &config.task, + worker_pool: &config.worker_pool, + prior_results: &prior_results, + replan_reason: replan_reason.as_deref(), + plan_version: current_version, + }; + + let plan = match plan_gen.generate(&req, Some(&cancel)).await { + Ok(p) => p, + Err(OrchestratorError::Cancelled) => return finalize_cancelled(auto_run), + Err(e) => { + let reason = format!("orchestrator error: {e}"); + return finalize_failed(auto_run, &event_tx, &config, reason, None).await; + } + }; + + auto_run.plan_versions.push(plan.clone()); + emit( + &event_tx, + AgentEvent::AutoPlan { + session_id: config.session_id.clone(), + run_id: config.auto_run_id.clone(), + version: plan.version, + reasoning: plan.reasoning.clone(), + plan: plan.workers.clone(), + }, + ) + .await; + + // ── 2. Approval (initial plan only; replans auto-proceed) ── + if current_version == 1 { + auto_run.status = AutoStatus::AwaitingApproval; + emit( + &event_tx, + AgentEvent::AutoAwaitingApproval { + session_id: config.session_id.clone(), + run_id: config.auto_run_id.clone(), + }, + ) + .await; + + let approved = tokio::select! { + a = approver.approve(&plan) => a, + _ = cancel.cancelled() => return finalize_cancelled(auto_run), + }; + if !approved { + let reason = "plan rejected by user".to_string(); + return finalize_failed(auto_run, &event_tx, &config, reason, None).await; + } + } + + // ── 3. Sequential worker execution ── + auto_run.status = AutoStatus::Running; + let mut hard_failure: Option = None; + let mut last_summary: Option = None; + + for spec in plan.workers.iter() { + if cancel.is_cancelled() { + return finalize_cancelled(auto_run); + } + + let result = worker_runner.run(spec, &prior_results).await; + last_summary = Some(result.summary.clone()); + auto_run.cost_cents = auto_run.cost_cents.saturating_add(result.cost_cents); + + if result.status == WorkerStatus::Cancelled { + auto_run.worker_results.push(result); + return finalize_cancelled(auto_run); + } + + if result.status.is_hard_failure() { + hard_failure = Some(format!( + "worker {} ({}) failed [{}]: {}", + spec.id, + spec.model, + status_label(&result.status), + truncate(&result.summary, 240), + )); + auto_run.worker_results.push(result); + break; + } + + // success — feed into both prior_results (visible to subsequent + // workers + next orchestrator call) and auto_run.worker_results + // (the persisted ledger). + prior_results.push(result.clone()); + auto_run.worker_results.push(result); + } + + // ── 4. Terminal or replan? ── + match hard_failure { + None => { + // All workers in this plan ran successfully. + return finalize_done(auto_run, &event_tx, &config, last_summary).await; + } + Some(reason) => { + if auto_run.replans_used >= config.replan_budget { + return finalize_failed(auto_run, &event_tx, &config, reason, last_summary).await; + } + auto_run.replans_used += 1; + current_version += 1; + emit( + &event_tx, + AgentEvent::AutoReplan { + session_id: config.session_id.clone(), + run_id: config.auto_run_id.clone(), + reason: reason.clone(), + }, + ) + .await; + replan_reason = Some(reason); + continue; + } + } + } +} + +// ── Finalizers ─────────────────────────────────────────────────────────── + +async fn finalize_done( + mut run: AutoRun, + event_tx: &mpsc::Sender, + config: &ExecutorConfig, + summary_hint: Option, +) -> AutoResult { + run.status = AutoStatus::Done; + let summary = summary_hint.unwrap_or_else(|| "Auto task complete.".to_string()); + emit( + event_tx, + AgentEvent::AutoDone { + session_id: config.session_id.clone(), + run_id: config.auto_run_id.clone(), + summary: summary.clone(), + total_cost_cents: run.cost_cents, + }, + ) + .await; + AutoResult::Done { summary, run } +} + +async fn finalize_failed( + mut run: AutoRun, + event_tx: &mpsc::Sender, + config: &ExecutorConfig, + reason: String, + last_worker_output: Option, +) -> AutoResult { + run.status = AutoStatus::Failed; + emit( + event_tx, + AgentEvent::AutoFailed { + session_id: config.session_id.clone(), + run_id: config.auto_run_id.clone(), + reason: reason.clone(), + last_worker_output: last_worker_output.clone(), + }, + ) + .await; + AutoResult::Failed { reason, run } +} + +fn finalize_cancelled(mut run: AutoRun) -> AutoResult { + run.status = AutoStatus::Cancelled; + // No AgentEvent::AutoCancelled in the enum; the UI infers cancel from the + // user's Cancel click. We could re-purpose AutoFailed with reason + // "cancelled" if downstream surfaces need an explicit signal — for now, + // returning the result is enough; AutoRun.status carries the state. + AutoResult::Cancelled { run } +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +async fn emit(tx: &mpsc::Sender, ev: AgentEvent) { + let _ = tx.send(ev).await; +} + +fn status_label(s: &WorkerStatus) -> &'static str { + match s { + WorkerStatus::Ok => "ok", + WorkerStatus::MaxIterations => "max_iterations", + WorkerStatus::ToolError { .. } => "tool_error", + WorkerStatus::LlmError { .. } => "llm_error", + WorkerStatus::Cancelled => "cancelled", + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let truncated: String = s.chars().take(max).collect(); + format!("{truncated}…") + } +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::auto::types::{SeePrior, SeePriorKeyword}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + // ── Mock impls ── + + /// Returns pre-queued plans in order. Errors if asked for more plans than + /// were queued (catches off-by-one bugs in the replan loop). + struct MockPlanGen { + queue: Mutex>>, + calls: Mutex, + } + impl MockPlanGen { + fn new(plans: Vec>) -> Self { + Self { + queue: Mutex::new(plans), + calls: Mutex::new(0), + } + } + fn call_count(&self) -> u32 { + *self.calls.lock().unwrap() + } + } + #[async_trait] + impl PlanGenerator for MockPlanGen { + async fn generate( + &self, + _req: &OrchestratorRequest<'_>, + _cancel: Option<&CancellationToken>, + ) -> Result { + *self.calls.lock().unwrap() += 1; + let mut q = self.queue.lock().unwrap(); + if q.is_empty() { + panic!("MockPlanGen: no more plans queued"); + } + q.remove(0) + } + } + + /// Returns pre-queued `WorkerResult`s in order, keyed by worker id with + /// fallthrough. If the executor asks for a worker id not in the canned + /// map, returns a generic Ok. + struct MockRunner { + results_by_id: Mutex>>, + invocations: Mutex>, + } + impl MockRunner { + fn new() -> Self { + Self { + results_by_id: Mutex::new(Default::default()), + invocations: Mutex::new(Vec::new()), + } + } + fn enqueue(&self, id: &str, result: WorkerResult) { + self.results_by_id.lock().unwrap().entry(id.into()).or_default().push(result); + } + fn invocations(&self) -> Vec { + self.invocations.lock().unwrap().clone() + } + } + #[async_trait] + impl WorkerRunner for MockRunner { + async fn run(&self, spec: &WorkerSpec, _prior: &[WorkerResult]) -> WorkerResult { + self.invocations.lock().unwrap().push(spec.id.clone()); + let mut map = self.results_by_id.lock().unwrap(); + if let Some(queue) = map.get_mut(&spec.id) { + if !queue.is_empty() { + return queue.remove(0); + } + } + // Fallthrough: generic Ok. + WorkerResult { + id: spec.id.clone(), + model: spec.model.clone(), + prompt: spec.prompt.clone(), + summary: format!("ok: {}", spec.prompt), + tool_count: 0, + cost_cents: 0, + status: WorkerStatus::Ok, + } + } + } + + /// Approver that always returns false. + struct AlwaysReject; + #[async_trait] + impl PlanApprover for AlwaysReject { + async fn approve(&self, _: &Plan) -> bool { + false + } + } + + // ── Fixtures ── + + fn plan(version: u32, ids: &[&str]) -> Plan { + Plan { + version, + reasoning: format!("plan v{version}"), + workers: ids + .iter() + .map(|id| WorkerSpec { + id: (*id).into(), + model: "test-model".into(), + prompt: format!("task for {id}"), + see_prior: SeePrior::Keyword(SeePriorKeyword::None), + }) + .collect(), + } + } + + fn worker_ok(id: &str, summary: &str) -> WorkerResult { + WorkerResult { + id: id.into(), + model: "test-model".into(), + prompt: "p".into(), + summary: summary.into(), + tool_count: 1, + cost_cents: 5, + status: WorkerStatus::Ok, + } + } + fn worker_fail(id: &str, kind: WorkerStatus) -> WorkerResult { + WorkerResult { + id: id.into(), + model: "test-model".into(), + prompt: "p".into(), + summary: format!("worker {id} failed"), + tool_count: 0, + cost_cents: 1, + status: kind, + } + } + + fn cfg(replan_budget: u32) -> (ExecutorConfig, mpsc::Sender, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(128); + ( + ExecutorConfig { + task: "test task".into(), + auto_run_id: "auto-run-test".into(), + session_id: "sess-test".into(), + worker_pool: vec![WorkerPoolEntry { + provider_id: "anthropic".into(), + model: "test-model".into(), + description: "the only worker".into(), + }], + replan_budget, + }, + tx, + rx, + ) + } + + /// Collect events until terminal (AutoDone or AutoFailed) or a timeout. + async fn drain(mut rx: mpsc::Receiver) -> Vec { + let mut out = Vec::new(); + while let Ok(Some(ev)) = + tokio::time::timeout(Duration::from_millis(500), rx.recv()).await + { + let terminal = + matches!(ev, AgentEvent::AutoDone { .. } | AgentEvent::AutoFailed { .. }); + out.push(ev); + if terminal { + break; + } + } + out + } + + // ── Tests ── + + #[tokio::test] + async fn happy_path_all_workers_ok_yields_autodone() { + let plan_gen = Arc::new(MockPlanGen::new(vec![Ok(plan(1, &["w1", "w2"]))])); + let runner = Arc::new(MockRunner::new()); + runner.enqueue("w1", worker_ok("w1", "explored 12 files")); + runner.enqueue("w2", worker_ok("w2", "synthesized result")); + + let (config, tx, rx) = cfg(2); + let result = run( + config, + tx, + plan_gen.clone(), + runner.clone(), + Arc::new(AutoApprove), + CancellationToken::new(), + ) + .await; + + let events = drain(rx).await; + + match result { + AutoResult::Done { ref summary, ref run } => { + assert!(summary.contains("synthesized"), "summary={summary}"); + assert_eq!(run.status, AutoStatus::Done); + assert_eq!(run.worker_results.len(), 2); + assert_eq!(run.replans_used, 0); + assert_eq!(run.plan_versions.len(), 1); + assert_eq!(run.cost_cents, 10); // 5 + 5 + } + other => panic!("expected Done, got {other:?}"), + } + assert_eq!(plan_gen.call_count(), 1, "no replans expected on happy path"); + assert_eq!(runner.invocations(), vec!["w1", "w2"]); + + // Event ordering sanity. + let has = |needle: &str| { + events + .iter() + .any(|e| format!("{e:?}").contains(needle)) + }; + assert!(has("AutoPlanning")); + assert!(has("AutoPlan {")); + assert!(has("AutoAwaitingApproval")); + assert!(has("AutoDone")); + assert!(!has("AutoReplan"), "no replan expected"); + assert!(!has("AutoFailed"), "no failure expected"); + } + + #[tokio::test] + async fn worker_hard_failure_triggers_replan_then_succeeds() { + // Plan v1 has [w1, w2]. w1 succeeds; w2 fails (max_iter). + // Plan v2 has [w2_retry]. w2_retry succeeds. + let plan_gen = Arc::new(MockPlanGen::new(vec![ + Ok(plan(1, &["w1", "w2"])), + Ok(plan(2, &["w2_retry"])), + ])); + let runner = Arc::new(MockRunner::new()); + runner.enqueue("w1", worker_ok("w1", "step 1 done")); + runner.enqueue("w2", worker_fail("w2", WorkerStatus::MaxIterations)); + runner.enqueue("w2_retry", worker_ok("w2_retry", "step 2 recovered")); + + let (config, tx, rx) = cfg(2); + let result = run( + config, + tx, + plan_gen.clone(), + runner.clone(), + Arc::new(AutoApprove), + CancellationToken::new(), + ) + .await; + let events = drain(rx).await; + + assert!( + matches!(result, AutoResult::Done { .. }), + "expected Done after successful replan, got {result:?}" + ); + assert_eq!(plan_gen.call_count(), 2); + assert_eq!(runner.invocations(), vec!["w1", "w2", "w2_retry"]); + + let auto_run = match &result { + AutoResult::Done { run, .. } => run, + _ => unreachable!(), + }; + assert_eq!(auto_run.replans_used, 1); + assert_eq!(auto_run.plan_versions.len(), 2); + assert_eq!(auto_run.worker_results.len(), 3); // w1 ok, w2 failed, w2_retry ok + + assert!(events.iter().any(|e| matches!(e, AgentEvent::AutoReplan { .. }))); + } + + #[tokio::test] + async fn replan_budget_exhaustion_yields_autofailed() { + // Budget = 1. Plan v1 fails, plan v2 also fails → AutoFailed. + let plan_gen = Arc::new(MockPlanGen::new(vec![ + Ok(plan(1, &["w1"])), + Ok(plan(2, &["w1_retry"])), + ])); + let runner = Arc::new(MockRunner::new()); + runner.enqueue("w1", worker_fail("w1", WorkerStatus::ToolError { message: "boom".into() })); + runner.enqueue( + "w1_retry", + worker_fail("w1_retry", WorkerStatus::ToolError { message: "still boom".into() }), + ); + + let (config, tx, rx) = cfg(1); + let result = run( + config, + tx, + plan_gen, + runner, + Arc::new(AutoApprove), + CancellationToken::new(), + ) + .await; + let events = drain(rx).await; + + match &result { + AutoResult::Failed { reason, run } => { + assert!(reason.contains("w1_retry"), "reason should mention the last failed worker: {reason}"); + assert_eq!(run.replans_used, 1, "budget=1 means exactly 1 replan attempted"); + assert_eq!(run.plan_versions.len(), 2); + assert_eq!(run.status, AutoStatus::Failed); + } + other => panic!("expected Failed, got {other:?}"), + } + assert!(events.iter().any(|e| matches!(e, AgentEvent::AutoFailed { .. }))); + } + + #[tokio::test] + async fn zero_replan_budget_fails_immediately_on_worker_failure() { + let plan_gen = Arc::new(MockPlanGen::new(vec![Ok(plan(1, &["w1"]))])); + let runner = Arc::new(MockRunner::new()); + runner.enqueue("w1", worker_fail("w1", WorkerStatus::LlmError { message: "5xx".into() })); + + let (config, tx, rx) = cfg(0); + let result = run(config, tx, plan_gen, runner, Arc::new(AutoApprove), CancellationToken::new()).await; + let _ = drain(rx).await; + + match result { + AutoResult::Failed { run, .. } => { + assert_eq!(run.replans_used, 0); + assert_eq!(run.plan_versions.len(), 1); + } + other => panic!("expected immediate Failed with budget=0, got {other:?}"), + } + } + + #[tokio::test] + async fn user_rejects_plan_yields_failed_with_rejection_reason() { + let plan_gen = Arc::new(MockPlanGen::new(vec![Ok(plan(1, &["w1"]))])); + let runner = Arc::new(MockRunner::new()); + + let (config, tx, rx) = cfg(2); + let result = run(config, tx, plan_gen, runner.clone(), Arc::new(AlwaysReject), CancellationToken::new()).await; + let events = drain(rx).await; + + match result { + AutoResult::Failed { reason, run } => { + assert!(reason.contains("rejected"), "reason: {reason}"); + assert_eq!(run.plan_versions.len(), 1, "plan was still generated and emitted"); + assert!(run.worker_results.is_empty(), "no workers should run if rejected"); + } + other => panic!("expected Failed on plan rejection, got {other:?}"), + } + assert!(runner.invocations().is_empty(), "runner must not be invoked on rejection"); + assert!(events.iter().any(|e| matches!(e, AgentEvent::AutoFailed { .. }))); + } + + #[tokio::test] + async fn orchestrator_error_yields_failed_no_replan() { + let plan_gen = Arc::new(MockPlanGen::new(vec![Err(OrchestratorError::Parse { + attempts: 3, + message: "all parse attempts failed".into(), + })])); + let runner = Arc::new(MockRunner::new()); + + let (config, tx, rx) = cfg(2); + let result = run(config, tx, plan_gen, runner.clone(), Arc::new(AutoApprove), CancellationToken::new()).await; + let _ = drain(rx).await; + + match result { + AutoResult::Failed { reason, run } => { + assert!(reason.contains("orchestrator error"), "reason: {reason}"); + assert!(run.plan_versions.is_empty(), "no plan was produced"); + assert_eq!(run.replans_used, 0); + } + other => panic!("expected Failed on orchestrator error, got {other:?}"), + } + assert!(runner.invocations().is_empty()); + } + + #[tokio::test] + async fn cancel_during_worker_run_yields_cancelled() { + // First worker reports Cancelled status (simulates user cancelling + // mid-worker — the child agent loop's cancel token propagates). + let plan_gen = Arc::new(MockPlanGen::new(vec![Ok(plan(1, &["w1", "w2"]))])); + let runner = Arc::new(MockRunner::new()); + runner.enqueue("w1", worker_fail("w1", WorkerStatus::Cancelled)); + + let (config, tx, _rx) = cfg(2); + let result = run(config, tx, plan_gen, runner.clone(), Arc::new(AutoApprove), CancellationToken::new()).await; + + match result { + AutoResult::Cancelled { run } => { + assert_eq!(run.status, AutoStatus::Cancelled); + assert_eq!(run.worker_results.len(), 1, "only w1 ran before cancel"); + assert_eq!(runner.invocations(), vec!["w1"]); + } + other => panic!("expected Cancelled, got {other:?}"), + } + } + + #[tokio::test] + async fn cancel_token_fired_before_orchestrator_yields_cancelled() { + let plan_gen = Arc::new(MockPlanGen::new(vec![Ok(plan(1, &["w1"]))])); + let runner = Arc::new(MockRunner::new()); + + let (config, tx, _rx) = cfg(2); + let cancel = CancellationToken::new(); + cancel.cancel(); + let result = run(config, tx, plan_gen.clone(), runner.clone(), Arc::new(AutoApprove), cancel).await; + + match result { + AutoResult::Cancelled { run } => { + assert!(run.plan_versions.is_empty(), "no plan should be generated"); + } + other => panic!("expected Cancelled with pre-fired token, got {other:?}"), + } + assert_eq!(plan_gen.call_count(), 0, "orchestrator must not be called when cancelled"); + } +} diff --git a/crates/agent/src/auto/mod.rs b/crates/agent/src/auto/mod.rs index a5c95465..42950c73 100644 --- a/crates/agent/src/auto/mod.rs +++ b/crates/agent/src/auto/mod.rs @@ -10,11 +10,16 @@ //! P2: `orchestrator.rs` — orchestrator LLM call + plan parsing (forced tool use). //! P3: `executor.rs` — sequencer + reactive replan loop. +pub mod executor; pub mod orchestrator; pub mod schema; pub mod types; pub mod worker; +pub use executor::{ + run as run_auto, AutoApprove, ExecutorConfig, LiveWorkerRunner, LlmPlanGenerator, + PlanApprover, PlanGenerator, WorkerRunner, +}; pub use orchestrator::{ build_orchestrator_messages, generate_plan, parse_plan_from_tool_input, OrchestratorError, OrchestratorRequest, diff --git a/crates/agent/tests/integration.rs b/crates/agent/tests/integration.rs index ec740dba..1abc9139 100644 --- a/crates/agent/tests/integration.rs +++ b/crates/agent/tests/integration.rs @@ -1451,3 +1451,176 @@ async fn test_auto_orchestrator_generates_parseable_plan_live() { assert!(w.id.starts_with('w'), "worker id should be wN, got `{}`", w.id); } } + +/// Full end-to-end Auto mode against a real LLM stack: real orchestrator, +/// real `LiveWorkerRunner`, real `LlmPlanGenerator`, sequenced by +/// `executor::run`. This is the test that P3's mocked unit tests cannot +/// replace — it proves the components compose correctly under real cancel +/// tokens, async boundaries, and channel back-pressure. +/// +/// Task is intentionally small (create a file with specific contents) so the +/// orchestrator likely emits a single-worker plan, keeping the test fast and +/// cheap (~$0.02–0.05 per run on Sonnet 4.6). +#[tokio::test] +#[ignore = "requires ANTHROPIC_API_KEY (Auto orchestrator + workers both Anthropic)"] +async fn test_auto_executor_end_to_end_live() { + use agent::auto::{ + run_auto, AutoApprove, AutoResult, AutoStatus, ExecutorConfig, LiveWorkerRunner, + LlmPlanGenerator, WorkerContext, WorkerPoolEntry, + }; + use agent::llm::client::LlmPolicy; + use agent::llm::{LlmClientConfig, Provider}; + use std::sync::Arc; + use std::time::Duration; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + + let _ = env_logger::try_init(); + let tmp = tempdir().unwrap(); + let working_dir = tmp.path().to_path_buf(); + let api_key = api_key(); + + // Worker pool: two real Anthropic models. Description tells the + // orchestrator how to pick. + let pool = vec![ + WorkerPoolEntry { + provider_id: "anthropic".into(), + model: "claude-haiku-4-5".into(), + description: "fast + cheap; best for simple file ops, reads, single-shot writes".into(), + }, + WorkerPoolEntry { + provider_id: "anthropic".into(), + model: "claude-sonnet-4-6".into(), + description: "stronger reasoning + multi-step coding; best for design and complex edits".into(), + }, + ]; + + let orchestrator_llm = LlmClientConfig { + provider: Provider::Anthropic, + base_url: "https://api.anthropic.com".into(), + model: "claude-sonnet-4-6".into(), + api_key: api_key.clone(), + temperature: Some(0.0), + max_completion_tokens: Some(4096), + extra_headers: vec![], + thinking: None, + disable_cache_control: true, + policy: LlmPolicy::default(), + }; + + // Worker LLM template — model is overridden per worker by run_worker. + let worker_llm_base = LlmClientConfig { + provider: Provider::Anthropic, + base_url: "https://api.anthropic.com".into(), + model: "placeholder".into(), + api_key: api_key.clone(), + temperature: Some(0.0), + max_completion_tokens: Some(2048), + extra_headers: vec![], + thinking: None, + disable_cache_control: true, + policy: LlmPolicy::default(), + }; + + let cancel = CancellationToken::new(); + let (event_tx, mut event_rx) = mpsc::channel::(256); + + let auto_run_id = "auto-run-e2e".to_string(); + let session_id = "sess-e2e".to_string(); + + let worker_ctx = WorkerContext { + session_id: session_id.clone(), + run_id: auto_run_id.clone(), + working_dir: working_dir.clone(), + event_tx: event_tx.clone(), + cancel_token: cancel.clone(), + llm_base_config: worker_llm_base, + retry_config: RetryConfig::default(), + compaction_config: Default::default(), + compaction_llm: None, + max_iterations: 20, + context_engine: None, + context_engine_repo_path: None, + persister: None, + approval_handler: None, + checkpoint_dir: None, + }; + + let plan_gen = Arc::new(LlmPlanGenerator { llm: orchestrator_llm }); + let runner = Arc::new(LiveWorkerRunner { ctx: worker_ctx }); + let approver = Arc::new(AutoApprove); + + let config = ExecutorConfig { + task: "Create a file named `result.txt` in the working directory whose \ + sole contents are the exact string `hello from auto mode` followed \ + by a single trailing newline. Do not create any other files." + .into(), + auto_run_id: auto_run_id.clone(), + session_id: session_id.clone(), + worker_pool: pool, + replan_budget: 1, + }; + + // Collect events on a side task so they don't fill the channel buffer. + let collected_events = Arc::new(std::sync::Mutex::new(Vec::::new())); + let collector = { + let collected = Arc::clone(&collected_events); + tokio::spawn(async move { + while let Ok(Some(ev)) = + tokio::time::timeout(Duration::from_secs(120), event_rx.recv()).await + { + let is_terminal = matches!( + ev, + AgentEvent::AutoDone { .. } | AgentEvent::AutoFailed { .. } + ); + eprintln!("[auto-e2e] {ev:?}"); + collected.lock().unwrap().push(ev); + if is_terminal { + break; + } + } + }) + }; + + let result = run_auto(config, event_tx, plan_gen, runner, approver, cancel).await; + // Wait for the collector to drain the terminal event. + let _ = tokio::time::timeout(Duration::from_secs(5), collector).await; + + // ── Result-level assertions ── + match &result { + AutoResult::Done { run, summary } => { + eprintln!("[auto-e2e] AutoDone summary: {summary}"); + assert_eq!(run.status, AutoStatus::Done); + assert!(!run.plan_versions.is_empty(), "plan must have been generated"); + assert!(!run.worker_results.is_empty(), "at least one worker must have run"); + assert!( + run.replans_used <= 1, + "should not have exhausted replan budget on a simple task; used={}", + run.replans_used + ); + } + other => panic!( + "expected AutoResult::Done for a trivial file-write task, got: {other:?}" + ), + } + + // ── File-side assertion: the worker actually wrote the file ── + let written = std::fs::read_to_string(working_dir.join("result.txt")) + .expect("result.txt should exist after Auto task completes"); + assert_eq!( + written.trim_end_matches('\n'), + "hello from auto mode", + "file contents mismatch; got: {written:?}" + ); + + // ── Event-stream assertions: the whole bracket fired in order ── + let events = collected_events.lock().unwrap().clone(); + let has = |needle: &str| events.iter().any(|e| format!("{e:?}").contains(needle)); + assert!(has("AutoPlanning"), "missing AutoPlanning event"); + assert!(has("AutoPlan {"), "missing AutoPlan event"); + assert!(has("AutoAwaitingApproval"), "missing AutoAwaitingApproval event"); + assert!(has("AutoWorkerStart"), "missing AutoWorkerStart event"); + assert!(has("AutoWorkerEnd"), "missing AutoWorkerEnd event"); + assert!(has("AutoDone"), "missing AutoDone event"); + assert!(!has("AutoFailed"), "should not have failed on a trivial task"); +} From 32c95600a29d69324de684557418c0d1e56f2677 Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Wed, 24 Jun 2026 18:47:28 +0530 Subject: [PATCH 07/14] feat(desktop): Tauri bridge for Auto mode (P4) --- .../src-tauri/src/agent_bridge/auto.rs | 546 ++++++++++++++++++ .../src-tauri/src/agent_bridge/commands.rs | 72 ++- apps/desktop/src-tauri/src/agent_bridge/db.rs | 294 ++++++++++ .../src-tauri/src/agent_bridge/events.rs | 138 ++++- .../desktop/src-tauri/src/agent_bridge/mod.rs | 1 + apps/desktop/src-tauri/src/lib.rs | 5 + 6 files changed, 1045 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src-tauri/src/agent_bridge/auto.rs diff --git a/apps/desktop/src-tauri/src/agent_bridge/auto.rs b/apps/desktop/src-tauri/src/agent_bridge/auto.rs new file mode 100644 index 00000000..d1dc47eb --- /dev/null +++ b/apps/desktop/src-tauri/src/agent_bridge/auto.rs @@ -0,0 +1,546 @@ +//! Auto-mode Tauri bridge. +//! +//! Wires the backend `agent::auto` module (orchestrator + workers + executor) +//! into the desktop app. Phase P4 of PHASE_AUTO_MODE.md. +//! +//! Surface: +//! - Settings persistence: orchestrator model, worker pool, replan budget +//! stored in the SQLite settings k-v table; 4 Tauri commands surface them. +//! - `TauriPlanApprover` — mirrors `TauriApprovalHandler` for tool approvals. +//! Per-run_id `oneshot` channel via a shared `HashMap` on `AgentState`; +//! `agent_approve_auto_plan` resolves it from the frontend. +//! - `run_auto_turn` — when `run_agent_turn` detects the Auto sentinel +//! (provider_id="auto" + model="auto"), it dispatches here instead of the +//! regular session-manager path. Builds `ExecutorConfig`, `WorkerContext`, +//! `LlmPlanGenerator`, `LiveWorkerRunner`, and `TauriPlanApprover`; spawns +//! `agent::auto::run_auto` on a task whose events drain through the same +//! `spawn_event_relay` the regular path uses. +//! +//! v1 limitation: all workers in the pool are dispatched through the +//! orchestrator's provider config (model name is overridden per worker, but +//! base_url + api_key + provider come from the orchestrator). Cross-provider +//! worker pools will work only if the orchestrator's endpoint accepts every +//! model name; otherwise the failing worker triggers a reactive replan. True +//! multi-provider dispatch is deferred to a future phase. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; +use tokio::sync::{mpsc, oneshot}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use agent::agent::config::{CompactionConfig, RetryConfig}; +use agent::auto::{ + self, AutoResult, AutoRun, AutoStatus, ExecutorConfig, LiveWorkerRunner, LlmPlanGenerator, + Plan, PlanApprover, WorkerContext, WorkerPoolEntry, +}; + +use crate::AppState; +use super::commands::{provider_to_llm_config, AgentState, ModelRef}; +use super::events::spawn_event_relay; +use super::traits::{EventEmitter, TauriEventEmitter}; + +// ── Constants ──────────────────────────────────────────────────────────── + +/// Sentinel `(provider_id, model)` pair that signals "this session is in Auto +/// mode". The model picker writes these into `sessions.provider_id` / +/// `sessions.model` (and into `ModelSelection.active`) when the user picks +/// the Auto entry. `agent_set_model_selection` special-cases these to skip +/// the usual provider-exists validation. +pub const AUTO_SENTINEL_PROVIDER_ID: &str = "auto"; +pub const AUTO_SENTINEL_MODEL: &str = "auto"; + +/// Settings DB keys. Same k-v table that holds `llm_selection`, +/// `llm_providers`, `context_engine`. Values are JSON strings. +const AUTO_ORCHESTRATOR_KEY: &str = "auto_orchestrator_model"; +const AUTO_WORKER_POOL_KEY: &str = "auto_worker_pool"; +const AUTO_REPLAN_BUDGET_KEY: &str = "auto_replan_budget"; + +/// Default replan budget when the user has not configured it. +/// Matches PHASE_AUTO_MODE.md "Replan budget" decision. +pub const DEFAULT_REPLAN_BUDGET: u32 = 2; + +/// Returns true iff `(provider_id, model)` is the Auto sentinel. +pub fn is_auto_mode(provider_id: &str, model: &str) -> bool { + provider_id == AUTO_SENTINEL_PROVIDER_ID && model == AUTO_SENTINEL_MODEL +} + +// ── Settings DTO + persistence ─────────────────────────────────────────── + +/// All three Auto settings in a single struct, returned by +/// `agent_get_auto_settings` for the Settings UI to consume. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AutoSettings { + /// User-picked orchestrator model. `None` when Auto mode is not yet + /// configured — `run_auto_turn` rejects with a "not configured" error + /// in that case. + pub orchestrator: Option, + /// User-curated pool of worker models with per-entry descriptions. + #[serde(default)] + pub worker_pool: Vec, + /// Max reactive replans per task; total orchestrator calls = 1 + budget. + pub replan_budget: u32, +} + +pub(crate) fn read_auto_orchestrator(app_state: &AppState) -> Option { + app_state + .db + .get_setting(AUTO_ORCHESTRATOR_KEY) + .ok() + .flatten() + .and_then(|raw| serde_json::from_str(&raw).ok()) +} + +pub(crate) fn read_auto_worker_pool(app_state: &AppState) -> Vec { + app_state + .db + .get_setting(AUTO_WORKER_POOL_KEY) + .ok() + .flatten() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default() +} + +pub(crate) fn read_auto_replan_budget(app_state: &AppState) -> u32 { + app_state + .db + .get_setting(AUTO_REPLAN_BUDGET_KEY) + .ok() + .flatten() + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(DEFAULT_REPLAN_BUDGET) +} + +pub(crate) fn read_auto_settings(app_state: &AppState) -> AutoSettings { + AutoSettings { + orchestrator: read_auto_orchestrator(app_state), + worker_pool: read_auto_worker_pool(app_state), + replan_budget: read_auto_replan_budget(app_state), + } +} + +// ── Tauri commands: settings ───────────────────────────────────────────── + +#[tauri::command] +pub async fn agent_get_auto_settings( + app_state: State<'_, AppState>, +) -> Result { + Ok(read_auto_settings(&app_state)) +} + +#[tauri::command] +pub async fn agent_set_auto_orchestrator_model( + model: Option, + app_state: State<'_, AppState>, +) -> Result<(), String> { + match model { + Some(ref m) => { + let raw = serde_json::to_string(m).map_err(|e| e.to_string())?; + app_state.db.set_setting(AUTO_ORCHESTRATOR_KEY, &raw) + } + None => app_state.db.delete_setting(AUTO_ORCHESTRATOR_KEY), + } +} + +#[tauri::command] +pub async fn agent_set_auto_worker_pool( + pool: Vec, + app_state: State<'_, AppState>, +) -> Result<(), String> { + let raw = serde_json::to_string(&pool).map_err(|e| e.to_string())?; + app_state.db.set_setting(AUTO_WORKER_POOL_KEY, &raw) +} + +#[tauri::command] +pub async fn agent_set_auto_replan_budget( + budget: u32, + app_state: State<'_, AppState>, +) -> Result<(), String> { + // Per PHASE_AUTO_MODE.md the Settings slider exposes range 0–5. + if budget > 5 { + return Err(format!( + "replan budget {budget} exceeds max (5); see PHASE_AUTO_MODE.md" + )); + } + app_state.db.set_setting(AUTO_REPLAN_BUDGET_KEY, &budget.to_string()) +} + +// ── Plan approval ──────────────────────────────────────────────────────── + +/// Pending plan approvals keyed by `run_id`. Lives on `AgentState`; populated +/// by `TauriPlanApprover::approve` (waits) and drained by +/// `agent_approve_auto_plan` (resolves). +pub type AutoApprovalPending = Arc>>>; + +pub fn new_pending_map() -> AutoApprovalPending { + Arc::new(Mutex::new(HashMap::new())) +} + +/// `PlanApprover` impl that blocks on a `oneshot` channel keyed by `run_id`. +/// The frontend resolves the wait via `agent_approve_auto_plan`. Mirrors the +/// shape of `TauriApprovalHandler` in `events.rs`. +/// +/// Note: the executor already emits `AutoAwaitingApproval` immediately before +/// calling `approve`, so this approver does NOT emit any event of its own — +/// it just waits. +pub struct TauriPlanApprover { + run_id: String, + pending: AutoApprovalPending, +} + +impl TauriPlanApprover { + pub fn new(run_id: String, pending: AutoApprovalPending) -> Self { + Self { run_id, pending } + } +} + +#[async_trait] +impl PlanApprover for TauriPlanApprover { + async fn approve(&self, _plan: &Plan) -> bool { + let (tx, rx) = oneshot::channel(); + self.pending.lock().unwrap().insert(self.run_id.clone(), tx); + // Wait. If the sender is dropped (e.g. the run was cancelled before + // the user clicked anything), treat as a denial. + rx.await.unwrap_or(false) + } +} + +/// Resolve a pending plan approval. Called by the frontend after the user +/// clicks Run (`approved=true`) or Cancel (`approved=false`). +#[tauri::command] +pub async fn agent_approve_auto_plan( + run_id: String, + approved: bool, + agent_state: State<'_, AgentState>, +) -> Result<(), String> { + let sender = agent_state.auto_plan_pending.lock().unwrap().remove(&run_id); + match sender { + Some(tx) => { + let _ = tx.send(approved); + Ok(()) + } + None => Err(format!( + "No pending Auto-mode approval for run_id={run_id} (already resolved or expired)" + )), + } +} + +// ── Spawn entry point ──────────────────────────────────────────────────── + +/// Dispatched from `run_agent_turn` when the session is in Auto mode. +/// Validates Auto settings, builds the executor pipeline, persists the +/// initial `AutoRun` row, and spawns the executor on a background task. +/// The task drains its events through the standard `spawn_event_relay` so +/// the existing frontend wiring (`agent:text_delta`, `agent:tool_start`, +/// and the new `agent:auto_*` events forwarded in P4) all flow uniformly. +/// +/// Releases the folder lock on completion via the supplied `on_complete` +/// callback (mirrors the regular path's monitor task). +#[allow(clippy::too_many_arguments)] +pub async fn run_auto_turn( + app_handle: AppHandle, + app_state: &AppState, + agent_state: &AgentState, + session_id: String, + folder: String, + message: String, + on_complete: impl FnOnce() + Send + 'static, +) -> Result<(), String> { + // ── 1. Validate settings ── + let settings = read_auto_settings(app_state); + let Some(orchestrator_ref) = settings.orchestrator else { + return Err("Auto mode: orchestrator model not configured in Settings → Auto".into()); + }; + if settings.worker_pool.is_empty() { + return Err("Auto mode: worker pool is empty in Settings → Auto".into()); + } + let orchestrator_provider = super::commands::provider_by_id_pub(app_state, &orchestrator_ref.provider_id) + .ok_or_else(|| { + format!( + "Auto mode: orchestrator provider `{}` is not configured", + orchestrator_ref.provider_id + ) + })?; + + // ── 2. Build orchestrator + worker LLM configs ── + let mut orchestrator_llm = provider_to_llm_config(&orchestrator_provider, &orchestrator_ref.model); + // The orchestrator is a one-shot tool-use call — explicit cache_control + // is meaningless for it and the auto::orchestrator IO layer doesn't + // emit any cache markers anyway. + orchestrator_llm.disable_cache_control = true; + // v1 limitation noted at the top of this file: workers all use the + // orchestrator's provider config; only the model field is overridden. + let worker_llm_base = orchestrator_llm.clone(); + + // ── 3. Build event channel + relay ── + // The relay runs in parallel with the executor; both end-of-Auto event + // (AutoDone / AutoFailed) and ToolEnd-from-workers are forwarded to the + // frontend via the existing pipeline. + let (event_tx, event_rx) = mpsc::channel::(256); + let emitter: Arc = Arc::new(TauriEventEmitter::new(app_handle.clone())); + let relay_handle = spawn_event_relay( + emitter, + event_rx, + session_id.clone(), + Some(Arc::clone(&agent_state.db)), + folder.clone(), + None, // Auto mode: no per-turn checkpoint capture for v1 + ); + + // ── 4. Build WorkerContext ── + let work_dir = PathBuf::from(&folder); + let cancel = CancellationToken::new(); + let auto_run_id = Uuid::new_v4().to_string(); + + let worker_ctx = WorkerContext { + session_id: session_id.clone(), + run_id: auto_run_id.clone(), + working_dir: work_dir.clone(), + event_tx: event_tx.clone(), + cancel_token: cancel.clone(), + llm_base_config: worker_llm_base, + retry_config: RetryConfig::default(), + compaction_config: CompactionConfig::default(), + compaction_llm: None, + // Worker max_iter matches AgentConfig default per PHASE_AUTO_MODE.md. + // 100 is the same ceiling a normal agent run gets. + max_iterations: 100, + context_engine: None, + context_engine_repo_path: None, + persister: None, + approval_handler: None, + checkpoint_dir: None, + }; + + // ── 5. Build executor pipeline ── + let plan_gen = Arc::new(LlmPlanGenerator { llm: orchestrator_llm }); + let runner = Arc::new(LiveWorkerRunner { ctx: worker_ctx }); + let approver = Arc::new(TauriPlanApprover::new( + auto_run_id.clone(), + Arc::clone(&agent_state.auto_plan_pending), + )); + + let exec_config = ExecutorConfig { + task: message, + auto_run_id: auto_run_id.clone(), + session_id: session_id.clone(), + worker_pool: settings.worker_pool, + replan_budget: settings.replan_budget, + }; + + // ── 6. Persist initial AutoRun row ── + let initial_run = AutoRun { + id: auto_run_id.clone(), + session_id: session_id.clone(), + status: AutoStatus::Planning, + plan_versions: Vec::new(), + worker_results: Vec::new(), + cost_cents: 0, + replans_used: 0, + }; + let db = Arc::clone(&agent_state.db); + let initial_run_for_db = initial_run.clone(); + tokio::task::spawn_blocking(move || db.insert_auto_run(&initial_run_for_db)) + .await + .map_err(|e| format!("join error: {e}"))? + .map_err(|e| format!("Failed to insert auto_run: {e}"))?; + + // ── 7. Spawn the executor task ── + let db_for_task = Arc::clone(&agent_state.db); + let pending_for_cancel = Arc::clone(&agent_state.auto_plan_pending); + let auto_run_id_for_task = auto_run_id.clone(); + tokio::spawn(async move { + let result = auto::run_auto(exec_config, event_tx, plan_gen, runner, approver, cancel).await; + + // Persist terminal state. + let final_run = match &result { + AutoResult::Done { run, .. } + | AutoResult::Failed { run, .. } + | AutoResult::Cancelled { run } => run.clone(), + }; + let _ = tokio::task::spawn_blocking({ + let final_run = final_run.clone(); + move || db_for_task.update_auto_run(&final_run) + }) + .await; + + // Clean up any leftover pending approval (defensive — the executor + // shouldn't terminate while still holding one, but a cancelled run + // mid-approval would). + pending_for_cancel + .lock() + .unwrap() + .remove(&auto_run_id_for_task); + + // Await the relay so the final AutoDone/AutoFailed event has been + // emitted to the frontend before we release the folder lock. + let _ = relay_handle.await; + on_complete(); + }); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use parking_lot::Mutex as PlMutex; + use rusqlite::Connection; + + /// In-memory `Database` with the settings table pre-created. Lets us + /// exercise the settings k-v helpers without touching the real app data + /// dir or going through Tauri's `State` wrapper. + fn mk_app_state() -> AppState { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + );", + ) + .unwrap(); + AppState { + db: Arc::new(crate::Database { conn: PlMutex::new(conn) }), + } + } + + // ── is_auto_mode ── + + #[test] + fn is_auto_mode_recognizes_only_the_sentinel_pair() { + assert!(is_auto_mode(AUTO_SENTINEL_PROVIDER_ID, AUTO_SENTINEL_MODEL)); + assert!(!is_auto_mode("auto", "something-else")); + assert!(!is_auto_mode("anthropic", "auto")); + assert!(!is_auto_mode("anthropic", "claude-sonnet-4-6")); + assert!(!is_auto_mode("", "")); + } + + // ── defaults ── + + #[test] + fn read_auto_settings_returns_documented_defaults_when_unset() { + let state = mk_app_state(); + let s = read_auto_settings(&state); + assert!(s.orchestrator.is_none()); + assert!(s.worker_pool.is_empty()); + assert_eq!(s.replan_budget, DEFAULT_REPLAN_BUDGET); + assert_eq!(DEFAULT_REPLAN_BUDGET, 2, "default budget pinned by PHASE_AUTO_MODE.md"); + } + + // ── orchestrator round-trip ── + + #[test] + fn orchestrator_model_writes_then_reads_unchanged() { + let state = mk_app_state(); + let model = ModelRef { + provider_id: "anthropic".into(), + model: "claude-sonnet-4-6".into(), + }; + let raw = serde_json::to_string(&model).unwrap(); + state.db.set_setting(AUTO_ORCHESTRATOR_KEY, &raw).unwrap(); + + let loaded = read_auto_orchestrator(&state).expect("should round-trip"); + assert_eq!(loaded.provider_id, "anthropic"); + assert_eq!(loaded.model, "claude-sonnet-4-6"); + } + + #[test] + fn orchestrator_returns_none_when_value_is_corrupt() { + let state = mk_app_state(); + state + .db + .set_setting(AUTO_ORCHESTRATOR_KEY, "not valid json") + .unwrap(); + assert!(read_auto_orchestrator(&state).is_none()); + } + + // ── worker pool round-trip ── + + #[test] + fn worker_pool_writes_then_reads_unchanged() { + let state = mk_app_state(); + let pool = vec![ + WorkerPoolEntry { + provider_id: "anthropic".into(), + model: "claude-haiku-4-5".into(), + description: "cheap exploration".into(), + }, + WorkerPoolEntry { + provider_id: "anthropic".into(), + model: "claude-sonnet-4-6".into(), + description: "design + synthesis".into(), + }, + ]; + let raw = serde_json::to_string(&pool).unwrap(); + state.db.set_setting(AUTO_WORKER_POOL_KEY, &raw).unwrap(); + + let loaded = read_auto_worker_pool(&state); + assert_eq!(loaded.len(), 2); + assert_eq!(loaded[0].model, "claude-haiku-4-5"); + assert_eq!(loaded[0].description, "cheap exploration"); + assert_eq!(loaded[1].model, "claude-sonnet-4-6"); + } + + #[test] + fn worker_pool_defaults_to_empty_when_corrupt() { + let state = mk_app_state(); + state.db.set_setting(AUTO_WORKER_POOL_KEY, "{broken").unwrap(); + assert!(read_auto_worker_pool(&state).is_empty()); + } + + // ── replan budget round-trip ── + + #[test] + fn replan_budget_writes_then_reads_unchanged() { + let state = mk_app_state(); + state.db.set_setting(AUTO_REPLAN_BUDGET_KEY, "5").unwrap(); + assert_eq!(read_auto_replan_budget(&state), 5); + state.db.set_setting(AUTO_REPLAN_BUDGET_KEY, "0").unwrap(); + assert_eq!(read_auto_replan_budget(&state), 0); + } + + #[test] + fn replan_budget_defaults_when_unparseable() { + let state = mk_app_state(); + state.db.set_setting(AUTO_REPLAN_BUDGET_KEY, "garbage").unwrap(); + assert_eq!(read_auto_replan_budget(&state), DEFAULT_REPLAN_BUDGET); + } + + // ── composite read_auto_settings ── + + #[test] + fn read_auto_settings_assembles_all_three_keys() { + let state = mk_app_state(); + let model = ModelRef { + provider_id: "anthropic".into(), + model: "claude-sonnet-4-6".into(), + }; + state + .db + .set_setting(AUTO_ORCHESTRATOR_KEY, &serde_json::to_string(&model).unwrap()) + .unwrap(); + let pool = vec![WorkerPoolEntry { + provider_id: "anthropic".into(), + model: "claude-haiku-4-5".into(), + description: "cheap".into(), + }]; + state + .db + .set_setting(AUTO_WORKER_POOL_KEY, &serde_json::to_string(&pool).unwrap()) + .unwrap(); + state.db.set_setting(AUTO_REPLAN_BUDGET_KEY, "3").unwrap(); + + let s = read_auto_settings(&state); + assert!(s.orchestrator.is_some()); + assert_eq!(s.orchestrator.unwrap().model, "claude-sonnet-4-6"); + assert_eq!(s.worker_pool.len(), 1); + assert_eq!(s.replan_budget, 3); + } +} diff --git a/apps/desktop/src-tauri/src/agent_bridge/commands.rs b/apps/desktop/src-tauri/src/agent_bridge/commands.rs index 2c341618..128ab7d5 100644 --- a/apps/desktop/src-tauri/src/agent_bridge/commands.rs +++ b/apps/desktop/src-tauri/src/agent_bridge/commands.rs @@ -37,6 +37,10 @@ pub struct AgentState { pub(crate) approval_handlers: RwLock>>, pub(crate) model_registry: RwLock, pub(crate) write_lock_registry: Arc, + /// Auto-mode plan-approval channel registry. `TauriPlanApprover::approve` + /// inserts a `oneshot::Sender` keyed by `run_id`; + /// `agent_approve_auto_plan` drains it. See `agent_bridge::auto`. + pub(crate) auto_plan_pending: crate::agent_bridge::auto::AutoApprovalPending, } impl AgentState { @@ -49,6 +53,7 @@ impl AgentState { approval_handlers: RwLock::new(std::collections::HashMap::new()), model_registry: RwLock::new(agent::agent::model_profile::ModelRegistry::with_defaults()), write_lock_registry: Arc::new(agent::subagents::WriteLockRegistry::new()), + auto_plan_pending: crate::agent_bridge::auto::new_pending_map(), } } @@ -548,6 +553,26 @@ fn provider_by_id(app_state: &AppState, id: &str) -> Option { read_providers(app_state).into_iter().find(|p| p.id == id) } +/// Public wrapper used by the `agent_bridge::auto` module to look up the +/// orchestrator's provider. Same semantics as the private `provider_by_id`. +pub(crate) fn provider_by_id_pub(app_state: &AppState, id: &str) -> Option { + provider_by_id(app_state, id) +} + +/// True iff this session resolves to Auto mode — either explicitly +/// (`session.provider_id` + `session.model` are the Auto sentinel) or +/// implicitly via the global active selection. +fn resolve_session_auto_mode(session: &SessionRow, app_state: &AppState) -> bool { + use crate::agent_bridge::auto::is_auto_mode; + match (session.provider_id.as_deref(), session.model.as_deref()) { + (Some(pid), Some(model)) => is_auto_mode(pid, model), + _ => match read_selection(app_state).active { + Some(r) => is_auto_mode(&r.provider_id, &r.model), + None => false, + }, + } +} + /// Resolve a `ModelRef` to its provider + model id. fn resolve_ref(app_state: &AppState, r: &ModelRef) -> Option<(ProviderConfig, String)> { provider_by_id(app_state, &r.provider_id).map(|p| (p, r.model.clone())) @@ -1328,6 +1353,44 @@ async fn run_agent_turn( return Err(format!("Folder does not exist: {folder}")); } + // ── Auto mode branch ── + // If this session is in Auto mode (sentinel provider/model on the row, or + // the global active selection is the sentinel), dispatch to the Auto + // executor instead of the regular agent loop. The Auto path owns its own + // folder-release on completion via `on_complete`. + if resolve_session_auto_mode(&session, app_state) { + let folder_for_release_closure = folder.clone(); + let folder_for_call = folder.clone(); + let folder_for_err = folder.clone(); + let running_for_release = Arc::clone(&agent_state.running_folders); + let release_folder = move || { + let running = running_for_release; + let folder = folder_for_release_closure; + tokio::spawn(async move { + running.write().await.remove(&folder); + }); + }; + return match crate::agent_bridge::auto::run_auto_turn( + app_handle, + app_state, + agent_state, + session_id, + folder_for_call, + message, + release_folder, + ) + .await + { + Ok(()) => Ok(()), + Err(e) => { + // run_auto_turn returned early without spawning the task; the + // on_complete callback will never fire, so release here. + release(folder_for_err); + Err(e) + } + }; + } + let (provider, model) = match session_model(app_state, &session) { Ok(pm) => pm, Err(e) => { @@ -1904,9 +1967,16 @@ pub async fn agent_set_model_selection( model: String, app_state: State<'_, AppState>, ) -> Result<(), String> { - if provider_by_id(&app_state, &provider_id).is_none() { + // Auto sentinel skips the provider-exists check. It's a virtual model + // that dispatches into the Auto executor at spawn time. Only valid for + // the "active" role — compaction/title don't make sense as Auto. + let is_auto = crate::agent_bridge::auto::is_auto_mode(&provider_id, &model); + if !is_auto && provider_by_id(&app_state, &provider_id).is_none() { return Err(format!("Provider not found: {provider_id}")); } + if is_auto && role != "active" { + return Err(format!("Auto mode only applies to `active` role, not `{role}`")); + } let mut sel = read_selection(&app_state); let r = Some(ModelRef { provider_id, model }); match role.as_str() { diff --git a/apps/desktop/src-tauri/src/agent_bridge/db.rs b/apps/desktop/src-tauri/src/agent_bridge/db.rs index 57debb23..5157c94b 100644 --- a/apps/desktop/src-tauri/src/agent_bridge/db.rs +++ b/apps/desktop/src-tauri/src/agent_bridge/db.rs @@ -142,6 +142,20 @@ impl AgentDb { enabled INTEGER NOT NULL DEFAULT 1 ); + CREATE TABLE IF NOT EXISTS auto_runs ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + status TEXT NOT NULL, + plan_versions TEXT NOT NULL DEFAULT '[]', + worker_results TEXT NOT NULL DEFAULT '[]', + cost_cents INTEGER NOT NULL DEFAULT 0, + replans_used INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_auto_runs_session + ON auto_runs(session_id, updated_at DESC); + PRAGMA user_version = 1;", )?; @@ -654,6 +668,148 @@ impl AgentDb { let llm = serde_json::json!({"role": "system", "content": summary}).to_string(); self.insert_message(session_id, project_path, "system", "compaction", &llm, &metadata, None) } + + // ── Auto-mode runs ──────────────────────────────────────────────────── + + /// Insert a fresh `auto_runs` row when a new Auto task starts. + /// `plan_versions` and `worker_results` start as empty JSON arrays; the + /// executor calls `update_auto_run` after each phase to flush state. + pub fn insert_auto_run(&self, run: &agent::auto::AutoRun) -> Result<(), AgentDbError> { + let conn = self.conn.lock(); + let plan_versions = serde_json::to_string(&run.plan_versions)?; + let worker_results = serde_json::to_string(&run.worker_results)?; + let status = serde_json::to_string(&run.status)? + .trim_matches('"') + .to_string(); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO auto_runs + (id, session_id, status, plan_versions, worker_results, + cost_cents, replans_used, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + rusqlite::params![ + run.id, + run.session_id, + status, + plan_versions, + worker_results, + run.cost_cents as i64, + run.replans_used as i64, + now, + now, + ], + )?; + Ok(()) + } + + /// Overwrite an existing `auto_runs` row with the executor's latest snapshot. + /// Called after each plan / worker / replan / terminal transition so the + /// row mirrors the in-memory `AutoRun`. + pub fn update_auto_run(&self, run: &agent::auto::AutoRun) -> Result<(), AgentDbError> { + let conn = self.conn.lock(); + let plan_versions = serde_json::to_string(&run.plan_versions)?; + let worker_results = serde_json::to_string(&run.worker_results)?; + let status = serde_json::to_string(&run.status)? + .trim_matches('"') + .to_string(); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE auto_runs SET + status = ?, plan_versions = ?, worker_results = ?, + cost_cents = ?, replans_used = ?, updated_at = ? + WHERE id = ?", + rusqlite::params![ + status, + plan_versions, + worker_results, + run.cost_cents as i64, + run.replans_used as i64, + now, + run.id, + ], + )?; + Ok(()) + } + + /// Load a single `auto_runs` row by id. Returns `None` if missing. + pub fn get_auto_run(&self, id: &str) -> Result, AgentDbError> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare( + "SELECT id, session_id, status, plan_versions, worker_results, + cost_cents, replans_used + FROM auto_runs WHERE id = ?", + )?; + let mut rows = stmt.query(rusqlite::params![id])?; + if let Some(row) = rows.next()? { + let status_raw: String = row.get(2)?; + let plan_versions_raw: String = row.get(3)?; + let worker_results_raw: String = row.get(4)?; + // Status round-trip: serde wraps strings in quotes, so we add them + // back before deserializing the snake_case-renamed enum. + let status: agent::auto::AutoStatus = + serde_json::from_str(&format!("\"{}\"", status_raw))?; + let plan_versions = serde_json::from_str(&plan_versions_raw)?; + let worker_results = serde_json::from_str(&worker_results_raw)?; + Ok(Some(agent::auto::AutoRun { + id: row.get(0)?, + session_id: row.get(1)?, + status, + plan_versions, + worker_results, + cost_cents: row.get::<_, i64>(5)? as u32, + replans_used: row.get::<_, i64>(6)? as u32, + })) + } else { + Ok(None) + } + } + + /// List `auto_runs` rows for a session, most-recently-updated first. + /// Used by the UI to surface prior Auto invocations under a session. + pub fn list_auto_runs_for_session( + &self, + session_id: &str, + ) -> Result, AgentDbError> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare( + "SELECT id, session_id, status, plan_versions, worker_results, + cost_cents, replans_used + FROM auto_runs WHERE session_id = ? + ORDER BY updated_at DESC", + )?; + let rows = stmt.query_map(rusqlite::params![session_id], |row| { + // Defer serde to outside the closure; capture raw strings here so + // the closure stays rusqlite::Error-typed. + let status_raw: String = row.get(2)?; + let plan_versions_raw: String = row.get(3)?; + let worker_results_raw: String = row.get(4)?; + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + status_raw, + plan_versions_raw, + worker_results_raw, + row.get::<_, i64>(5)?, + row.get::<_, i64>(6)?, + )) + })?; + let mut out = Vec::new(); + for row in rows { + let (id, session_id, status_raw, plans_raw, results_raw, cost, replans) = row?; + let status: agent::auto::AutoStatus = + serde_json::from_str(&format!("\"{}\"", status_raw))?; + out.push(agent::auto::AutoRun { + id, + session_id, + status, + plan_versions: serde_json::from_str(&plans_raw)?, + worker_results: serde_json::from_str(&results_raw)?, + cost_cents: cost as u32, + replans_used: replans as u32, + }); + } + Ok(out) + } } // ── Row mappers ────────────────────────────────────────────────────────────── @@ -1130,4 +1286,142 @@ mod tests { assert_eq!(remaining.len(), 1); assert!(remaining[0].llm_message.contains("keep")); } + + // ── auto_runs (P4) ──────────────────────────────────────────────────── + + /// End-to-end CRUD on the `auto_runs` table. Pins: + /// - status enum round-trip (snake_case in DB, restored to variant) + /// - plan_versions + worker_results JSON columns survive non-trivial + /// nested payloads (Plan with workers, WorkerResult with status) + /// - update overwrites status / cost / replans correctly + /// - list_for_session ordering is by updated_at DESC + /// - get_auto_run returns None for unknown id + #[test] + fn test_auto_runs_crud_roundtrip() { + use agent::auto::{ + AutoRun, AutoStatus, Plan, SeePrior, SeePriorKeyword, WorkerResult, WorkerSpec, + WorkerStatus, + }; + + let (_d, db) = make_db(); + + let initial = AutoRun { + id: "auto-1".into(), + session_id: "sess-1".into(), + status: AutoStatus::Planning, + plan_versions: Vec::new(), + worker_results: Vec::new(), + cost_cents: 0, + replans_used: 0, + }; + db.insert_auto_run(&initial).unwrap(); + + // Fresh insert is readable. + let loaded = db.get_auto_run("auto-1").unwrap().expect("row should exist"); + assert_eq!(loaded.id, "auto-1"); + assert_eq!(loaded.session_id, "sess-1"); + assert_eq!(loaded.status, AutoStatus::Planning); + assert!(loaded.plan_versions.is_empty()); + assert!(loaded.worker_results.is_empty()); + + // Update with non-trivial nested payloads. + let updated = AutoRun { + id: "auto-1".into(), + session_id: "sess-1".into(), + status: AutoStatus::Done, + plan_versions: vec![Plan { + version: 1, + reasoning: "decompose into one cheap worker".into(), + workers: vec![WorkerSpec { + id: "w1".into(), + model: "claude-haiku-4-5".into(), + prompt: "read README".into(), + see_prior: SeePrior::Keyword(SeePriorKeyword::None), + }], + }], + worker_results: vec![WorkerResult { + id: "w1".into(), + model: "claude-haiku-4-5".into(), + prompt: "read README".into(), + summary: "README describes a Rust project".into(), + tool_count: 1, + cost_cents: 3, + status: WorkerStatus::Ok, + }], + cost_cents: 42, + replans_used: 1, + }; + db.update_auto_run(&updated).unwrap(); + + let reloaded = db.get_auto_run("auto-1").unwrap().unwrap(); + assert_eq!(reloaded.status, AutoStatus::Done); + assert_eq!(reloaded.cost_cents, 42); + assert_eq!(reloaded.replans_used, 1); + assert_eq!(reloaded.plan_versions.len(), 1); + let plan = &reloaded.plan_versions[0]; + assert_eq!(plan.version, 1); + assert_eq!(plan.workers.len(), 1); + assert_eq!(plan.workers[0].id, "w1"); + assert!(matches!(plan.workers[0].see_prior, SeePrior::Keyword(SeePriorKeyword::None))); + assert_eq!(reloaded.worker_results.len(), 1); + let result = &reloaded.worker_results[0]; + assert_eq!(result.summary, "README describes a Rust project"); + assert_eq!(result.status, WorkerStatus::Ok); + + // Unknown id returns None, not error. + assert!(db.get_auto_run("does-not-exist").unwrap().is_none()); + + // list_auto_runs_for_session returns this run; insert a second one for + // a different session to confirm filtering. + let other = AutoRun { + id: "auto-2".into(), + session_id: "sess-2".into(), + status: AutoStatus::Failed, + plan_versions: Vec::new(), + worker_results: Vec::new(), + cost_cents: 0, + replans_used: 0, + }; + db.insert_auto_run(&other).unwrap(); + let runs = db.list_auto_runs_for_session("sess-1").unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].id, "auto-1"); + let other_runs = db.list_auto_runs_for_session("sess-2").unwrap(); + assert_eq!(other_runs.len(), 1); + assert_eq!(other_runs[0].status, AutoStatus::Failed); + } + + /// Every `AutoStatus` variant must round-trip through the DB column. If + /// the snake_case serde tag drifts, this catches it before users see a + /// silent rename. + #[test] + fn test_auto_runs_status_all_variants_roundtrip() { + use agent::auto::{AutoRun, AutoStatus}; + let (_d, db) = make_db(); + for (i, status) in [ + AutoStatus::Planning, + AutoStatus::AwaitingApproval, + AutoStatus::Running, + AutoStatus::Done, + AutoStatus::Failed, + AutoStatus::Cancelled, + ] + .into_iter() + .enumerate() + { + let id = format!("auto-{i}"); + let run = AutoRun { + id: id.clone(), + session_id: "sess".into(), + status, + plan_versions: Vec::new(), + worker_results: Vec::new(), + cost_cents: 0, + replans_used: 0, + }; + db.insert_auto_run(&run).unwrap(); + let loaded = db.get_auto_run(&id).unwrap().unwrap(); + assert_eq!(loaded.status, status, "{status:?} did not round-trip"); + } + } } diff --git a/apps/desktop/src-tauri/src/agent_bridge/events.rs b/apps/desktop/src-tauri/src/agent_bridge/events.rs index 995ad2b5..50cd0f76 100644 --- a/apps/desktop/src-tauri/src/agent_bridge/events.rs +++ b/apps/desktop/src-tauri/src/agent_bridge/events.rs @@ -426,16 +426,134 @@ async fn relay_event( } // ── Auto mode events (PHASE_AUTO_MODE.md) ── - // Emitted by the executor (P3+). Frontend wiring lands in P6 — for - // now these are no-ops at the relay layer. Terminal variants - // (AutoDone / AutoFailed) terminate the loop just like Done. - AgentEvent::AutoPlanning { .. } - | AgentEvent::AutoPlan { .. } - | AgentEvent::AutoAwaitingApproval { .. } - | AgentEvent::AutoWorkerStart { .. } - | AgentEvent::AutoWorkerEnd { .. } - | AgentEvent::AutoReplan { .. } => false, - AgentEvent::AutoDone { .. } | AgentEvent::AutoFailed { .. } => true, + // Forward each to the frontend under `agent:auto_*` channels. The UI + // (P6) subscribes to these to render plan cards, worker cards, and the + // final result. Terminal variants (AutoDone / AutoFailed) terminate + // the relay loop just like the regular Done. + AgentEvent::AutoPlanning { run_id, .. } => { + emit_or_log( + ctx.emitter.as_ref(), + "agent:auto_planning", + serde_json::json!({ "thread_id": tid, "run_id": run_id }), + ); + false + } + AgentEvent::AutoPlan { + run_id, + version, + reasoning, + plan, + .. + } => { + emit_or_log( + ctx.emitter.as_ref(), + "agent:auto_plan", + serde_json::json!({ + "thread_id": tid, + "run_id": run_id, + "version": version, + "reasoning": reasoning, + "plan": plan, + }), + ); + false + } + AgentEvent::AutoAwaitingApproval { run_id, .. } => { + emit_or_log( + ctx.emitter.as_ref(), + "agent:auto_awaiting_approval", + serde_json::json!({ "thread_id": tid, "run_id": run_id }), + ); + false + } + AgentEvent::AutoWorkerStart { + run_id, + worker_id, + model, + prompt, + .. + } => { + emit_or_log( + ctx.emitter.as_ref(), + "agent:auto_worker_start", + serde_json::json!({ + "thread_id": tid, + "run_id": run_id, + "worker_id": worker_id, + "model": model, + "prompt": prompt, + }), + ); + false + } + AgentEvent::AutoWorkerEnd { + run_id, + worker_id, + summary, + cost_cents, + tool_count, + status, + .. + } => { + emit_or_log( + ctx.emitter.as_ref(), + "agent:auto_worker_end", + serde_json::json!({ + "thread_id": tid, + "run_id": run_id, + "worker_id": worker_id, + "summary": summary, + "cost_cents": cost_cents, + "tool_count": tool_count, + "status": status, + }), + ); + false + } + AgentEvent::AutoReplan { run_id, reason, .. } => { + emit_or_log( + ctx.emitter.as_ref(), + "agent:auto_replan", + serde_json::json!({ "thread_id": tid, "run_id": run_id, "reason": reason }), + ); + false + } + AgentEvent::AutoFailed { + run_id, + reason, + last_worker_output, + .. + } => { + emit_or_log( + ctx.emitter.as_ref(), + "agent:auto_failed", + serde_json::json!({ + "thread_id": tid, + "run_id": run_id, + "reason": reason, + "last_worker_output": last_worker_output, + }), + ); + true + } + AgentEvent::AutoDone { + run_id, + summary, + total_cost_cents, + .. + } => { + emit_or_log( + ctx.emitter.as_ref(), + "agent:auto_done", + serde_json::json!({ + "thread_id": tid, + "run_id": run_id, + "summary": summary, + "total_cost_cents": total_cost_cents, + }), + ); + true + } } } diff --git a/apps/desktop/src-tauri/src/agent_bridge/mod.rs b/apps/desktop/src-tauri/src/agent_bridge/mod.rs index 64adc3cd..8099c648 100644 --- a/apps/desktop/src-tauri/src/agent_bridge/mod.rs +++ b/apps/desktop/src-tauri/src/agent_bridge/mod.rs @@ -1,3 +1,4 @@ +pub mod auto; pub mod commands; pub mod db; pub mod events; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index eafc27a0..04dafb99 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -646,6 +646,11 @@ pub fn run() { agent_bridge::commands::agent_verify_provider, agent_bridge::commands::agent_resolve_model_capability, agent_bridge::commands::agent_list_models, + agent_bridge::auto::agent_get_auto_settings, + agent_bridge::auto::agent_set_auto_orchestrator_model, + agent_bridge::auto::agent_set_auto_worker_pool, + agent_bridge::auto::agent_set_auto_replan_budget, + agent_bridge::auto::agent_approve_auto_plan, agent_bridge::commands::agent_get_context_engine, agent_bridge::commands::agent_set_context_engine, agent_bridge::commands::agent_context_engine_status, From 6c648c2fcb5e042c86de8030a2f190751faac3fe Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Thu, 25 Jun 2026 11:09:51 +0530 Subject: [PATCH 08/14] feat(desktop): auto_enabled flag + enable/disable gating for Auto mode (P5a) --- .../src-tauri/src/agent_bridge/auto.rs | 129 +++++++++++++++++- apps/desktop/src-tauri/src/lib.rs | 2 + 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/agent_bridge/auto.rs b/apps/desktop/src-tauri/src/agent_bridge/auto.rs index d1dc47eb..17c68d90 100644 --- a/apps/desktop/src-tauri/src/agent_bridge/auto.rs +++ b/apps/desktop/src-tauri/src/agent_bridge/auto.rs @@ -56,10 +56,16 @@ pub const AUTO_SENTINEL_PROVIDER_ID: &str = "auto"; pub const AUTO_SENTINEL_MODEL: &str = "auto"; /// Settings DB keys. Same k-v table that holds `llm_selection`, -/// `llm_providers`, `context_engine`. Values are JSON strings. +/// `llm_providers`, `context_engine`. Values are JSON strings (or raw for +/// the bool/u32 keys). const AUTO_ORCHESTRATOR_KEY: &str = "auto_orchestrator_model"; const AUTO_WORKER_POOL_KEY: &str = "auto_worker_pool"; const AUTO_REPLAN_BUDGET_KEY: &str = "auto_replan_budget"; +/// Master switch. Auto entry is shown in the model picker iff this is true. +/// Prereqs (orchestrator picked + worker pool non-empty) are enforced by +/// `agent_set_auto_enabled` when flipping on, and by a live-session check +/// when flipping off (see `agent_list_sessions_using_auto`). +const AUTO_ENABLED_KEY: &str = "auto_enabled"; /// Default replan budget when the user has not configured it. /// Matches PHASE_AUTO_MODE.md "Replan budget" decision. @@ -72,11 +78,14 @@ pub fn is_auto_mode(provider_id: &str, model: &str) -> bool { // ── Settings DTO + persistence ─────────────────────────────────────────── -/// All three Auto settings in a single struct, returned by +/// All four Auto settings in a single struct, returned by /// `agent_get_auto_settings` for the Settings UI to consume. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct AutoSettings { + /// Master switch. `true` ⇒ the Auto entry appears in the model picker + /// and `run_auto_turn` accepts dispatches. `false` ⇒ both gates closed. + pub enabled: bool, /// User-picked orchestrator model. `None` when Auto mode is not yet /// configured — `run_auto_turn` rejects with a "not configured" error /// in that case. @@ -117,8 +126,19 @@ pub(crate) fn read_auto_replan_budget(app_state: &AppState) -> u32 { .unwrap_or(DEFAULT_REPLAN_BUDGET) } +pub(crate) fn read_auto_enabled(app_state: &AppState) -> bool { + app_state + .db + .get_setting(AUTO_ENABLED_KEY) + .ok() + .flatten() + .map(|raw| raw == "true") + .unwrap_or(false) +} + pub(crate) fn read_auto_settings(app_state: &AppState) -> AutoSettings { AutoSettings { + enabled: read_auto_enabled(app_state), orchestrator: read_auto_orchestrator(app_state), worker_pool: read_auto_worker_pool(app_state), replan_budget: read_auto_replan_budget(app_state), @@ -171,6 +191,74 @@ pub async fn agent_set_auto_replan_budget( app_state.db.set_setting(AUTO_REPLAN_BUDGET_KEY, &budget.to_string()) } +/// Flip the master Auto-enable switch. +/// - On ENABLE: prerequisites must be met (orchestrator picked + non-empty +/// worker pool). The Settings UI also gates the toggle, but this is the +/// authoritative check. +/// - On DISABLE: no session may currently have Auto as its active model. +/// The Settings UI surfaces the offending sessions in a modal; the user +/// must switch them off Auto before they can disable the feature. This +/// matches the locked design's "block disable" decision. +#[tauri::command] +pub async fn agent_set_auto_enabled( + enabled: bool, + app_state: State<'_, AppState>, + agent_state: State<'_, AgentState>, +) -> Result<(), String> { + if enabled { + // Prereqs: orchestrator picked + pool non-empty. + if read_auto_orchestrator(&app_state).is_none() { + return Err( + "Cannot enable Auto: pick an orchestrator model in Settings → Auto first.".into(), + ); + } + if read_auto_worker_pool(&app_state).is_empty() { + return Err( + "Cannot enable Auto: add at least one model to the worker pool first.".into(), + ); + } + } else { + // Block disable if any session still references Auto. + let blockers = list_sessions_using_auto_impl(&agent_state).await?; + if !blockers.is_empty() { + return Err(format!( + "Cannot disable Auto: {} session(s) still use it. Switch them to a regular model first.", + blockers.len() + )); + } + } + app_state + .db + .set_setting(AUTO_ENABLED_KEY, if enabled { "true" } else { "false" }) +} + +/// List sessions whose active model is the Auto sentinel. Used by the +/// Settings UI to populate the "switch these first" modal when the user +/// tries to disable Auto. Excludes soft-deleted sessions. +#[tauri::command] +pub async fn agent_list_sessions_using_auto( + agent_state: State<'_, AgentState>, +) -> Result, String> { + list_sessions_using_auto_impl(&agent_state).await +} + +async fn list_sessions_using_auto_impl( + agent_state: &AgentState, +) -> Result, String> { + let db = Arc::clone(&agent_state.db); + let all = tokio::task::spawn_blocking(move || db.list_sessions()) + .await + .map_err(|e| format!("join error: {e}"))? + .map_err(|e| format!("Failed to list sessions: {e}"))?; + Ok(all + .into_iter() + .filter(|s| match (s.provider_id.as_deref(), s.model.as_deref()) { + (Some(p), Some(m)) => is_auto_mode(p, m), + _ => false, + }) + .collect()) +} + // ── Plan approval ──────────────────────────────────────────────────────── /// Pending plan approvals keyed by `run_id`. Lives on `AgentState`; populated @@ -254,6 +342,11 @@ pub async fn run_auto_turn( ) -> Result<(), String> { // ── 1. Validate settings ── let settings = read_auto_settings(app_state); + if !settings.enabled { + return Err( + "Auto mode is disabled in Settings. Re-enable it, or pick a regular model.".into(), + ); + } let Some(orchestrator_ref) = settings.orchestrator else { return Err("Auto mode: orchestrator model not configured in Settings → Auto".into()); }; @@ -516,7 +609,7 @@ mod tests { // ── composite read_auto_settings ── #[test] - fn read_auto_settings_assembles_all_three_keys() { + fn read_auto_settings_assembles_all_four_keys() { let state = mk_app_state(); let model = ModelRef { provider_id: "anthropic".into(), @@ -536,11 +629,41 @@ mod tests { .set_setting(AUTO_WORKER_POOL_KEY, &serde_json::to_string(&pool).unwrap()) .unwrap(); state.db.set_setting(AUTO_REPLAN_BUDGET_KEY, "3").unwrap(); + state.db.set_setting(AUTO_ENABLED_KEY, "true").unwrap(); let s = read_auto_settings(&state); + assert!(s.enabled); assert!(s.orchestrator.is_some()); assert_eq!(s.orchestrator.unwrap().model, "claude-sonnet-4-6"); assert_eq!(s.worker_pool.len(), 1); assert_eq!(s.replan_budget, 3); } + + // ── enabled flag ── + + #[test] + fn enabled_defaults_false_when_unset() { + let state = mk_app_state(); + assert!(!read_auto_enabled(&state)); + assert!(!read_auto_settings(&state).enabled); + } + + #[test] + fn enabled_roundtrips_both_directions() { + let state = mk_app_state(); + state.db.set_setting(AUTO_ENABLED_KEY, "true").unwrap(); + assert!(read_auto_enabled(&state)); + state.db.set_setting(AUTO_ENABLED_KEY, "false").unwrap(); + assert!(!read_auto_enabled(&state)); + } + + #[test] + fn enabled_defaults_false_when_value_is_garbage() { + // Anything other than "true" → false. No partial-truth like "yes". + let state = mk_app_state(); + state.db.set_setting(AUTO_ENABLED_KEY, "yes").unwrap(); + assert!(!read_auto_enabled(&state)); + state.db.set_setting(AUTO_ENABLED_KEY, "1").unwrap(); + assert!(!read_auto_enabled(&state)); + } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 04dafb99..8eab2fad 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -650,6 +650,8 @@ pub fn run() { agent_bridge::auto::agent_set_auto_orchestrator_model, agent_bridge::auto::agent_set_auto_worker_pool, agent_bridge::auto::agent_set_auto_replan_budget, + agent_bridge::auto::agent_set_auto_enabled, + agent_bridge::auto::agent_list_sessions_using_auto, agent_bridge::auto::agent_approve_auto_plan, agent_bridge::commands::agent_get_context_engine, agent_bridge::commands::agent_set_context_engine, From 05cb0b75eacf47b4cc3c5886db93b41ccd06b8ae Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Tue, 30 Jun 2026 18:03:15 +0530 Subject: [PATCH 09/14] fix(markdown): wrap refractor highlight output in hast Root node refractor@3 returns Array; hast-util-to-jsx-runtime expects a Root. Without the wrap, fenced code blocks rendered as empty boxes while inline code worked fine. --- apps/desktop/src/components/common/Markdown/Markdown.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/components/common/Markdown/Markdown.tsx b/apps/desktop/src/components/common/Markdown/Markdown.tsx index 73f227af..4de07455 100644 --- a/apps/desktop/src/components/common/Markdown/Markdown.tsx +++ b/apps/desktop/src/components/common/Markdown/Markdown.tsx @@ -58,7 +58,9 @@ const LANG_ALIAS: Record = { function highlight(code: string, lang?: string): ReactNode { const name = lang ? (LANG_ALIAS[lang] ?? lang) : undefined; if (name && refractor.registered(name)) { - const tree = refractor.highlight(code, name) as unknown as JsxRuntimeTree; + // refractor v3 returns Array; hast-util-to-jsx-runtime needs a Root. + const children = refractor.highlight(code, name) as unknown as unknown[]; + const tree = { type: 'root', children } as unknown as JsxRuntimeTree; return toJsxRuntime(tree, { Fragment, jsx, jsxs }); } return code; From 6a9d40ded2ef4d3409770029a2694c2cf8d2dbaf Mon Sep 17 00:00:00 2001 From: Adithyan K Date: Tue, 30 Jun 2026 18:12:23 +0530 Subject: [PATCH 10/14] feat(auto): resume saved runs, cross-provider workers, pause on failure - P7 Resume: hydrate AutoRun panel from DB snapshot; approve/cancel awaiting_approval runs after restart via agent_resume_auto_turn. - P8 Cross-provider: worker pool dispatches each model to its own provider via build_worker_llm_configs (fail-fast on missing provider or duplicate model names). Removes the orchestrator-config-shared- with-workers v1 limitation. - P9a Capped LLM retries in workers (RetryConfig::auto_worker, max_retries=1) so transient endpoint failures surface fast. - P9b Replan budget setting removed (AUTO_REPLAN_BUDGET=0 hardcoded); user is the budget via P9c. - P9c Pause on hard worker failure: new AwaitingFailureDecision status + WorkerFailureContext + pending_failure DB column. Surfaces Retry / Replan / Cancel in an amber banner; survives restart via reaper skip. Cancel emits agent:auto_failed directly so the panel transitions without a reload. Plus: serialized DbAutoStateListener writes (race fix), tolerant plan parsing (stringified plan + missing see_prior default), tool list tail-5 + collapse on RunningWorkerCard. --- .../src-tauri/src/agent_bridge/auto.rs | 918 ++++++++++++++++-- .../src-tauri/src/agent_bridge/commands.rs | 40 +- apps/desktop/src-tauri/src/agent_bridge/db.rs | 144 ++- .../src-tauri/src/agent_bridge/events.rs | 44 + apps/desktop/src-tauri/src/lib.rs | 11 +- .../AgentThreadPanel/AgentThreadPanel.tsx | 28 +- .../agent/ModelPicker/ModelPicker.tsx | 70 +- .../src/components/auto/AddWorkerDialog.tsx | 160 +++ .../src/components/auto/AutoRunPanel.tsx | 612 ++++++++++++ .../components/auto/AutoSettingsSection.tsx | 354 +++++++ .../components/auto/DisableConfirmModal.tsx | 49 + .../src/components/auto/knownDescriptions.ts | 35 + apps/desktop/src/hooks/useAgentEvents.ts | 171 +++- apps/desktop/src/hooks/useAgentSend.ts | 24 +- apps/desktop/src/pages/Settings.tsx | 93 +- .../desktop/src/services/agentTauriService.ts | 50 + apps/desktop/src/store/agentSlice.ts | 396 +++++++- apps/desktop/src/types/agent.ts | 180 ++++ apps/desktop/src/utils/agentMessageAdapter.ts | 1 + crates/agent/src/agent/config.rs | 17 + crates/agent/src/auto/executor.rs | 362 +++++-- crates/agent/src/auto/mod.rs | 8 +- crates/agent/src/auto/orchestrator.rs | 133 ++- crates/agent/src/auto/schema.rs | 124 ++- crates/agent/src/auto/types.rs | 54 +- crates/agent/src/auto/worker.rs | 122 ++- crates/agent/src/llm/anthropic.rs | 2 +- crates/agent/src/llm/types.rs | 11 + crates/agent/src/tool/mod.rs | 1 + crates/agent/src/types.rs | 21 + crates/agent/tests/integration.rs | 47 +- 31 files changed, 4026 insertions(+), 256 deletions(-) create mode 100644 apps/desktop/src/components/auto/AddWorkerDialog.tsx create mode 100644 apps/desktop/src/components/auto/AutoRunPanel.tsx create mode 100644 apps/desktop/src/components/auto/AutoSettingsSection.tsx create mode 100644 apps/desktop/src/components/auto/DisableConfirmModal.tsx create mode 100644 apps/desktop/src/components/auto/knownDescriptions.ts diff --git a/apps/desktop/src-tauri/src/agent_bridge/auto.rs b/apps/desktop/src-tauri/src/agent_bridge/auto.rs index 17c68d90..90990bcb 100644 --- a/apps/desktop/src-tauri/src/agent_bridge/auto.rs +++ b/apps/desktop/src-tauri/src/agent_bridge/auto.rs @@ -16,12 +16,13 @@ //! `agent::auto::run_auto` on a task whose events drain through the same //! `spawn_event_relay` the regular path uses. //! -//! v1 limitation: all workers in the pool are dispatched through the -//! orchestrator's provider config (model name is overridden per worker, but -//! base_url + api_key + provider come from the orchestrator). Cross-provider -//! worker pools will work only if the orchestrator's endpoint accepts every -//! model name; otherwise the failing worker triggers a reactive replan. True -//! multi-provider dispatch is deferred to a future phase. +//! Cross-provider worker dispatch (P8): each `WorkerPoolEntry`'s `provider_id` +//! is resolved at run start by `build_worker_llm_configs` into a +//! `HashMap` that the executor's `WorkerContext` +//! holds. `worker::run_worker` looks up its config per spec — mixed-provider +//! pools (e.g. Anthropic orchestrator + nvidia/Nemotron worker via +//! OpenRouter) now route to the correct endpoint instead of all hitting the +//! orchestrator's API. use std::collections::HashMap; use std::path::PathBuf; @@ -29,15 +30,15 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use tokio::sync::{mpsc, oneshot}; use tokio_util::sync::CancellationToken; use uuid::Uuid; use agent::agent::config::{CompactionConfig, RetryConfig}; use agent::auto::{ - self, AutoResult, AutoRun, AutoStatus, ExecutorConfig, LiveWorkerRunner, LlmPlanGenerator, - Plan, PlanApprover, WorkerContext, WorkerPoolEntry, + self, AutoResult, AutoRun, AutoStateListener, AutoStatus, ExecutorConfig, LiveWorkerRunner, + LlmPlanGenerator, Plan, PlanApprover, WorkerContext, WorkerPoolEntry, }; use crate::AppState; @@ -60,16 +61,18 @@ pub const AUTO_SENTINEL_MODEL: &str = "auto"; /// the bool/u32 keys). const AUTO_ORCHESTRATOR_KEY: &str = "auto_orchestrator_model"; const AUTO_WORKER_POOL_KEY: &str = "auto_worker_pool"; -const AUTO_REPLAN_BUDGET_KEY: &str = "auto_replan_budget"; /// Master switch. Auto entry is shown in the model picker iff this is true. /// Prereqs (orchestrator picked + worker pool non-empty) are enforced by /// `agent_set_auto_enabled` when flipping on, and by a live-session check /// when flipping off (see `agent_list_sessions_using_auto`). const AUTO_ENABLED_KEY: &str = "auto_enabled"; -/// Default replan budget when the user has not configured it. -/// Matches PHASE_AUTO_MODE.md "Replan budget" decision. -pub const DEFAULT_REPLAN_BUDGET: u32 = 2; +/// Hardcoded ceiling on automatic replans inside the executor. Set to 0 +/// because P9c moves replan/retry control to the user (worker failure → +/// pause → user picks Retry / Replan / Cancel on the panel). The +/// executor's automatic replan loop is effectively disabled; this constant +/// is the safety value passed into `ExecutorConfig.replan_budget`. +pub const AUTO_REPLAN_BUDGET: u32 = 0; /// Returns true iff `(provider_id, model)` is the Auto sentinel. pub fn is_auto_mode(provider_id: &str, model: &str) -> bool { @@ -78,8 +81,9 @@ pub fn is_auto_mode(provider_id: &str, model: &str) -> bool { // ── Settings DTO + persistence ─────────────────────────────────────────── -/// All four Auto settings in a single struct, returned by -/// `agent_get_auto_settings` for the Settings UI to consume. +/// Auto settings returned by `agent_get_auto_settings` for the Settings UI +/// to consume. Replan budget removed in P9b — user is the budget now via +/// the Retry / Replan / Cancel buttons on the panel's failure banner. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct AutoSettings { @@ -93,8 +97,6 @@ pub struct AutoSettings { /// User-curated pool of worker models with per-entry descriptions. #[serde(default)] pub worker_pool: Vec, - /// Max reactive replans per task; total orchestrator calls = 1 + budget. - pub replan_budget: u32, } pub(crate) fn read_auto_orchestrator(app_state: &AppState) -> Option { @@ -116,16 +118,6 @@ pub(crate) fn read_auto_worker_pool(app_state: &AppState) -> Vec u32 { - app_state - .db - .get_setting(AUTO_REPLAN_BUDGET_KEY) - .ok() - .flatten() - .and_then(|raw| raw.parse::().ok()) - .unwrap_or(DEFAULT_REPLAN_BUDGET) -} - pub(crate) fn read_auto_enabled(app_state: &AppState) -> bool { app_state .db @@ -141,7 +133,186 @@ pub(crate) fn read_auto_settings(app_state: &AppState) -> AutoSettings { enabled: read_auto_enabled(app_state), orchestrator: read_auto_orchestrator(app_state), worker_pool: read_auto_worker_pool(app_state), - replan_budget: read_auto_replan_budget(app_state), + } +} + +// ── agent_messages anchor rows (P7) ────────────────────────────────────── +// +// Every Auto turn writes one user row + one placeholder assistant row to +// `agent_messages`, both tagged with `{auto_run_id, kind:"auto"}` metadata. +// The rich snapshot lives in the `auto_runs` table; these rows are the +// chat-thread anchors that let the UI render an AutoRunPanel inline at the +// right position, and that feed the next turn's LLM context with a plain +// summary string once the run terminates. + +fn build_llm_message_json(role: &str, content: &str) -> String { + serde_json::json!({ "role": role, "content": content }).to_string() +} + +fn build_auto_metadata_json(auto_run_id: &str) -> String { + serde_json::json!({ "auto_run_id": auto_run_id, "kind": "auto" }).to_string() +} + +/// Persist the user prompt + placeholder assistant row for an Auto turn. +/// Returns the assistant row's numeric id so the executor's terminal handler +/// can overwrite its `llm_message` with the final summary text. +async fn persist_auto_anchor_rows( + db: Arc, + session_id: String, + folder: String, + prompt: String, + auto_run_id: String, +) -> Result { + let metadata = build_auto_metadata_json(&auto_run_id); + + let user_llm = build_llm_message_json("user", &prompt); + let user_id = { + let db = Arc::clone(&db); + let sid = session_id.clone(); + let folder = folder.clone(); + let meta = metadata.clone(); + tokio::task::spawn_blocking(move || { + db.insert_message(&sid, &folder, "user", "text", &user_llm, &meta, None) + }) + .await + .map_err(|e| format!("join error: {e}"))? + .map_err(|e| format!("Failed to persist Auto user row: {e}"))? + }; + + let asst_llm = build_llm_message_json("assistant", "Auto run in progress…"); + let id_str = { + let db = Arc::clone(&db); + let sid = session_id.clone(); + let folder = folder.clone(); + let meta = metadata.clone(); + tokio::task::spawn_blocking(move || { + db.insert_message(&sid, &folder, "assistant", "text", &asst_llm, &meta, None) + }) + .await + .map_err(|e| format!("join error: {e}"))? + .map_err(|e| format!("Failed to persist Auto assistant row: {e}"))? + }; + let asst_id = id_str + .parse::() + .map_err(|e| format!("bad insert id: {e}"))?; + log::info!( + "[Auto-P7] persisted anchor rows: session={} run={} user_row={} assistant_row={}", + session_id, auto_run_id, user_id, asst_id + ); + Ok(asst_id) +} + +/// Build the per-worker LLM config map for an Auto run by snapshotting the +/// configured worker pool against the current provider settings. +/// +/// Each pool entry's `provider_id` is resolved to a `ProviderConfig`, which +/// feeds `provider_to_llm_config` along with the entry's `model`. The map is +/// keyed by model name (matching `WorkerSpec.model`), so the executor's +/// per-worker dispatch (`worker::run_worker`) is a single lookup. +/// +/// Fail-fast validation here surfaces config drift before any LLM call: +/// - Missing provider (user deleted it from Settings after adding to pool) +/// - Duplicate model name across different providers (ambiguous lookup — +/// `WorkerSpec` carries only `model`, so two pool entries with the same +/// model under different providers can't be told apart by the dispatcher). +fn build_worker_llm_configs( + app_state: &AppState, + pool: &[WorkerPoolEntry], +) -> Result, String> { + let mut map: HashMap = HashMap::new(); + for entry in pool { + if map.contains_key(&entry.model) { + return Err(format!( + "Auto mode: worker pool has duplicate model name `{}` across providers — \ + make pool entries' model names unique (the dispatcher looks up by model only)", + entry.model + )); + } + let provider = super::commands::provider_by_id_pub(app_state, &entry.provider_id) + .ok_or_else(|| { + format!( + "Auto mode: worker pool entry `{}` references missing provider id=`{}`. \ + Fix Settings → Auto (remove the worker or re-add the provider).", + entry.model, entry.provider_id + ) + })?; + let mut cfg = provider_to_llm_config(&provider, &entry.model); + // Workers are coding-mode agent loops that take many turns each; their + // child loops opt in to cache_control on a per-message basis. We don't + // pre-disable it here (let the per-provider LlmClient decide). + cfg.disable_cache_control = false; + map.insert(entry.model.clone(), cfg); + } + Ok(map) +} + +/// Flush incremental `AutoRun` snapshots to the `auto_runs` table whenever +/// the executor mutates state (plan added, worker finished, replan, status +/// flip). Without this, the DB row stays at the initial `Planning + empty` +/// state until the run terminates — and a mid-flight reload (or the app +/// being killed) leaves the panel staring at a stale "Orchestrator is +/// planning…" forever. +/// +/// **Ordering invariant**: notifies fire back-to-back (e.g. status=Planning +/// immediately followed by status=AwaitingApproval). Each `on_state` queues +/// a write onto a single-writer task via an unbounded mpsc, so writes land +/// in the exact order the executor produced them. Earlier fire-and-forget +/// spawning let later snapshots land *before* earlier ones — the older +/// state would clobber the newer one, the reaper would see stale +/// `planning` after restart, and the saved `awaiting_approval` got marked +/// failed on app start. mpsc serialization eliminates the race. +struct DbAutoStateListener { + tx: tokio::sync::mpsc::UnboundedSender, +} + +impl DbAutoStateListener { + fn new(db: Arc) -> Self { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + tokio::spawn(async move { + while let Some(run) = rx.recv().await { + let db = Arc::clone(&db); + let run_id = run.id.clone(); + match tokio::task::spawn_blocking(move || db.update_auto_run(&run)).await { + Err(e) => log::warn!("[Auto-P7] DbAutoStateListener join error: {e}"), + Ok(Err(e)) => log::warn!( + "[Auto-P7] DbAutoStateListener db write failed for run={run_id}: {e}" + ), + Ok(Ok(())) => {} + } + } + }); + Self { tx } + } +} + +impl AutoStateListener for DbAutoStateListener { + fn on_state(&self, run: &AutoRun) { + if let Err(e) = self.tx.send(run.clone()) { + log::warn!("[Auto-P7] DbAutoStateListener queue closed: {e}"); + } + } +} + +/// Overwrite the placeholder assistant row with the run's terminal summary. +/// Best-effort: logged and swallowed on failure (the user-facing chat row +/// stays at "Auto run in progress…" but the live event stream still updates +/// the panel itself, so the loss is purely textual continuity for the next +/// LLM turn). +async fn update_auto_assistant_summary( + db: Arc, + row_id: i64, + summary: &str, +) { + let llm = build_llm_message_json("assistant", summary); + let preview: String = summary.chars().take(80).collect(); + let res = tokio::task::spawn_blocking(move || db.update_message_llm_content(row_id, &llm)).await; + match res { + Ok(Ok(())) => log::info!( + "[Auto-P7] updated assistant row {} (len={}, preview={:?})", + row_id, summary.len(), preview + ), + Ok(Err(e)) => log::warn!("[Auto-P7] update_message_llm_content failed row={row_id}: {e}"), + Err(e) => log::warn!("[Auto-P7] update_message_llm_content join error row={row_id}: {e}"), } } @@ -177,20 +348,6 @@ pub async fn agent_set_auto_worker_pool( app_state.db.set_setting(AUTO_WORKER_POOL_KEY, &raw) } -#[tauri::command] -pub async fn agent_set_auto_replan_budget( - budget: u32, - app_state: State<'_, AppState>, -) -> Result<(), String> { - // Per PHASE_AUTO_MODE.md the Settings slider exposes range 0–5. - if budget > 5 { - return Err(format!( - "replan budget {budget} exceeds max (5); see PHASE_AUTO_MODE.md" - )); - } - app_state.db.set_setting(AUTO_REPLAN_BUDGET_KEY, &budget.to_string()) -} - /// Flip the master Auto-enable switch. /// - On ENABLE: prerequisites must be met (orchestrator picked + non-empty /// worker pool). The Settings UI also gates the toggle, but this is the @@ -270,6 +427,16 @@ pub fn new_pending_map() -> AutoApprovalPending { Arc::new(Mutex::new(HashMap::new())) } +/// Cancellation tokens for in-flight Auto runs, keyed by `session_id`. Only +/// one Auto run per session at a time (enforced by the folder lock), so the +/// session id is a sufficient key. `agent_cancel_session` looks here when +/// the stop button fires. +pub type AutoCancelRegistry = Arc>>; + +pub fn new_cancel_registry() -> AutoCancelRegistry { + Arc::new(Mutex::new(HashMap::new())) +} + /// `PlanApprover` impl that blocks on a `oneshot` channel keyed by `run_id`. /// The frontend resolves the wait via `agent_approve_auto_plan`. Mirrors the /// shape of `TauriApprovalHandler` in `events.rs`. @@ -299,23 +466,176 @@ impl PlanApprover for TauriPlanApprover { } } +/// Fetch a persisted `auto_runs` snapshot by `run_id`. The AutoRunPanel +/// calls this on mount to hydrate from disk when its in-memory state for +/// that run is empty (e.g. after a page reload, or when opening a session +/// that has prior Auto turns in its history). +#[tauri::command] +pub async fn agent_get_auto_run( + run_id: String, + agent_state: State<'_, AgentState>, +) -> Result, String> { + let db = Arc::clone(&agent_state.db); + let run_id_for_log = run_id.clone(); + let snapshot = tokio::task::spawn_blocking(move || db.get_auto_run(&run_id)) + .await + .map_err(|e| format!("join error: {e}"))? + .map_err(|e| format!("Failed to load auto_run: {e}"))?; + log::info!( + "[Auto-P7] agent_get_auto_run({}) -> {}", + run_id_for_log, + snapshot.as_ref().map(|s| format!("status={:?} workers={} plans={}", + s.status, s.worker_results.len(), s.plan_versions.len())).unwrap_or_else(|| "None".into()) + ); + Ok(snapshot) +} + /// Resolve a pending plan approval. Called by the frontend after the user /// clicks Run (`approved=true`) or Cancel (`approved=false`). +/// +/// Two paths: +/// 1. **Live executor** — a `TauriPlanApprover` is parked on a oneshot +/// keyed by `run_id`. Resolving it unblocks the executor. +/// 2. **Resume after restart** — no live oneshot exists because the prior +/// executor task died (app restart, crash). The saved plan is on disk +/// in `auto_runs.plan_versions`. On `approved=true` we spawn a fresh +/// executor seeded with the saved snapshot (no orchestrator re-call). +/// On `approved=false` we just mark the snapshot as failed. #[tauri::command] pub async fn agent_approve_auto_plan( + app_handle: AppHandle, run_id: String, approved: bool, + app_state: State<'_, AppState>, agent_state: State<'_, AgentState>, ) -> Result<(), String> { let sender = agent_state.auto_plan_pending.lock().unwrap().remove(&run_id); - match sender { - Some(tx) => { - let _ = tx.send(approved); + if let Some(tx) = sender { + let _ = tx.send(approved); + return Ok(()); + } + + // No live oneshot → resume path. + log::info!("[Auto-P7] approve fallback: no live oneshot for run={run_id}, treating as resume (approved={approved})"); + let snapshot = { + let db = Arc::clone(&agent_state.db); + let run_id_clone = run_id.clone(); + tokio::task::spawn_blocking(move || db.get_auto_run(&run_id_clone)) + .await + .map_err(|e| format!("join error: {e}"))? + .map_err(|e| format!("Failed to load auto_run: {e}"))? + .ok_or_else(|| format!("No saved Auto run with id={run_id}"))? + }; + if !matches!(snapshot.status, AutoStatus::AwaitingApproval) { + return Err(format!( + "Auto run id={run_id} is in status {:?}; resume is only allowed from awaiting_approval", + snapshot.status + )); + } + if !approved { + // User clicked Cancel on a stale awaiting — just mark failed. + let db = Arc::clone(&agent_state.db); + let mut snap = snapshot; + snap.status = AutoStatus::Failed; + let _ = tokio::task::spawn_blocking(move || db.update_auto_run(&snap)).await; + return Ok(()); + } + + // Spawn a fresh executor seeded with the saved snapshot. + resume_auto_turn(app_handle, &app_state, &agent_state, snapshot, ResumeAction::ApproveSavedPlan).await +} + +/// P9c: resolve a paused-for-failure Auto run with the user's chosen action. +/// The snapshot status must be `AwaitingFailureDecision`. Actions: +/// - `"retry"` → spawn a fresh executor that skips the orchestrator and +/// restarts the failed worker (via `ResumeAction::RetryWorker`). +/// - `"replan"` → spawn a fresh executor that calls the orchestrator with +/// the failure context as the replan reason +/// (via `ResumeAction::ReplanAfterFailure`). +/// - `"cancel"` → mark the run terminally Failed in the DB; no executor. +#[tauri::command] +pub async fn agent_resolve_worker_failure( + app_handle: AppHandle, + run_id: String, + action: String, + app_state: State<'_, AppState>, + agent_state: State<'_, AgentState>, +) -> Result<(), String> { + log::info!("[Auto-P7] agent_resolve_worker_failure run={run_id} action={action}"); + let snapshot = { + let db = Arc::clone(&agent_state.db); + let run_id_clone = run_id.clone(); + tokio::task::spawn_blocking(move || db.get_auto_run(&run_id_clone)) + .await + .map_err(|e| format!("join error: {e}"))? + .map_err(|e| format!("Failed to load auto_run: {e}"))? + .ok_or_else(|| format!("No saved Auto run with id={run_id}"))? + }; + if !matches!(snapshot.status, AutoStatus::AwaitingFailureDecision) { + return Err(format!( + "Auto run id={run_id} is in status {:?}; failure-resolve is only allowed from awaiting_failure_decision", + snapshot.status + )); + } + let failure = snapshot + .pending_failure + .as_ref() + .ok_or_else(|| format!("Auto run id={run_id} is awaiting_failure_decision but has no pending_failure context"))? + .clone(); + + match action.as_str() { + "cancel" => { + // 1. Flip the DB row to terminally Failed and clear the pause context. + let db = Arc::clone(&agent_state.db); + let session_id = snapshot.session_id.clone(); + let reason = format!( + "Cancelled by user after worker {} ({}) failed: {}", + failure.worker_id, failure.worker_model, failure.error_message + ); + let mut snap = snapshot; + snap.status = AutoStatus::Failed; + snap.pending_failure = None; + let _ = tokio::task::spawn_blocking(move || db.update_auto_run(&snap)).await; + + // 2. Emit AutoFailed so the frontend's existing handler runs: + // setAutoFailed flips the panel to the terminal red banner, and + // the post-event snapshot refetch sees pending_failure=null and + // doesn't re-hydrate back to amber. Without this emit, the panel + // sits on the stale amber banner until the session is reloaded. + if let Err(e) = app_handle.emit( + "agent:auto_failed", + serde_json::json!({ + "thread_id": session_id, + "run_id": run_id, + "reason": reason, + "last_worker_output": serde_json::Value::Null, + }), + ) { + log::warn!("[Auto-P9c] failed to emit agent:auto_failed on cancel: {e}"); + } Ok(()) } - None => Err(format!( - "No pending Auto-mode approval for run_id={run_id} (already resolved or expired)" - )), + "retry" => { + resume_auto_turn( + app_handle, + &app_state, + &agent_state, + snapshot, + ResumeAction::RetryWorker { worker_id: failure.worker_id }, + ) + .await + } + "replan" => { + resume_auto_turn( + app_handle, + &app_state, + &agent_state, + snapshot, + ResumeAction::ReplanAfterFailure { reason: failure.error_message }, + ) + .await + } + other => Err(format!("Unknown action `{other}`; expected one of: retry, replan, cancel")), } } @@ -340,6 +660,10 @@ pub async fn run_auto_turn( message: String, on_complete: impl FnOnce() + Send + 'static, ) -> Result<(), String> { + log::info!( + "[Auto-P7] run_auto_turn START session={} folder={} prompt_len={}", + session_id, folder, message.len() + ); // ── 1. Validate settings ── let settings = read_auto_settings(app_state); if !settings.enabled { @@ -361,15 +685,61 @@ pub async fn run_auto_turn( ) })?; - // ── 2. Build orchestrator + worker LLM configs ── + // ── 2. Build orchestrator + per-worker LLM configs ── let mut orchestrator_llm = provider_to_llm_config(&orchestrator_provider, &orchestrator_ref.model); // The orchestrator is a one-shot tool-use call — explicit cache_control // is meaningless for it and the auto::orchestrator IO layer doesn't // emit any cache markers anyway. orchestrator_llm.disable_cache_control = true; - // v1 limitation noted at the top of this file: workers all use the - // orchestrator's provider config; only the model field is overridden. - let worker_llm_base = orchestrator_llm.clone(); + // Snapshot the worker pool to a model→LlmClientConfig map. Failures here + // (missing provider, duplicate model) reject the whole run before any + // LLM call. See `build_worker_llm_configs` for the rules. + let worker_llm_configs = build_worker_llm_configs(app_state, &settings.worker_pool)?; + log::info!( + "[Auto-P7] built worker_llm_configs ({} models): {:?}", + worker_llm_configs.len(), + worker_llm_configs.keys().collect::>() + ); + + // ── 2b. Context engine pass-through ── + // When semantic search is ON in Settings, give every worker the + // codebase_search + codebase_graph tools (same wiring the regular + // run_agent_turn path does). Workers each spawn their own AgentLoop in + // Coding mode, which auto-registers those tools when + // `AgentConfig.context_engine` is set. + let ce_settings = super::commands::read_context_engine(app_state); + let ce_for_workers: Option> = + if ce_settings.enabled { + let cfg = agent::context_engine::ContextEngineConfig { + base_url: super::commands::normalize_base_url(&ce_settings.base_url), + user_id: "local".to_string(), + workspace_id: 0, + machine_id: super::commands::machine_id(app_state), + repo_path: folder.clone(), + auth_token: String::new(), + }; + Some(Arc::new(agent::context_engine::ContextEngineClient::new(cfg))) + } else { + None + }; + let ce_repo_path: Option = + if ce_settings.enabled { Some(PathBuf::from(&folder)) } else { None }; + + // Keep the index live for this repo while the Auto task runs. Idempotent + // — bumps last_used_at + triggers a fresh full_sync + starts the fs + // watcher if it wasn't already. Same pattern as run_agent_turn. + if ce_settings.enabled { + let wm = app_handle + .state::>() + .inner() + .clone(); + let folder_owned = folder.clone(); + tokio::spawn(async move { + if let Err(e) = wm.start_watching(&folder_owned).await { + log::warn!("[ContextWatcher] start_watching failed for {folder_owned}: {e}"); + } + }); + } // ── 3. Build event channel + relay ── // The relay runs in parallel with the executor; both end-of-Auto event @@ -397,15 +767,15 @@ pub async fn run_auto_turn( working_dir: work_dir.clone(), event_tx: event_tx.clone(), cancel_token: cancel.clone(), - llm_base_config: worker_llm_base, - retry_config: RetryConfig::default(), + worker_llm_configs, + retry_config: RetryConfig::auto_worker(), compaction_config: CompactionConfig::default(), compaction_llm: None, // Worker max_iter matches AgentConfig default per PHASE_AUTO_MODE.md. // 100 is the same ceiling a normal agent run gets. max_iterations: 100, - context_engine: None, - context_engine_repo_path: None, + context_engine: ce_for_workers, + context_engine_repo_path: ce_repo_path, persister: None, approval_handler: None, checkpoint_dir: None, @@ -420,14 +790,22 @@ pub async fn run_auto_turn( )); let exec_config = ExecutorConfig { - task: message, + task: message.clone(), auto_run_id: auto_run_id.clone(), session_id: session_id.clone(), worker_pool: settings.worker_pool, - replan_budget: settings.replan_budget, + replan_budget: AUTO_REPLAN_BUDGET, + resume_from: None, + start_at_worker_id: None, + initial_replan_reason: None, }; - // ── 6. Persist initial AutoRun row ── + // ── 6. Persist initial AutoRun row + agent_messages anchor pair ── + // The auto_runs row is the rich snapshot the AutoRunPanel hydrates from. + // The two agent_messages rows are the chat-thread anchors: the user row + // shows the prompt, and the placeholder assistant row gets overwritten + // with the terminal summary (so the next turn's LLM context sees a plain + // continuation, not a hole in history). let initial_run = AutoRun { id: auto_run_id.clone(), session_id: session_id.clone(), @@ -436,6 +814,8 @@ pub async fn run_auto_turn( worker_results: Vec::new(), cost_cents: 0, replans_used: 0, + current_worker: None, + pending_failure: None, }; let db = Arc::clone(&agent_state.db); let initial_run_for_db = initial_run.clone(); @@ -444,12 +824,64 @@ pub async fn run_auto_turn( .map_err(|e| format!("join error: {e}"))? .map_err(|e| format!("Failed to insert auto_run: {e}"))?; + let assistant_row_id = persist_auto_anchor_rows( + Arc::clone(&agent_state.db), + session_id.clone(), + folder.clone(), + message.clone(), + auto_run_id.clone(), + ) + .await?; + + // Register the cancellation token so `agent_cancel_session` can trigger + // it when the user hits the stop button. Removed in the terminal handler + // below regardless of how the run ends. + agent_state + .auto_cancellation + .lock() + .unwrap() + .insert(session_id.clone(), cancel.clone()); + log::info!("[Auto-P7] registered cancel token for session={}", session_id); + // ── 7. Spawn the executor task ── + // Keep a clone of event_tx for the terminal handler — `run_auto` consumes + // its own copy, so we need this clone to emit the synthetic AutoFailed + // event on user cancellation (executor::finalize_cancelled is silent by + // design — terminal cancel signalling is a Tauri-side concern). + let event_tx_for_cancel = event_tx.clone(); let db_for_task = Arc::clone(&agent_state.db); let pending_for_cancel = Arc::clone(&agent_state.auto_plan_pending); + let cancel_registry = Arc::clone(&agent_state.auto_cancellation); let auto_run_id_for_task = auto_run_id.clone(); + let session_id_for_task = session_id.clone(); + // Persist intermediate snapshots so reload during a long-running Auto + // turn shows the actual progress (plan, completed workers) instead of + // the initial Planning state. + let state_listener: Option> = Some(Arc::new(DbAutoStateListener::new( + Arc::clone(&agent_state.db), + ))); + tokio::spawn(async move { - let result = auto::run_auto(exec_config, event_tx, plan_gen, runner, approver, cancel).await; + let result = auto::run_auto( + exec_config, + event_tx, + plan_gen, + runner, + approver, + cancel, + state_listener, + ) + .await; + + log::info!( + "[Auto-P7] executor returned: run={} variant={}", + auto_run_id_for_task, + match &result { + AutoResult::Done { .. } => "Done", + AutoResult::Failed { .. } => "Failed", + AutoResult::Cancelled { .. } => "Cancelled", + } + ); // Persist terminal state. let final_run = match &result { @@ -458,11 +890,45 @@ pub async fn run_auto_turn( | AutoResult::Cancelled { run } => run.clone(), }; let _ = tokio::task::spawn_blocking({ + let db_for_run = Arc::clone(&db_for_task); let final_run = final_run.clone(); - move || db_for_task.update_auto_run(&final_run) + move || db_for_run.update_auto_run(&final_run) }) .await; + // Overwrite the placeholder assistant row with the terminal summary + // so the next LLM turn (Auto or normal) sees the run as a normal + // chat exchange instead of "Auto run in progress…". + let summary_text = match &result { + AutoResult::Done { summary, .. } => summary.clone(), + AutoResult::Failed { reason, .. } => format!("Auto run failed: {reason}"), + AutoResult::Cancelled { .. } => "Auto run interrupted by user".to_string(), + }; + update_auto_assistant_summary( + Arc::clone(&db_for_task), + assistant_row_id, + &summary_text, + ) + .await; + + // User-cancelled runs don't emit a terminal event from the executor. + // Synthesize an AutoFailed so the frontend's existing handler fires + // (stops the thinking placeholder, marks the panel terminal, clears + // approvals) — same UX as a regular session interrupt. + if matches!(&result, AutoResult::Cancelled { .. }) { + let _ = event_tx_for_cancel + .send(agent::types::AgentEvent::AutoFailed { + session_id: session_id_for_task.clone(), + run_id: auto_run_id_for_task.clone(), + reason: "Interrupted by user".to_string(), + last_worker_output: None, + }) + .await; + } + // Drop our clone so the relay's event_rx closes once executor's tx + // is also dropped — relay terminates cleanly. + drop(event_tx_for_cancel); + // Clean up any leftover pending approval (defensive — the executor // shouldn't terminate while still holding one, but a cancelled run // mid-approval would). @@ -470,16 +936,320 @@ pub async fn run_auto_turn( .lock() .unwrap() .remove(&auto_run_id_for_task); + // Drop the cancellation token registration. + cancel_registry + .lock() + .unwrap() + .remove(&session_id_for_task); // Await the relay so the final AutoDone/AutoFailed event has been - // emitted to the frontend before we release the folder lock. + // emitted to the frontend before we touch session state and release + // the folder lock — matches the ordering the regular agent path uses. let _ = relay_handle.await; + + // Flip session status idle so the sidebar's "running" badge clears. + // Regular run_agent_turn does this in its monitor task right after + // relay_handle.await; we mirror that here. + let _ = tokio::task::spawn_blocking({ + let db_for_status = Arc::clone(&db_for_task); + let sid = session_id_for_task.clone(); + move || db_for_status.set_session_status(&sid, "idle") + }) + .await; + on_complete(); }); Ok(()) } +/// Spawn a fresh executor that picks up where a saved `auto_runs` snapshot +/// left off. Used by `agent_approve_auto_plan`'s fallback path when a user +/// clicks Run on an `awaiting_approval` panel after the original executor +/// task is gone (app restart, crash). The saved plan is reused; the +/// orchestrator is NOT called on the first iteration. Replans inside the +/// resumed run go through the orchestrator normally. +/// +/// Duplicates most of `run_auto_turn`'s body for now — both should be +/// folded into a shared `spawn_auto_executor` helper in a follow-up. +/// What kind of resume this is — drives how the executor's first iteration +/// behaves. `ApproveSavedPlan` is the original P7 awaiting_approval resume; +/// the two `Retry…` and `Replan…` variants are P9c's user-driven worker +/// failure recovery. +#[derive(Debug, Clone)] +pub(crate) enum ResumeAction { + /// User clicked Run on the saved awaiting_approval plan. Executor uses + /// `plan_versions.last()` as-is and runs from worker[0] forward. + ApproveSavedPlan, + /// User clicked Retry on the awaiting_failure_decision banner. Executor + /// uses the saved plan; the worker loop skips ahead to `worker_id` + /// instead of starting at index 0. + RetryWorker { worker_id: String }, + /// User clicked Replan on the awaiting_failure_decision banner. Executor + /// invokes the orchestrator on iteration 1 with `reason` baked into the + /// replan prompt (saved plan ignored, prior worker results preserved). + ReplanAfterFailure { reason: String }, +} + +async fn resume_auto_turn( + app_handle: AppHandle, + app_state: &AppState, + agent_state: &AgentState, + snapshot: AutoRun, + action: ResumeAction, +) -> Result<(), String> { + let session_id = snapshot.session_id.clone(); + let auto_run_id = snapshot.id.clone(); + log::info!( + "[Auto-P7] resume_auto_turn START session={} run={} plans={} workers={} action={:?}", + session_id, auto_run_id, snapshot.plan_versions.len(), snapshot.worker_results.len(), action + ); + // Translate the high-level ResumeAction into the executor's lower-level + // start_at_worker_id / initial_replan_reason fields. + let (resume_start_at_worker_id, resume_initial_replan_reason) = match &action { + ResumeAction::ApproveSavedPlan => (None, None), + ResumeAction::RetryWorker { worker_id } => (Some(worker_id.clone()), None), + ResumeAction::ReplanAfterFailure { reason } => (None, Some(reason.clone())), + }; + + // ── 1. Look up session (folder) + chat anchors ── + let db = Arc::clone(&agent_state.db); + let session_row = { + let sid = session_id.clone(); + tokio::task::spawn_blocking(move || db.get_session(&sid)) + .await + .map_err(|e| format!("join error: {e}"))? + .map_err(|e| format!("Failed to load session: {e}"))? + .ok_or_else(|| format!("Session {session_id} not found"))? + }; + let folder = session_row.folder.clone(); + let db = Arc::clone(&agent_state.db); + let assistant_row_id = { + let run_id_clone = auto_run_id.clone(); + tokio::task::spawn_blocking(move || db.find_auto_assistant_row_id(&run_id_clone)) + .await + .map_err(|e| format!("join error: {e}"))? + .map_err(|e| format!("Failed to look up assistant row: {e}"))? + .ok_or_else(|| format!("No assistant anchor row for run_id={auto_run_id}"))? + }; + let db = Arc::clone(&agent_state.db); + let task = { + let run_id_clone = auto_run_id.clone(); + tokio::task::spawn_blocking(move || db.find_auto_user_prompt(&run_id_clone)) + .await + .map_err(|e| format!("join error: {e}"))? + .map_err(|e| format!("Failed to look up user prompt: {e}"))? + .ok_or_else(|| format!("No user prompt found for run_id={auto_run_id}"))? + }; + + // ── 2. Validate Auto settings still configured ── + let settings = read_auto_settings(app_state); + let Some(orchestrator_ref) = settings.orchestrator else { + return Err("Resume: orchestrator model not configured in Settings → Auto".into()); + }; + if settings.worker_pool.is_empty() { + return Err("Resume: worker pool is empty in Settings → Auto".into()); + } + let orchestrator_provider = super::commands::provider_by_id_pub(app_state, &orchestrator_ref.provider_id) + .ok_or_else(|| format!("Resume: orchestrator provider `{}` is not configured", orchestrator_ref.provider_id))?; + + // ── 3. Build LLM + context engine + worker context (same as run_auto_turn) ── + let mut orchestrator_llm = provider_to_llm_config(&orchestrator_provider, &orchestrator_ref.model); + orchestrator_llm.disable_cache_control = true; + let worker_llm_configs = build_worker_llm_configs(app_state, &settings.worker_pool)?; + log::info!( + "[Auto-P7] resume built worker_llm_configs ({} models): {:?}", + worker_llm_configs.len(), + worker_llm_configs.keys().collect::>() + ); + + let ce_settings = super::commands::read_context_engine(app_state); + let ce_for_workers: Option> = + if ce_settings.enabled { + let cfg = agent::context_engine::ContextEngineConfig { + base_url: super::commands::normalize_base_url(&ce_settings.base_url), + user_id: "local".to_string(), + workspace_id: 0, + machine_id: super::commands::machine_id(app_state), + repo_path: folder.clone(), + auth_token: String::new(), + }; + Some(Arc::new(agent::context_engine::ContextEngineClient::new(cfg))) + } else { None }; + let ce_repo_path: Option = + if ce_settings.enabled { Some(PathBuf::from(&folder)) } else { None }; + if ce_settings.enabled { + let wm = app_handle + .state::>() + .inner() + .clone(); + let folder_owned = folder.clone(); + tokio::spawn(async move { + if let Err(e) = wm.start_watching(&folder_owned).await { + log::warn!("[ContextWatcher] start_watching failed for {folder_owned}: {e}"); + } + }); + } + + // ── 4. Folder lock (one active session per folder) ── + { + let mut running = agent_state.running_folders.write().await; + if running.contains(&folder) { + return Err(format!("A session is already running for {folder}")); + } + running.insert(folder.clone()); + } + let running_for_release = Arc::clone(&agent_state.running_folders); + let folder_for_release = folder.clone(); + let release_folder = move || { + let running = running_for_release; + let folder = folder_for_release; + tokio::spawn(async move { + running.write().await.remove(&folder); + }); + }; + + // ── 5. Event channel + relay ── + let (event_tx, event_rx) = mpsc::channel::(256); + let emitter: Arc = Arc::new(TauriEventEmitter::new(app_handle.clone())); + let relay_handle = spawn_event_relay( + emitter, + event_rx, + session_id.clone(), + Some(Arc::clone(&agent_state.db)), + folder.clone(), + None, + ); + + // ── 6. Worker context + pipeline ── + let work_dir = PathBuf::from(&folder); + let cancel = CancellationToken::new(); + + let worker_ctx = WorkerContext { + session_id: session_id.clone(), + run_id: auto_run_id.clone(), + working_dir: work_dir.clone(), + event_tx: event_tx.clone(), + cancel_token: cancel.clone(), + worker_llm_configs, + retry_config: RetryConfig::auto_worker(), + compaction_config: CompactionConfig::default(), + compaction_llm: None, + max_iterations: 100, + context_engine: ce_for_workers, + context_engine_repo_path: ce_repo_path, + persister: None, + approval_handler: None, + checkpoint_dir: None, + }; + + let plan_gen = Arc::new(LlmPlanGenerator { llm: orchestrator_llm }); + let runner = Arc::new(LiveWorkerRunner { ctx: worker_ctx }); + // Resume = user already approved by clicking Run on the panel. + let approver: Arc = Arc::new(agent::auto::AutoApprove); + + let exec_config = ExecutorConfig { + task, + auto_run_id: auto_run_id.clone(), + session_id: session_id.clone(), + worker_pool: settings.worker_pool, + replan_budget: AUTO_REPLAN_BUDGET, + resume_from: Some(snapshot), + // Default resume = AwaitingApproval flow (user clicked Run on the + // saved plan). P9c overrides these when resuming after a worker + // failure: RetryWorker sets start_at_worker_id, ReplanAfterFailure + // sets initial_replan_reason. See `resume_auto_turn`'s callers. + start_at_worker_id: resume_start_at_worker_id, + initial_replan_reason: resume_initial_replan_reason, + }; + + // Register cancel token + state listener. + agent_state + .auto_cancellation + .lock() + .unwrap() + .insert(session_id.clone(), cancel.clone()); + let state_listener: Option> = Some(Arc::new(DbAutoStateListener::new( + Arc::clone(&agent_state.db), + ))); + + // ── 7. Spawn executor (terminal handler mirrors run_auto_turn) ── + let event_tx_for_cancel = event_tx.clone(); + let db_for_task = Arc::clone(&agent_state.db); + let pending_for_cancel = Arc::clone(&agent_state.auto_plan_pending); + let cancel_registry = Arc::clone(&agent_state.auto_cancellation); + let auto_run_id_for_task = auto_run_id.clone(); + let session_id_for_task = session_id.clone(); + + tokio::spawn(async move { + let result = auto::run_auto( + exec_config, event_tx, plan_gen, runner, approver, cancel, state_listener, + ) + .await; + log::info!( + "[Auto-P7] resume executor returned: run={} variant={}", + auto_run_id_for_task, + match &result { + AutoResult::Done { .. } => "Done", + AutoResult::Failed { .. } => "Failed", + AutoResult::Cancelled { .. } => "Cancelled", + } + ); + + let final_run = match &result { + AutoResult::Done { run, .. } + | AutoResult::Failed { run, .. } + | AutoResult::Cancelled { run } => run.clone(), + }; + let _ = tokio::task::spawn_blocking({ + let db = Arc::clone(&db_for_task); + let r = final_run.clone(); + move || db.update_auto_run(&r) + }) + .await; + + let summary_text = match &result { + AutoResult::Done { summary, .. } => summary.clone(), + AutoResult::Failed { reason, .. } => format!("Auto run failed: {reason}"), + AutoResult::Cancelled { .. } => "Auto run interrupted by user".to_string(), + }; + update_auto_assistant_summary( + Arc::clone(&db_for_task), + assistant_row_id, + &summary_text, + ) + .await; + + if matches!(&result, AutoResult::Cancelled { .. }) { + let _ = event_tx_for_cancel + .send(agent::types::AgentEvent::AutoFailed { + session_id: session_id_for_task.clone(), + run_id: auto_run_id_for_task.clone(), + reason: "Interrupted by user".to_string(), + last_worker_output: None, + }) + .await; + } + drop(event_tx_for_cancel); + + pending_for_cancel.lock().unwrap().remove(&auto_run_id_for_task); + cancel_registry.lock().unwrap().remove(&session_id_for_task); + + let _ = relay_handle.await; + + let _ = tokio::task::spawn_blocking({ + let db = Arc::clone(&db_for_task); + let sid = session_id_for_task.clone(); + move || db.set_session_status(&sid, "idle") + }) + .await; + + release_folder(); + }); + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -523,8 +1293,6 @@ mod tests { let s = read_auto_settings(&state); assert!(s.orchestrator.is_none()); assert!(s.worker_pool.is_empty()); - assert_eq!(s.replan_budget, DEFAULT_REPLAN_BUDGET); - assert_eq!(DEFAULT_REPLAN_BUDGET, 2, "default budget pinned by PHASE_AUTO_MODE.md"); } // ── orchestrator round-trip ── @@ -588,28 +1356,10 @@ mod tests { assert!(read_auto_worker_pool(&state).is_empty()); } - // ── replan budget round-trip ── - - #[test] - fn replan_budget_writes_then_reads_unchanged() { - let state = mk_app_state(); - state.db.set_setting(AUTO_REPLAN_BUDGET_KEY, "5").unwrap(); - assert_eq!(read_auto_replan_budget(&state), 5); - state.db.set_setting(AUTO_REPLAN_BUDGET_KEY, "0").unwrap(); - assert_eq!(read_auto_replan_budget(&state), 0); - } - - #[test] - fn replan_budget_defaults_when_unparseable() { - let state = mk_app_state(); - state.db.set_setting(AUTO_REPLAN_BUDGET_KEY, "garbage").unwrap(); - assert_eq!(read_auto_replan_budget(&state), DEFAULT_REPLAN_BUDGET); - } - // ── composite read_auto_settings ── #[test] - fn read_auto_settings_assembles_all_four_keys() { + fn read_auto_settings_assembles_all_keys() { let state = mk_app_state(); let model = ModelRef { provider_id: "anthropic".into(), @@ -628,7 +1378,6 @@ mod tests { .db .set_setting(AUTO_WORKER_POOL_KEY, &serde_json::to_string(&pool).unwrap()) .unwrap(); - state.db.set_setting(AUTO_REPLAN_BUDGET_KEY, "3").unwrap(); state.db.set_setting(AUTO_ENABLED_KEY, "true").unwrap(); let s = read_auto_settings(&state); @@ -636,7 +1385,6 @@ mod tests { assert!(s.orchestrator.is_some()); assert_eq!(s.orchestrator.unwrap().model, "claude-sonnet-4-6"); assert_eq!(s.worker_pool.len(), 1); - assert_eq!(s.replan_budget, 3); } // ── enabled flag ── diff --git a/apps/desktop/src-tauri/src/agent_bridge/commands.rs b/apps/desktop/src-tauri/src/agent_bridge/commands.rs index 128ab7d5..2ded88d0 100644 --- a/apps/desktop/src-tauri/src/agent_bridge/commands.rs +++ b/apps/desktop/src-tauri/src/agent_bridge/commands.rs @@ -41,6 +41,11 @@ pub struct AgentState { /// inserts a `oneshot::Sender` keyed by `run_id`; /// `agent_approve_auto_plan` drains it. See `agent_bridge::auto`. pub(crate) auto_plan_pending: crate::agent_bridge::auto::AutoApprovalPending, + /// Cancellation tokens for in-flight Auto runs, keyed by `session_id`. + /// `run_auto_turn` inserts when spawning the executor and removes on + /// completion; `agent_cancel_session` triggers it so the stop button + /// reaches the executor. + pub(crate) auto_cancellation: crate::agent_bridge::auto::AutoCancelRegistry, } impl AgentState { @@ -54,6 +59,7 @@ impl AgentState { model_registry: RwLock::new(agent::agent::model_profile::ModelRegistry::with_defaults()), write_lock_registry: Arc::new(agent::subagents::WriteLockRegistry::new()), auto_plan_pending: crate::agent_bridge::auto::new_pending_map(), + auto_cancellation: crate::agent_bridge::auto::new_cancel_registry(), } } @@ -373,13 +379,13 @@ pub(crate) fn read_context_engine_db(db: &crate::Database) -> ContextEngineSetti .unwrap_or_default() } -fn read_context_engine(app_state: &AppState) -> ContextEngineSettings { +pub(crate) fn read_context_engine(app_state: &AppState) -> ContextEngineSettings { read_context_engine_db(&app_state.db) } /// Stable per-machine UUID for context-engine tenancy headers. Generated and /// persisted to the settings DB on first use (no accounts in v1). -fn machine_id(app_state: &AppState) -> String { +pub(crate) fn machine_id(app_state: &AppState) -> String { if let Ok(Some(id)) = app_state.db.get_setting(MACHINE_ID_KEY) { if !id.is_empty() { return id; @@ -990,6 +996,11 @@ pub struct AgentDisplayMessage { pub duration_seconds: u32, /// Image data-URLs attached to this message (rebuilt from on-disk refs). pub images: Vec, + /// Present iff this row anchors an Auto-mode turn (P7). The frontend + /// swaps the normal text bubble for an `` when + /// this is set. Parsed from the row's `metadata.auto_run_id`. + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_run_id: Option, } /// Pull a short, human-friendly summary out of a tool call's JSON arguments. @@ -1618,7 +1629,10 @@ async fn run_agent_turn( Ok(()) } -/// Cancel a running session. +/// Cancel a running session. Triggers both the regular session-manager path +/// (no-op for sessions not registered there) and the Auto-mode cancellation +/// token (no-op for sessions not running an Auto turn) — the same stop +/// button drives both, so we don't need to know which one is active. #[tauri::command] pub async fn agent_cancel_session( session_id: String, @@ -1630,6 +1644,22 @@ pub async fn agent_cancel_session( manager.cancel_session(&session_id).await; } } + // Auto-mode: trigger the executor's CancellationToken if a run is in + // flight for this session. The executor checks `cancel.cancelled()` at + // every iteration boundary and exits via `finalize_cancelled`. + let auto_token = agent_state + .auto_cancellation + .lock() + .unwrap() + .get(&session_id) + .cloned(); + log::info!( + "[Auto-P7] agent_cancel_session session={} auto_token_present={}", + session_id, auto_token.is_some() + ); + if let Some(token) = auto_token { + token.cancel(); + } agent_state.approval_handlers.write().await.remove(&session_id); Ok(()) } @@ -1704,6 +1734,9 @@ pub async fn agent_get_messages( pending_started = None; (Vec::new(), 0) }; + let auto_run_id = serde_json::from_str::(&msg.metadata) + .ok() + .and_then(|v| v.get("auto_run_id").and_then(|x| x.as_str()).map(String::from)); out.push(AgentDisplayMessage { id: msg.id.to_string(), role: msg.role.clone(), @@ -1713,6 +1746,7 @@ pub async fn agent_get_messages( tools, duration_seconds, images, + auto_run_id, }); } Ok(out) diff --git a/apps/desktop/src-tauri/src/agent_bridge/db.rs b/apps/desktop/src-tauri/src/agent_bridge/db.rs index 5157c94b..9b3fca36 100644 --- a/apps/desktop/src-tauri/src/agent_bridge/db.rs +++ b/apps/desktop/src-tauri/src/agent_bridge/db.rs @@ -150,6 +150,8 @@ impl AgentDb { worker_results TEXT NOT NULL DEFAULT '[]', cost_cents INTEGER NOT NULL DEFAULT 0, replans_used INTEGER NOT NULL DEFAULT 0, + current_worker TEXT NOT NULL DEFAULT 'null', + pending_failure TEXT NOT NULL DEFAULT 'null', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -168,6 +170,28 @@ impl AgentDb { } } } + // Idempotent migration: `current_worker` lets a mid-flight reload + // show "Running: wN (model)" instead of just "status: running". + // Stored as JSON; literal 'null' means no worker in flight. + if let Err(e) = conn.execute( + "ALTER TABLE auto_runs ADD COLUMN current_worker TEXT NOT NULL DEFAULT 'null'", + [], + ) { + if !e.to_string().contains("duplicate column") { + return Err(e.into()); + } + } + // Idempotent migration: `pending_failure` carries the + // WorkerFailureContext for the user-driven Retry/Replan/Cancel + // banner (P9c). JSON; literal 'null' means no pending failure. + if let Err(e) = conn.execute( + "ALTER TABLE auto_runs ADD COLUMN pending_failure TEXT NOT NULL DEFAULT 'null'", + [], + ) { + if !e.to_string().contains("duplicate column") { + return Err(e.into()); + } + } Ok(Self { conn: Mutex::new(conn), @@ -548,6 +572,22 @@ impl AgentDb { Ok(conn.changes() as usize) } + /// Overwrite the `llm_message` JSON of an existing row. Used by the Auto + /// path to update a placeholder assistant row's content once the run + /// terminates (last worker summary, failure reason, or "cancelled"). + pub fn update_message_llm_content( + &self, + row_id: i64, + llm_message: &str, + ) -> Result<(), AgentDbError> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE agent_messages SET llm_message = ? WHERE id = ?", + rusqlite::params![llm_message, row_id], + )?; + Ok(()) + } + /// Soft-delete all messages with turn_count >= from_turn for a session. /// Used after a checkpoint restore so the conversation matches the files. pub fn rewind_from_turn( @@ -678,6 +718,8 @@ impl AgentDb { let conn = self.conn.lock(); let plan_versions = serde_json::to_string(&run.plan_versions)?; let worker_results = serde_json::to_string(&run.worker_results)?; + let current_worker = serde_json::to_string(&run.current_worker)?; + let pending_failure = serde_json::to_string(&run.pending_failure)?; let status = serde_json::to_string(&run.status)? .trim_matches('"') .to_string(); @@ -685,8 +727,9 @@ impl AgentDb { conn.execute( "INSERT INTO auto_runs (id, session_id, status, plan_versions, worker_results, - cost_cents, replans_used, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + cost_cents, replans_used, current_worker, pending_failure, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", rusqlite::params![ run.id, run.session_id, @@ -695,6 +738,8 @@ impl AgentDb { worker_results, run.cost_cents as i64, run.replans_used as i64, + current_worker, + pending_failure, now, now, ], @@ -709,6 +754,8 @@ impl AgentDb { let conn = self.conn.lock(); let plan_versions = serde_json::to_string(&run.plan_versions)?; let worker_results = serde_json::to_string(&run.worker_results)?; + let current_worker = serde_json::to_string(&run.current_worker)?; + let pending_failure = serde_json::to_string(&run.pending_failure)?; let status = serde_json::to_string(&run.status)? .trim_matches('"') .to_string(); @@ -716,7 +763,8 @@ impl AgentDb { conn.execute( "UPDATE auto_runs SET status = ?, plan_versions = ?, worker_results = ?, - cost_cents = ?, replans_used = ?, updated_at = ? + cost_cents = ?, replans_used = ?, current_worker = ?, + pending_failure = ?, updated_at = ? WHERE id = ?", rusqlite::params![ status, @@ -724,6 +772,8 @@ impl AgentDb { worker_results, run.cost_cents as i64, run.replans_used as i64, + current_worker, + pending_failure, now, run.id, ], @@ -736,7 +786,7 @@ impl AgentDb { let conn = self.conn.lock(); let mut stmt = conn.prepare( "SELECT id, session_id, status, plan_versions, worker_results, - cost_cents, replans_used + cost_cents, replans_used, current_worker, pending_failure FROM auto_runs WHERE id = ?", )?; let mut rows = stmt.query(rusqlite::params![id])?; @@ -744,12 +794,16 @@ impl AgentDb { let status_raw: String = row.get(2)?; let plan_versions_raw: String = row.get(3)?; let worker_results_raw: String = row.get(4)?; + let current_worker_raw: String = row.get(7)?; + let pending_failure_raw: String = row.get(8)?; // Status round-trip: serde wraps strings in quotes, so we add them // back before deserializing the snake_case-renamed enum. let status: agent::auto::AutoStatus = serde_json::from_str(&format!("\"{}\"", status_raw))?; let plan_versions = serde_json::from_str(&plan_versions_raw)?; let worker_results = serde_json::from_str(&worker_results_raw)?; + let current_worker = serde_json::from_str(¤t_worker_raw)?; + let pending_failure = serde_json::from_str(&pending_failure_raw)?; Ok(Some(agent::auto::AutoRun { id: row.get(0)?, session_id: row.get(1)?, @@ -758,12 +812,76 @@ impl AgentDb { worker_results, cost_cents: row.get::<_, i64>(5)? as u32, replans_used: row.get::<_, i64>(6)? as u32, + current_worker, + pending_failure, })) } else { Ok(None) } } + /// Mark zombie `auto_runs` rows as `failed` at app startup. Reaps rows + /// where there was an actively-executing task that died (planning = + /// orchestrator in flight, running = workers in flight). Deliberately + /// SKIPS `awaiting_approval` and `awaiting_failure_decision` — those + /// rows have no in-flight work and the resume path spawns a fresh + /// executor on the user's button click. Returns rows updated. + pub fn mark_zombie_auto_runs_failed(&self) -> Result { + let conn = self.conn.lock(); + let n = conn.execute( + "UPDATE auto_runs + SET status = 'failed', updated_at = ? + WHERE status IN ('planning', 'running')", + rusqlite::params![now_iso()], + )?; + Ok(n) + } + + /// Find the `agent_messages.id` of the placeholder assistant row that + /// anchors a given Auto run, used by the Resume path to reuse the + /// existing chat-thread anchor instead of creating duplicates. + pub fn find_auto_assistant_row_id(&self, auto_run_id: &str) -> Result, AgentDbError> { + let conn = self.conn.lock(); + let result = conn.query_row( + "SELECT id FROM agent_messages + WHERE role = 'assistant' + AND json_extract(metadata, '$.auto_run_id') = ? + AND rewound_at IS NULL + ORDER BY id ASC LIMIT 1", + [auto_run_id], + |row| row.get::<_, i64>(0), + ); + match result { + Ok(id) => Ok(Some(id)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Recover the original user prompt for an Auto run (the user row + /// `run_auto_turn` persisted at start). Needed by the Resume path so the + /// executor can call the orchestrator on replan with the same task. + pub fn find_auto_user_prompt(&self, auto_run_id: &str) -> Result, AgentDbError> { + let conn = self.conn.lock(); + let result = conn.query_row( + "SELECT llm_message FROM agent_messages + WHERE role = 'user' + AND json_extract(metadata, '$.auto_run_id') = ? + AND rewound_at IS NULL + ORDER BY id ASC LIMIT 1", + [auto_run_id], + |row| row.get::<_, String>(0), + ); + let llm_json = match result { + Ok(j) => j, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e.into()), + }; + // llm_message is `{"role":"user","content":""}`. + let v: serde_json::Value = serde_json::from_str(&llm_json)?; + Ok(v.get("content").and_then(|c| c.as_str()).map(String::from)) + } + /// List `auto_runs` rows for a session, most-recently-updated first. /// Used by the UI to surface prior Auto invocations under a session. pub fn list_auto_runs_for_session( @@ -773,7 +891,7 @@ impl AgentDb { let conn = self.conn.lock(); let mut stmt = conn.prepare( "SELECT id, session_id, status, plan_versions, worker_results, - cost_cents, replans_used + cost_cents, replans_used, current_worker, pending_failure FROM auto_runs WHERE session_id = ? ORDER BY updated_at DESC", )?; @@ -783,6 +901,8 @@ impl AgentDb { let status_raw: String = row.get(2)?; let plan_versions_raw: String = row.get(3)?; let worker_results_raw: String = row.get(4)?; + let current_worker_raw: String = row.get(7)?; + let pending_failure_raw: String = row.get(8)?; Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, @@ -791,11 +911,13 @@ impl AgentDb { worker_results_raw, row.get::<_, i64>(5)?, row.get::<_, i64>(6)?, + current_worker_raw, + pending_failure_raw, )) })?; let mut out = Vec::new(); for row in rows { - let (id, session_id, status_raw, plans_raw, results_raw, cost, replans) = row?; + let (id, session_id, status_raw, plans_raw, results_raw, cost, replans, current_raw, pending_raw) = row?; let status: agent::auto::AutoStatus = serde_json::from_str(&format!("\"{}\"", status_raw))?; out.push(agent::auto::AutoRun { @@ -806,6 +928,8 @@ impl AgentDb { worker_results: serde_json::from_str(&results_raw)?, cost_cents: cost as u32, replans_used: replans as u32, + current_worker: serde_json::from_str(¤t_raw)?, + pending_failure: serde_json::from_str(&pending_raw)?, }); } Ok(out) @@ -1313,6 +1437,8 @@ mod tests { worker_results: Vec::new(), cost_cents: 0, replans_used: 0, + current_worker: None, + pending_failure: None, }; db.insert_auto_run(&initial).unwrap(); @@ -1350,6 +1476,8 @@ mod tests { }], cost_cents: 42, replans_used: 1, + current_worker: None, + pending_failure: None, }; db.update_auto_run(&updated).unwrap(); @@ -1381,6 +1509,8 @@ mod tests { worker_results: Vec::new(), cost_cents: 0, replans_used: 0, + current_worker: None, + pending_failure: None, }; db.insert_auto_run(&other).unwrap(); let runs = db.list_auto_runs_for_session("sess-1").unwrap(); @@ -1418,6 +1548,8 @@ mod tests { worker_results: Vec::new(), cost_cents: 0, replans_used: 0, + current_worker: None, + pending_failure: None, }; db.insert_auto_run(&run).unwrap(); let loaded = db.get_auto_run(&id).unwrap().unwrap(); diff --git a/apps/desktop/src-tauri/src/agent_bridge/events.rs b/apps/desktop/src-tauri/src/agent_bridge/events.rs index 50cd0f76..b2da3328 100644 --- a/apps/desktop/src-tauri/src/agent_bridge/events.rs +++ b/apps/desktop/src-tauri/src/agent_bridge/events.rs @@ -510,6 +510,50 @@ async fn relay_event( ); false } + AgentEvent::AutoWorkerToolStart { + run_id, + worker_id, + tool_call_id, + tool_name, + args_summary, + .. + } => { + emit_or_log( + ctx.emitter.as_ref(), + "agent:auto_worker_tool_start", + serde_json::json!({ + "thread_id": tid, + "run_id": run_id, + "worker_id": worker_id, + "tool_call_id": tool_call_id, + "tool_name": tool_name, + "args_summary": args_summary, + }), + ); + false + } + AgentEvent::AutoWorkerToolEnd { + run_id, + worker_id, + tool_call_id, + success, + summary, + .. + } => { + emit_or_log( + ctx.emitter.as_ref(), + "agent:auto_worker_tool_end", + serde_json::json!({ + "thread_id": tid, + "run_id": run_id, + "worker_id": worker_id, + "tool_call_id": tool_call_id, + "success": success, + "summary": summary, + }), + ); + false + } AgentEvent::AutoReplan { run_id, reason, .. } => { emit_or_log( ctx.emitter.as_ref(), diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 8eab2fad..8c3d1254 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -575,6 +575,14 @@ pub fn run() { let data_dir = app_data_dir(); let agent_db = agent_bridge::db::AgentDb::new(&data_dir) .expect("Failed to create agent database"); + // Reap zombie Auto runs from a previous app session — any + // non-terminal row at startup has no live executor and would + // hydrate the panel into a spinning-forever state. + match agent_db.mark_zombie_auto_runs_failed() { + Ok(0) => {} + Ok(n) => log::info!("[Auto-P7] reaped {n} zombie auto_runs at startup"), + Err(e) => log::warn!("[Auto-P7] zombie auto_runs cleanup failed: {e}"), + } let agent_db_arc = Arc::new(agent_db); let checkpoint_root = data_dir.join("checkpoints"); @@ -649,10 +657,11 @@ pub fn run() { agent_bridge::auto::agent_get_auto_settings, agent_bridge::auto::agent_set_auto_orchestrator_model, agent_bridge::auto::agent_set_auto_worker_pool, - agent_bridge::auto::agent_set_auto_replan_budget, agent_bridge::auto::agent_set_auto_enabled, agent_bridge::auto::agent_list_sessions_using_auto, agent_bridge::auto::agent_approve_auto_plan, + agent_bridge::auto::agent_resolve_worker_failure, + agent_bridge::auto::agent_get_auto_run, agent_bridge::commands::agent_get_context_engine, agent_bridge::commands::agent_set_context_engine, agent_bridge::commands::agent_context_engine_status, diff --git a/apps/desktop/src/components/agent/AgentThreadPanel/AgentThreadPanel.tsx b/apps/desktop/src/components/agent/AgentThreadPanel/AgentThreadPanel.tsx index 1cbc6c7b..e8fb4c05 100644 --- a/apps/desktop/src/components/agent/AgentThreadPanel/AgentThreadPanel.tsx +++ b/apps/desktop/src/components/agent/AgentThreadPanel/AgentThreadPanel.tsx @@ -7,6 +7,7 @@ import ArtifactRenderer from "../artifacts/ArtifactRenderer"; import AgentInput from "../AgentInput/AgentInput"; import ApprovalBanner from "./ApprovalBanner"; import QuestionBanner from "./QuestionBanner"; +import AutoRunPanel from "@/components/auto/AutoRunPanel"; import TodoProgress from "./TodoProgress"; import PlanBanner from "./PlanBanner"; import RewindEditor from "./RewindEditor"; @@ -38,6 +39,21 @@ export default function AgentThreadPanel() { const streaming = useAppStore((s) => (activeAgentThreadId ? s.agentStreaming[activeAgentThreadId] : null)); const isLoadingThread = useAppStore((s) => (activeAgentThreadId ? !!s.agentThreadLoading[activeAgentThreadId] : false)); + // True iff any Auto run for this session is in flight — used to suppress + // the generic "Thinking…" placeholder, since the inline AutoRunPanel + // already shows live progress. + const hasInFlightAutoRun = useAppStore((s) => + activeAgentThreadId + ? Object.values(s.autoRuns).some( + (r) => + r.sessionId === activeAgentThreadId && + (r.status === 'planning' || + r.status === 'awaiting_approval' || + r.status === 'running' || + r.status === 'replanning'), + ) + : false, + ); const messagesEndRef = useRef(null); const thread = activeAgentThreadId ? agentThreads[activeAgentThreadId] : null; @@ -250,6 +266,16 @@ export default function AgentThreadPanel() { const isUserMsg = msg.role === "user"; const canRewind = isUserMsg && !streaming?.isStreaming && !isRewindingRef.current && Number.isFinite(Number(msg.id)); + // Auto-mode anchor row: swap the bubble for the inline panel, + // keyed by the auto_run_id carried on the persisted row. + if (msg.role === "agent" && msg.auto_run_id) { + return ( +
+ +
+ ); + } + return (
{isEditing ? ( @@ -302,7 +328,7 @@ export default function AgentThreadPanel() {
)} - {streaming?.isStreaming && !streaming.textBuffer && (() => { + {streaming?.isStreaming && !streaming.textBuffer && !hasInFlightAutoRun && (() => { const runningSubagent = streaming.toolCalls.find( (tc) => tc.status === "running" && tc.toolName.toLowerCase().startsWith("subagent:"), ); diff --git a/apps/desktop/src/components/agent/ModelPicker/ModelPicker.tsx b/apps/desktop/src/components/agent/ModelPicker/ModelPicker.tsx index f4685d30..83e54c6b 100644 --- a/apps/desktop/src/components/agent/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/components/agent/ModelPicker/ModelPicker.tsx @@ -1,10 +1,15 @@ -import { useEffect, useMemo, useCallback } from "react"; -import { Bot, ChevronDown } from "lucide-react"; +import { useEffect, useMemo, useCallback, useState } from "react"; +import { Bot, ChevronDown, Sparkles } from "lucide-react"; import { useAppStore } from "@/store"; +import { agentTauriService } from "@/services/agentTauriService"; import ActionChip from "@/components/common/ActionChip/ActionChip"; import CustomDropdown from "@/components/common/CustomDropdown/CustomDropdown"; import type { CustomDropdownItem } from "@/components/common/CustomDropdown/types"; -import type { ProviderConfig } from "@/types/agent"; +import { + AUTO_SENTINEL_MODEL, + AUTO_SENTINEL_PROVIDER_ID, + type ProviderConfig, +} from "@/types/agent"; /** Display name for a provider group: kind label, or the host for OpenAI-compatible. */ function providerName(p: ProviderConfig): string { @@ -35,10 +40,24 @@ export default function ModelPicker() { activeThreadId ? s.sessions.find((x) => x.id === activeThreadId) : undefined, ); + // Auto-mode availability is a Settings flag, not derived from providers. + // Re-fetched whenever the dropdown opens so a toggle change in Settings + // shows up without a full reload. + const [autoEnabled, setAutoEnabled] = useState(false); + useEffect(() => { if (!providersLoaded) loadProviders(); }, [providersLoaded, loadProviders]); + // Initial fetch of auto_enabled. Errors are silent (the picker just won't + // show Auto) — Settings is the place to surface them. + useEffect(() => { + agentTauriService + .getAutoSettings() + .then((s) => setAutoEnabled(s.enabled)) + .catch(() => setAutoEnabled(false)); + }, []); + // When a session opens, align the in-memory active model + vision gating to it. useEffect(() => { if (activeThreadId) syncPickerToSession(activeThreadId); @@ -53,11 +72,34 @@ export default function ModelPicker() { ); // Flatten providers → models as grouped dropdown items (header + model rows). + // When Auto is enabled in Settings, a magic ✨ entry sits at the top with a + // divider before the first regular provider. const dropdownItems: CustomDropdownItem[] = useMemo(() => { const items: CustomDropdownItem[] = []; + if (autoEnabled) { + items.push({ + key: `${AUTO_SENTINEL_PROVIDER_ID}::${AUTO_SENTINEL_MODEL}`, + label: "Auto", + // Sparkles sized + colored to match the Bot icon used by regular + // models (text-gray-500, w-4 h-4) — Auto is a peer of the other + // models in the picker, not a banner. The chip prefix when Auto is + // active uses the same muted color for consistency. + icon: , + onClick: () => + handleSelect(AUTO_SENTINEL_PROVIDER_ID, AUTO_SENTINEL_MODEL), + }); + } + let firstProviderHeader = true; for (const p of providers) { if (p.models.length === 0) continue; - items.push({ key: `hdr-${p.id}`, label: providerName(p), disabled: true }); + items.push({ + key: `hdr-${p.id}`, + label: providerName(p), + disabled: true, + // Visual break between the Auto entry and the regular providers. + dividerBefore: autoEnabled && firstProviderHeader, + }); + firstProviderHeader = false; for (const m of p.models) { items.push({ key: `${p.id}::${m}`, @@ -68,10 +110,16 @@ export default function ModelPicker() { } } return items; - }, [providers, handleSelect]); + }, [autoEnabled, providers, handleSelect]); - // Show the open session's model when one is open; otherwise the global default. - const displayLabel = openSession?.model ?? selection.active?.model ?? "Select model"; + // Active model resolution. Auto sentinel is shown as "✨ Auto" (we never + // want the literal sentinel string to leak into the chip). + const activeProviderId = openSession?.providerId ?? selection.active?.providerId; + const activeModel = openSession?.model ?? selection.active?.model; + const isAuto = + activeProviderId === AUTO_SENTINEL_PROVIDER_ID && + activeModel === AUTO_SENTINEL_MODEL; + const displayLabel = isAuto ? "Auto" : activeModel ?? "Select model"; return ( } + prefix={ + isAuto ? ( + + ) : ( + + ) + } suffix={} /> } diff --git a/apps/desktop/src/components/auto/AddWorkerDialog.tsx b/apps/desktop/src/components/auto/AddWorkerDialog.tsx new file mode 100644 index 00000000..be1a8139 --- /dev/null +++ b/apps/desktop/src/components/auto/AddWorkerDialog.tsx @@ -0,0 +1,160 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Modal, Select, Input, Button } from 'antd'; +import type { ProviderConfig, WorkerPoolEntry } from '@/types/agent'; +import { suggestedDescription } from './knownDescriptions'; + +interface Props { + open: boolean; + providers: ProviderConfig[]; + /** Existing pool — used to prevent adding a duplicate {provider, model}. */ + existingPool: WorkerPoolEntry[]; + onCancel: () => void; + onAdd: (entry: WorkerPoolEntry) => void; +} + +/** + * Modal for adding one worker to the pool. Constrained dropdowns by design: + * the user picks from already-configured providers, then from that provider's + * curated model list. This catches typos that would otherwise surface as + * runtime "model not found" failures during a real Auto task. + * + * On known models the description auto-fills from `KNOWN_MODEL_DESCRIPTIONS`; + * the user can edit before saving. + */ +export default function AddWorkerDialog({ + open, + providers, + existingPool, + onCancel, + onAdd, +}: Props) { + const [providerId, setProviderId] = useState(); + const [model, setModel] = useState(); + const [description, setDescription] = useState(''); + + // Reset state every time the modal opens so a previous edit doesn't bleed in. + useEffect(() => { + if (open) { + setProviderId(undefined); + setModel(undefined); + setDescription(''); + } + }, [open]); + + const providerOptions = useMemo( + () => + providers + // Only providers with at least one configured model are useful. + .filter((p) => p.models.length > 0) + .map((p) => ({ + value: p.id, + label: p.label?.trim() || p.kind || p.id, + })), + [providers], + ); + + const modelOptions = useMemo(() => { + const provider = providers.find((p) => p.id === providerId); + if (!provider) return []; + // Hide duplicates already in the pool (per-provider). + const alreadyInPool = new Set( + existingPool + .filter((e) => e.providerId === providerId) + .map((e) => e.model), + ); + return provider.models + .filter((m) => !alreadyInPool.has(m)) + .map((m) => ({ value: m, label: m })); + }, [providers, providerId, existingPool]); + + const handleModelChange = (m: string) => { + setModel(m); + // Pre-fill suggested description for known models; never clobber an + // edit the user has already typed. + if (description.trim() === '') { + setDescription(suggestedDescription(m)); + } + }; + + const canAdd = + !!providerId && !!model && description.trim().length > 0; + + const handleAdd = () => { + if (!providerId || !model) return; + onAdd({ providerId, model, description: description.trim() }); + }; + + return ( + + + + + } + destroyOnClose + > +
+
+ + +
+ +
+ + setDescription(e.target.value)} + placeholder="e.g. Fast and cheap; best for reads and simple writes." + autoSize={{ minRows: 2, maxRows: 5 }} + /> +
+ The orchestrator sees this when picking models. Keep it short and + capability-focused — when to use, what it's bad at. +
+
+
+
+ ); +} diff --git a/apps/desktop/src/components/auto/AutoRunPanel.tsx b/apps/desktop/src/components/auto/AutoRunPanel.tsx new file mode 100644 index 00000000..0c580a4a --- /dev/null +++ b/apps/desktop/src/components/auto/AutoRunPanel.tsx @@ -0,0 +1,612 @@ +import { useEffect, useState } from 'react'; +import { Button, message as toast } from 'antd'; +import { + AlertCircle, + CheckCircle2, + ChevronDown, + ChevronRight, + Loader2, + Play, + RotateCcw, + Sparkles, + X, + XCircle, +} from 'lucide-react'; +import { useAppStore } from '@/store'; +import { agentTauriService } from '@/services/agentTauriService'; +import Markdown from '@/components/common/Markdown/Markdown'; +import type { AutoToolActivity } from '@/store/agentSlice'; +import type { WorkerResult, WorkerSpec, WorkerStatus } from '@/types/agent'; + +interface Props { + runId: string; +} + +/** + * Renders the live state of one Auto-mode run, keyed by `runId`. Reads from + * `useAppStore().autoRuns[runId]`; if missing (page reload, opening a session + * with prior Auto turns), self-hydrates via `agent_get_auto_run` so the + * persisted snapshot fills the panel until live events take over. + * + * Lifecycle states the panel handles: + * - planning → "Planning…" spinner + * - awaiting_approval → plan card + Run / Cancel + * - running → plan card (collapsed) + worker cards streaming + * - replanning → replan banner + worker cards so far + * - done → done banner + collapsed worker cards + * - failed → failure banner + Retry + */ +export default function AutoRunPanel({ runId }: Props) { + const run = useAppStore((s) => s.autoRuns[runId]); + const hydrateAutoRun = useAppStore((s) => s.hydrateAutoRun); + const clearAutoRun = useAppStore((s) => s.clearAutoRun); + + // Lazy-hydrate from disk when state for this run isn't in memory yet. + // No-op on the slice side if it's already present (live events win). + useEffect(() => { + console.info('[Auto-P7] panel mount runId=%s hasState=%s', runId, !!run); + if (run) return; + let cancelled = false; + agentTauriService + .getAutoRun(runId) + .then((snapshot) => { + if (cancelled || !snapshot) { + console.info('[Auto-P7] panel hydrate runId=%s -> %s', runId, snapshot ? 'cancelled' : 'null snapshot'); + return; + } + console.info('[Auto-P7] panel hydrate runId=%s status=%s workers=%d plans=%d', + runId, snapshot.status, snapshot.worker_results.length, snapshot.plan_versions.length); + hydrateAutoRun(snapshot); + }) + .catch((e) => { + console.warn('[Auto-P7] panel hydrate failed runId=%s err=%o', runId, e); + }); + return () => { + cancelled = true; + }; + }, [runId, run, hydrateAutoRun]); + + if (!run) return null; + + return ( +
+ {/* Plan card — visible at all stages once a plan exists, collapsed + after approval to avoid taking too much vertical room. */} + {run.plan && ( + + )} + + {/* Status banners by lifecycle state. */} + {run.status === 'planning' && ( + } + tone="info" + text="Orchestrator is planning…" + /> + )} + {run.status === 'replanning' && ( + } + tone="warning" + text={`Replanning — ${run.replanReason ?? 'worker failed'}`} + /> + )} + + {/* Completed worker cards (collapsed by default). */} + {run.workerResults.map((result, idx) => ( + + ))} + + {/* Currently-running worker (no result yet). */} + {run.currentWorker && ( + + )} + + {/* Terminal banners. */} + {run.status === 'awaiting_failure_decision' && run.pendingFailure && ( + + )} + {run.status === 'failed' && ( + clearAutoRun(runId)} + sessionId={run.sessionId} + runId={runId} + /> + )} + {run.status === 'done' && ( + + )} +
+ ); +} + +// ── PendingFailureBanner ────────────────────────────────────────────────── +// P9c: when a worker hard-fails the executor pauses the run and surfaces +// this banner with three actions. Retry re-runs the same failed worker +// (cheapest; useful for transient infra errors). Replan kicks the +// orchestrator with the failure context (when the picked model just can't +// do it). Cancel marks the run terminally Failed. + +function PendingFailureBanner({ + runId, + failure, +}: { + runId: string; + failure: { worker_id: string; worker_model: string; error_message: string }; +}) { + const [submitting, setSubmitting] = useState<'retry' | 'replan' | 'cancel' | null>(null); + const dispatch = async (action: 'retry' | 'replan' | 'cancel') => { + if (submitting) return; + setSubmitting(action); + try { + await agentTauriService.resolveWorkerFailure(runId, action); + } catch (e) { + toast.error(`${action} failed: ${e}`); + setSubmitting(null); + } + // On success the new executor (or DB update) drives state from here; + // no need to clear `submitting` because the panel will re-render via + // events / hydrate. + }; + return ( +
+
+ +
+
+ Worker {failure.worker_id} ({failure.worker_model}) failed +
+
+ {failure.error_message} +
+
+
+
+ + + +
+
+ ); +} + +// ── PlanCard ────────────────────────────────────────────────────────────── + +interface PlanCardProps { + plan: { version: number; reasoning: string; workers: WorkerSpec[] }; + showActions: boolean; + collapsed: boolean; + runId: string; +} + +function PlanCard({ plan, showActions, collapsed, runId }: PlanCardProps) { + const [open, setOpen] = useState(!collapsed); + const [submitting, setSubmitting] = useState<'run' | 'cancel' | null>(null); + + const handle = async (approved: boolean) => { + setSubmitting(approved ? 'run' : 'cancel'); + try { + await agentTauriService.approveAutoPlan(runId, approved); + } catch (e) { + toast.error(`${e}`); + } finally { + setSubmitting(null); + } + }; + + return ( +
+ + {open && ( +
+

+ {plan.reasoning} +

+
    + {plan.workers.map((w, i) => ( +
  1. + + {i + 1}. + + + {w.id} + + · + + {w.model} + + + {w.prompt} + +
  2. + ))} +
+ {showActions && ( +
+ + +
+ )} +
+ )} +
+ ); +} + +// ── WorkerCard (completed) ──────────────────────────────────────────────── + +function WorkerCard({ result }: { result: WorkerResult }) { + const [open, setOpen] = useState(false); + const ok = result.status.kind === 'ok'; + const cancelled = result.status.kind === 'cancelled'; + const statusIcon = ok ? ( + + ) : cancelled ? ( + + ) : ( + + ); + + return ( +
+ + {open && ( +
+
+ Status: {statusLabel(result.status)} +
+ {result.summary} +
+ )} +
+ ); +} + +// ── RunningWorkerCard ───────────────────────────────────────────────────── + +const TOOL_TAIL_LIMIT = 5; + +function RunningWorkerCard({ + workerId, + model, + prompt, + tools, +}: { + workerId: string; + model: string; + prompt: string; + tools: AutoToolActivity[]; +}) { + // Default expanded — live progress is the whole point of this card while a + // worker is running. User can collapse to reclaim space. + const [open, setOpen] = useState(true); + // Only the tail is visible by default. A long-running worker can rack up + // dozens of tool calls; showing them all pushes the chat composer off-screen. + const [showAllTools, setShowAllTools] = useState(false); + + const hidden = Math.max(0, tools.length - TOOL_TAIL_LIMIT); + const visibleTools = + showAllTools || tools.length <= TOOL_TAIL_LIMIT + ? tools + : tools.slice(-TOOL_TAIL_LIMIT); + + return ( +
+ + + {open && tools.length > 0 && ( +
+ {hidden > 0 && !showAllTools && ( + + )} + {visibleTools.map((t) => ( + + ))} +
+ )} +
+ ); +} + +/** + * One row in the live tool list under the running worker. Status icon mirrors + * what completed worker cards use, with an in-flight spinner for running tools. + */ +function ToolActivityRow({ tool }: { tool: AutoToolActivity }) { + const icon = + tool.status === 'running' ? ( + + ) : tool.status === 'ok' ? ( + + ) : ( + + ); + return ( +
+ {icon} + {tool.toolName} + {(tool.summary || tool.argsSummary) && ( + + {tool.summary || tool.argsSummary} + + )} +
+ ); +} + +// ── Status / failure / done banners ─────────────────────────────────────── + +function StatusBanner({ + icon, + tone, + text, +}: { + icon: React.ReactNode; + tone: 'info' | 'warning'; + text: string; +}) { + const cls = + tone === 'warning' + ? 'border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20' + : 'border-[var(--border)] bg-[var(--bg-secondary)]'; + return ( +
+ {icon} + {text} +
+ ); +} + +function FailedBanner({ + reason, + lastWorkerOutput, + onDismiss, + sessionId, + runId, +}: { + reason: string; + lastWorkerOutput: string | null; + onDismiss: () => void; + sessionId: string; + runId: string; +}) { + // Look up the original user prompt from the chat thread — the user row + // tagged with this auto_run_id (written by run_auto_turn at run start) + // carries the prompt verbatim as its text. + const originalPrompt = useAppStore((s) => { + const thread = s.agentThreads[sessionId]; + if (!thread) return null; + const userMsg = thread.messages.find( + (m) => m.role === 'user' && m.auto_run_id === runId, + ); + return userMsg?.text ?? null; + }); + const [retrying, setRetrying] = useState(false); + + const handleRetry = async () => { + if (!originalPrompt || retrying) return; + setRetrying(true); + try { + // Re-send the same prompt → run_auto_turn dispatches a fresh + // executor with a new run_id. The original failed panel stays + // (Dismiss still works); the new run renders its own panel below. + // Note: the orchestrator runs again, so the new plan may differ. + await agentTauriService.sendMessage(sessionId, originalPrompt); + } catch (e) { + toast.error(`Retry failed: ${e}`); + } finally { + setRetrying(false); + } + }; + + return ( +
+
+ +
+
+ Auto mode failed +
+
+ {reason} +
+
+ {originalPrompt && ( + + )} + +
+ {lastWorkerOutput && ( +
+ Last worker output +
+            {lastWorkerOutput}
+          
+
+ )} +
+ ); +} + +function DoneBanner({ summary }: { summary: string }) { + return ( +
+ +
+
+ Auto mode complete +
+ {summary} +
+
+ ); +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +function statusLabel(status: WorkerStatus): string { + switch (status.kind) { + case 'ok': + return 'ok'; + case 'max_iterations': + return 'max iterations reached'; + case 'tool_error': + return `tool error: ${status.message}`; + case 'llm_error': + return `llm error: ${status.message}`; + case 'cancelled': + return 'cancelled'; + } +} + +function firstLine(s: string): string { + return s.split('\n', 1)[0]; +} diff --git a/apps/desktop/src/components/auto/AutoSettingsSection.tsx b/apps/desktop/src/components/auto/AutoSettingsSection.tsx new file mode 100644 index 00000000..34dcedb2 --- /dev/null +++ b/apps/desktop/src/components/auto/AutoSettingsSection.tsx @@ -0,0 +1,354 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Button, Select, Switch, Tooltip, message as toast } from 'antd'; +import { Plus, Sparkles, Trash2 } from 'lucide-react'; +import { agentTauriService } from '@/services/agentTauriService'; +import { useAppStore } from '@/store'; +import type { + AutoSettings, + ModelRef, + SessionRow, + WorkerPoolEntry, +} from '@/types/agent'; +import AddWorkerDialog from './AddWorkerDialog'; +import DisableConfirmModal from './DisableConfirmModal'; + +/** + * "Auto" Settings section. Sits at the end of the Settings page (after + * Semantic search) per the locked design — opt-in advanced feature. + * + * Gating: + * - Enable toggle is disabled (with a tooltip) unless: ≥1 provider with + * models exists AND orchestrator picked AND worker pool non-empty. + * - Disable is blocked if any session still has Auto as its active model + * (modal lists them and tells the user to switch first). + * + * Auto-fill: when a user adds a known model (e.g. claude-sonnet-4-6) to the + * worker pool, the description pre-fills with a sensible suggestion they can + * edit. See `knownDescriptions.ts`. + */ +export default function AutoSettingsSection() { + const providers = useAppStore((s) => s.providers); + const providersLoaded = useAppStore((s) => s.providersLoaded); + const loadProviders = useAppStore((s) => s.loadProviders); + + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [addOpen, setAddOpen] = useState(false); + const [blockingSessions, setBlockingSessions] = useState([]); + + // Initial load: settings + providers (the latter only if not already cached). + useEffect(() => { + if (!providersLoaded) loadProviders(); + agentTauriService + .getAutoSettings() + .then((s) => setSettings(s)) + .catch((e) => toast.error(`Failed to load Auto settings: ${e}`)) + .finally(() => setLoading(false)); + }, [providersLoaded, loadProviders]); + + // ── Derived: prereq state for the enable toggle ── + const hasProviderWithModels = providers.some((p) => p.models.length > 0); + const hasOrchestrator = !!settings?.orchestrator; + const hasWorkers = (settings?.workerPool.length ?? 0) > 0; + const prereqsMet = hasProviderWithModels && hasOrchestrator && hasWorkers; + const prereqHint = !hasProviderWithModels + ? 'Add a provider with at least one model first.' + : !hasOrchestrator + ? 'Pick an orchestrator model below first.' + : !hasWorkers + ? 'Add at least one model to the worker pool first.' + : ''; + + // ── Options for the orchestrator dropdown: flat list grouped by provider. ── + const orchestratorOptions = useMemo(() => { + return providers + .filter((p) => p.models.length > 0) + .map((p) => ({ + label: p.label?.trim() || p.kind, + options: p.models.map((m) => ({ + value: `${p.id}::${m}`, + label: m, + })), + })); + }, [providers]); + + const orchestratorValue = settings?.orchestrator + ? `${settings.orchestrator.providerId}::${settings.orchestrator.model}` + : undefined; + + // ── Handlers ── + + const handleToggleEnabled = async (checked: boolean) => { + if (!settings) return; + try { + await agentTauriService.setAutoEnabled(checked); + setSettings({ ...settings, enabled: checked }); + toast.success(checked ? 'Auto mode enabled' : 'Auto mode disabled'); + } catch (e) { + // Backend rejects disable when sessions still use Auto. Surface them + // in the modal so the user knows what to fix. + if (!checked) { + try { + const blockers = await agentTauriService.listSessionsUsingAuto(); + if (blockers.length > 0) { + setBlockingSessions(blockers); + return; + } + } catch { + /* fall through to the generic error */ + } + } + toast.error(`${e}`); + } + }; + + /** + * Try to flip Auto off because a prerequisite was just invalidated (worker + * pool emptied or orchestrator cleared). If the backend gate refuses + * because sessions still use Auto, leave it enabled and surface the + * blockers — same path as a manual disable attempt. + */ + const tryAutoDisable = async (reason: string) => { + try { + await agentTauriService.setAutoEnabled(false); + setSettings((prev) => (prev ? { ...prev, enabled: false } : prev)); + toast.warning(reason); + } catch { + try { + const blockers = await agentTauriService.listSessionsUsingAuto(); + if (blockers.length > 0) { + setBlockingSessions(blockers); + toast.warning(`${reason} Switch the listed sessions off Auto first to disable.`); + return; + } + } catch { + /* fall through */ + } + toast.warning(`${reason} Auto stays enabled — tasks will fail until you fix it.`); + } + }; + + const handleOrchestratorChange = async (value: string | undefined) => { + if (!settings) return; + const ref: ModelRef | null = value + ? (() => { + const [providerId, model] = value.split('::'); + return { providerId, model }; + })() + : null; + try { + await agentTauriService.setAutoOrchestratorModel(ref); + setSettings({ ...settings, orchestrator: ref }); + toast.success(ref ? `Orchestrator set to ${ref.model}` : 'Orchestrator cleared'); + // Clearing the orchestrator invalidates Auto — flip it off so the + // model picker drops the ✨ Auto entry and no future turn errors + // on dispatch. + if (!ref && settings.enabled) { + await tryAutoDisable('Orchestrator cleared — Auto disabled.'); + } + } catch (e) { + toast.error(`Failed to set orchestrator: ${e}`); + } + }; + + const handleAddWorker = async (entry: WorkerPoolEntry) => { + if (!settings) return; + const next = [...settings.workerPool, entry]; + try { + await agentTauriService.setAutoWorkerPool(next); + setSettings({ ...settings, workerPool: next }); + setAddOpen(false); + toast.success(`Added ${entry.model} to worker pool`); + } catch (e) { + toast.error(`Failed to save worker pool: ${e}`); + } + }; + + const handleRemoveWorker = async (idx: number) => { + if (!settings) return; + const removed = settings.workerPool[idx]; + const next = settings.workerPool.filter((_, i) => i !== idx); + try { + await agentTauriService.setAutoWorkerPool(next); + setSettings({ ...settings, workerPool: next }); + toast.success(`Removed ${removed.model} from worker pool`); + // Empty pool invalidates Auto — flip it off via the same gate the + // manual disable uses (will surface blocking sessions if any). + if (next.length === 0 && settings.enabled) { + await tryAutoDisable('Worker pool is empty — Auto disabled.'); + } + } catch (e) { + toast.error(`Failed to update worker pool: ${e}`); + } + }; + + const handleEditDescription = async (idx: number, description: string) => { + if (!settings) return; + // Skip save+toast when blur fires without an actual change (common when + // the user clicks the textarea and then clicks away). + if (settings.workerPool[idx]?.description === description) return; + const next = settings.workerPool.map((e, i) => + i === idx ? { ...e, description } : e, + ); + try { + await agentTauriService.setAutoWorkerPool(next); + setSettings({ ...settings, workerPool: next }); + toast.success(`Updated description for ${next[idx].model}`); + } catch (e) { + toast.error(`Failed to update description: ${e}`); + } + }; + + // ── Render ── + + if (loading || !settings) { + return ( +
+ Loading Auto settings… +
+ ); + } + + const enableSwitch = ( + + ); + + return ( + <> +
+

+ + Auto +

+ {prereqHint && !settings.enabled ? ( + + {enableSwitch} + + ) : ( + enableSwitch + )} +
+

+ Multi-model orchestration. An orchestrator LLM decomposes your request + into subtasks and dispatches each to the best worker model from your + configured pool. Power-user feature — keep disabled if you prefer + picking one model per session. +

+ +
+ {/* Step 1: Orchestrator model */} +
+
+ + + The model that plans the work. + +
+