diff --git a/.gitignore b/.gitignore index f45926a..c4df330 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ package.json package-lock.json .superpowers/ docs/lesson/ -.kilo/ \ No newline at end of file +.kilo/ +.hermes/ \ No newline at end of file diff --git a/apps/application/src/agent/explore.rs b/apps/application/src/agent/explore.rs deleted file mode 100644 index e7b2567..0000000 --- a/apps/application/src/agent/explore.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Mandatory explore phase — spawns parallel subagents to discover context -//! before the main agent begins its turn. -//! -//! # Flow -//! -//! Before the main agent's LLM loop, [`ExploreService::explore`] dispatches -//! at least 3 subagents in parallel (code-structure scan, symbol-index query, -//! semantic-context search). Their findings are consolidated into a single -//! system message that is prepended to the conversation. -//! -//! # Why mandatory -//! -//! Without structured exploration the main agent works from an empty context -//! window. The explore phase guarantees that every turn starts with a compact -//! snapshot of what the codebase contains and where relevant code lives. - -use anyhow::Result; -use std::collections::VecDeque; -use std::future::Future; -use std::pin::Pin; -use std::sync::{Arc, Mutex}; -use zesdex_domain::agent::TurnEvent; - -/// The consolidated output of an explore phase — a set of system-level -/// context messages injected before the main agent prompt. -#[derive(Debug, Clone)] -pub struct ExploreOutput { - /// One or more system messages summarising what the explore subagents - /// discovered. Prepended to the conversation by the turn service. - pub context_messages: Vec, - /// Short human-readable summary of what was explored. - pub summary: String, -} - -/// Service trait for the mandatory pre-turn exploration phase. -/// -/// Implementors spawn ≥3 parallel subagents, each analysing a different -/// aspect of the workspace, and return a consolidated summary. -/// -/// # Object safety -/// -/// This trait is `dyn`-safe — it returns `Pin>` so it can -/// be stored as `Arc`. -pub trait ExploreService: Send + Sync { - /// Run the explore phase. - /// - /// `query` — the user's current input phrase. - /// `workspace_root` — absolute path to the workspace root. - /// `turn_events` — shared event queue for TUI updates. - /// Returns structured context messages and a summary blob. - fn explore<'a>( - &'a self, - query: &'a str, - workspace_root: &'a str, - turn_events: &'a Arc>>, - ) -> Pin> + Send + 'a>>; -} diff --git a/apps/application/src/agent/mod.rs b/apps/application/src/agent/mod.rs index ece48c3..f606543 100644 --- a/apps/application/src/agent/mod.rs +++ b/apps/application/src/agent/mod.rs @@ -19,8 +19,6 @@ pub trait AgentTurnService: Send + Sync { fn run_turn(&self, params: AgentTurnParams) -> impl Future> + Send; } -pub mod explore; pub mod turn_service; -pub use explore::{ExploreOutput, ExploreService}; pub use turn_service::{compact_messages_with_ai, AgentTurnServiceImpl}; diff --git a/apps/application/src/agent/turn_service.rs b/apps/application/src/agent/turn_service.rs index ab42708..5c3de56 100644 --- a/apps/application/src/agent/turn_service.rs +++ b/apps/application/src/agent/turn_service.rs @@ -7,12 +7,29 @@ use zesdex_domain::agent::{AgentTurnParams, TurnEvent}; use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef}; use zesdex_domain::main_agent_prompt; -use super::{ExploreService, ToolExecutor}; +use super::ToolExecutor; use crate::ports::ProviderService; /// Maximum tool-call iterations per agent turn before forcing termination. const MAX_TURN_ITERATIONS: u32 = 50; +/// Maximum number of consecutive identical tool errors before the loop +/// injects a recovery note and forces a different approach. +const MAX_CONSECUTIVE_TOOL_ERRORS: usize = 3; + +/// Total tool-call errors tolerated per turn before the loop is stopped. +const MAX_TOTAL_TOOL_ERRORS: usize = 8; + +/// Ceiling for a single tool-result message inserted into context. +/// +/// Tool outputs can be huge (read / semantic_search). Truncating keeps the +/// context window from exploding while preserving the important head. +const TOOL_OUTPUT_MAX_CHARS: usize = 12_000; + +/// Total conversation characters that trigger auto-compaction before the +/// next LLM call. +const AUTO_COMPACT_CHARS: usize = 60_000; + // --------------------------------------------------------------------------- // Helper: push a TurnEvent onto the shared queue. // --------------------------------------------------------------------------- @@ -51,6 +68,89 @@ fn make_stream_callback( }) } +// --------------------------------------------------------------------------- +// Helper: truncate a long tool output before it enters the conversation +// context. Preserves the head and appends a clear truncation marker. +// --------------------------------------------------------------------------- + +fn truncate_tool_output(output: String) -> String { + if output.len() <= TOOL_OUTPUT_MAX_CHARS { + return output; + } + let mut result: String = output.chars().take(TOOL_OUTPUT_MAX_CHARS).collect(); + result.push_str(&format!( + "\n...[truncated {} chars]", + output.len() - TOOL_OUTPUT_MAX_CHARS + )); + result +} + +// --------------------------------------------------------------------------- +// Helper: adaptive generation parameters. +// --------------------------------------------------------------------------- + +/// Pick a `max_tokens` budget for the turn's next LLM call based on the +/// length of the user's request. Short requests need far fewer tokens than +/// the current hardcoded 4096 — big savings on small tasks. +fn adaptive_max_tokens(request_len: usize) -> u32 { + if request_len <= 80 { + 800 + } else if request_len <= 400 { + 1600 + } else { + 4096 + } +} + +/// Sum the character length of the conversation (user + assistant + +/// tool content) as a cheap proxy for context size. +fn conversation_chars(messages: &[ChatMessage]) -> usize { + messages + .iter() + .map(|m| m.content.as_deref().map(str::len).unwrap_or(0)) + .sum() +} + +/// Track repeated tool-call errors so the loop can recover instead of +/// burning iterations retrying the same failing tool. +#[derive(Default)] +struct ErrorTracker { + consecutive: usize, + total: usize, + last_tool: String, + last_error: String, +} + +impl ErrorTracker { + fn record(&mut self, tool_name: &str, error: &str, messages: &mut Vec) { + if self.last_tool == tool_name { + self.consecutive += 1; + } else { + self.consecutive = 1; + } + self.last_tool = tool_name.to_string(); + self.last_error = error.to_string(); + self.total += 1; + + // Inject a recovery note once the same tool keeps failing. + if self.consecutive >= MAX_CONSECUTIVE_TOOL_ERRORS + && !messages.iter().any(|m| { + m.content + .as_deref() + .is_some_and(|c| c.contains("[System note]")) + }) + { + messages.push(ChatMessage::system( + zesdex_domain::agent::prompt::error_recovery_note(tool_name, error), + )); + } + } + + fn should_stop(&self) -> bool { + self.consecutive >= MAX_CONSECUTIVE_TOOL_ERRORS * 2 || self.total >= MAX_TOTAL_TOOL_ERRORS + } +} + // --------------------------------------------------------------------------- // Helper: execute a single tool call, push events, return the result string. // --------------------------------------------------------------------------- @@ -71,6 +171,7 @@ async fn execute_tool_call( }; let is_error = output.starts_with("Error:"); + let output = truncate_tool_output(output); push_event( turn_events, @@ -108,19 +209,19 @@ fn emit_usage(turn_events: &Arc>>, usage: Option<(u64, /// Service implementation for executing an agent turn asynchronously. /// -/// # Explore phase -/// -/// Before the main LLM loop begins, [`AgentTurnServiceImpl`] runs a mandatory -/// explore phase that spawns ≥3 parallel subagents (code structure, symbol -/// index, semantic context) and injects their consolidated findings as a -/// system message. See [`ExploreService`] for the trait contract. +/// The turn loop is adaptive and token-aware: +/// - No mandatory explore phase — the *agent* decides when to call the +/// `explore_codebase` tool (see the main prompt), so simple queries skip +/// exploration entirely. +/// - `max_tokens` / `temperature` adapt to the request length and phase. +/// - Repeated tool errors trigger a system recovery note and eventually +/// stop the loop instead of burning iterations. +/// - Tool outputs are truncated before entering context. +/// - Oversized histories are auto-compacted before the next LLM call. pub struct AgentTurnServiceImpl { provider: Arc

, tool_executor: Arc, tool_defs: Vec, - /// Optional explore-phase service. When `Some`, the explore phase runs - /// before every turn; when `None` it is skipped (tests, daemon mode). - explore_service: Option>, } impl AgentTurnServiceImpl { @@ -129,19 +230,9 @@ impl AgentTurnServiceImpl { provider, tool_executor, tool_defs, - explore_service: None, } } - /// Attach an optional explore-phase service. - /// - /// When set, every call to `run_turn` will first run the explore phase - /// and inject the consolidated context as a system message. - pub fn with_explore(mut self, service: Arc) -> Self { - self.explore_service = Some(service); - self - } - /// Execute a single LLM call with the current message list, handling /// streaming events and error reporting. async fn call_llm( @@ -149,6 +240,8 @@ impl AgentTurnServiceImpl { messages: &[ChatMessage], abort: &Arc, turn_events: &Arc>>, + max_tokens: u32, + temperature: f32, ) -> Result<(ChatMessage, Option<(u64, u64)>), String> { let on_event = make_stream_callback(abort, turn_events); @@ -156,13 +249,39 @@ impl AgentTurnServiceImpl { .chat_stream( messages, Some(self.tool_defs.clone()), - Some(4096), - Some(0.7), + Some(max_tokens), + Some(temperature), on_event, ) .await .map_err(|e| format!("LLM error: {e}")) } + + /// Auto-compact the history in place if it exceeds the threshold. + /// + /// Runs at most once per turn. Skips the synthetic system prompt that + /// this service inserts at index 0. + async fn auto_compact_if_needed(&self, messages: &mut Vec) { + if conversation_chars(messages) <= AUTO_COMPACT_CHARS { + return; + } + // Keep the system prompt (index 0) out of compaction. + let sys = messages[0].clone(); + let mut rest: Vec = messages.drain(1..).collect(); + let before = rest.len(); + if let Err(e) = super::compact_messages_with_ai(&mut rest, self.provider.as_ref()).await { + warn!("auto-compact failed (non-fatal): {e}"); + } + info!( + "auto-compacted history: {} messages -> {}", + before, + rest.len() + ); + let mut rebuilt = Vec::with_capacity(rest.len() + 1); + rebuilt.push(sys); + rebuilt.extend(rest); + *messages = rebuilt; + } } impl super::AgentTurnService for AgentTurnServiceImpl { @@ -173,73 +292,26 @@ impl super::AgentTurnService for AgentTurnS params.model ); - // ── Phase 0: Mandatory explore ────────────────────────────────── - // Spawn ≥3 parallel subagents to discover code structure, symbols, - // and semantic context. The consolidated summary is injected as a - // system message before the main agent prompt. - if let Some(ref explorer) = self.explore_service { - // Determine workspace root from the first message's context or - // the first workspace root in params. - let user_query = params - .messages - .last() - .map(|m| m.content.clone().unwrap_or_default()) - .unwrap_or_default(); - let workspace_root = params - .workspace_roots - .first() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| ".".to_string()); - - push_event( - ¶ms.turn_events, - TurnEvent::SystemNote { - kind: "info".into(), - message: "🔍 Exploring codebase structure...".into(), - }, - ); - - match explorer - .explore(&user_query, &workspace_root, ¶ms.turn_events) - .await - { - Ok(output) => { - // Insert each context message as a system message. - // They go at index 0 and are removed after the turn - // like the main agent prompt. - for ctx_msg in &output.context_messages { - params - .messages - .insert(0, ChatMessage::system(ctx_msg.clone())); - } - info!( - "Explore phase complete: {} context messages, {}", - output.context_messages.len(), - output.summary - ); - } - Err(e) => { - warn!("Explore phase failed (non-fatal): {e}"); - push_event( - ¶ms.turn_events, - TurnEvent::SystemNote { - kind: "warn".into(), - message: format!("Explore phase failed: {e}"), - }, - ); - } - } - } - // Insert system prompt at position 0 once and keep it there for the // entire turn, avoiding per-iteration clones of the full message list. - // It is removed before emitting the Compacted event so persistence - // does not store the prompt redundantly. params .messages .insert(0, ChatMessage::system(main_agent_prompt())); let original_count = params.messages.len(); + // Estimate request complexity from the last user message. + let request_len = params + .messages + .last() + .and_then(|m| m.content.as_deref()) + .map(str::len) + .unwrap_or(0); + + let mut errors = ErrorTracker::default(); + // Track whether the previous call produced tool calls — used to + // lower temperature once the agent starts producing a final answer. + let mut saw_tool_calls = false; + for iteration in 0..MAX_TURN_ITERATIONS { // ── Check abort flag ──────────────────────────────────────── if params.abort.load(Ordering::SeqCst) { @@ -254,15 +326,40 @@ impl super::AgentTurnService for AgentTurnS break; } + if errors.should_stop() { + push_event( + ¶ms.turn_events, + TurnEvent::SystemNote { + kind: "warn".into(), + message: "Stopping: repeated tool errors without progress".into(), + }, + ); + break; + } + debug!("agent turn iteration {iteration}"); + // ── Auto-compact oversized history before the LLM call ───── + self.auto_compact_if_needed(&mut params.messages).await; + + // ── Adaptive generation parameters ───────────────────────── + let max_tokens = adaptive_max_tokens(request_len); + // Lower temperature while the agent is still choosing tools to + // keep tool selection deterministic; raise it for the final + // free-form answer. + let temperature = if saw_tool_calls { 0.2 } else { 0.7 }; + // ── Stream start + call LLM ───────────────────────────────── push_event(¶ms.turn_events, TurnEvent::StreamStart); - // Uses params.messages directly (sys_msg[0] already in place - // from the insert above) — no per-iteration clone needed. let result = self - .call_llm(¶ms.messages, ¶ms.abort, ¶ms.turn_events) + .call_llm( + ¶ms.messages, + ¶ms.abort, + ¶ms.turn_events, + max_tokens, + temperature, + ) .await; match result { @@ -283,6 +380,7 @@ impl super::AgentTurnService for AgentTurnS break; } + saw_tool_calls = true; params.messages.push(assistant_msg); // ── Execute each tool call ────────────────────────── @@ -290,6 +388,9 @@ impl super::AgentTurnService for AgentTurnS let output = execute_tool_call(self.tool_executor.as_ref(), ¶ms.turn_events, tc) .await; + if output.starts_with("Error:") { + errors.record(&tc.function.name, &output, &mut params.messages); + } params .messages .push(ChatMessage::tool(tc.id.clone(), output)); @@ -370,3 +471,69 @@ pub async fn compact_messages_with_ai( } } } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_short_output_is_unchanged() { + let out = "short".to_string(); + assert_eq!(truncate_tool_output(out.clone()), out); + } + + #[test] + fn truncate_long_output_preserves_head_and_marks_cut() { + let long = "x".repeat(TOOL_OUTPUT_MAX_CHARS + 500); + let truncated = truncate_tool_output(long.clone()); + assert!(truncated.len() < long.len()); + assert!(truncated.contains("...[truncated")); + assert!(truncated.starts_with("xxx")); + } + + #[test] + fn adaptive_max_tokens_scales_with_request_len() { + assert_eq!(adaptive_max_tokens(10), 800); + assert_eq!(adaptive_max_tokens(200), 1600); + assert_eq!(adaptive_max_tokens(5000), 4096); + } + + #[test] + fn error_tracker_injects_recovery_note_after_repeats() { + let mut tracker = ErrorTracker::default(); + let mut messages: Vec = Vec::new(); + tracker.record("read", "Error: File not found", &mut messages); + tracker.record("read", "Error: File not found", &mut messages); + assert!(!tracker.should_stop()); + // Third consecutive failure → recovery note injected. + tracker.record("read", "Error: File not found", &mut messages); + assert!(messages.iter().any(|m| m + .content + .as_deref() + .is_some_and(|c| c.contains("[System note]")))); + } + + #[test] + fn error_tracker_stops_after_too_many_errors() { + let mut tracker = ErrorTracker::default(); + let mut messages: Vec = Vec::new(); + for i in 0..MAX_TOTAL_TOOL_ERRORS { + tracker.record("bash", &format!("Error: boom {i}"), &mut messages); + } + assert!(tracker.should_stop()); + } + + #[test] + fn conversation_chars_sums_content_only() { + let messages = vec![ + ChatMessage::system("sys".to_string()), + ChatMessage::user("hello world".to_string()), + ChatMessage::tool("id".to_string(), "output".to_string()), + ]; + assert_eq!(conversation_chars(&messages), 3 + 11 + 6); + } +} diff --git a/apps/application/src/lib.rs b/apps/application/src/lib.rs index 5b9b767..09db573 100644 --- a/apps/application/src/lib.rs +++ b/apps/application/src/lib.rs @@ -52,5 +52,5 @@ pub use cms::{ pub use agent::{ turn_service::{compact_messages_with_ai, AgentTurnServiceImpl}, - AgentTurnService, ExploreOutput, ExploreService, ToolExecutor, + AgentTurnService, ToolExecutor, }; diff --git a/apps/domain/src/agent/prompt.rs b/apps/domain/src/agent/prompt.rs index 739c002..1fb9cdc 100644 --- a/apps/domain/src/agent/prompt.rs +++ b/apps/domain/src/agent/prompt.rs @@ -22,12 +22,20 @@ pub fn main_agent_prompt() -> String { You are Zesdex, an AI coding assistant. You have access to various tools \ via native function calling to help the user. +TOKEN BUDGET — BE EFFICIENT: +- For simple/factual questions, answer directly. Do NOT call tools. +- For complex or unfamiliar code tasks, call `explore_codebase` ONCE at the \ +start to locate relevant code, then work from that context. +- Keep tool usage minimal: prefer `grep`/`glob`/`read` for targeted lookups; \ +avoid re-reading files you already have in context. +- Keep responses concise; do not repeat tool output verbatim. + CRITICAL DIRECTIVES & PRIORITY HIERARCHY: 1. WORKFLOW FIRST: For any multi-step, complex, or non-trivial task, \ you MUST prioritise using `workflow_run` (to construct and execute a \ multi-phase YAML workflow) or `hive_mind` (to orchestrate parallel \ autonomous agents). Workflows are your primary strategy. -2. PLANNING & TODOS: Use `plan_enter` to establish high-level \ +2. PLANNING & TODOs: Use `plan_enter` to establish high-level \ architectural plans and `todowrite` to maintain granular task checklists. 3. REASONING: Use `seq_think` for deep step-by-step analysis. 4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) \ @@ -70,6 +78,35 @@ executed, and modified files. Format as a clear bulleted list." .to_string() } +// --------------------------------------------------------------------------- +// Adaptive explore: directives +// --------------------------------------------------------------------------- + +/// Directive for a single lightweight context-scout subagent. +pub fn explore_scout_directive() -> String { + "\ +You are a codebase context scout. \ +Given the workspace root, quickly locate the code that is most relevant \ +to the user's request: \ +1. Run semantic_search once with the user's key terms. \ +2. Read up to the 3 most relevant files (use grep for symbols if needed). \ +3. Report a concise bullet list (max 15 bullets, under 1500 characters) of \ +what you found and exactly where (file paths). \ +Do NOT rebuild the index. Do NOT enumerate unrelated files. Be brief." + .to_string() +} + +/// Build a system note injected after repeated tool errors to steer the +/// agent toward an alternative approach instead of retrying the same call. +pub fn error_recovery_note(tool_name: &str, last_error: &str) -> String { + format!( + "\ +[System note] The tool `{tool_name}` failed repeatedly with: \"{last_error}\". \ +Try an alternative approach (verify paths, correct arguments, use a \ +different tool, or finish without this tool). Do NOT retry the same call." + ) +} + #[cfg(test)] mod tests { use super::*; @@ -89,4 +126,18 @@ mod tests { assert!(prompt.contains("/home")); assert!(prompt.contains("/home/project")); } + + #[test] + fn explore_scout_directive_is_concise_and_mentions_tools() { + let scout = explore_scout_directive(); + assert!(scout.contains("scout")); + assert!(scout.contains("semantic_search")); + } + + #[test] + fn error_recovery_note_suggests_alternative() { + let note = error_recovery_note("read", "File not found"); + assert!(note.contains("read")); + assert!(note.contains("alternative")); + } } diff --git a/apps/infrastructure/src/best_practice/explore.rs b/apps/infrastructure/src/best_practice/explore.rs index 9c92686..2d88e20 100644 --- a/apps/infrastructure/src/best_practice/explore.rs +++ b/apps/infrastructure/src/best_practice/explore.rs @@ -1,304 +1,141 @@ -//! Mandatory explore phase — spawns ≥3 parallel subagents to discover -//! codebase context before every agent turn, visible in the TUI workflow tab. +//! `explore_codebase` tool — lazy, agent-initiated codebase exploration. +//! +//! The main agent decides (via the system prompt) when it needs codebase +//! context. Unlike the old mandatory explore phase (which ran 3 subagents on +//! every turn regardless of the question), this tool is invoked only when the +//! agent judges it necessary — saving tokens on trivial queries while keeping +//! context available for complex tasks. //! //! # Flow //! -//! `ExploreServiceImpl::explore()` → -//! -//! 1. Push `WorkflowAgentUpdate { Pending }` for each agent onto the turn-event -//! queue so the TUI workflow tab shows all 3. -//! 2. Spawn **Code Structure** subagent (thread + tokio runtime). -//! 3. Spawn **Symbol Index** subagent (thread + tokio runtime). -//! 4. Spawn **Semantic Context** subagent (thread + tokio runtime). -//! 5. Join all handles via `spawn_blocking`. -//! 6. Push `Completed` / `Failed` events for each agent. -//! 7. Consolidate findings into a system message → return. +//! `ExploreCodebase::run` → +//! 1. Parse the user's goal / target from args. +//! 2. Resolve subagent provider credentials from settings. +//! 3. Spawn a single "context scout" subagent (read-only, semantic_search + +//! read of up to 3 relevant files). +//! 4. Join the result and return a concise bullet summary as a tool message. +use anyhow::{Context, Result}; +use serde_json::{json, Value}; +use tracing::{info, warn}; + +use zesdex_domain::agent::prompt::explore_scout_directive; +use zesdex_domain::cms::{AppConfigRepository, SettingsRepository}; +use zesdex_domain::core::Store; + +use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository}; use crate::subagent::context::SubagentContext; use crate::subagent::division::AccessTier; use crate::subagent::engine::run_agent; -use crate::tools::ToolCtx; -use anyhow::{Context, Result}; -use std::collections::VecDeque; -use std::future::Future; -use std::pin::Pin; -use std::sync::{Arc, Mutex}; -use std::thread; -use tracing::{info, warn}; -use zesdex_application::agent::{ExploreOutput, ExploreService}; -use zesdex_domain::agent::{AgentStatus, TurnEvent}; +use crate::tools::{Tool, ToolCtx}; -/// Number of parallel explore subagents. -const EXPLORE_AGENT_COUNT: usize = 3; +/// Maximum characters of the scout's final output to keep in context. +/// The scout is directed to stay under 1500 chars, but this ceiling protects +/// against rogue output. +const EXPLORE_OUTPUT_MAX_CHARS: usize = 4000; -/// IDs for each explore agent (shown in the workflow tab). -const EXPLORE_IDS: [&str; 3] = ["explore-structure", "explore-symbols", "explore-context"]; +/// `explore_codebase` tool — ask a read-only context-scout subagent to +/// locate relevant code for the current task. +pub struct ExploreCodebase; -/// Display names for the TUI workflow tab. -const EXPLORE_LABELS: [&str; 3] = [ - "📁 Code Structure", - "🔣 Symbol Index", - "🔍 Semantic Context", -]; - -/// Directives for each explore subagent. -const EXPLORE_DIRECTIVES: [&str; 3] = [ - // Agent 0: Code Structure - "You are a codebase structure explorer.\n\ - 1. List all top-level directories and files in the workspace root.\n\ - 2. Read Cargo.toml, package.json, or pyproject.toml at the root.\n\ - 3. List the apps/ or src/ directory contents.\n\ - 4. Identify main entry points (main.rs, main.py, index.ts, etc.).\n\ - 5. Count files by extension type.\n\ - Use the ls_dir, read, grep, and glob tools. Be concise.", - // Agent 1: Symbol Index - "You are a symbol index explorer.\n\ - 1. Call the 'rebuild_index' tool to rebuild the symbol index.\n\ - 2. Call the 'list_symbols' tool with max_results: 100.\n\ - 3. Identify public APIs, entry points, and key types.\n\ - 4. Group symbols by language and kind.\n\ - Be concise. Report what symbols exist and where they live.", - // Agent 2: Semantic Context - "You are a semantic context explorer.\n\ - 1. Call the 'rebuild_index' tool to ensure the index is fresh.\n\ - 2. Search for symbols related to the user's query using semantic_search.\n\ - 3. Search for config files, env variables, and settings.\n\ - 4. Search for test files and test patterns.\n\ - Be concise. Report relevant code areas for the task.\n\ - Use the semantic_search, grep, glob, and read tools.", -]; - -// --------------------------------------------------------------------------- -// Credentials -// --------------------------------------------------------------------------- - -/// LLM credentials for explore subagents. -pub struct Credentials { - pub base_url: String, - pub api_key: String, - pub model: String, -} - -// --------------------------------------------------------------------------- -// ExploreServiceImpl — implements the application-layer trait -// --------------------------------------------------------------------------- - -/// Concrete [`ExploreService`] that the turn service calls. -/// -/// Owns a shared `ToolCtx` and LLM credentials. Each call to `explore()` -/// spawns 3 subagents in parallel with TUI workflow-tab visibility. -pub struct ExploreServiceImpl { - tool_ctx: ToolCtx, - credentials: Credentials, -} - -impl ExploreServiceImpl { - pub fn new(tool_ctx: ToolCtx, credentials: Credentials) -> Self { - ExploreServiceImpl { - tool_ctx, - credentials, - } +impl Tool for ExploreCodebase { + fn name(&self) -> &'static str { + "explore_codebase" } -} -impl ExploreService for ExploreServiceImpl { - fn explore<'a>( - &'a self, - query: &'a str, - workspace_root: &'a str, - turn_events: &'a Arc>>, - ) -> Pin> + Send + 'a>> { - Box::pin(async move { - let context = run_explore_phase( - query, - workspace_root, - &self.tool_ctx, - &self.credentials, - turn_events, - ) - .await?; + fn description(&self) -> &'static str { + "Explore the codebase to locate code relevant to a task. Use this \ + once at the start of complex or unfamiliar tasks (implementing a \ + feature, fixing a bug, refactoring, navigating a large repo). \ + Do NOT use for simple factual questions about the current \ + conversation." + } - Ok(ExploreOutput { - context_messages: vec![context], - summary: format!("{EXPLORE_AGENT_COUNT} explore agents dispatched"), - }) + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "goal": { + "type": "string", + "description": "The task or question to explore for" + } + }, + "required": ["goal"] }) } -} -// --------------------------------------------------------------------------- -// Helpers for pushing workflow events -// --------------------------------------------------------------------------- + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let goal = args + .get("goal") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); -fn push_event(events: &Arc>>, event: TurnEvent) { - if let Ok(mut q) = events.lock() { - q.push_back(event); - } -} - -fn emit_pending(events: &Arc>>, agent_id: &str, display: &str) { - push_event( - events, - TurnEvent::WorkflowAgentUpdate { - agent_id: agent_id.to_string(), - agent_name: display.to_string(), - status: AgentStatus::Pending, - }, - ); -} - -fn emit_running(events: &Arc>>, agent_id: &str, display: &str) { - push_event( - events, - TurnEvent::WorkflowAgentUpdate { - agent_id: agent_id.to_string(), - agent_name: display.to_string(), - status: AgentStatus::Running, - }, - ); -} - -fn emit_completed(events: &Arc>>, agent_id: &str, display: &str) { - push_event( - events, - TurnEvent::WorkflowAgentUpdate { - agent_id: agent_id.to_string(), - agent_name: display.to_string(), - status: AgentStatus::Completed, - }, - ); -} - -fn emit_failed(events: &Arc>>, agent_id: &str, display: &str, msg: &str) { - push_event( - events, - TurnEvent::WorkflowAgentUpdate { - agent_id: agent_id.to_string(), - agent_name: display.to_string(), - status: AgentStatus::Failed(msg.to_string()), - }, - ); -} - -// --------------------------------------------------------------------------- -// Core orchestration -// --------------------------------------------------------------------------- - -/// Spawn `EXPLORE_AGENT_COUNT` subagents in parallel, emit workflow events -/// for the TUI tab, join, and consolidate. -async fn run_explore_phase( - query: &str, - workspace_root: &str, - tool_ctx: &ToolCtx, - credentials: &Credentials, - turn_events: &Arc>>, -) -> Result { - // ── 1. Emit Pending for all agents (appears instantly in workflow tab) ─ - for (i, &id) in EXPLORE_IDS.iter().enumerate() { - emit_pending(turn_events, id, EXPLORE_LABELS[i]); - } - - // ── 2. Prepare directives ─────────────────────────────────────────── - let mut directives: Vec = Vec::with_capacity(EXPLORE_AGENT_COUNT); - for (i, &d) in EXPLORE_DIRECTIVES.iter().enumerate() { - let mut d = d.to_string(); - if i == 2 { - d.push_str(&format!("\n\nThe user's current query is: \"{query}\"")); + if goal.is_empty() { + return Err(anyhow::anyhow!("missing non-empty 'goal'")); } - d.push_str(&format!("\n\nWorkspace root: {workspace_root}")); - directives.push(d); - } - // ── 3. Spawn all agents on threads ────────────────────────────────── - let mut handles: Vec<(usize, thread::JoinHandle>)> = - Vec::with_capacity(EXPLORE_AGENT_COUNT); + info!("explore_codebase: {goal}"); - for i in 0..EXPLORE_AGENT_COUNT { - emit_running(turn_events, EXPLORE_IDS[i], EXPLORE_LABELS[i]); + let store = Store::new(); + let settings = JsonSettingsRepository::new() + .load(&store.base_dir) + .unwrap_or_default(); + let app_config = JsonAppConfigRepository::new() + .load(&store.base_dir) + .unwrap_or_default(); - let ctx = SubagentContext::new( - directives[i].clone(), - tool_ctx.clone(), - "read".to_string(), - credentials.base_url.clone(), - credentials.api_key.clone(), - credentials.model.clone(), + let (provider, model) = + crate::subagent::provider::resolve_subagent_provider(&settings, &app_config); + + let base_url = app_config + .providers + .get(&provider) + .map(|p| p.api_base.clone()) + .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string()); + + let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config); + + let workspace_root = ctx + .workspaces + .first() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| ".".to_string()); + + // One lightweight scout — no parallel agents, no index rebuild. + let directive = format!( + "{}\n\nUser's task: {goal}\nWorkspace root: {workspace_root}", + explore_scout_directive() ); - let directive = directives[i].clone(); - let tc = tool_ctx.clone(); + let subagent_ctx = SubagentContext::new( + directive.clone(), + ctx.clone(), + "read".to_string(), + base_url, + api_key, + model, + ); - let handle = thread::spawn(move || { - let rt = - tokio::runtime::Runtime::new().context("create explore subagent tokio runtime")?; - rt.block_on(run_agent(ctx, &directive, AccessTier::Read, tc)) - }); + let rt = tokio::runtime::Runtime::new().context("create explore tokio runtime")?; + let result = rt.block_on(run_agent( + subagent_ctx, + &directive, + AccessTier::Read, + ctx.clone(), + ))?; - handles.push((i, handle)); - } - - // ── 4. Join handles via spawn_blocking ────────────────────────────── - let turn_events_clone = Arc::clone(turn_events); - let results: Vec<(usize, String, bool)> = tokio::task::spawn_blocking(move || { - let mut out = Vec::with_capacity(EXPLORE_AGENT_COUNT); - for (i, handle) in handles { - let entry = match handle.join() { - Ok(Ok(output)) => { - info!(agent = i, "explore subagent completed"); - emit_completed(&turn_events_clone, EXPLORE_IDS[i], EXPLORE_LABELS[i]); - (i, output, true) - } - Ok(Err(e)) => { - warn!(agent = i, error = %e, "explore subagent failed"); - emit_failed( - &turn_events_clone, - EXPLORE_IDS[i], - EXPLORE_LABELS[i], - &e.to_string(), - ); - (i, format!("Error: {e}"), false) - } - Err(e) => { - warn!(agent = i, error = ?e, "explore subagent panicked"); - emit_failed( - &turn_events_clone, - EXPLORE_IDS[i], - EXPLORE_LABELS[i], - "thread panicked", - ); - (i, format!("Thread panic: {e:?}"), false) - } - }; - out.push(entry); - } - out - }) - .await - .context("explore join task panicked")?; - - // ── 5. Build consolidated context ─────────────────────────────────── - Ok(build_explore_context(&results)) -} - -// --------------------------------------------------------------------------- -// Consolidation -// --------------------------------------------------------------------------- - -/// Format explore results as a system-level context message. -fn build_explore_context(results: &[(usize, String, bool)]) -> String { - let success_count = results.iter().filter(|r| r.2).count(); - let total = results.len(); - - let mut msg = format!("[Explore Phase — {success_count}/{total} agents succeeded]\n\n"); - - for (i, output, success) in results { - let label = EXPLORE_LABELS.get(*i).unwrap_or(&"❓ Unknown"); - if *success { - msg.push_str(&format!("=== {label} ===\n{output}\n\n")); - } else { - msg.push_str(&format!("=== {label} (FAILED) ===\n{output}\n\n")); + let mut out = format!("[Codebase scout report]\n{goal}\n\n----------\n{}", result); + if out.len() > EXPLORE_OUTPUT_MAX_CHARS { + warn!( + "explore_codebase output truncated: {} chars -> {}", + out.len(), + EXPLORE_OUTPUT_MAX_CHARS + ); + out.truncate(EXPLORE_OUTPUT_MAX_CHARS); + out.push_str("\n...[truncated]"); } + Ok(out) } - - msg } diff --git a/apps/infrastructure/src/tools/registry.rs b/apps/infrastructure/src/tools/registry.rs index 0d533b9..1eca55c 100644 --- a/apps/infrastructure/src/tools/registry.rs +++ b/apps/infrastructure/src/tools/registry.rs @@ -42,6 +42,7 @@ pub fn all_tools() -> Vec> { // ── Best-practice tools (built-in) ───────────────────────── Box::new(super::best_practice::BestPractice), Box::new(super::best_practice::CommitConvention), + Box::new(crate::best_practice::explore::ExploreCodebase), ] } diff --git a/apps/interfaces/tui/src/turn.rs b/apps/interfaces/tui/src/turn.rs index 8fd81b4..d4d8965 100644 --- a/apps/interfaces/tui/src/turn.rs +++ b/apps/interfaces/tui/src/turn.rs @@ -110,13 +110,6 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) { api_base: api_base.clone(), }; - // Clone credentials before moving into LlmClient. - let explore_api_key = api_key.clone(); - let explore_model = model.clone(); - let explore_base_url = api_base - .clone() - .unwrap_or_else(|| "https://api.openai.com/v1".to_string()); - let client = std::sync::Arc::new(LlmClient::new(api_key, model, api_base)); let tool_ctx = ToolCtx::builder() @@ -125,29 +118,12 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) { .turn_events(turn_events) .build(); - // Clone ToolCtx for the explore service (before moving into executor). - let explore_ctx = tool_ctx.clone(); - let tool_executor = std::sync::Arc::new(InfrastructureToolExecutor::new(tool_ctx)); let tools = all_tools(); let defs = tool_defs(&tools); - // Wire the mandatory explore phase (3+ parallel subagents). - let explore_creds = zesdex_infrastructure::best_practice::explore::Credentials { - base_url: explore_base_url, - api_key: explore_api_key, - model: explore_model, - }; - let explore_service = std::sync::Arc::new( - zesdex_infrastructure::best_practice::explore::ExploreServiceImpl::new( - explore_ctx, - explore_creds, - ), - ); - - let turn_service = - AgentTurnServiceImpl::new(client, tool_executor, defs).with_explore(explore_service); + let turn_service = AgentTurnServiceImpl::new(client, tool_executor, defs); tokio::spawn(async move { let _ = turn_service.run_turn(params).await;