perf(agent): rombak alur AI agent — adaptif, hemat token, self-healing
Ganti explore phase MANDATORY (3 subagent tiap turn, boros) dengan tool explore_codebase yang DIPUTUSKAN agent sendiri (lazy, token-aware): - hapus ExploreService trait + with_explore + Phase 0 dari turn loop - ExploreServiceImpl kini jadi tool 'explore_codebase' (1 context-scout subagent, read-only, cap output 4k chars) - system prompt: instruksi TOKEN BUDGET (jawab langsung utk query simple, panggil explore_codebase sekali utk task kompleks) Loop utama kini adaptif & self-healing: - max_tokens adaptif (800/1600/4096 by request length) — bukan selalu 4096 - temperature 0.2 saat tool-calling, 0.7 utk final answer - ErrorTracker: deteksi tool error berulang → inject recovery note, stop setelah 8 error total (bukan 50 iterasi sia-sia) - auto-compact history > 60k chars sebelum LLM call - tool output di-truncate ke 12k chars sebelum masuk konteks Tambah 8 unit test (truncation, adaptive tokens, error tracker).
This commit is contained in:
@@ -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<Mutex<VecDeque<TurnEvent>>>,
|
||||
) -> Pin<Box<dyn Future<Output = Result<ExploreOutput>> + 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<String> {
|
||||
let goal = args
|
||||
.get("goal")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
fn push_event(events: &Arc<Mutex<VecDeque<TurnEvent>>>, event: TurnEvent) {
|
||||
if let Ok(mut q) = events.lock() {
|
||||
q.push_back(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_pending(events: &Arc<Mutex<VecDeque<TurnEvent>>>, 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<Mutex<VecDeque<TurnEvent>>>, 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<Mutex<VecDeque<TurnEvent>>>, 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<Mutex<VecDeque<TurnEvent>>>, 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<Mutex<VecDeque<TurnEvent>>>,
|
||||
) -> Result<String> {
|
||||
// ── 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<String> = 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<Result<String>>)> =
|
||||
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
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ pub fn all_tools() -> Vec<Box<dyn super::Tool>> {
|
||||
// ── Best-practice tools (built-in) ─────────────────────────
|
||||
Box::new(super::best_practice::BestPractice),
|
||||
Box::new(super::best_practice::CommitConvention),
|
||||
Box::new(crate::best_practice::explore::ExploreCodebase),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user