feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators feat(tui): create workflow panel for agent status and progress visualization feat(web): introduce web frontend interface with static file serving feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
//! Auto-subagents — automatically run review/test subagents at the end of
|
||||
//! each turn.
|
||||
|
||||
pub mod paths;
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Auto-subagent path resolution.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Resolve paths for auto-subagent scripts.
|
||||
pub fn auto_subagent_dir(base_dir: &PathBuf) -> PathBuf {
|
||||
base_dir.join("auto-agents")
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Subagent execution context — wraps the shared state needed by a subagent.
|
||||
//!
|
||||
//! Includes LLM connection parameters (base_url, api_key, model) so the
|
||||
//! engine can construct an `LlmClient` without loading settings itself.
|
||||
|
||||
use crate::tools::ToolCtx;
|
||||
|
||||
/// Context for a single subagent execution.
|
||||
///
|
||||
/// Flow: constructed by the caller (e.g. `execute_primitive`) with resolved
|
||||
/// LLM credentials → passed to `engine::run_agent` → used to create the
|
||||
/// `LlmClient` for LLM interaction.
|
||||
pub struct SubagentContext {
|
||||
/// The directive/instruction the subagent should execute.
|
||||
pub directive: String,
|
||||
/// Shared tool execution context (workspaces, session, memory paths).
|
||||
pub tool_ctx: ToolCtx,
|
||||
/// Access tier as a string (used for logging/serialization).
|
||||
pub access_tier: String,
|
||||
/// Base URL for the LLM provider API.
|
||||
pub base_url: String,
|
||||
/// API key for the LLM provider.
|
||||
pub api_key: String,
|
||||
/// Model identifier for the LLM provider.
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
impl SubagentContext {
|
||||
/// Create a new subagent context with all required fields.
|
||||
pub fn new(
|
||||
directive: String,
|
||||
tool_ctx: ToolCtx,
|
||||
access_tier: String,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
) -> Self {
|
||||
SubagentContext {
|
||||
directive,
|
||||
tool_ctx,
|
||||
access_tier,
|
||||
base_url,
|
||||
api_key,
|
||||
model,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Subagent division — access-tier tool filtering for subagent permissions.
|
||||
//!
|
||||
//! Flow: the calling code picks an `AccessTier` → `tools_for()` returns the
|
||||
//! subset of all built-in tools allowed at that tier → those tools are passed
|
||||
//! to `engine::run_agent` for the subagent's tool-execution loop.
|
||||
|
||||
use crate::tools::Tool;
|
||||
|
||||
/// Access tier for subagent tool permissions.
|
||||
///
|
||||
/// Tiers are cumulative: `Write` includes everything in `Read`, and `Full`
|
||||
/// includes everything in `Write`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccessTier {
|
||||
/// Read-only: search, read, glob, utility tools (no mutations).
|
||||
Read,
|
||||
/// Read + Write: above plus write, edit, delete, git, memory.
|
||||
Write,
|
||||
/// Full: above plus bash, shell, LSP, workflow, plan tools.
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Filter the available tools to match the given access tier.
|
||||
///
|
||||
/// Flow: `all_tools()` → filter by tier → return owned `Vec<Box<dyn Tool>>`.
|
||||
///
|
||||
/// Read tier: non-mutating introspection and utility tools only.
|
||||
/// Write tier: everything except dangerous system/network/process tools.
|
||||
/// Full tier: all 37 tools.
|
||||
pub fn tools_for(access: &AccessTier) -> Vec<Box<dyn Tool>> {
|
||||
let all = crate::tools::all_tools();
|
||||
|
||||
match access {
|
||||
AccessTier::Read => all
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
let name = t.name();
|
||||
matches!(
|
||||
name,
|
||||
"read"
|
||||
| "grep"
|
||||
| "glob"
|
||||
| "pong"
|
||||
| "todowrite"
|
||||
| "todofinish"
|
||||
| "dir_list"
|
||||
| "dir_cache_update"
|
||||
| "cd"
|
||||
| "remember"
|
||||
| "recall"
|
||||
| "forget"
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
|
||||
AccessTier::Write => all
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
let name = t.name();
|
||||
!matches!(
|
||||
name,
|
||||
"bash"
|
||||
| "bash_output"
|
||||
| "bash_kill"
|
||||
| "git_operator"
|
||||
| "git_worktree"
|
||||
| "git_cred"
|
||||
| "shell"
|
||||
| "workflow_run"
|
||||
| "note_finding"
|
||||
| "read_findings"
|
||||
| "hive_mind"
|
||||
| "spawn_agents"
|
||||
| "spawn_pipeline"
|
||||
| "plan_enter"
|
||||
| "plan_ready"
|
||||
| "sequential_think"
|
||||
| "lsp_connect"
|
||||
| "lsp_disconnect"
|
||||
| "lsp_hover"
|
||||
| "lsp_completion"
|
||||
| "lsp_definition"
|
||||
| "lsp_references"
|
||||
| "lsp_diagnostics"
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
|
||||
AccessTier::Full => all, // everything
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Subagent engine — runs an LLM-powered agent with tool execution loop.
|
||||
//!
|
||||
//! Flow: construct system message → call LLM → parse tool calls → execute
|
||||
//! tools → continue until the model returns a final text response (no more
|
||||
//! tool calls) or the iteration limit is reached.
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::llm::provider::LlmClient;
|
||||
use crate::subagent::context::SubagentContext;
|
||||
use crate::subagent::division::{tools_for, AccessTier};
|
||||
use crate::tools::{tool_defs, ToolCtx};
|
||||
use zesdex_domain::core::tool_call::sanitize_tool_arguments;
|
||||
use zesdex_domain::core::ChatMessage;
|
||||
|
||||
/// Maximum number of tool-call iterations before the engine gives up.
|
||||
const MAX_ITERATIONS: u32 = 25;
|
||||
|
||||
/// Run an agent with a directive, access tier, and tool context.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Resolve allowed tools for the given `access` tier.
|
||||
/// 2. Build a system prompt from the directive.
|
||||
/// 3. Loop (up to `MAX_ITERATIONS`):
|
||||
/// a. Call the LLM (non-streaming) with accumulated messages + tool defs.
|
||||
/// b. If the response has no tool calls → return the text content.
|
||||
/// c. Otherwise execute each tool call and append the result as a
|
||||
/// tool-role message.
|
||||
/// d. If the response also contained text, append an assistant message.
|
||||
/// 4. If the loop exits naturally, return the iteration-limit message.
|
||||
pub async fn run_agent(
|
||||
ctx: SubagentContext,
|
||||
directive: &str,
|
||||
access: AccessTier,
|
||||
tool_ctx: ToolCtx,
|
||||
) -> Result<String> {
|
||||
info!("Subagent starting with directive: {directive}");
|
||||
|
||||
let tools = tools_for(&access);
|
||||
let defs = tool_defs(&tools);
|
||||
|
||||
let mut messages = vec![ChatMessage::system(format!(
|
||||
"You are a focused subagent.\n\nYour directive:\n{directive}\n\n\
|
||||
Complete the directive autonomously using the tools available to you. \
|
||||
Return your final answer when done."
|
||||
))];
|
||||
|
||||
let client = LlmClient::new(
|
||||
ctx.api_key.clone(),
|
||||
ctx.model.clone(),
|
||||
Some(ctx.base_url.clone()),
|
||||
);
|
||||
|
||||
// Limited iteration loop so we don't run forever
|
||||
for iteration in 0..MAX_ITERATIONS {
|
||||
let (response_msg, _usage) = client.chat_with_tools_non_streaming(
|
||||
&messages,
|
||||
Some(defs.clone()),
|
||||
Some(4096),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
|
||||
let content = response_msg.content.clone().unwrap_or_default();
|
||||
let tool_calls = response_msg.tool_calls.unwrap_or_default();
|
||||
|
||||
// If no tool calls, we're done — return content
|
||||
if tool_calls.is_empty() {
|
||||
info!("Subagent completed after {iteration} iterations");
|
||||
return Ok(content);
|
||||
}
|
||||
|
||||
// Execute tool calls
|
||||
for tc in &tool_calls {
|
||||
let tool_name = &tc.function.name;
|
||||
let args = sanitize_tool_arguments(&tc.function.arguments);
|
||||
|
||||
debug!("Subagent executing tool: {tool_name}");
|
||||
|
||||
let result = if let Some(tool) = tools.iter().find(|t| t.name() == tool_name) {
|
||||
match tool.run(&tool_ctx, &args) {
|
||||
Ok(output) => output,
|
||||
Err(e) => format!("Error: {e}"),
|
||||
}
|
||||
} else {
|
||||
format!("Unknown tool: {tool_name}")
|
||||
};
|
||||
|
||||
messages.push(ChatMessage::tool(tc.id.clone(), result));
|
||||
}
|
||||
|
||||
// Add assistant response if there was text content
|
||||
if !content.is_empty() {
|
||||
messages.push(ChatMessage::assistant(Some(content)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok("Subagent reached iteration limit".to_string())
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Subagent event types — events emitted during subagent execution.
|
||||
|
||||
/// Events emitted by a running subagent.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SubagentEvent {
|
||||
Started {
|
||||
agent_id: String,
|
||||
directive: String,
|
||||
},
|
||||
ToolCall {
|
||||
agent_id: String,
|
||||
tool_name: String,
|
||||
},
|
||||
ToolResult {
|
||||
agent_id: String,
|
||||
tool_name: String,
|
||||
output: String,
|
||||
},
|
||||
Completed {
|
||||
agent_id: String,
|
||||
output: String,
|
||||
},
|
||||
Failed {
|
||||
agent_id: String,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Subagent gating — decide whether to run review/test/arch agents based
|
||||
//! on the current context.
|
||||
|
||||
/// Determine whether an auto-review should be triggered after an edit.
|
||||
pub fn should_review(edit_count: u32, consecutive_empty_reviews: u32, max_skip: u32) -> bool {
|
||||
if edit_count == 0 {
|
||||
return false;
|
||||
}
|
||||
// Skip review if we've had several consecutive empty reviews
|
||||
if consecutive_empty_reviews >= max_skip {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Subagent spawning and execution engine — spawn managed sub-processes
|
||||
//! for test generation, architecture review, security review, etc.
|
||||
|
||||
pub mod context;
|
||||
pub mod division;
|
||||
pub mod engine;
|
||||
pub mod event;
|
||||
pub mod gating;
|
||||
pub mod provider;
|
||||
pub mod spawn;
|
||||
pub mod tools;
|
||||
pub mod workspace;
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Subagent LLM provider — resolves provider/model from settings and wraps
|
||||
//! `LlmClient` in a higher-level API for subagent use.
|
||||
//!
|
||||
//! Flow: `resolve_subagent_provider` is called at startup to pick a provider
|
||||
//! + model → `SubagentProvider` wraps that pair around an `LlmClient` for use
|
||||
//! inside the subagent engine loop.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::llm::provider::LlmClient;
|
||||
use crate::tools::{tool_defs, Tool};
|
||||
use zesdex_domain::core::ChatMessage;
|
||||
|
||||
/// Provider wrapper for subagent LLM interactions.
|
||||
///
|
||||
/// Provides two convenience methods (`chat`, `chat_with_tools`) that
|
||||
/// abstract away the raw `LlmClient` parameter plumbing so the engine
|
||||
/// loop only deals with messages and tools.
|
||||
///
|
||||
/// The model identifier is already embedded in the `LlmClient` itself
|
||||
/// (its `model` field), so `SubagentProvider` does not duplicate it.
|
||||
pub struct SubagentProvider {
|
||||
client: LlmClient,
|
||||
}
|
||||
|
||||
impl SubagentProvider {
|
||||
/// Wrap an existing `LlmClient` for higher-level use.
|
||||
pub fn new(client: LlmClient) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Send messages to the LLM without any tool definitions.
|
||||
///
|
||||
/// Use this for a plain text-in/text-out conversation.
|
||||
pub fn chat(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||
self.client
|
||||
.chat_with_tools_non_streaming(messages, None, Some(4096), None, None)
|
||||
}
|
||||
|
||||
/// Send messages with available tool definitions.
|
||||
///
|
||||
/// Automatically converts the `&[Box<dyn Tool>]` slice to
|
||||
/// `Vec<ToolDef>` before passing to the underlying client.
|
||||
pub fn chat_with_tools(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
tools: &[Box<dyn Tool>],
|
||||
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||
let defs = tool_defs(tools);
|
||||
self.client
|
||||
.chat_with_tools_non_streaming(messages, Some(defs), Some(4096), None, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve subagent provider and model from settings.
|
||||
///
|
||||
/// Flow: reads `settings.provider` and `settings.model` → if model is empty,
|
||||
/// falls back to the provider config's `default_model` → if that is also
|
||||
/// empty, uses `"deepseek-v4-flash-free"` as the ultimate default.
|
||||
pub fn resolve_subagent_provider(
|
||||
settings: &zesdex_domain::cms::Settings,
|
||||
app_config: &zesdex_domain::cms::AppConfig,
|
||||
) -> (String, String) {
|
||||
let provider = settings.provider.clone();
|
||||
let model = settings.model.clone();
|
||||
|
||||
// Use the default model from the provider config if available
|
||||
let model = if model.is_empty() {
|
||||
app_config
|
||||
.providers
|
||||
.get(&provider)
|
||||
.and_then(|p| p.default_model.clone())
|
||||
.unwrap_or_else(|| "deepseek-v4-flash-free".to_string())
|
||||
} else {
|
||||
model
|
||||
};
|
||||
|
||||
(provider, model)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Subagent spawning — launch a subagent on a background OS thread.
|
||||
//!
|
||||
//! Flow: creates a new tokio runtime on a dedicated OS thread, then
|
||||
//! `block_on` the engine's `run_agent` future. Returns a
|
||||
//! `JoinHandle<Result<String>>` the caller can `.join()`.
|
||||
|
||||
use std::thread;
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
use crate::subagent::context::SubagentContext;
|
||||
use crate::subagent::division::AccessTier;
|
||||
use crate::subagent::engine::run_agent;
|
||||
use crate::tools::ToolCtx;
|
||||
|
||||
/// Spawn a subagent on a background OS thread.
|
||||
///
|
||||
/// The subagent runs inside its own tokio runtime so it can make async calls
|
||||
/// without blocking the calling thread's runtime.
|
||||
///
|
||||
/// Flow: `thread::spawn` → create `tokio::runtime::Runtime` →
|
||||
/// `runtime.block_on(run_agent(...))` → return.
|
||||
///
|
||||
/// Returns a `JoinHandle` the caller can `join()` to await the result.
|
||||
pub fn spawn_subagent(
|
||||
ctx: SubagentContext,
|
||||
directive: String,
|
||||
access: AccessTier,
|
||||
tool_ctx: ToolCtx,
|
||||
) -> thread::JoinHandle<Result<String>> {
|
||||
info!("Spawning subagent: {directive}");
|
||||
thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(run_agent(ctx, &directive, access, tool_ctx))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Subagent tool helpers — wrap tool execution for subagent use.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Execute a single tool call within a subagent context.
|
||||
pub fn execute_tool_call(
|
||||
tool: &dyn Tool,
|
||||
ctx: &ToolCtx,
|
||||
args: &serde_json::Value,
|
||||
) -> Result<String> {
|
||||
tool.run(ctx, args)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Subagent workspace management — create isolated workspaces for subagents.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Create an isolated workspace directory for a subagent.
|
||||
pub fn create_subagent_workspace(base_dir: &PathBuf, agent_id: &str) -> anyhow::Result<PathBuf> {
|
||||
let ws = base_dir.join("subagent-workspaces").join(agent_id);
|
||||
std::fs::create_dir_all(&ws)?;
|
||||
Ok(ws)
|
||||
}
|
||||
Reference in New Issue
Block a user