feat(tui): introduce comprehensive state management for TUI interface

- Add AppStateRest as the central state struct for managing TUI state.
- Implement InputState for handling user input, autocomplete, and history.
- Create MiscState to manage overlays, notifications, and editor state.
- Introduce ScrollState for viewport scrolling functionality.
- Develop TranscriptCache for efficient message rendering in the chat pane.
- Implement SimpleAgent and SimpleWorkflowEngine for agent lifecycle management.
- Add helper functions for managing effort levels and token counting.
- Organize state-related modules for better maintainability and clarity.
This commit is contained in:
asepharyana
2026-07-21 06:42:53 +07:00
parent 802346f909
commit 8c58faf292
25 changed files with 2594 additions and 2158 deletions
+72 -32
View File
@@ -3,6 +3,10 @@
//! 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.
//!
//! Progress reporting: when a `TurnEvent` queue is available via the
//! `ToolCtx`, the engine emits `AgentProgress` events so the TUI can show
//! which tool the subagent is currently executing.
use anyhow::Result;
use tracing::{debug, info, instrument};
@@ -11,17 +15,44 @@ 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::agent::progress::AgentProgress;
use zesdex_domain::core::tool_call::sanitize_tool_arguments;
use zesdex_domain::core::ChatMessage;
use zesdex_domain::subagent_directive;
/// Maximum number of tool-call iterations before the engine gives up.
const MAX_ITERATIONS: u32 = 25;
/// Emit an `AgentProgress` event onto the turn-event queue, if one is
/// configured in the `ToolCtx`.
fn report_progress(tool_ctx: &ToolCtx, progress: AgentProgress) {
if let Some(ref queue) = tool_ctx.turn_events {
if let Ok(mut q) = queue.lock() {
q.push_back(zesdex_domain::agent::TurnEvent::AgentProgress(progress));
}
}
}
/// Build the system message for a subagent, including current working
/// directory and workspace root information.
fn build_system_message(directive: &str, tool_ctx: &ToolCtx) -> ChatMessage {
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "unknown".to_string());
let ws_root = tool_ctx
.workspaces
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| cwd.clone());
ChatMessage::system(subagent_directive(directive, &cwd, &ws_root))
}
/// 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.
/// 2. Build a system prompt from the directive using the domain prompt module.
/// 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.
@@ -29,6 +60,9 @@ const MAX_ITERATIONS: u32 = 25;
/// 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.
///
/// Progress: each tool invocation is reported via `AgentProgress` if a
/// turn-event queue is available in the `ToolCtx`.
#[instrument(skip(ctx, tool_ctx))]
pub async fn run_agent(
ctx: SubagentContext,
@@ -41,23 +75,9 @@ pub async fn run_agent(
let tools = tools_for(&access);
let defs = tool_defs(&tools);
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "unknown".to_string());
let ws_root = tool_ctx
.workspaces
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| cwd.clone());
let sys_msg = build_system_message(directive, &tool_ctx);
let mut messages = vec![ChatMessage::system(format!(
"You are a focused subagent.\n\n\
Current directory (PWD): {cwd}\n\
Workspace root: {ws_root}\n\n\
Your directive:\n{directive}\n\n\
Complete the directive autonomously using the tools available to you. \
Return your final answer when done."
))];
let mut messages = vec![sys_msg];
let client = LlmClient::new(
ctx.api_key.clone(),
@@ -68,12 +88,9 @@ pub async fn run_agent(
// Limited iteration loop so we don't run forever
for iteration in 0..MAX_ITERATIONS {
use zesdex_application::ports::ProviderService;
let (response_msg, _usage) = client.chat(
&messages,
Some(defs.clone()),
Some(4096),
None,
).await?;
let (response_msg, _usage) = client
.chat(&messages, Some(defs.clone()), Some(4096), None)
.await?;
let content = response_msg.content.clone().unwrap_or_default();
let tool_calls = response_msg.tool_calls.unwrap_or_default();
@@ -81,6 +98,10 @@ pub async fn run_agent(
// If no tool calls, we're done — return content
if tool_calls.is_empty() {
info!("Subagent completed after {iteration} iterations");
report_progress(
&tool_ctx,
AgentProgress::completed("subagent", directive),
);
return Ok(content);
}
@@ -91,14 +112,24 @@ pub async fn run_agent(
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}")
};
report_progress(
&tool_ctx,
AgentProgress::running(
"subagent",
format!("{}:{}", directive, tool_name),
Some(tool_name.clone()),
),
);
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));
}
@@ -109,5 +140,14 @@ pub async fn run_agent(
}
}
Ok("Subagent reached iteration limit".to_string())
info!("Subagent reached iteration limit ({MAX_ITERATIONS})");
report_progress(
&tool_ctx,
AgentProgress::failed(
"subagent",
directive,
format!("iteration limit ({MAX_ITERATIONS})"),
),
);
Ok(format!("Subagent reached iteration limit ({MAX_ITERATIONS})"))
}
+109
View File
@@ -0,0 +1,109 @@
//! Tool execution context: shared state passed to every `Tool::run` call.
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
/// Shared execution context passed to every `Tool::run` call: workspace roots,
/// session paths, cached directory state, and workflow-level findings sharing.
#[derive(Clone)]
pub struct ToolCtx {
pub workspaces: Vec<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: Arc<tokio::sync::RwLock<crate::DirCache>>,
pub mention_index: crate::MentionIndex,
pub origin: crate::Origin,
pub graduated_checks: Vec<crate::tools::GraduatedCheck>,
pub lsp_manager: Arc<Mutex<crate::lsp::manager::LspManager>>,
pub turn_events:
Option<Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>>,
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
pub abort_flag: Option<Arc<AtomicBool>>,
}
impl ToolCtx {
pub fn builder() -> ToolCtxBuilder {
ToolCtxBuilder::default()
}
}
/// Builder for `ToolCtx`; fields default to empty paths and a fresh `DirCache`.
#[derive(Clone)]
pub struct ToolCtxBuilder {
pub workspaces: Vec<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: Arc<tokio::sync::RwLock<crate::DirCache>>,
pub mention_index: crate::MentionIndex,
pub origin: crate::Origin,
pub graduated_checks: Vec<crate::tools::GraduatedCheck>,
pub lsp_manager: Arc<Mutex<crate::lsp::manager::LspManager>>,
pub turn_events:
Option<Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>>,
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
pub abort_flag: Option<Arc<AtomicBool>>,
}
impl Default for ToolCtxBuilder {
fn default() -> Self {
ToolCtxBuilder {
workspaces: Vec::new(),
session_dir: PathBuf::new(),
memory_dir: PathBuf::new(),
worktrees_dir: PathBuf::new(),
dir_cache: Arc::new(tokio::sync::RwLock::new(crate::DirCache::new())),
mention_index: crate::MentionIndex::new(),
origin: crate::Origin::Main,
graduated_checks: Vec::new(),
lsp_manager: Arc::new(Mutex::new(crate::lsp::manager::LspManager::new())),
turn_events: None,
workflow_findings: None,
abort_flag: None,
}
}
}
impl ToolCtxBuilder {
pub fn session_dir(mut self, v: PathBuf) -> Self {
self.session_dir = v;
self
}
pub fn workspaces(mut self, v: Vec<PathBuf>) -> Self {
self.workspaces = v;
self
}
pub fn origin(mut self, v: crate::Origin) -> Self {
self.origin = v;
self
}
pub fn turn_events(
mut self,
v: Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>,
) -> Self {
self.turn_events = Some(v);
self
}
pub fn workflow_findings(mut self, v: Option<Arc<Mutex<Vec<String>>>>) -> Self {
self.workflow_findings = v;
self
}
pub fn build(self) -> ToolCtx {
ToolCtx {
workspaces: self.workspaces,
session_dir: self.session_dir,
memory_dir: self.memory_dir,
worktrees_dir: self.worktrees_dir,
dir_cache: self.dir_cache,
mention_index: self.mention_index,
origin: self.origin,
graduated_checks: self.graduated_checks,
lsp_manager: self.lsp_manager,
turn_events: self.turn_events,
workflow_findings: self.workflow_findings,
abort_flag: self.abort_flag,
}
}
}
@@ -0,0 +1,26 @@
//! Graduated check rules: project-defined patterns that flag matching
//! file paths or content for review during write/edit operations.
/// A project-defined rule that flags a matching file path or content pattern
/// for review.
#[derive(Debug, Clone)]
pub struct GraduatedCheck {
pub name: String,
pub pattern: String,
pub rule: String,
}
/// Check which graduated checks apply to a given file path/content pair.
pub fn check_graduated_checks(
path: &str,
content: &str,
checks: &[GraduatedCheck],
) -> Vec<String> {
let mut matches = Vec::new();
for check in checks {
if path.contains(&check.pattern) || content.contains(&check.rule) {
matches.push(check.name.clone());
}
}
matches
}
+43 -350
View File
@@ -1,26 +1,50 @@
//! Tool trait, execution context, and the registry of all built-in tools.
//!
//! This module defines the core `Tool` trait that every agent-invocable tool
//! must implement, the shared `ToolCtx` execution context passed to every tool
//! invocation, and utility functions for path resolution, command execution,
//! argument extraction, and edit-log persistence.
//! must implement, the shared `ToolCtx` execution context, and utility
//! functions for path resolution, command execution, argument extraction,
//! and graduated-check rules.
//!
//! # Organisation
//!
//! ```text
//! tools/
//! ├── mod.rs — Tool trait, re-exports
//! ├── context.rs — ToolCtx, ToolCtxBuilder
//! ├── registry.rs — all_tools(), tool_defs(), tool_is_risky()
//! ├── util.rs — arg_str(), execute_cmd(), resolve_path(),
//! │ log_write_edit_tool()
//! ├── graduated.rs — GraduatedCheck, check_graduated_checks()
//! ├── executor.rs — InfrastructureToolExecutor
//! ├── fs/ — read, write, edit, delete
//! ├── git/ — git_operator, git_worktree, git_cred
//! ├── lsp/ — connect, disconnect, diagnostics, completion, etc.
//! ├── memory/ — remember, forget, recall
//! ├── utility/ — cd, dir_list, pong, todowrite, todofinish, etc.
//! ├── shell.rs — Bash tool
//! ├── bash_tools.rs
//! ├── search.rs — Grep, Glob
//! ├── semantic_search.rs
//! ├── web_search.rs
//! ├── sequential_think.rs
//! ├── plan.rs, spawn.rs, workflow.rs
//! └── parallel_delegate.rs
//! ```
use crate::utils::CastOr;
use anyhow::Result;
use serde_json::Value;
use sha2::Digest;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use tracing::{debug, info, instrument, warn};
pub mod bash_tools;
pub mod context;
pub mod executor;
pub mod fs;
pub mod git;
pub mod graduated;
pub mod lsp;
pub mod memory;
pub mod parallel_delegate;
pub mod plan;
pub mod registry;
pub mod search;
pub mod semantic_search;
pub mod sequential_think;
@@ -28,13 +52,16 @@ pub mod shell;
pub mod shell_filter;
pub mod spawn;
pub mod utility;
pub mod util;
pub mod web_search;
pub mod workflow;
pub mod executor;
pub use git::git_cred;
pub use git::git_operator;
pub use git::git_worktree;
// Re-export commonly used items at the `tools` root so existing imports
// like `crate::tools::{Tool, ToolCtx}` continue to work.
pub use context::{ToolCtx, ToolCtxBuilder};
pub use graduated::{check_graduated_checks, GraduatedCheck};
pub use registry::{all_tools, tool_defs, tool_is_risky};
pub use util::{arg_str, execute_cmd, log_write_edit_tool, resolve_path};
/// Common interface every agent-invocable tool implements.
pub trait Tool: Send + Sync {
@@ -44,340 +71,6 @@ pub trait Tool: Send + Sync {
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String>;
}
/// A project-defined rule that flags a matching file path or content pattern
/// for review.
#[derive(Debug, Clone)]
pub struct GraduatedCheck {
pub name: String,
pub pattern: String,
pub rule: String,
}
/// Shared execution context passed to every `Tool::run` call: workspace roots,
/// session paths, cached directory state, and workflow-level findings sharing.
#[derive(Clone)]
pub struct ToolCtx {
pub workspaces: Vec<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: Arc<tokio::sync::RwLock<crate::DirCache>>,
pub mention_index: crate::MentionIndex,
pub origin: crate::Origin,
pub graduated_checks: Vec<GraduatedCheck>,
pub lsp_manager: Arc<Mutex<crate::lsp::manager::LspManager>>,
pub turn_events:
Option<Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>>,
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
pub abort_flag: Option<Arc<AtomicBool>>,
}
impl ToolCtx {
pub fn builder() -> ToolCtxBuilder {
ToolCtxBuilder::default()
}
}
/// Builder for `ToolCtx`; fields default to empty paths and a fresh `DirCache`.
#[derive(Clone)]
pub struct ToolCtxBuilder {
pub workspaces: Vec<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: Arc<tokio::sync::RwLock<crate::DirCache>>,
pub mention_index: crate::MentionIndex,
pub origin: crate::Origin,
pub graduated_checks: Vec<GraduatedCheck>,
pub lsp_manager: Arc<Mutex<crate::lsp::manager::LspManager>>,
pub turn_events:
Option<Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>>,
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
pub abort_flag: Option<Arc<AtomicBool>>,
}
impl Default for ToolCtxBuilder {
fn default() -> Self {
ToolCtxBuilder {
workspaces: Vec::new(),
session_dir: PathBuf::new(),
memory_dir: PathBuf::new(),
worktrees_dir: PathBuf::new(),
dir_cache: Arc::new(tokio::sync::RwLock::new(crate::DirCache::new())),
mention_index: crate::MentionIndex::new(),
origin: crate::Origin::Main,
graduated_checks: Vec::new(),
lsp_manager: Arc::new(Mutex::new(crate::lsp::manager::LspManager::new())),
turn_events: None,
workflow_findings: None,
abort_flag: None,
}
}
}
impl ToolCtxBuilder {
pub fn session_dir(mut self, v: PathBuf) -> Self {
self.session_dir = v;
self
}
pub fn workspaces(mut self, v: Vec<PathBuf>) -> Self {
self.workspaces = v;
self
}
pub fn origin(mut self, v: crate::Origin) -> Self {
self.origin = v;
self
}
pub fn turn_events(
mut self,
v: Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>,
) -> Self {
self.turn_events = Some(v);
self
}
pub fn workflow_findings(mut self, v: Option<Arc<Mutex<Vec<String>>>>) -> Self {
self.workflow_findings = v;
self
}
pub fn build(self) -> ToolCtx {
ToolCtx {
workspaces: self.workspaces,
session_dir: self.session_dir,
memory_dir: self.memory_dir,
worktrees_dir: self.worktrees_dir,
dir_cache: self.dir_cache,
mention_index: self.mention_index,
origin: self.origin,
graduated_checks: self.graduated_checks,
lsp_manager: self.lsp_manager,
turn_events: self.turn_events,
workflow_findings: self.workflow_findings,
abort_flag: self.abort_flag,
}
}
}
/// Check which graduated checks apply to a given file path/content pair.
pub fn check_graduated_checks(path: &str, content: &str, checks: &[GraduatedCheck]) -> Vec<String> {
let mut matches = Vec::new();
for check in checks {
if path.contains(&check.pattern) || content.contains(&check.rule) {
matches.push(check.name.clone());
}
}
matches
}
/// Construct one instance of every built-in tool.
pub fn all_tools() -> Vec<Box<dyn Tool>> {
vec![
Box::new(fs::read::Read),
Box::new(fs::write::Write),
Box::new(fs::edit::Edit),
Box::new(fs::delete::Delete),
Box::new(search::Grep),
Box::new(search::Glob),
Box::new(bash_tools::BashOutput),
Box::new(bash_tools::BashKill),
Box::new(shell::Bash),
Box::new(git_operator::GitOperator),
Box::new(git_worktree::GitWorktree),
Box::new(git_cred::GitCred),
Box::new(sequential_think::SeqThink),
Box::new(plan::PlanEnter),
Box::new(plan::PlanReady),
Box::new(workflow::WorkflowRun),
Box::new(workflow::NoteFinding),
Box::new(workflow::ReadFindings),
Box::new(workflow::HiveMind),
Box::new(spawn::SpawnAgents),
Box::new(spawn::SpawnPipeline),
Box::new(memory::remember::Remember),
Box::new(memory::forget::Forget),
Box::new(memory::recall::Recall),
Box::new(utility::cd::Cd),
Box::new(utility::dir_list::DirList),
Box::new(utility::dir_cache_update::DirCacheUpdate),
Box::new(utility::pong::Pong),
Box::new(utility::todowrite::Todowrite),
Box::new(utility::todofinish::Todofinish),
Box::new(lsp::LspConnect),
Box::new(lsp::LspDiagnostics),
Box::new(lsp::LspHover),
Box::new(lsp::LspCompletion),
Box::new(lsp::LspDefinition),
Box::new(lsp::LspReferences),
Box::new(lsp::LspDisconnect),
Box::new(web_search::WebSearch),
Box::new(semantic_search::SemanticSearch),
Box::new(semantic_search::RebuildIndex),
Box::new(parallel_delegate::ParallelDelegate),
]
}
/// Whether a tool by name can mutate the filesystem or run arbitrary shell commands.
pub fn tool_is_risky(name: &str) -> bool {
matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator")
}
/// Extract a required string argument from a JSON args map.
pub fn arg_str(args: &Value, name: &str) -> Result<String> {
args.get(name)
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string)
.ok_or_else(|| anyhow::anyhow!("missing required argument: {name}"))
}
/// Execute a `std::process::Command` and return its combined stdout/stderr.
///
/// Flow: spawn → collect stdout + stderr → check exit code → return combined output
/// or bail with the error message.
#[instrument(skip(cmd))]
pub fn execute_cmd(cmd: &mut std::process::Command) -> Result<String> {
let output = cmd
.output()
.map_err(|e| anyhow::anyhow!("command execution failed: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout_len = stdout.len();
let stderr_len = stderr.len();
let combined = if stderr.is_empty() {
stdout
} else {
format!("{}\n{}", stdout, stderr)
.trim()
.to_string()
};
let code = output.status.code().unwrap_or(-1);
if output.status.success() {
info!(exit_code = code, stdout_len, "command succeeded");
Ok(combined)
} else {
warn!(exit_code = code, stderr_len, "command failed");
anyhow::bail!("command failed with exit code {code}:\n{combined}")
}
}
/// Resolve a tool-supplied relative path to an absolute path within a workspace
/// root, rejecting escapes.
///
/// Flow: parse optional `[idx]` prefix → join with workspace root → canonicalize
/// → verify result is inside one of the workspace roots.
#[instrument(skip(workspaces))]
pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
let (ws_idx, path) = if rel.starts_with('[') {
let close = rel
.find(']')
.ok_or_else(|| anyhow::anyhow!("invalid workspace prefix"))?;
let idx: usize = rel[1..close]
.parse()
.map_err(|_| anyhow::anyhow!("invalid workspace index"))?;
(idx, &rel[close + 1..])
} else {
(0, rel)
};
let base = workspaces
.get(ws_idx)
.ok_or_else(|| anyhow::anyhow!("workspace index {ws_idx} out of range"))?;
let abs = if path.is_empty() {
base.clone()
} else {
base.join(path)
};
let canon = if let Ok(c) = abs.canonicalize() {
c
} else {
let base_canon = workspaces
.iter()
.find_map(|w| w.canonicalize().ok())
.unwrap_or_else(|| base.clone());
let mut resolved = base_canon.clone();
if let Ok(rel_components) = abs.strip_prefix(&base_canon) {
for comp in rel_components.components() {
match comp {
std::path::Component::ParentDir => {
resolved.pop();
}
std::path::Component::CurDir => {}
c => resolved.push(c),
}
}
}
resolved
};
debug!(resolved = %canon.display(), "path resolved within workspace");
if workspaces.iter().any(|w| canon.starts_with(w)) {
Ok(canon)
} else {
warn!(path = %canon.display(), rel = rel, "path is outside all workspace roots");
anyhow::bail!("path '{rel}' is outside all workspace roots")
}
}
/// After a successful write/edit tool run, compute content hash and byte
/// delta, then persist an `EditLogEntry` to the session's edit log.
///
/// Flow: extract path/content/reason from args → compute SHA-256 of content
/// → compute byte delta → build `EditLogEntry` → open repo → append entry.
#[instrument(skip(args, session_dir))]
pub fn log_write_edit_tool(
args: &serde_json::Value,
tool_name: &str,
origin_tag: &str,
session_dir: &std::path::Path,
session_id: &str,
) {
let reason = args
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("unnamed");
let path = args
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let content = args.get("content").or_else(|| args.get("new"));
let content_str = content.and_then(|v| v.as_str()).unwrap_or("");
let content_sha256 = hex::encode(sha2::Sha256::digest(content_str.as_bytes()));
let bytes_delta = if tool_name == "write" {
content_str.len().cast_or(0i64)
} else {
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
let new_len: i64 = new.len().cast_or(0i64);
let old_len: i64 = old.len().cast_or(0i64);
(new_len - old_len).abs()
};
let entry = zesdex_domain::cms::EditLogEntry {
ts: chrono::Utc::now().timestamp_millis(),
tool: tool_name.to_string(),
path: path.to_string(),
reason: reason.to_string(),
content_sha256,
bytes_delta,
origin: origin_tag.to_string(),
session_id: session_id.to_string(),
};
use zesdex_domain::cms::repository::EditLogRepository;
let repo = crate::persistence::cms::edit_log_repo::JsonlEditLogRepository::new();
if let Ok(mut el) = repo.open(session_dir) {
let _ = repo.append(session_dir, &mut el, entry);
debug!(tool = tool_name, path = path, "edit-log entry persisted");
} else {
warn!(tool = tool_name, path = path, "failed to open edit-log repository");
}
}
/// Convert a list of tools into provider-facing `ToolDef` request schema.
pub fn tool_defs(tools: &[Box<dyn Tool>]) -> Vec<zesdex_domain::core::ToolDef> {
tools
.iter()
.map(|t| zesdex_domain::core::ToolDef {
type_: "function".to_string(),
function: zesdex_domain::core::ToolFunctionDef {
name: t.name().to_string(),
description: t.description().to_string(),
parameters: t.parameters(),
},
})
.collect()
}
pub use git::git_cred;
pub use git::git_operator;
pub use git::git_worktree;
+70
View File
@@ -0,0 +1,70 @@
//! Tool registry: the master list of all built-in tools, plus conversion
//! utilities for generating provider-facing tool definitions.
/// Construct one instance of every built-in tool.
pub fn all_tools() -> Vec<Box<dyn super::Tool>> {
vec![
Box::new(super::fs::read::Read),
Box::new(super::fs::write::Write),
Box::new(super::fs::edit::Edit),
Box::new(super::fs::delete::Delete),
Box::new(super::search::Grep),
Box::new(super::search::Glob),
Box::new(super::bash_tools::BashOutput),
Box::new(super::bash_tools::BashKill),
Box::new(super::shell::Bash),
Box::new(super::git::git_operator::GitOperator),
Box::new(super::git::git_worktree::GitWorktree),
Box::new(super::git::git_cred::GitCred),
Box::new(super::sequential_think::SeqThink),
Box::new(super::plan::PlanEnter),
Box::new(super::plan::PlanReady),
Box::new(super::workflow::WorkflowRun),
Box::new(super::workflow::NoteFinding),
Box::new(super::workflow::ReadFindings),
Box::new(super::workflow::HiveMind),
Box::new(super::spawn::SpawnAgents),
Box::new(super::spawn::SpawnPipeline),
Box::new(super::memory::remember::Remember),
Box::new(super::memory::forget::Forget),
Box::new(super::memory::recall::Recall),
Box::new(super::utility::cd::Cd),
Box::new(super::utility::dir_list::DirList),
Box::new(super::utility::dir_cache_update::DirCacheUpdate),
Box::new(super::utility::pong::Pong),
Box::new(super::utility::todowrite::Todowrite),
Box::new(super::utility::todofinish::Todofinish),
Box::new(super::lsp::LspConnect),
Box::new(super::lsp::LspDiagnostics),
Box::new(super::lsp::LspHover),
Box::new(super::lsp::LspCompletion),
Box::new(super::lsp::LspDefinition),
Box::new(super::lsp::LspReferences),
Box::new(super::lsp::LspDisconnect),
Box::new(super::web_search::WebSearch),
Box::new(super::semantic_search::SemanticSearch),
Box::new(super::semantic_search::RebuildIndex),
Box::new(super::parallel_delegate::ParallelDelegate),
]
}
/// Whether a tool by name can mutate the filesystem or run arbitrary shell
/// commands.
pub fn tool_is_risky(name: &str) -> bool {
matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator")
}
/// Convert a list of tools into provider-facing `ToolDef` request schema.
pub fn tool_defs(tools: &[Box<dyn super::Tool>]) -> Vec<zesdex_domain::core::ToolDef> {
tools
.iter()
.map(|t| zesdex_domain::core::ToolDef {
type_: "function".to_string(),
function: zesdex_domain::core::ToolFunctionDef {
name: t.name().to_string(),
description: t.description().to_string(),
parameters: t.parameters(),
},
})
.collect()
}
+156
View File
@@ -0,0 +1,156 @@
//! Shared utility functions used by tool implementations: JSON argument
//! extraction, command execution, path resolution, and edit-log persistence.
use crate::utils::CastOr;
use anyhow::Result;
use serde_json::Value;
use sha2::Digest;
use std::path::PathBuf;
use tracing::{debug, info, instrument, warn};
/// Extract a required string argument from a JSON args map.
pub fn arg_str(args: &Value, name: &str) -> Result<String> {
args.get(name)
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string)
.ok_or_else(|| anyhow::anyhow!("missing required argument: {name}"))
}
/// Execute a `std::process::Command` and return its combined stdout/stderr.
///
/// Flow: spawn \u{2192} collect stdout + stderr \u{2192} check exit code \u{2192} return
/// combined output or bail with the error message.
#[instrument(skip(cmd))]
pub fn execute_cmd(cmd: &mut std::process::Command) -> Result<String> {
let output = cmd
.output()
.map_err(|e| anyhow::anyhow!("command execution failed: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout_len = stdout.len();
let stderr_len = stderr.len();
let combined = if stderr.is_empty() {
stdout
} else {
format!("{}\n{}", stdout, stderr)
.trim()
.to_string()
};
let code = output.status.code().unwrap_or(-1);
if output.status.success() {
info!(exit_code = code, stdout_len, "command succeeded");
Ok(combined)
} else {
warn!(exit_code = code, stderr_len, "command failed");
anyhow::bail!("command failed with exit code {code}:\n{combined}")
}
}
/// Resolve a tool-supplied relative path to an absolute path within a workspace
/// root, rejecting escapes.
///
/// Flow: parse optional `[idx]` prefix \u{2192} join with workspace root \u{2192}
/// canonicalize \u{2192} verify result is inside one of the workspace roots.
#[instrument(skip(workspaces))]
pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
let (ws_idx, path) = if rel.starts_with('[') {
let close = rel
.find(']')
.ok_or_else(|| anyhow::anyhow!("invalid workspace prefix"))?;
let idx: usize = rel[1..close]
.parse()
.map_err(|_| anyhow::anyhow!("invalid workspace index"))?;
(idx, &rel[close + 1..])
} else {
(0, rel)
};
let base = workspaces
.get(ws_idx)
.ok_or_else(|| anyhow::anyhow!("workspace index {ws_idx} out of range"))?;
let abs = if path.is_empty() {
base.clone()
} else {
base.join(path)
};
let canon = if let Ok(c) = abs.canonicalize() {
c
} else {
let base_canon = workspaces
.iter()
.find_map(|w| w.canonicalize().ok())
.unwrap_or_else(|| base.clone());
let mut resolved = base_canon.clone();
if let Ok(rel_components) = abs.strip_prefix(&base_canon) {
for comp in rel_components.components() {
match comp {
std::path::Component::ParentDir => {
resolved.pop();
}
std::path::Component::CurDir => {}
c => resolved.push(c),
}
}
}
resolved
};
debug!(resolved = %canon.display(), "path resolved within workspace");
if workspaces.iter().any(|w| canon.starts_with(w)) {
Ok(canon)
} else {
warn!(path = %canon.display(), rel = rel, "path is outside all workspace roots");
anyhow::bail!("path '{rel}' is outside all workspace roots")
}
}
/// After a successful write/edit tool run, compute content hash and byte
/// delta, then persist an `EditLogEntry` to the session's edit log.
///
/// Flow: extract path/content/reason from args \u{2192} compute SHA-256 of content
/// \u{2192} compute byte delta \u{2192} build `EditLogEntry` \u{2192} open repo \u{2192} append entry.
#[instrument(skip(args, session_dir))]
pub fn log_write_edit_tool(
args: &serde_json::Value,
tool_name: &str,
origin_tag: &str,
session_dir: &std::path::Path,
session_id: &str,
) {
let reason = args
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("unnamed");
let path = args
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let content = args.get("content").or_else(|| args.get("new"));
let content_str = content.and_then(|v| v.as_str()).unwrap_or("");
let content_sha256 = hex::encode(sha2::Sha256::digest(content_str.as_bytes()));
let bytes_delta = if tool_name == "write" {
content_str.len().cast_or(0i64)
} else {
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
let new_len: i64 = new.len().cast_or(0i64);
let old_len: i64 = old.len().cast_or(0i64);
(new_len - old_len).abs()
};
let entry = zesdex_domain::cms::EditLogEntry {
ts: chrono::Utc::now().timestamp_millis(),
tool: tool_name.to_string(),
path: path.to_string(),
reason: reason.to_string(),
content_sha256,
bytes_delta,
origin: origin_tag.to_string(),
session_id: session_id.to_string(),
};
use zesdex_domain::cms::repository::EditLogRepository;
let repo = crate::persistence::cms::edit_log_repo::JsonlEditLogRepository::new();
if let Ok(mut el) = repo.open(session_dir) {
let _ = repo.append(session_dir, &mut el, entry);
debug!(tool = tool_name, path = path, "edit-log entry persisted");
} else {
warn!(tool = tool_name, path = path, "failed to open edit-log repository");
}
}