Compare commits

..
9 Commits
Author SHA1 Message Date
semantic-release-bot 4dccf0cee4 chore(release): 1.19.3 [skip ci]
## [1.19.3](https://github.com/asepharyana/zesdex/compare/v1.19.2...v1.19.3) (2026-08-27)

### Bug Fixes

* **api:** model Opus pakai URL + API custom dari ~/.claude/settings.json ([1f91b44](https://github.com/asepharyana/zesdex/commit/1f91b447080e7106201d77585edb7d38b74e34bb))
2026-08-27 16:49:19 +00:00
asepharyana 1f91b44708 fix(api): model Opus pakai URL + API custom dari ~/.claude/settings.json
Perbaiki provider claude agar selalu refresh dari settings.json dan
menjadi default (claude-opus-4-8) setiap startup:

- fix(app_config_repo): ganti or_insert -> insert untuk provider claude —
  base_url/key dari ~/.claude/settings.json selalu di-refresh, tidak
  tertutup snapshot lama app_config.json.
- fix(app_config_repo): hapus kondisi default_provider == default — saat
  settings.json terdeteksi, default_provider='claude' dan
  default_model='claude-opus-4-8' SELALU di-set (sebelumnya skip kalau
  user pernah ganti provider).
- fix(subagent/provider): resolve_subagent_provider fallback ke
  app_config.default_provider/default_model kalau settings.provider/model
  kosong — subagent ikut pakai Opus.
- test: 4 unit test (parse settings.json, refresh stale provider, custom
  model, env fallback). Verified live: settings.json terbaca (9router URL
  + key).
2026-08-27 23:45:30 +07:00
semantic-release-bot b25929824a chore(release): 1.19.2 [skip ci]
## [1.19.2](https://github.com/asepharyana/zesdex/compare/v1.19.1...v1.19.2) (2026-08-27)

### Performance Improvements

* **agent:** stabilkan async & parallel — satu runtime, bounded concurrency, isolasi error ([6a98d52](https://github.com/asepharyana/zesdex/commit/6a98d52d54a69f78710d852a69dda3ac0a4ead31))
2026-08-27 16:30:31 +00:00
asepharyana 3fd9a2b2db chore: sinkronkan Cargo.lock dengan versi 1.19.1 2026-08-27 23:26:37 +07:00
asepharyana 6a98d52d54 perf(agent): stabilkan async & parallel — satu runtime, bounded concurrency, isolasi error
Seperti Claude Code: satu runtime shared, concurrency dibatasi, error
subagent terisolasi (satu node gagal tidak menggagalkan cycle).

- feat(runtime): global tokio runtime via OnceLock — ganti 9+ titik
  Runtime::new() per tool call (spawn, parallel_delegate, workflow,
  explore, dir_cache, daemon handler). Hemat resource, hilangkan panic
  path Runtime::new().expect() di daemon compaction.
- fix(workflow): execute_cycle ganti try_join_all (fail-fast) →
  buffer_unordered(8) + isolasi error per node; node gagal di-log dan
  diganti [ERROR], hasil node lain tetap dipakai (Claude Code-style).
- fix(parallel_delegate): spawn subagent dibatasi per batch max_parallel
  (tidak unbounded threads).
- perf(subagent): run_agent adaptif max_tokens (800/1600/4096), temp 0.2,
  truncate tool output 12k, error-recovery note utk tool error berulang.
- test: runtime singleton + block_on (2 test).
2026-08-27 23:25:44 +07:00
semantic-release-bot 3847c0e6fd chore(release): 1.19.1 [skip ci]
## [1.19.1](https://github.com/asepharyana/zesdex/compare/v1.19.0...v1.19.1) (2026-08-27)

### Performance Improvements

* **agent:** rombak alur AI agent — adaptif, hemat token, self-healing ([eac0443](https://github.com/asepharyana/zesdex/commit/eac0443c4c3b8bfcbefd4bad9554168fb6525b94))
2026-08-27 16:10:48 +00:00
asepharyana 5023e5dfa1 chore: sinkronkan Cargo.lock dengan versi 1.19.0 2026-08-27 23:06:57 +07:00
asepharyana eac0443c4c 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).
2026-08-27 23:06:37 +07:00
asepharyana 14f3eae62a a 2026-08-27 23:06:37 +07:00
24 changed files with 830 additions and 813 deletions
+1
View File
@@ -7,3 +7,4 @@ package-lock.json
.superpowers/ .superpowers/
docs/lesson/ docs/lesson/
.kilo/ .kilo/
.hermes/
+21
View File
@@ -1,3 +1,24 @@
## [1.19.3](https://github.com/asepharyana/zesdex/compare/v1.19.2...v1.19.3) (2026-08-27)
### Bug Fixes
* **api:** model Opus pakai URL + API custom dari ~/.claude/settings.json ([1f91b44](https://github.com/asepharyana/zesdex/commit/1f91b447080e7106201d77585edb7d38b74e34bb))
## [1.19.2](https://github.com/asepharyana/zesdex/compare/v1.19.1...v1.19.2) (2026-08-27)
### Performance Improvements
* **agent:** stabilkan async & parallel — satu runtime, bounded concurrency, isolasi error ([6a98d52](https://github.com/asepharyana/zesdex/commit/6a98d52d54a69f78710d852a69dda3ac0a4ead31))
## [1.19.1](https://github.com/asepharyana/zesdex/compare/v1.19.0...v1.19.1) (2026-08-27)
### Performance Improvements
* **agent:** rombak alur AI agent — adaptif, hemat token, self-healing ([eac0443](https://github.com/asepharyana/zesdex/commit/eac0443c4c3b8bfcbefd4bad9554168fb6525b94))
# [1.19.0](https://github.com/asepharyana/zesdex/compare/v1.18.4...v1.19.0) (2026-08-27) # [1.19.0](https://github.com/asepharyana/zesdex/compare/v1.18.4...v1.19.0) (2026-08-27)
Generated
+11 -11
View File
@@ -4862,7 +4862,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-api" name = "zesdex-api"
version = "1.18.3" version = "1.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -4885,7 +4885,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-application" name = "zesdex-application"
version = "1.18.3" version = "1.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -4902,7 +4902,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-bootstrap" name = "zesdex-bootstrap"
version = "1.18.3" version = "1.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -4919,7 +4919,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-daemon" name = "zesdex-daemon"
version = "1.18.3" version = "1.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -4943,7 +4943,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-domain" name = "zesdex-domain"
version = "1.18.3" version = "1.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -4959,7 +4959,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-gateway" name = "zesdex-gateway"
version = "1.18.3" version = "1.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -4986,7 +4986,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-grpc" name = "zesdex-grpc"
version = "1.18.3" version = "1.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -5003,7 +5003,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-infrastructure" name = "zesdex-infrastructure"
version = "1.18.3" version = "1.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
@@ -5051,7 +5051,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-tui" name = "zesdex-tui"
version = "1.18.3" version = "1.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -5077,7 +5077,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-web" name = "zesdex-web"
version = "1.18.3" version = "1.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -5097,7 +5097,7 @@ dependencies = [
[[package]] [[package]]
name = "zesdex-ws" name = "zesdex-ws"
version = "1.18.3" version = "1.19.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
+1 -1
View File
@@ -15,7 +15,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.19.0" version = "1.19.3"
edition = "2021" edition = "2021"
authors = ["asepharyana <superaseph@gmail.com>"] authors = ["asepharyana <superaseph@gmail.com>"]
-250
View File
@@ -1,250 +0,0 @@
# Zesdex — Autonomous AI Coding Agent
Zesdex is an autonomous AI coding agent with a Terminal UI (TUI). It acts as an
OpenAI/Anthropic-compatible LLM client wrapped in a tool-use harness with **37
built-in tools** — file operations, git, shell execution, LSP integration, MCP,
subagent orchestration, and more.
```
┌──────────────────────────────────────────────────────────────┐
│ Mode Selector │
│ TUI (default) ─── Daemon ─── Attach ─── API ─── WS/gRPC/Web │
└──────────────────────────────────────────────────────────────┘
```
---
## Quick Start
```bash
# Run the TUI (default mode)
cargo run
# Run the REST API server
cargo run -- --api --api-port 8080
# Run in daemon mode (background + IPC)
cargo run -- --daemon
# Attach TUI to a running daemon session
cargo run -- --attach <session-id>
# Seed initial data (first run)
cargo run --bin bootstrap
```
### Prerequisites
- **Rust** 1.81+ (edition 2021)
- **Linux** or **macOS** (Unix domain sockets required for daemon mode)
- An **API key** for an OpenAI/Anthropic-compatible LLM provider (set via
settings or environment variable)
---
## Modes
| Flag | Mode | Description |
|------|------|-------------|
| *(none)* | **TUI** | Full terminal UI with chat, overlays, and agent loop in one process |
| `--daemon` | **Daemon** | Background daemon with IPC socket; clients attach separately |
| `--attach <id>` | **Attach** | Connect TUI to an existing daemon session via Unix socket |
| `--api` | **REST API** | HTTP server with session management and chat endpoints |
| `--ws` | **WebSocket** | WebSocket server for real-time communication |
| `--grpc` | **gRPC** | gRPC server for programmatic access |
| `--web` | **Web** | Serves the web frontend |
| `--api-port`, `--ws-port`, `--grpc-port`, `--web-port` | *(ports)* | Configure server ports (defaults: 8080, 8081, 50051, 3000) |
---
## Architecture
### Clean Architecture Layering
```
apps/
├── domain/ # Pure entities, value objects, repository/service traits
│ # Zero framework deps — only serde + chrono + uuid
├── application/ # Use-case services (auth, sessions, conversations, memory)
│ # Depends only on domain-layer trait interfaces
├── infrastructure/ # All I/O: LLM client, IPC, persistence, LSP, MCP, tools
│ # Implements domain/application port interfaces
└── interfaces/ # Entry points
├── tui/ # Ratatui terminal UI
├── api/ # Axum REST API
├── daemon/ # Unix socket daemon + client
├── ws/ # WebSocket server
├── grpc/ # gRPC server
└── web/ # Web frontend (static file server)
```
### Tool System
37 tools across 9 categories:
| Category | Tools |
|----------|-------|
| **File System** | `read`, `write`, `edit`, `delete`, `dir_list`, `dir_cache_update` |
| **Shell** | `bash`, `bash_interactive`, `bash_kill`, `bash_output` |
| **Git** | `git_operator`, `git_cred`, `git_worktree` |
| **Search** | `search`, `grep`, `glob`, `semantic_search` |
| **LSP** | `lsp_connect`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_diagnostics`, `lsp_disconnect` |
| **Memory** | `remember`, `recall`, `forget` |
| **Workflow** | `spawn_agents`, `spawn_pipeline`, `plan`, `sequential_think`, `hive_mind` |
| **Utility** | `todo_write`, `todo_finish`, `pong`, `cd` |
| **Background** | Background bash jobs with `cancel/status/list` |
Each tool implements the `Tool` trait:
```rust
pub trait Tool: Send + Sync {
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn parameters(&self) -> Value;
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String>;
}
```
### Hive Mind Orchestration
The multi-agent orchestration system compiles a **cognitive cycle plan** per
task — ordered cycles of parallel processing nodes. Each node has a directive
and an **access tier** (`read` / `write` / `full`). Node outputs merge into a
shared collective state in real time, and a final **consensus synthesis**
produces the unified result.
- **Auto-trigger**: Complex requests automatically use the hive mind
- **Manual entry**: The `hive_mind` tool lets the LLM specify cycles explicitly
- **Live progress**: TUI panel shows each node's status and current tool
- **Guaranteed docs**: Every convergence writes to `docs/runs/`
### IPC Protocol (Daemon Mode)
```
┌──────────┐ Unix socket ┌──────────┐
│ Client │ ◄──────────────► │ Daemon │
│ (TUI) │ length-prefixed│ │
└──────────┘ serde_json └──────────┘
Frame format: [4-byte BE length][JSON payload]
```
The daemon holds `AppStateRest` and drives the agent loop. Clients are stateless
renderers that receive full state snapshots after each action.
---
## Built-in Features
| Feature | Description |
|---------|-------------|
| **LLM Provider** | OpenAI/Anthropic-compatible API (streaming + non-streaming) with automatic retry and fallback |
| **Tool Harness** | Safety-gated tool execution with graduated review checks |
| **Subagents** | Auto-inline review, background test-gen, arch-review, security-review |
| **OAuth 2.0** | PKCE flow for LLM provider authentication |
| **MCP** | Model Context Protocol server management (stdio + HTTP transport) |
| **LSP** | Language Server Protocol integration (completion, hover, diagnostics, references) |
| **Session Mgmt** | SQLite-persisted sessions with lock-based concurrency control |
| **Memory** | File-based memory system with frontmatter metadata |
| **Edit Log** | Append-only edit history with configurable retention |
| **Rate Limiting** | Sliding-window per-client rate limiter |
| **JWT Auth** | HS256 JWT access/refresh tokens (API mode) |
| **Password Auth** | Argon2 password hashing with pepper |
| **OAuth Loopback** | Localhost HTTP server for OAuth redirect capture |
| **Background Jobs** | Long-running shell jobs with cancellation and output collection |
| **Settings** | JSON-persisted settings with hot-reload |
---
## TUI Overlays
16 overlays accessible from the terminal UI:
| Overlay | Purpose |
|---------|---------|
| Chat Input | Main input bar with autocomplete |
| Bash Panel | Interactive shell panel |
| File Editor | Built-in file editor |
| Effort Selector | LLM reasoning effort selector |
| Help | Keybindings reference |
| Key Input | Custom key binding configuration |
| Learning | Lesson viewer |
| Loading | Generating spinner |
| MCP Manager | MCP server management |
| Model Selector | LLM model picker |
| Quit Confirm | Exit confirmation dialog |
| Rewind | Message/history rewind |
| Settings | Settings panel |
| Todo | Task/TODO list |
| Usage | Token usage statistics |
| Workflow | Hive-mind node progress |
---
## Data & Persistence
All data lives under the platform's data directory (`~/.local/share/zesdex/`):
```
~/.local/share/zesdex/
├── settings.json # User settings (provider, model, keys)
├── app_config.json # Provider definitions (endpoints, env vars)
├── sessions/ # Chat sessions (one subdirectory per session)
│ └── <uuid>/
│ ├── session.json # Session metadata
│ ├── messages.jsonl # Message log
│ └── .lock # Session lock file
└── memories/ # Memory files with frontmatter metadata
└── *.md
```
---
## Development
```bash
# Build all crates
cargo build
# Run all unit tests (8 tests across 11 crates)
cargo test
# Run clippy linting
cargo clippy --all-targets
# Run with verbose logging
RUST_LOG=debug cargo run
```
### Workspace Crates
| Crate | Path | Layer |
|-------|------|-------|
| `zesdex-domain` | `apps/domain/` | Pure domain entities & traits |
| `zesdex-application` | `apps/application/` | Use-case services |
| `zesdex-infrastructure` | `apps/infrastructure/` | All I/O & tool implementations |
| `zesdex-tui` | `apps/interfaces/tui/` | Ratatui terminal interface |
| `zesdex-api` | `apps/interfaces/api/` | Axum REST API |
| `zesdex-daemon` | `apps/interfaces/daemon/` | Unix socket daemon |
| `zesdex-ws` | `apps/interfaces/ws/` | WebSocket server |
| `zesdex-grpc` | `apps/interfaces/grpc/` | gRPC server |
| `zesdex-web` | `apps/interfaces/web/` | Web frontend |
| `zesdex-gateway` | `apps/gateway/` | CLI entry point & dispatcher |
| `zesdex-bootstrap` | `apps/bootstrap/` | Initial data seeder |
### Code Map
Detailed architecture documentation is in `docs/CODEMAPS/`:
| File | Covers |
|------|--------|
| `docs/CODEMAPS/architecture.md` | System layout, process modes, data flow |
| `docs/CODEMAPS/backend.md` | Provider, OAuth, IPC, workflow engine, MCP, LSP, review |
| `docs/CODEMAPS/frontend.md` | TUI render pipeline, 16 overlays, toasts, input handling |
| `docs/CODEMAPS/data.md` | Persistence, SQLite msglog, memory files, settings/config |
| `docs/CODEMAPS/dependencies.md` | All Rust crates and external services |
---
## License
See `CHANGELOG.md` for release history.
-57
View File
@@ -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<String>,
/// 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<Box<dyn Future>>` so it can
/// be stored as `Arc<dyn ExploreService>`.
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<Mutex<VecDeque<TurnEvent>>>,
) -> Pin<Box<dyn Future<Output = Result<ExploreOutput>> + Send + 'a>>;
}
-2
View File
@@ -19,8 +19,6 @@ pub trait AgentTurnService: Send + Sync {
fn run_turn(&self, params: AgentTurnParams) -> impl Future<Output = Result<()>> + Send; fn run_turn(&self, params: AgentTurnParams) -> impl Future<Output = Result<()>> + Send;
} }
pub mod explore;
pub mod turn_service; pub mod turn_service;
pub use explore::{ExploreOutput, ExploreService};
pub use turn_service::{compact_messages_with_ai, AgentTurnServiceImpl}; pub use turn_service::{compact_messages_with_ai, AgentTurnServiceImpl};
+252 -85
View File
@@ -7,12 +7,29 @@ use zesdex_domain::agent::{AgentTurnParams, TurnEvent};
use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef}; use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef};
use zesdex_domain::main_agent_prompt; use zesdex_domain::main_agent_prompt;
use super::{ExploreService, ToolExecutor}; use super::ToolExecutor;
use crate::ports::ProviderService; use crate::ports::ProviderService;
/// Maximum tool-call iterations per agent turn before forcing termination. /// Maximum tool-call iterations per agent turn before forcing termination.
const MAX_TURN_ITERATIONS: u32 = 50; 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. // 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<ChatMessage>) {
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. // Helper: execute a single tool call, push events, return the result string.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -71,6 +171,7 @@ async fn execute_tool_call<T: ToolExecutor>(
}; };
let is_error = output.starts_with("Error:"); let is_error = output.starts_with("Error:");
let output = truncate_tool_output(output);
push_event( push_event(
turn_events, turn_events,
@@ -108,19 +209,19 @@ fn emit_usage(turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>, usage: Option<(u64,
/// Service implementation for executing an agent turn asynchronously. /// Service implementation for executing an agent turn asynchronously.
/// ///
/// # Explore phase /// The turn loop is adaptive and token-aware:
/// /// - No mandatory explore phase — the *agent* decides when to call the
/// Before the main LLM loop begins, [`AgentTurnServiceImpl`] runs a mandatory /// `explore_codebase` tool (see the main prompt), so simple queries skip
/// explore phase that spawns ≥3 parallel subagents (code structure, symbol /// exploration entirely.
/// index, semantic context) and injects their consolidated findings as a /// - `max_tokens` / `temperature` adapt to the request length and phase.
/// system message. See [`ExploreService`] for the trait contract. /// - 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<P: ProviderService, T: ToolExecutor> { pub struct AgentTurnServiceImpl<P: ProviderService, T: ToolExecutor> {
provider: Arc<P>, provider: Arc<P>,
tool_executor: Arc<T>, tool_executor: Arc<T>,
tool_defs: Vec<ToolDef>, tool_defs: Vec<ToolDef>,
/// Optional explore-phase service. When `Some`, the explore phase runs
/// before every turn; when `None` it is skipped (tests, daemon mode).
explore_service: Option<Arc<dyn ExploreService>>,
} }
impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> { impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> {
@@ -129,19 +230,9 @@ impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> {
provider, provider,
tool_executor, tool_executor,
tool_defs, 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<dyn ExploreService>) -> Self {
self.explore_service = Some(service);
self
}
/// Execute a single LLM call with the current message list, handling /// Execute a single LLM call with the current message list, handling
/// streaming events and error reporting. /// streaming events and error reporting.
async fn call_llm( async fn call_llm(
@@ -149,6 +240,8 @@ impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> {
messages: &[ChatMessage], messages: &[ChatMessage],
abort: &Arc<AtomicBool>, abort: &Arc<AtomicBool>,
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>, turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
max_tokens: u32,
temperature: f32,
) -> Result<(ChatMessage, Option<(u64, u64)>), String> { ) -> Result<(ChatMessage, Option<(u64, u64)>), String> {
let on_event = make_stream_callback(abort, turn_events); let on_event = make_stream_callback(abort, turn_events);
@@ -156,13 +249,39 @@ impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> {
.chat_stream( .chat_stream(
messages, messages,
Some(self.tool_defs.clone()), Some(self.tool_defs.clone()),
Some(4096), Some(max_tokens),
Some(0.7), Some(temperature),
on_event, on_event,
) )
.await .await
.map_err(|e| format!("LLM error: {e}")) .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<ChatMessage>) {
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<ChatMessage> = 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<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnServiceImpl<P, T> { impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnServiceImpl<P, T> {
@@ -173,73 +292,26 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
params.model 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(
&params.turn_events,
TurnEvent::SystemNote {
kind: "info".into(),
message: "🔍 Exploring codebase structure...".into(),
},
);
match explorer
.explore(&user_query, &workspace_root, &params.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(
&params.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 // Insert system prompt at position 0 once and keep it there for the
// entire turn, avoiding per-iteration clones of the full message list. // 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 params
.messages .messages
.insert(0, ChatMessage::system(main_agent_prompt())); .insert(0, ChatMessage::system(main_agent_prompt()));
let original_count = params.messages.len(); 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 { for iteration in 0..MAX_TURN_ITERATIONS {
// ── Check abort flag ──────────────────────────────────────── // ── Check abort flag ────────────────────────────────────────
if params.abort.load(Ordering::SeqCst) { if params.abort.load(Ordering::SeqCst) {
@@ -254,15 +326,40 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
break; break;
} }
if errors.should_stop() {
push_event(
&params.turn_events,
TurnEvent::SystemNote {
kind: "warn".into(),
message: "Stopping: repeated tool errors without progress".into(),
},
);
break;
}
debug!("agent turn iteration {iteration}"); 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 ───────────────────────────────── // ── Stream start + call LLM ─────────────────────────────────
push_event(&params.turn_events, TurnEvent::StreamStart); push_event(&params.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 let result = self
.call_llm(&params.messages, &params.abort, &params.turn_events) .call_llm(
&params.messages,
&params.abort,
&params.turn_events,
max_tokens,
temperature,
)
.await; .await;
match result { match result {
@@ -283,6 +380,7 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
break; break;
} }
saw_tool_calls = true;
params.messages.push(assistant_msg); params.messages.push(assistant_msg);
// ── Execute each tool call ────────────────────────── // ── Execute each tool call ──────────────────────────
@@ -290,6 +388,9 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
let output = let output =
execute_tool_call(self.tool_executor.as_ref(), &params.turn_events, tc) execute_tool_call(self.tool_executor.as_ref(), &params.turn_events, tc)
.await; .await;
if output.starts_with("Error:") {
errors.record(&tc.function.name, &output, &mut params.messages);
}
params params
.messages .messages
.push(ChatMessage::tool(tc.id.clone(), output)); .push(ChatMessage::tool(tc.id.clone(), output));
@@ -370,3 +471,69 @@ pub async fn compact_messages_with_ai<P: ProviderService>(
} }
} }
} }
// ---------------------------------------------------------------------------
// 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<ChatMessage> = 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<ChatMessage> = 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);
}
}
+1 -1
View File
@@ -52,5 +52,5 @@ pub use cms::{
pub use agent::{ pub use agent::{
turn_service::{compact_messages_with_ai, AgentTurnServiceImpl}, turn_service::{compact_messages_with_ai, AgentTurnServiceImpl},
AgentTurnService, ExploreOutput, ExploreService, ToolExecutor, AgentTurnService, ToolExecutor,
}; };
+52 -1
View File
@@ -22,12 +22,20 @@ pub fn main_agent_prompt() -> String {
You are Zesdex, an AI coding assistant. You have access to various tools \ You are Zesdex, an AI coding assistant. You have access to various tools \
via native function calling to help the user. 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: CRITICAL DIRECTIVES & PRIORITY HIERARCHY:
1. WORKFLOW FIRST: For any multi-step, complex, or non-trivial task, \ 1. WORKFLOW FIRST: For any multi-step, complex, or non-trivial task, \
you MUST prioritise using `workflow_run` (to construct and execute a \ you MUST prioritise using `workflow_run` (to construct and execute a \
multi-phase YAML workflow) or `hive_mind` (to orchestrate parallel \ multi-phase YAML workflow) or `hive_mind` (to orchestrate parallel \
autonomous agents). Workflows are your primary strategy. 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. architectural plans and `todowrite` to maintain granular task checklists.
3. REASONING: Use `seq_think` for deep step-by-step analysis. 3. REASONING: Use `seq_think` for deep step-by-step analysis.
4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) \ 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() .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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -89,4 +126,18 @@ mod tests {
assert!(prompt.contains("/home")); assert!(prompt.contains("/home"));
assert!(prompt.contains("/home/project")); 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"));
}
} }
+113 -276
View File
@@ -1,304 +1,141 @@
//! Mandatory explore phase — spawns ≥3 parallel subagents to discover //! `explore_codebase` tool — lazy, agent-initiated codebase exploration.
//! codebase context before every agent turn, visible in the TUI workflow tab. //!
//! 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 //! # Flow
//! //!
//! `ExploreServiceImpl::explore()` → //! `ExploreCodebase::run` →
//! //! 1. Parse the user's goal / target from args.
//! 1. Push `WorkflowAgentUpdate { Pending }` for each agent onto the turn-event //! 2. Resolve subagent provider credentials from settings.
//! queue so the TUI workflow tab shows all 3. //! 3. Spawn a single "context scout" subagent (read-only, semantic_search +
//! 2. Spawn **Code Structure** subagent (thread + tokio runtime). //! read of up to 3 relevant files).
//! 3. Spawn **Symbol Index** subagent (thread + tokio runtime). //! 4. Join the result and return a concise bullet summary as a tool message.
//! 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.
use anyhow::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::context::SubagentContext;
use crate::subagent::division::AccessTier; use crate::subagent::division::AccessTier;
use crate::subagent::engine::run_agent; use crate::subagent::engine::run_agent;
use crate::tools::ToolCtx; use crate::tools::{Tool, 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};
/// Number of parallel explore subagents. /// Maximum characters of the scout's final output to keep in context.
const EXPLORE_AGENT_COUNT: usize = 3; /// 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). /// `explore_codebase` tool — ask a read-only context-scout subagent to
const EXPLORE_IDS: [&str; 3] = ["explore-structure", "explore-symbols", "explore-context"]; /// locate relevant code for the current task.
pub struct ExploreCodebase;
/// Display names for the TUI workflow tab. impl Tool for ExploreCodebase {
const EXPLORE_LABELS: [&str; 3] = [ fn name(&self) -> &'static str {
"📁 Code Structure", "explore_codebase"
"🔣 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 ExploreService for ExploreServiceImpl { fn description(&self) -> &'static str {
fn explore<'a>( "Explore the codebase to locate code relevant to a task. Use this \
&'a self, once at the start of complex or unfamiliar tasks (implementing a \
query: &'a str, feature, fixing a bug, refactoring, navigating a large repo). \
workspace_root: &'a str, Do NOT use for simple factual questions about the current \
turn_events: &'a Arc<Mutex<VecDeque<TurnEvent>>>, conversation."
) -> 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?;
Ok(ExploreOutput { fn parameters(&self) -> Value {
context_messages: vec![context], json!({
summary: format!("{EXPLORE_AGENT_COUNT} explore agents dispatched"), "type": "object",
}) "properties": {
"goal": {
"type": "string",
"description": "The task or question to explore for"
}
},
"required": ["goal"]
}) })
} }
}
// --------------------------------------------------------------------------- fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
// Helpers for pushing workflow events 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 goal.is_empty() {
if let Ok(mut q) = events.lock() { return Err(anyhow::anyhow!("missing non-empty 'goal'"));
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}\""));
} }
d.push_str(&format!("\n\nWorkspace root: {workspace_root}"));
directives.push(d);
}
// ── 3. Spawn all agents on threads ────────────────────────────────── info!("explore_codebase: {goal}");
let mut handles: Vec<(usize, thread::JoinHandle<Result<String>>)> =
Vec::with_capacity(EXPLORE_AGENT_COUNT);
for i in 0..EXPLORE_AGENT_COUNT { let store = Store::new();
emit_running(turn_events, EXPLORE_IDS[i], EXPLORE_LABELS[i]); 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( let (provider, model) =
directives[i].clone(), crate::subagent::provider::resolve_subagent_provider(&settings, &app_config);
tool_ctx.clone(),
"read".to_string(), let base_url = app_config
credentials.base_url.clone(), .providers
credentials.api_key.clone(), .get(&provider)
credentials.model.clone(), .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 subagent_ctx = SubagentContext::new(
let tc = tool_ctx.clone(); directive.clone(),
ctx.clone(),
"read".to_string(),
base_url,
api_key,
model,
);
let handle = thread::spawn(move || { let rt = crate::runtime::runtime();
let rt = let result = rt.block_on(run_agent(
tokio::runtime::Runtime::new().context("create explore subagent tokio runtime")?; subagent_ctx,
rt.block_on(run_agent(ctx, &directive, AccessTier::Read, tc)) &directive,
}); AccessTier::Read,
ctx.clone(),
))?;
handles.push((i, handle)); let mut out = format!("[Codebase scout report]\n{goal}\n\n----------\n{}", result);
} if out.len() > EXPLORE_OUTPUT_MAX_CHARS {
warn!(
// ── 4. Join handles via spawn_blocking ────────────────────────────── "explore_codebase output truncated: {} chars -> {}",
let turn_events_clone = Arc::clone(turn_events); out.len(),
let results: Vec<(usize, String, bool)> = tokio::task::spawn_blocking(move || { EXPLORE_OUTPUT_MAX_CHARS
let mut out = Vec::with_capacity(EXPLORE_AGENT_COUNT); );
for (i, handle) in handles { out.truncate(EXPLORE_OUTPUT_MAX_CHARS);
let entry = match handle.join() { out.push_str("\n...[truncated]");
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"));
} }
Ok(out)
} }
msg
} }
+1
View File
@@ -34,6 +34,7 @@ pub mod llm;
pub mod mcp; pub mod mcp;
pub mod middleware; pub mod middleware;
pub mod persistence; pub mod persistence;
pub mod runtime;
pub mod subagent; pub mod subagent;
pub mod tools; pub mod tools;
pub mod utils; pub mod utils;
@@ -72,6 +72,55 @@ fn detect_claude_settings_provider() -> Option<(ProviderConfig, Option<String>)>
)) ))
} }
/// Apply a detected Claude provider + custom model onto an `AppConfig`.
///
/// Pure (no I/O) so it can be unit-tested. Flow:
/// 1. Always `insert`s the "claude" provider (refreshing a possibly stale
/// persisted entry with the current base URL + key from settings.json).
/// 2. Registers known Claude model roles if missing.
/// 3. Always sets `default_provider = "claude"` and
/// `default_model = custom_model.unwrap_or("claude-opus-4-8")` so Opus
/// is the default whenever `~/.claude/settings.json` is present.
fn apply_claude_provider(
cfg: &mut AppConfig,
claude_provider: ProviderConfig,
custom_model: Option<String>,
) {
cfg.providers.insert("claude".to_string(), claude_provider);
let claude_models: [(&str, &str); 3] = [
("claude-opus-4-8", "claude-opus-4-8"),
("claude-sonnet-5", "claude-sonnet-5"),
("claude-haiku-4-5", "claude-haiku-4-5-20251001"),
];
for (role_name, model_name) in &claude_models {
cfg.model_roles
.entry(role_name.to_string())
.or_insert(ModelRole {
provider: "claude".to_string(),
model: model_name.to_string(),
max_tokens: Some(8192),
context_window: Some(200_000),
temperature: Some(0.7),
});
}
if let Some(custom) = &custom_model {
cfg.model_roles.entry(custom.clone()).or_insert(ModelRole {
provider: "claude".to_string(),
model: custom.clone(),
max_tokens: Some(8192),
context_window: Some(200_000),
temperature: Some(0.7),
});
}
// Always prefer the Claude provider + Opus model when settings.json
// is present — this is the user's explicit custom endpoint choice.
cfg.default_provider = "claude".to_string();
cfg.default_model = custom_model.unwrap_or_else(|| "claude-opus-4-8".to_string());
}
impl AppConfigRepository for JsonAppConfigRepository { impl AppConfigRepository for JsonAppConfigRepository {
fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError> { fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError> {
let path = base_dir.join("app_config.json"); let path = base_dir.join("app_config.json");
@@ -87,41 +136,7 @@ impl AppConfigRepository for JsonAppConfigRepository {
} }
if let Some((claude_provider, custom_model)) = detect_claude_settings_provider() { if let Some((claude_provider, custom_model)) = detect_claude_settings_provider() {
cfg.providers apply_claude_provider(&mut cfg, claude_provider, custom_model);
.entry("claude".to_string())
.or_insert(claude_provider);
let claude_models: [(&str, &str); 3] = [
("claude-opus-4-8", "claude-opus-4-8"),
("claude-sonnet-5", "claude-sonnet-5"),
("claude-haiku-4-5", "claude-haiku-4-5-20251001"),
];
for (role_name, model_name) in &claude_models {
cfg.model_roles
.entry(role_name.to_string())
.or_insert(ModelRole {
provider: "claude".to_string(),
model: model_name.to_string(),
max_tokens: Some(8192),
context_window: Some(200_000),
temperature: Some(0.7),
});
}
if let Some(custom) = &custom_model {
cfg.model_roles.entry(custom.clone()).or_insert(ModelRole {
provider: "claude".to_string(),
model: custom.clone(),
max_tokens: Some(8192),
context_window: Some(200_000),
temperature: Some(0.7),
});
}
if cfg.default_provider == defaults.default_provider {
cfg.default_provider = "claude".to_string();
cfg.default_model = custom_model.unwrap_or_else(|| "claude-opus-4-8".to_string());
}
} }
Ok(cfg) Ok(cfg)
@@ -134,3 +149,106 @@ impl AppConfigRepository for JsonAppConfigRepository {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn claude_provider(base: &str, key: Option<&str>) -> ProviderConfig {
ProviderConfig {
api_base: base.to_string(),
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
default_model: Some("claude-opus-4-8".to_string()),
default_api_key: key.map(|s| s.to_string()),
}
}
#[test]
fn claude_settings_parse_env() {
let parsed: ClaudeSettings = serde_json::from_str(
r#"{"env":{"ANTHROPIC_BASE_URL":"https://9router.example/v1","ANTHROPIC_API_KEY":"sk-test"}}"#,
)
.unwrap();
let env = parsed.env.unwrap();
assert_eq!(
env.anthropic_base_url.as_deref(),
Some("https://9router.example/v1")
);
assert_eq!(env.anthropic_api_key.as_deref(), Some("sk-test"));
}
#[test]
fn apply_claude_refreshes_stale_provider_and_sets_opus_default() {
// Simulate a previously-persisted app_config.json with a STALE claude
// provider + non-opus default (e.g. user had switched provider).
let mut cfg = AppConfig {
providers: {
let mut m = HashMap::new();
m.insert(
"claude".to_string(),
claude_provider("https://old.example/v1", Some("sk-old")),
);
m
},
model_roles: HashMap::new(),
default_provider: "router".to_string(),
default_model: "other-model".to_string(),
default_context_window: 256_000,
};
// Detect returned a fresh provider from ~/.claude/settings.json.
apply_claude_provider(
&mut cfg,
claude_provider("https://9router.example/v1", Some("sk-new")),
None,
);
let claude = cfg.providers.get("claude").unwrap();
assert_eq!(claude.api_base, "https://9router.example/v1");
assert_eq!(claude.default_api_key.as_deref(), Some("sk-new"));
// Insert (not or_insert) → stale entry refreshed.
assert_eq!(cfg.default_provider, "claude");
assert_eq!(cfg.default_model, "claude-opus-4-8");
// Claude model roles registered.
assert!(cfg.model_roles.contains_key("claude-opus-4-8"));
assert!(cfg.model_roles.contains_key("claude-sonnet-5"));
assert!(cfg.model_roles.contains_key("claude-haiku-4-5"));
}
#[test]
fn apply_claude_honors_custom_model_from_settings() {
let mut cfg = AppConfig::default();
apply_claude_provider(
&mut cfg,
claude_provider("https://9router.example/v1", Some("sk-new")),
Some("claude-opus-4-8".to_string()),
);
assert_eq!(cfg.default_model, "claude-opus-4-8");
assert!(cfg.model_roles.contains_key("claude-opus-4-8"));
}
#[test]
fn detect_uses_env_creds_as_fallback() {
// When ~/.claude/settings.json is absent/unreadable, the env-var
// fallback should produce a "claude" provider. Set env vars, call
// detect, and assert the resulting provider uses them.
std::env::set_var("ANTHROPIC_BASE_URL", "https://env.example/v1");
std::env::set_var("ANTHROPIC_API_KEY", "sk-env");
match detect_claude_settings_provider() {
Some((provider, _custom)) => {
// If the real settings.json exists it wins (base could be the
// real 9router URL); otherwise env creds are used. Either way,
// the provider must have api_key_env pointing at ANTHROPIC_API_KEY.
assert_eq!(provider.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
}
None => {
// No file + no env (shouldn't happen since we just set env).
panic!("expected env fallback to produce a provider");
}
}
std::env::remove_var("ANTHROPIC_BASE_URL");
std::env::remove_var("ANTHROPIC_API_KEY");
}
}
+62
View File
@@ -0,0 +1,62 @@
//! Process-wide shared Tokio runtime for sync → async bridging.
//!
//! Many `Tool::run` implementations are synchronous but need to drive async
//! work (LLM calls, subagent execution). Creating a fresh
//! [`tokio::runtime::Runtime`] on every call is expensive (spawns a thread
//! pool + runtime each time) and can fail randomly under thread pressure.
//!
//! # Flow
//!
//! [`runtime()`] returns a lazily-initialised process-wide runtime created
//! exactly once via [`std::sync::OnceLock`]. Callers use
//! `runtime().block_on(...)` exactly like they would with a local runtime —
//! the only difference is the runtime is shared, so the cost is paid once per
//! process instead of once per tool call.
//!
//! # Safety
//!
//! `block_on` panics if called from within a running Tokio runtime. The
//! tools that use this helper are synchronous (`Tool::run`), so this is safe
//! in practice. Async code should never call `runtime().block_on`.
use std::sync::OnceLock;
/// Maximum worker threads for the shared runtime. Kept modest — tools are
/// mostly I/O-bound and rarely need more concurrency than this.
const RUNTIME_WORKER_THREADS: usize = 8;
static SHARED_RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
/// Return the process-wide shared Tokio runtime, initialising it on first use.
///
/// The runtime is configured with `worker_threads = 8` and
/// `enable_all()` (time + IO drivers) so streams, timers, and network calls
/// all work. If initialisation fails (extremely rare — resource exhaustion at
/// startup), the process aborts with a clear message rather than returning
/// an error on every subsequent call.
pub fn runtime() -> &'static tokio::runtime::Runtime {
SHARED_RUNTIME.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(RUNTIME_WORKER_THREADS)
.thread_name("zesdex-shared-rt")
.enable_all()
.build()
.expect("failed to create shared tokio runtime")
})
}
#[cfg(test)]
mod tests {
use super::runtime;
#[test]
fn runtime_is_singleton() {
assert!(std::ptr::eq(runtime(), runtime()));
}
#[test]
fn runtime_blocks_and_resolves() {
let val = runtime().block_on(async { 6 * 7 });
assert_eq!(val, 42);
}
}
+62 -3
View File
@@ -23,6 +23,37 @@ use zesdex_domain::subagent_directive;
/// Maximum number of tool-call iterations before the engine gives up. /// Maximum number of tool-call iterations before the engine gives up.
const MAX_ITERATIONS: u32 = 25; const MAX_ITERATIONS: u32 = 25;
/// A single tool-result message is truncated before entering the subagent's
/// context so it cannot blow the window (matches the main turn service).
const TOOL_OUTPUT_MAX_CHARS: usize = 12_000;
/// Maximum consecutive identical tool errors before the engine injects a
/// recovery note steering the model to a different approach.
const MAX_CONSECUTIVE_TOOL_ERRORS: usize = 3;
/// Pick a `max_tokens` budget proportional to the directive's length.
fn adaptive_max_tokens(directive_len: usize) -> u32 {
if directive_len <= 80 {
800
} else if directive_len <= 400 {
1600
} else {
4096
}
}
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
}
/// Emit an `AgentProgress` event onto the turn-event queue, if one is /// Emit an `AgentProgress` event onto the turn-event queue, if one is
/// configured in the `ToolCtx`. /// configured in the `ToolCtx`.
fn report_progress(tool_ctx: &ToolCtx, progress: AgentProgress) { fn report_progress(tool_ctx: &ToolCtx, progress: AgentProgress) {
@@ -85,11 +116,17 @@ pub async fn run_agent(
Some(ctx.base_url.clone()), Some(ctx.base_url.clone()),
); );
let max_tokens = adaptive_max_tokens(directive.len());
// Track repeated tool errors so the agent can recover from a dead end.
let mut consecutive_errors = 0usize;
let mut last_tool = String::new();
// Limited iteration loop so we don't run forever // Limited iteration loop so we don't run forever
for iteration in 0..MAX_ITERATIONS { for iteration in 0..MAX_ITERATIONS {
use zesdex_application::ports::ProviderService; use zesdex_application::ports::ProviderService;
let (response_msg, _usage) = client let (response_msg, _usage) = client
.chat(&messages, Some(defs.clone()), Some(4096), None) .chat(&messages, Some(defs.clone()), Some(max_tokens), Some(0.2))
.await?; .await?;
let content = response_msg.content.clone().unwrap_or_default(); let content = response_msg.content.clone().unwrap_or_default();
@@ -113,7 +150,7 @@ pub async fn run_agent(
&tool_ctx, &tool_ctx,
AgentProgress::running( AgentProgress::running(
"subagent", "subagent",
format!("{}:{}", directive, tool_name), format!("{}:{tool_name}", directive),
Some(tool_name.clone()), Some(tool_name.clone()),
), ),
); );
@@ -127,7 +164,29 @@ pub async fn run_agent(
format!("Unknown tool: {tool_name}") format!("Unknown tool: {tool_name}")
}; };
messages.push(ChatMessage::tool(tc.id.clone(), result)); // Error-recovery: if the same tool keeps failing, inject a
// system note steering the model to a different approach.
if result.starts_with("Error:") {
if last_tool.as_str() == tool_name.as_str() {
consecutive_errors += 1;
} else {
consecutive_errors = 1;
last_tool = tool_name.to_string();
}
if consecutive_errors >= MAX_CONSECUTIVE_TOOL_ERRORS {
messages.push(ChatMessage::system(
zesdex_domain::agent::prompt::error_recovery_note(tool_name, &result),
));
consecutive_errors = 0;
}
} else {
consecutive_errors = 0;
}
messages.push(ChatMessage::tool(
tc.id.clone(),
truncate_tool_output(result),
));
} }
// Add assistant response if there was text content // Add assistant response if there was text content
+10 -3
View File
@@ -63,15 +63,21 @@ impl SubagentProvider {
/// Resolve subagent provider and model from settings. /// Resolve subagent provider and model from settings.
/// ///
/// Flow: reads `settings.provider` and `settings.model` → if model is empty, /// Flow: reads `settings.provider` and `settings.model` → if provider is
/// empty, falls back to `app_config.default_provider` → if model is empty,
/// falls back to the provider config's `default_model` → if that is also /// falls back to the provider config's `default_model` → if that is also
/// empty, uses the domain default model constant. /// empty, uses `app_config.default_model` → finally the domain default model
/// constant.
#[instrument] #[instrument]
pub fn resolve_subagent_provider( pub fn resolve_subagent_provider(
settings: &zesdex_domain::cms::Settings, settings: &zesdex_domain::cms::Settings,
app_config: &zesdex_domain::cms::AppConfig, app_config: &zesdex_domain::cms::AppConfig,
) -> (String, String) { ) -> (String, String) {
let provider = settings.provider.clone(); let provider = if settings.provider.is_empty() {
app_config.default_provider.clone()
} else {
settings.provider.clone()
};
let model = settings.model.clone(); let model = settings.model.clone();
// Use the default model from the provider config if available // Use the default model from the provider config if available
@@ -80,6 +86,7 @@ pub fn resolve_subagent_provider(
.providers .providers
.get(&provider) .get(&provider)
.and_then(|p| p.default_model.clone()) .and_then(|p| p.default_model.clone())
.or_else(|| Some(app_config.default_model.clone()))
.unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_MODEL.to_string()) .unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_MODEL.to_string())
} else { } else {
model model
+1 -2
View File
@@ -32,7 +32,6 @@ pub fn spawn_subagent(
) -> thread::JoinHandle<Result<String>> { ) -> thread::JoinHandle<Result<String>> {
info!("Spawning subagent: {directive}"); info!("Spawning subagent: {directive}");
thread::spawn(move || { thread::spawn(move || {
let rt = tokio::runtime::Runtime::new()?; crate::runtime::runtime().block_on(run_agent(ctx, &directive, access, tool_ctx))
rt.block_on(run_agent(ctx, &directive, access, tool_ctx))
}) })
} }
@@ -123,7 +123,7 @@ impl Tool for ParallelDelegate {
.collect() .collect()
} else { } else {
// Auto-split using LLM // Auto-split using LLM
let rt = tokio::runtime::Runtime::new()?; let rt = crate::runtime::runtime();
let directives = rt.block_on(auto_split_task( let directives = rt.block_on(auto_split_task(
&task, &task,
max_parallel, max_parallel,
@@ -146,49 +146,53 @@ impl Tool for ParallelDelegate {
"parallel delegation: starting subagents" "parallel delegation: starting subagents"
); );
// Spawn agents in parallel // Spawn agents in parallel — bounded: never more than `max_parallel`
let mut handles = Vec::new(); // subagent threads in flight at once (Claude Code-style isolation).
for (i, (directive, access)) in directives.iter().enumerate() {
let subagent_ctx = SubagentContext::new(
directive.clone(),
ctx.clone(),
format!("{access:?}"),
base_url.clone(),
api_key.clone(),
model.clone(),
);
debug!(agent_index = i, access = ?access, "spawning parallel agent");
let handle = spawn_subagent(subagent_ctx, directive.clone(), *access, ctx.clone());
handles.push((i, handle));
}
// Join all results
let mut results: Vec<(usize, String, String)> = Vec::new(); let mut results: Vec<(usize, String, String)> = Vec::new();
for (i, handle) in handles { for batch in directives.chunks(max_parallel) {
match handle.join() { let mut handles = Vec::with_capacity(batch.len());
Ok(Ok(output)) => { for (i, (directive, access)) in batch.iter().enumerate() {
info!(agent_index = i, "parallel agent completed"); let global_idx = results.len() + i;
results.push((i, directives[i].0.clone(), output)); let subagent_ctx = SubagentContext::new(
} directive.clone(),
Ok(Err(e)) => { ctx.clone(),
warn!(agent_index = i, error = %e, "parallel agent failed"); format!("{access:?}"),
results.push((i, directives[i].0.clone(), format!("[ERROR] {e}"))); base_url.clone(),
} api_key.clone(),
Err(e) => { model.clone(),
warn!(agent_index = i, error = ?e, "parallel agent panicked"); );
results.push((
i, debug!(agent_index = global_idx, access = ?access, "spawning parallel agent");
directives[i].0.clone(), let handle = spawn_subagent(subagent_ctx, directive.clone(), *access, ctx.clone());
"[ERROR] Agent panicked".to_string(), handles.push((global_idx, handle));
)); }
// Join this batch before spawning the next.
for (i, handle) in handles {
match handle.join() {
Ok(Ok(output)) => {
info!(agent_index = i, "parallel agent completed");
results.push((i, directives[i].0.clone(), output));
}
Ok(Err(e)) => {
warn!(agent_index = i, error = %e, "parallel agent failed");
results.push((i, directives[i].0.clone(), format!("[ERROR] {e}")));
}
Err(e) => {
warn!(agent_index = i, error = ?e, "parallel agent panicked");
results.push((
i,
directives[i].0.clone(),
"[ERROR] Agent panicked".to_string(),
));
}
} }
} }
} }
// Consolidate results // Consolidate results
if synthesize && results.len() > 1 { if synthesize && results.len() > 1 {
let rt = tokio::runtime::Runtime::new()?; let rt = crate::runtime::runtime();
let consolidated = let consolidated =
rt.block_on(consolidate_results(&results, &base_url, &api_key, &model))?; rt.block_on(consolidate_results(&results, &base_url, &api_key, &model))?;
Ok(format!( Ok(format!(
@@ -42,6 +42,7 @@ pub fn all_tools() -> Vec<Box<dyn super::Tool>> {
// ── Best-practice tools (built-in) ───────────────────────── // ── Best-practice tools (built-in) ─────────────────────────
Box::new(super::best_practice::BestPractice), Box::new(super::best_practice::BestPractice),
Box::new(super::best_practice::CommitConvention), Box::new(super::best_practice::CommitConvention),
Box::new(crate::best_practice::explore::ExploreCodebase),
] ]
} }
@@ -63,7 +63,7 @@ impl crate::tools::Tool for DirCacheUpdate {
// Persist the resolved paths into the shared DirCache so the TUI // Persist the resolved paths into the shared DirCache so the TUI
// and other tools can read the cached listing without re-scanning. // and other tools can read the cached listing without re-scanning.
let dc = ctx.dir_cache.clone(); let dc = ctx.dir_cache.clone();
let rt = tokio::runtime::Runtime::new()?; let rt = crate::runtime::runtime();
rt.block_on(async { dc.write().await.set(resolved).await }); rt.block_on(async { dc.write().await.set(resolved).await });
info!(count, "directory cache updated"); info!(count, "directory cache updated");
+2 -2
View File
@@ -65,7 +65,7 @@ impl Tool for WorkflowRun {
zesdex_domain::agent::defaults::DEFAULT_MODEL.to_string(), zesdex_domain::agent::defaults::DEFAULT_MODEL.to_string(),
None, None,
); );
let rt = tokio::runtime::Runtime::new()?; let rt = crate::runtime::runtime();
let result: Vec<String> = let result: Vec<String> =
rt.block_on(async { execute_workflow(&script, ctx, &llm_client).await })?; rt.block_on(async { execute_workflow(&script, ctx, &llm_client).await })?;
@@ -229,7 +229,7 @@ impl Tool for HiveMind {
.ok_or_else(|| anyhow::anyhow!("missing 'cycles' array"))?; .ok_or_else(|| anyhow::anyhow!("missing 'cycles' array"))?;
info!("Hive mind starting with {} cycles", cycles_val.len()); info!("Hive mind starting with {} cycles", cycles_val.len());
let rt = tokio::runtime::Runtime::new()?; let rt = crate::runtime::runtime();
let mut all_node_outputs = Vec::new(); let mut all_node_outputs = Vec::new();
for (cycle_idx, cycle_val) in cycles_val.iter().enumerate() { for (cycle_idx, cycle_val) in cycles_val.iter().enumerate() {
@@ -1,11 +1,15 @@
//! Hive-mind cycle execution — run one cycle of parallel nodes. //! Hive-mind cycle execution — run one cycle of parallel nodes.
//! //!
//! Flow: load settings → resolve LLM credentials → run all directives in the //! Flow: load settings → resolve LLM credentials → run all directives in the
//! cycle concurrently via try_join_all → collect Vec<NodeOutput>. //! cycle concurrently via a BOUNDED buffer (`buffer_unordered(MAX)`) → collect
//! `Vec<NodeOutput>`. Unlike `try_join_all`, a single failing node does NOT
//! fail the whole cycle — failed nodes are logged and replaced with an
//! `[ERROR]` output so the remaining results are preserved (like Claude
//! Code's isolated subagents).
use anyhow::Result; use anyhow::Result;
use futures_util::future::try_join_all; use futures_util::stream::StreamExt;
use tracing::info; use tracing::{info, warn};
use zesdex_domain::cms::{AppConfigRepository, SettingsRepository}; use zesdex_domain::cms::{AppConfigRepository, SettingsRepository};
use zesdex_domain::core::Store; use zesdex_domain::core::Store;
@@ -15,16 +19,21 @@ use crate::subagent::context::SubagentContext;
use crate::subagent::division::AccessTier; use crate::subagent::division::AccessTier;
use crate::subagent::engine::run_agent; use crate::subagent::engine::run_agent;
use crate::tools::ToolCtx; use crate::tools::ToolCtx;
use zesdex_domain::workflow::{CognitiveCycle, NodeOutput}; use zesdex_domain::workflow::{CognitiveCycle, NodeDirective, NodeOutput};
/// Maximum number of hive-mind nodes running concurrently per cycle.
/// Keeps thread/runtime pressure bounded (Claude Code-style).
const MAX_CONCURRENT_NODES: usize = 8;
/// Execute one cycle: run each node directive and collect outputs. /// Execute one cycle: run each node directive and collect outputs.
/// ///
/// Flow: /// Flow:
/// 1. Load `Settings` and `AppConfig` from the store directory. /// 1. Load `Settings` and `AppConfig` from the store directory.
/// 2. Resolve provider, model, base_url, and api_key. /// 2. Resolve provider, model, base_url, and api_key.
/// 3. Spawn all directives concurrently — each builds a `SubagentContext` /// 3. Spawn directives with bounded concurrency — each builds a
/// and calls `run_agent` (Full access). /// `SubagentContext` and calls `run_agent`.
/// 4. `try_join_all` waits for all to complete, then collect `NodeOutput`s. /// 4. Collect `NodeOutput`s; failed nodes are logged and replaced with an
/// `[ERROR]` placeholder so the cycle still completes.
pub async fn execute_cycle(cycle: &CognitiveCycle, tool_ctx: &ToolCtx) -> Result<Vec<NodeOutput>> { pub async fn execute_cycle(cycle: &CognitiveCycle, tool_ctx: &ToolCtx) -> Result<Vec<NodeOutput>> {
info!( info!(
"Executing cycle {} with {} directives", "Executing cycle {} with {} directives",
@@ -53,10 +62,9 @@ pub async fn execute_cycle(cycle: &CognitiveCycle, tool_ctx: &ToolCtx) -> Result
let cycle_index = cycle.index; let cycle_index = cycle.index;
use zesdex_domain::workflow::NodeDirective; // Run all directives with bounded concurrency. Each node is its own
// future; failures are collected, not propagated (isolated errors).
// Run all directives in this cycle concurrently. let tasks: Vec<_> = cycle
let handles: Vec<_> = cycle
.directives .directives
.iter() .iter()
.enumerate() .enumerate()
@@ -79,18 +87,33 @@ pub async fn execute_cycle(cycle: &CognitiveCycle, tool_ctx: &ToolCtx) -> Result
_ => AccessTier::Read, _ => AccessTier::Read,
}; };
let node_id = format!("Node-{}-{}", cycle_index, i);
async move { async move {
let result = run_agent(ctx, &dir, access, tc).await?; match run_agent(ctx, &dir, access, tc).await {
Ok::<NodeOutput, anyhow::Error>(NodeOutput { Ok(output) => Ok::<NodeOutput, anyhow::Error>(NodeOutput {
id: format!("Node-{}-{}", cycle_index, i), id: node_id.clone(),
directive: dir, directive: dir,
output: result, output,
}) }),
Err(e) => {
warn!(node = %node_id, error = %e, "hive-mind node failed (isolated)");
Ok::<NodeOutput, anyhow::Error>(NodeOutput {
id: node_id,
directive: dir,
output: format!("[ERROR] {e}"),
})
}
}
} }
}) })
.collect(); .collect();
let results = try_join_all(handles).await?; // Bounded concurrency: run at most MAX_CONCURRENT_NODES futures at once.
let mut stream = futures_util::stream::iter(tasks).buffer_unordered(MAX_CONCURRENT_NODES);
let mut results = Vec::with_capacity(cycle.directives.len());
while let Some(node) = stream.next().await {
results.push(node?);
}
Ok(results) Ok(results)
} }
+1 -2
View File
@@ -471,8 +471,7 @@ fn handle_compact(state: &mut AppStateRest) {
let client = zesdex_infrastructure::llm::provider::LlmClient::new(api_key, model, api_base); let client = zesdex_infrastructure::llm::provider::LlmClient::new(api_key, model, api_base);
if let Some(ref mut rt) = state.session_runtime { if let Some(ref mut rt) = state.session_runtime {
let tokio_rt = let tokio_rt = zesdex_infrastructure::runtime::runtime();
tokio::runtime::Runtime::new().expect("create tokio runtime for AI compaction");
if let Ok(()) = tokio_rt.block_on( if let Ok(()) = tokio_rt.block_on(
zesdex_application::agent::turn_service::compact_messages_with_ai( zesdex_application::agent::turn_service::compact_messages_with_ai(
&mut rt.messages, &mut rt.messages,
+1 -25
View File
@@ -110,13 +110,6 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
api_base: api_base.clone(), 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 client = std::sync::Arc::new(LlmClient::new(api_key, model, api_base));
let tool_ctx = ToolCtx::builder() let tool_ctx = ToolCtx::builder()
@@ -125,29 +118,12 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
.turn_events(turn_events) .turn_events(turn_events)
.build(); .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 tool_executor = std::sync::Arc::new(InfrastructureToolExecutor::new(tool_ctx));
let tools = all_tools(); let tools = all_tools();
let defs = tool_defs(&tools); let defs = tool_defs(&tools);
// Wire the mandatory explore phase (3+ parallel subagents). let turn_service = AgentTurnServiceImpl::new(client, tool_executor, defs);
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);
tokio::spawn(async move { tokio::spawn(async move {
let _ = turn_service.run_turn(params).await; let _ = turn_service.run_turn(params).await;