From 1f0ae9f55188b5d336f8686aaebbad1a7271f55a Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 17 Jul 2026 06:44:31 +0700 Subject: [PATCH] Refactor and clean up code across multiple modules - Simplified token type assignment in OAuth service. - Removed unused session_lock module and re-exported Session from zesdex_entities. - Cleaned up session entity by removing unnecessary comments and code. - Consolidated session handling in HTTP handlers for better readability. - Improved formatting and readability in OAuth repository tests. - Enhanced session lock repository with clearer match statements. - Streamlined session repository error handling. - Refined RNG tests for better clarity. - Adjusted module visibility and organization in lib.rs. - Updated IPC client and connection code for better error handling and clarity. - Improved frame handling in IPC for better readability. - Organized module imports and added test utilities for IPC. - Enhanced database connection error handling. - Simplified JWT token creation error handling. - Improved password verification error handling. - Cleaned up state management code for better readability. - Refactored middleware for session authentication and rate limiting. - Simplified clipboard utility for better error handling. - Enhanced logging initialization for better error reporting. - Improved pagination utility with clearer method annotations. - Cleaned up sanitization functions for filenames and paths. - Enhanced slug generation functions for better clarity and usability. --- .../zesdex-backend/src/app/mode/learning.rs | 10 +- crates/zesdex-backend/src/app/mode/rewind.rs | 3 +- crates/zesdex-backend/src/app/review/mod.rs | 177 +++++--- .../src/app/runtime/actions/mod.rs | 33 +- .../src/app/runtime/context/tokens.rs | 16 +- .../src/app/runtime/context/window.rs | 24 +- .../src/app/runtime/stream/mod.rs | 353 +--------------- crates/zesdex-backend/src/app/state/misc.rs | 55 ++- crates/zesdex-backend/src/app/state/rest.rs | 132 ++++-- .../zesdex-backend/src/app/state/runtime.rs | 26 +- .../zesdex-backend/src/app/subagent/engine.rs | 254 +++++++---- .../zesdex-backend/src/app/subagent/spawn.rs | 7 - .../zesdex-backend/src/app/workflow/docs.rs | 2 +- .../zesdex-backend/src/app/workflow/engine.rs | 291 ++++++------- .../src/app/workflow/hive_mind.rs | 51 +-- crates/zesdex-backend/src/dto/mod.rs | 2 +- crates/zesdex-backend/src/main.rs | 179 ++++---- .../src/model/agent_def/builtin.rs | 69 ++- .../src/model/agent_def/session.rs | 12 +- crates/zesdex-backend/src/model/mod.rs | 2 +- crates/zesdex-backend/src/service/provider.rs | 11 +- crates/zesdex-backend/src/tool/bash_tools.rs | 14 +- crates/zesdex-backend/src/tool/fs/delete.rs | 2 +- crates/zesdex-backend/src/tool/fs/edit.rs | 3 +- crates/zesdex-backend/src/tool/fs/helpers.rs | 41 +- crates/zesdex-backend/src/tool/fs/read.rs | 3 +- crates/zesdex-backend/src/tool/fs/write.rs | 3 +- crates/zesdex-backend/src/tool/git_cred.rs | 23 +- .../zesdex-backend/src/tool/git_operator.rs | 34 +- .../zesdex-backend/src/tool/git_worktree.rs | 43 +- crates/zesdex-backend/src/tool/lsp/mod.rs | 276 ++++-------- .../zesdex-backend/src/tool/memory/forget.rs | 12 +- .../zesdex-backend/src/tool/memory/recall.rs | 11 +- .../src/tool/memory/remember.rs | 29 +- crates/zesdex-backend/src/tool/mod.rs | 62 +++ crates/zesdex-backend/src/tool/plan.rs | 17 +- crates/zesdex-backend/src/tool/search.rs | 24 +- crates/zesdex-backend/src/tool/shell.rs | 6 +- .../src/tool/shell_filter/credentials.rs | 54 --- .../src/tool/shell_filter/git.rs | 5 +- .../src/tool/shell_filter/mod.rs | 6 + crates/zesdex-backend/src/tool/spawn.rs | 49 +-- crates/zesdex-backend/src/tool/utility/cd.rs | 9 +- .../src/tool/utility/dir_cache_update.rs | 7 +- .../src/tool/utility/dir_list.rs | 7 +- .../src/tool/utility/todowrite.rs | 5 +- crates/zesdex-backend/src/tool/workflow.rs | 19 +- crates/zesdex-backend/src/view/markdown.rs | 156 +++++-- crates/zesdex-backend/src/view/mod.rs | 395 +++++++++++++----- .../src/application/conversation_service.rs | 18 +- .../src/application/settings_service.rs | 10 +- crates/zesdex-cms/src/domain/conversation.rs | 87 +--- crates/zesdex-cms/src/domain/repository.rs | 8 +- crates/zesdex-cms/src/domain/service.rs | 3 +- .../src/infrastructure/http/handlers.rs | 12 +- .../persistence/app_config_repo.rs | 13 +- .../persistence/conversation_repo.rs | 9 +- .../persistence/edit_log_repo.rs | 8 +- .../infrastructure/persistence/memory_repo.rs | 27 +- .../persistence/rewind_blob_repo.rs | 11 +- .../persistence/settings_repo.rs | 20 +- crates/zesdex-cms/src/lib.rs | 2 +- crates/zesdex-dto/src/provider/request.rs | 58 +-- crates/zesdex-dto/src/provider/response.rs | 57 +-- crates/zesdex-dto/src/provider/usage.rs | 27 +- .../zesdex-entities/src/seaorm/common/mod.rs | 3 +- .../src/seaorm/common/provider.rs | 61 +-- .../src/seaorm/common/tool_call.rs | 4 +- .../src/seaorm/common/usage.rs | 16 +- .../src/application/oauth_service.rs | 5 +- crates/zesdex-iam/src/domain/mod.rs | 1 - crates/zesdex-iam/src/domain/session.rs | 60 +-- crates/zesdex-iam/src/domain/session_lock.rs | 29 -- .../src/infrastructure/http/handlers.rs | 13 +- .../infrastructure/persistence/oauth_repo.rs | 11 +- .../persistence/session_lock_repo.rs | 20 +- .../persistence/session_repo.rs | 8 +- crates/zesdex-iam/src/infrastructure/rng.rs | 5 +- crates/zesdex-iam/src/lib.rs | 2 +- crates/zesdex-ipc/src/client.rs | 27 +- crates/zesdex-ipc/src/conn.rs | 42 +- crates/zesdex-ipc/src/frame.rs | 12 +- crates/zesdex-ipc/src/lib.rs | 19 +- crates/zesdex-ipc/src/server.rs | 10 +- crates/zesdex-libs/src/database.rs | 5 +- crates/zesdex-libs/src/jwt.rs | 3 +- crates/zesdex-libs/src/password.rs | 4 +- crates/zesdex-libs/src/state.rs | 29 +- crates/zesdex-middleware/src/auth.rs | 17 +- crates/zesdex-middleware/src/rate_limit.rs | 34 +- crates/zesdex-utils/src/clipboard.rs | 26 +- crates/zesdex-utils/src/logger.rs | 35 +- crates/zesdex-utils/src/pagination.rs | 5 + crates/zesdex-utils/src/sanitize.rs | 16 +- crates/zesdex-utils/src/slug.rs | 7 +- 95 files changed, 1792 insertions(+), 2131 deletions(-) delete mode 100644 crates/zesdex-iam/src/domain/session_lock.rs diff --git a/crates/zesdex-backend/src/app/mode/learning.rs b/crates/zesdex-backend/src/app/mode/learning.rs index f4c2c44..8642bba 100644 --- a/crates/zesdex-backend/src/app/mode/learning.rs +++ b/crates/zesdex-backend/src/app/mode/learning.rs @@ -54,9 +54,15 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec { } // 2. Load stored memory lessons from long-term memory directory - let names = zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new().list(&state.memory_dir).unwrap_or_default(); + let names = + zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new() + .list(&state.memory_dir) + .unwrap_or_default(); for name in names { - if let Ok(mem) = zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new().load(&state.memory_dir, &name) { + if let Ok(mem) = + zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new() + .load(&state.memory_dir, &name) + { if mem.kind == "lesson" { items.push(LearningItem::Stored { name: mem.name, diff --git a/crates/zesdex-backend/src/app/mode/rewind.rs b/crates/zesdex-backend/src/app/mode/rewind.rs index 15e0907..dcb57dc 100644 --- a/crates/zesdex-backend/src/app/mode/rewind.rs +++ b/crates/zesdex-backend/src/app/mode/rewind.rs @@ -101,7 +101,8 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) { } // Log the rewind itself as an edit entry - let repo = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new(); + let repo = + zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new(); if let Ok(mut el) = repo.open(&state.session_dir) { let entry = zesdex_cms::domain::edit_log::EditLogEntry { ts: chrono::Utc::now().timestamp_millis(), diff --git a/crates/zesdex-backend/src/app/review/mod.rs b/crates/zesdex-backend/src/app/review/mod.rs index 10f7b61..e5007d5 100644 --- a/crates/zesdex-backend/src/app/review/mod.rs +++ b/crates/zesdex-backend/src/app/review/mod.rs @@ -1,7 +1,11 @@ -#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] //! Adaptive quality-review triggering, build/test probing, staleness //! sweeps for stored lessons, and the pending-lesson approval workflow. -use std::process::Command; use crate::app::state::rest::AppStateRest; use crate::app::state::runtime::TurnEvent; use crate::app::state::types::{Origin, Toast, ToastKind}; @@ -9,6 +13,7 @@ use crate::app::subagent::context::build_subagent_context; use crate::app::subagent::engine::run_subagent; use crate::app::subagent::spawn::AgentDefinition; use serde::{Deserialize, Serialize}; +use std::process::Command; use zesdex_cms::domain::memory::Memory; use zesdex_cms::domain::repository::MemoryRepository; use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository; @@ -74,11 +79,12 @@ pub struct Lesson { /// /// Return: `true` if a review should be triggered this turn. pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool { - if origin != Origin::Main { return false; } - let Some(runtime) = &state.session_runtime else { return false }; + let Some(runtime) = &state.session_runtime else { + return false; + }; if !state.settings.flags.review_enabled { return false; } @@ -119,18 +125,28 @@ pub struct ProbeResult { /// Return: `None` if no workspace exists, no command could be resolved, /// or the process failed to spawn/poll; otherwise `Some(ProbeResult)` /// describing pass/fail/timeout and truncated output. -pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Option<&str>, timeout_ms: u64) -> Option { +pub fn probe_build_test( + workspaces: &[std::path::PathBuf], + verify_command: Option<&str>, + timeout_ms: u64, +) -> Option { let probe_dir = workspaces.first()?; let cmd = resolve_verify_command(probe_dir, verify_command)?; - let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else(|| (cmd.clone(), String::new()), |(p, a)| (p.to_string(), a.to_string())); + let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else( + || (cmd.clone(), String::new()), + |(p, a)| (p.to_string(), a.to_string()), + ); let Ok(mut child) = Command::new(&cmd_prog) .args(cmd_args.split_whitespace()) .current_dir(probe_dir) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) - .spawn() else { return None }; + .spawn() + else { + return None; + }; let start = std::time::Instant::now(); let timed_out = loop { @@ -141,9 +157,19 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio match child.try_wait() { Ok(Some(status)) => { let output = child.wait_with_output().ok(); - let stdout = output.as_ref().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default(); - let stderr = output.as_ref().map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()).unwrap_or_default(); - let combined = if stderr.is_empty() { stdout } else { format!("{stdout}\n{stderr}") }; + let stdout = output + .as_ref() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default(); + let stderr = output + .as_ref() + .map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()) + .unwrap_or_default(); + let combined = if stderr.is_empty() { + stdout + } else { + format!("{stdout}\n{stderr}") + }; return Some(ProbeResult { command: cmd.clone(), passed: status.success(), @@ -151,7 +177,9 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio timed_out: false, }); } - Ok(None) => { std::thread::sleep(std::time::Duration::from_millis(50)); } + Ok(None) => { + std::thread::sleep(std::time::Duration::from_millis(50)); + } Err(_) => return None, } }; @@ -180,7 +208,10 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio /// /// Return: `Some(command)` if a command could be determined, `None` if /// no marker files matched (e.g. plain Python project with no test dir). -fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str>) -> Option { +fn resolve_verify_command( + probe_dir: &std::path::Path, + override_cmd: Option<&str>, +) -> Option { if let Some(cmd) = override_cmd { if !cmd.trim().is_empty() { return Some(cmd.trim().to_string()); @@ -201,18 +232,35 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?; if let Ok(v) = serde_json::from_str::(&pkg) { let scripts = v.get("scripts")?; - if scripts.get("test").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) { + if scripts + .get("test") + .and_then(|s| s.as_str()) + .as_ref() + .is_some_and(|s| !s.is_empty()) + { return Some("npm test 2>&1".to_string()); } - if scripts.get("build").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) { + if scripts + .get("build") + .and_then(|s| s.as_str()) + .as_ref() + .is_some_and(|s| !s.is_empty()) + { return Some("npm run build 2>&1".to_string()); } } return Some("npm test 2>&1".to_string()); } - if has_file("pyproject.toml") || has_file("requirements.txt") || has_file("setup.py") || has_file("setup.cfg") || has_file("Pipfile") || has_file("poetry.lock") { + if has_file("pyproject.toml") + || has_file("requirements.txt") + || has_file("setup.py") + || has_file("setup.cfg") + || has_file("Pipfile") + || has_file("poetry.lock") + { if has_file("pyproject.toml") { - let content = std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default(); + let content = + std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default(); if content.contains("[tool.pytest") { return Some("python -m pytest --tb=short -q 2>&1".to_string()); } @@ -307,10 +355,7 @@ fn truncate_output(s: &str, max: usize) -> String { /// propagate from constructing the subagent context, not from the review /// itself (that failure is reported via a `SystemNote` instead). /// Compose the system prompt for the quality-review subagent. -fn compose_review_prompt( - state: &AppStateRest, - probe_note: &str, -) -> String { +fn compose_review_prompt(state: &AppStateRest, probe_note: &str) -> String { let diff_output = if let Some(workspace) = state.workspace_roots.first() { std::process::Command::new("git") .arg("diff") @@ -323,10 +368,15 @@ fn compose_review_prompt( } else { String::new() }; - + let history_output = if let Some(rt) = &state.session_runtime { - let msgs: Vec = rt.messages.iter() - .filter(|m| m.role == crate::dto::chat::message::Role::Assistant || m.role == crate::dto::chat::message::Role::User) + let msgs: Vec = rt + .messages + .iter() + .filter(|m| { + m.role == crate::dto::chat::message::Role::Assistant + || m.role == crate::dto::chat::message::Role::User + }) .rev() .take(10) .map(|m| format!("{:?}: {}", m.role, m.content.as_deref().unwrap_or(""))) @@ -371,7 +421,6 @@ fn compose_review_prompt( /// Return: `Ok(())` once the review has been kicked off; errors only /// propagate from constructing the subagent context, not from the review /// itself (that failure is reported via a `SystemNote` instead). -#[allow(clippy::unnecessary_debug_formatting)] pub fn trigger_review(state: &mut AppStateRest) { state.misc.lesson_running = true; @@ -380,17 +429,22 @@ pub fn trigger_review(state: &mut AppStateRest) { let content = std::fs::read_to_string(&gitignore_path).unwrap_or_default(); if !content.contains("docs/lesson") { use std::io::Write; - if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&gitignore_path) { - let prefix = if content.is_empty() || content.ends_with('\n') { "" } else { "\n" }; + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&gitignore_path) + { + let prefix = if content.is_empty() || content.ends_with('\n') { + "" + } else { + "\n" + }; let _ = writeln!(file, "{prefix}docs/lesson/"); } } } - let mut def = AgentDefinition::new( - "lesson-generator".to_string(), - "reviewer".to_string(), - ); + let mut def = AgentDefinition::new("lesson-generator".to_string(), "reviewer".to_string()); // Explicitly allow write_file for docs/lesson def.allowed_tools = Some(vec![ "read".to_string(), @@ -402,7 +456,7 @@ pub fn trigger_review(state: &mut AppStateRest) { let mut ctx = build_subagent_context(&def); ctx.session_dir.clone_from(&state.session_dir); ctx.workspaces.clone_from(&state.workspace_roots); - + let probe_result = probe_build_test( &state.workspace_roots, state.settings.verify_command.as_deref(), @@ -416,7 +470,10 @@ pub fn trigger_review(state: &mut AppStateRest) { } else if r.timed_out { format!("Build/test verification timed out ({}).", r.command) } else { - format!("Build/test verification failed ({}). Output: {}", r.command, r.output) + format!( + "Build/test verification failed ({}). Output: {}", + r.command, r.output + ) } } None => "No build/test probe matched.".to_string(), @@ -432,13 +489,22 @@ pub fn trigger_review(state: &mut AppStateRest) { let mut rx = rx; while let Some(event) = rx.blocking_recv() { match &event { - SubagentEvent::ToolCall { tool, .. } => tracing::debug!("[review] tool call: {}", tool), - SubagentEvent::ToolResult { tool, .. } => tracing::debug!("[review] tool result: {}", tool), + SubagentEvent::ToolCall { tool, .. } => { + tracing::debug!("[review] tool call: {}", tool) + } + SubagentEvent::ToolResult { tool, .. } => { + tracing::debug!("[review] tool result: {}", tool) + } SubagentEvent::StepCompleted { .. } => tracing::trace!("[review] step completed"), - SubagentEvent::StepFailed { step, error } => tracing::warn!("[review] step {} failed: {}", step, error), + SubagentEvent::StepFailed { step, error } => { + tracing::warn!("[review] step {} failed: {}", step, error) + } SubagentEvent::Progress(_) => {} - SubagentEvent::Completed { .. } => tracing::debug!("[review] completed"), - SubagentEvent::Usage { tokens_in, tokens_out } => { + SubagentEvent::Completed => tracing::debug!("[review] completed"), + SubagentEvent::Usage { + tokens_in, + tokens_out, + } => { if let Ok(mut q) = turn_events_for_drain.lock() { q.push_back(TurnEvent::ReviewUsage { tokens_in: *tokens_in, @@ -449,7 +515,7 @@ pub fn trigger_review(state: &mut AppStateRest) { } } }); - + let turn_events = state.turn_events.clone(); std::thread::spawn(move || { @@ -487,15 +553,18 @@ const STALE_AFTER_DAYS: i64 = 60; /// `mem.write`. pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result> { let mut flagged = Vec::new(); - let names = MarkdownMemoryRepository::new().list(memory_dir).unwrap_or_default(); + let names = MarkdownMemoryRepository::new() + .list(memory_dir) + .unwrap_or_default(); let now = chrono::Utc::now().timestamp_millis(); let cutoff = now - STALE_AFTER_DAYS * 24 * 3600 * 1000; for name in names { if let Ok(mut mem) = MarkdownMemoryRepository::new().load(memory_dir, &name) { if mem.updated_at < cutoff && mem.lifecycle != "stale" { mem.lifecycle = "stale".to_string(); - MarkdownMemoryRepository::new().save(memory_dir, &mem) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + MarkdownMemoryRepository::new() + .save(memory_dir, &mem) + .map_err(|e| std::io::Error::other(e.to_string()))?; flagged.push(name); } } @@ -521,7 +590,11 @@ pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) { if !flagged.is_empty() { state.push_toast(Toast::new( ToastKind::Info, - format!("Staleness sweep: {} lesson(s) flagged as stale: {}", flagged.len(), flagged.join(", ")), + format!( + "Staleness sweep: {} lesson(s) flagged as stale: {}", + flagged.len(), + flagged.join(", ") + ), )); } } @@ -551,7 +624,10 @@ pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec /// Write the session's pending-lessons queue to disk as pretty JSON. /// /// Return: `Ok(())`, or an I/O error from writing the file. -pub fn save_pending_lessons(session_dir: &std::path::Path, pending: &[PendingLesson]) -> std::io::Result<()> { +pub fn save_pending_lessons( + session_dir: &std::path::Path, + pending: &[PendingLesson], +) -> std::io::Result<()> { let path = session_dir.join("pending_lessons.json"); let data = serde_json::to_string_pretty(pending)?; std::fs::write(&path, data) @@ -570,7 +646,10 @@ pub fn save_pending_lessons(session_dir: &std::path::Path, pending: &[PendingLes /// /// Return: the still-pending lessons (post-commit), or an I/O error from /// writing memory files or the queue. -pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::path::Path) -> std::io::Result> { +pub fn process_pending_lessons( + session_dir: &std::path::Path, + memory_dir: &std::path::Path, +) -> std::io::Result> { let pending = load_pending_lessons(session_dir); let now = chrono::Utc::now().timestamp_millis(); let grace_window = 5_000; @@ -599,8 +678,9 @@ pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std:: after_snippet: None, provenances: vec![], }; - MarkdownMemoryRepository::new().save(memory_dir, &mem) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + MarkdownMemoryRepository::new() + .save(memory_dir, &mem) + .map_err(|e| std::io::Error::other(e.to_string()))?; } save_pending_lessons(session_dir, &remaining)?; @@ -644,8 +724,9 @@ pub fn resolve_pending_lesson( after_snippet: None, provenances: vec![], }; - MarkdownMemoryRepository::new().save(memory_dir, &mem) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + MarkdownMemoryRepository::new() + .save(memory_dir, &mem) + .map_err(|e| std::io::Error::other(e.to_string()))?; } } else { remaining.push(p); diff --git a/crates/zesdex-backend/src/app/runtime/actions/mod.rs b/crates/zesdex-backend/src/app/runtime/actions/mod.rs index 6766267..8cc4495 100644 --- a/crates/zesdex-backend/src/app/runtime/actions/mod.rs +++ b/crates/zesdex-backend/src/app/runtime/actions/mod.rs @@ -100,7 +100,6 @@ pub enum Action { /// need to know how to *produce* actions. /// /// Return: nothing; `state` is mutated in place. -#[allow(clippy::too_many_lines)] pub fn apply_action(state: &mut AppStateRest, action: Action) { match action { Action::ForceQuit => { @@ -869,7 +868,7 @@ fn refresh_lesson_counters(memory_dir: &std::path::Path, rt: &mut crate::app::st /// Flow: if `db` is `Some`, lock the mutex and call `insert_message`. /// Errors are silently ignored. fn archive_message(db: Option<&std::sync::Arc>>, session_id: &str, msg: &ChatMessage) { - if let Some(arc) = db { + if let Some(arc) = sess.db { if let Ok(conn) = arc.lock() { let _ = crate::model::msglog::insert_message(&conn, session_id, msg); } @@ -913,7 +912,6 @@ const HIVE_MIND_KICKOFF_NOTE: &str = "The Hive is stirring — Core Intelligence /// /// Return: `Ok(())` on successful completion, or an error from the LLM /// API after retries are exhausted. -#[allow(clippy::too_many_lines)] fn run_agent_turn( tc: &TurnCtx, messages: &[ChatMessage], @@ -1328,9 +1326,11 @@ fn run_agent_turn( &tool_name, &tool_call.id, &args, - &tc_ref.edit_log_session_dir, - &tc_ref.session_id, - tc_ref.db.as_ref(), + &ToolExecSession { + dir: &tc_ref.edit_log_session_dir, + id: &tc_ref.session_id, + db: tc_ref.db.as_ref(), + }, ) { Ok(result) => (result, false, is_edit_tool), Err(e) => (e.to_string(), true, false), @@ -1538,28 +1538,31 @@ fn run_agent_turn( /// /// Return: the tool's stdout string, or an error if no matching tool was /// found or the tool run itself failed. -#[allow(clippy::too_many_arguments)] +struct ToolExecSession<'a> { + dir: &'a std::path::Path, + id: &'a str, + db: Option<&'a std::sync::Arc>>, +} + fn execute_one_tool( tools: &[Box], ctx: &crate::tool::ToolCtx, name: &str, tool_call_id: &str, args: &serde_json::Value, - session_dir: &std::path::Path, - session_id: &str, - db: Option<&std::sync::Arc>>, + sess: &ToolExecSession<'_>, ) -> anyhow::Result { for tool in tools { if tool.name() == name { // Snapshot current file content before write/edit for rewind if (name == "write" || name == "edit") && !tool_call_id.is_empty() { - if let Some(arc) = db { + if let Some(arc) = sess.db { if let Ok(conn) = arc.lock() { let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(""); if let Ok(abs_path) = crate::tool::resolve_path(&ctx.workspaces, path) { if let Ok(bytes) = std::fs::read(&abs_path) { let _ = crate::model::msglog::store_blob( - &conn, session_id, tool_call_id, &bytes, None, + &conn, sess.id, tool_call_id, &bytes, None, ); } } @@ -1600,11 +1603,11 @@ fn execute_one_tool( content_sha256, bytes_delta, origin: ctx.origin.tag(), - session_id: session_id.to_string(), + session_id: sess.id.to_string(), }; let repo = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new(); - if let Ok(mut el) = repo.open(session_dir) { - let _ = repo.append(session_dir, &mut el, entry); + if let Ok(mut el) = repo.open(sess.dir) { + let _ = repo.append(sess.dir, &mut el, entry); } } return Ok(result); diff --git a/crates/zesdex-backend/src/app/runtime/context/tokens.rs b/crates/zesdex-backend/src/app/runtime/context/tokens.rs index 51e9efa..ec27970 100644 --- a/crates/zesdex-backend/src/app/runtime/context/tokens.rs +++ b/crates/zesdex-backend/src/app/runtime/context/tokens.rs @@ -10,7 +10,7 @@ //! `o200k_base` is an approximation for non-OpenAI providers but is far //! closer than a flat byte-per-token guess; it's only used for the //! 85%/95% budget thresholds, not for billing-accurate counts. -use crate::dto::chat::message::ChatMessage; + /// Count tokens in a single string under `o200k_base`. /// @@ -25,20 +25,16 @@ pub fn count_tokens(text: &str) -> usize { .len() } -/// Count tokens in a `ChatMessage`'s text content. -/// -/// Return: 0 for a message with no `content` (e.g. an assistant message -/// that only carries `tool_calls`). -#[allow(dead_code)] -pub fn count_message_tokens(msg: &ChatMessage) -> usize { - msg.content.as_deref().map_or(0, count_tokens) -} - #[cfg(test)] mod tests { use super::*; use crate::dto::chat::message::ChatMessage; + /// Count tokens in a `ChatMessage`'s text content. + fn count_message_tokens(msg: &ChatMessage) -> usize { + msg.content.as_deref().map_or(0, count_tokens) + } + #[test] fn empty_string_has_zero_tokens() { assert_eq!(count_tokens(""), 0); diff --git a/crates/zesdex-backend/src/app/runtime/context/window.rs b/crates/zesdex-backend/src/app/runtime/context/window.rs index ca1cffe..c93af52 100644 --- a/crates/zesdex-backend/src/app/runtime/context/window.rs +++ b/crates/zesdex-backend/src/app/runtime/context/window.rs @@ -43,9 +43,11 @@ mod tests { temperature: None, }, ); - let mut settings = Settings::default(); - settings.provider = "zen".to_string(); - settings.model = "deepseek-v4-flash-free".to_string(); + let settings = Settings { + provider: "zen".to_string(), + model: "deepseek-v4-flash-free".to_string(), + ..Default::default() + }; assert_eq!(resolve(&app_config, &settings), 128_000); } @@ -53,9 +55,11 @@ mod tests { #[test] fn falls_back_to_default_context_window_when_no_role_matches() { let app_config = AppConfig::default(); - let mut settings = Settings::default(); - settings.provider = "nonexistent".to_string(); - settings.model = "nonexistent-model".to_string(); + let settings = Settings { + provider: "nonexistent".to_string(), + model: "nonexistent-model".to_string(), + ..Default::default() + }; assert_eq!( resolve(&app_config, &settings), @@ -76,9 +80,11 @@ mod tests { temperature: None, }, ); - let mut settings = Settings::default(); - settings.provider = "zen".to_string(); - settings.model = "deepseek-v4-flash-free".to_string(); + let settings = Settings { + provider: "zen".to_string(), + model: "deepseek-v4-flash-free".to_string(), + ..Default::default() + }; assert_eq!( resolve(&app_config, &settings), diff --git a/crates/zesdex-backend/src/app/runtime/stream/mod.rs b/crates/zesdex-backend/src/app/runtime/stream/mod.rs index aaf9f78..3b63446 100644 --- a/crates/zesdex-backend/src/app/runtime/stream/mod.rs +++ b/crates/zesdex-backend/src/app/runtime/stream/mod.rs @@ -2,355 +2,4 @@ //! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done). pub mod turn; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -/// One atomic event extracted from an LLM streaming response stream. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum StreamEvent { - Token(String), - Reasoning(String), - ToolCallDelta { - index: usize, - id: Option, - name: Option, - arguments_delta: String, - }, - Usage { - prompt_tokens: u64, - completion_tokens: u64, - total_tokens: u64, - }, - Done, - Error(String), -} - -/// Buffered SSE frame parser that accumulates raw `data:` lines and -/// flushes a `StreamEvent` on each blank-line boundary. -pub struct SseParser { - buffer: String, - event_type: Option, - data_lines: Vec, -} - -impl SseParser { - /// Create a new parser with an empty buffer. - pub fn new() -> Self { - SseParser { - buffer: String::new(), - event_type: None, - data_lines: Vec::new(), - } - } - - /// Feed a raw SSE chunk and produce any completed events. - /// - /// Flow: append chunk to buffer → scan for '\n' → strip '\r' → on - /// blank line, call `flush_event` to parse the accumulated data → - /// on `event:` line, store the event type → on `data:` line, append - /// to data accumulator → continue until buffer exhausted. - /// - /// Edge case: a chunk may split mid-line; the remainder stays in the - /// buffer for the next `feed()` call. - /// - /// Return: all `StreamEvent`s completed by this chunk. - pub fn feed(&mut self, chunk: &str) -> Vec { - self.buffer.push_str(chunk); - let mut events = Vec::new(); - while let Some(line_end) = self.buffer.find('\n') { - let line = self.buffer[..line_end].trim_end_matches('\r').to_string(); - self.buffer = self.buffer[line_end + 1..].to_string(); - if line.is_empty() { - events.extend(self.flush_event()); - } else if let Some(ty) = line.strip_prefix("event: ") { - self.event_type = Some(ty.trim().to_string()); - } else if let Some(data) = line.strip_prefix("data:") { - // Handle both "data: {...}" (with space) and "data:{...}" - // (without space). Some providers omit the trailing space. - let data = data.trim_start().to_string(); - self.data_lines.push(data); - } - } - events - } - - /// Flush the current buffered `data:` lines as one or more `StreamEvent`s. - /// - /// Flow: join data lines → handle `[DONE]` sentinel → JSON-parse → - /// emit `Usage` if a usage object is present → else match `event_type` - /// ("message.stop", "message.delta", etc.) → extract content, - /// reasoning, tool-call deltas, or finish-reason from the delta - /// structure (supporting both Anthropic-style top-level delta and - /// OpenAI-style `choices` array). - /// - /// Why: dual-format support in one method avoids a separate - /// provider-specific parsing layer. - /// - /// Return: 0, 1, or more `StreamEvent`s from the flushed frame. - - fn flush_event(&mut self) -> Vec { - let data = self.data_lines.join("\n"); - self.data_lines.clear(); - let event_type = self.event_type.take().unwrap_or_default(); - if data.is_empty() || data == "[DONE]" { - if data == "[DONE]" { - return vec![StreamEvent::Done]; - } - return vec![]; - } - let value: Value = match serde_json::from_str(&data) { - Ok(v) => v, - Err(e) => { - tracing::warn!("[stream] failed to parse chunk: {}", e); - return vec![]; - } - }; - - let mut events = Vec::new(); - - if let Some(usage) = value.get("usage") { - if !usage.is_null() { - let prompt_tokens = usage - .get("prompt_tokens") - .and_then(serde_json::Value::as_u64) - .unwrap_or_else(|| { - tracing::warn!("[stream] prompt_tokens missing in usage chunk"); - 0 - }); - let completion_tokens = usage - .get("completion_tokens") - .and_then(serde_json::Value::as_u64) - .unwrap_or_else(|| { - tracing::warn!("[stream] completion_tokens missing in usage chunk"); - 0 - }); - let total_tokens = usage - .get("total_tokens") - .and_then(serde_json::Value::as_u64) - .unwrap_or_else(|| { - tracing::warn!("[stream] total_tokens missing in usage chunk"); - prompt_tokens + completion_tokens - }); - events.push(StreamEvent::Usage { - prompt_tokens, - completion_tokens, - total_tokens, - }); - } - } - - let mut other_events = match event_type.as_str() { - "message.stop" => vec![StreamEvent::Done], - "message.delta" | "" => { - let mut d_events = Vec::new(); - if let Some(delta) = value.get("delta").or_else(|| value.get("choices")) { - if let Some(choices) = delta.as_array() { - if let Some(choice) = choices.first() { - if let Some(d) = choice.get("delta") { - // Content token - if let Some(content) = d.get("content").and_then(|c| c.as_str()) { - d_events.push(StreamEvent::Token(content.to_string())); - } - - // Reasoning token - if let Some(reasoning) = - d.get("reasoning_content").and_then(|r| r.as_str()) - { - d_events.push(StreamEvent::Reasoning(reasoning.to_string())); - } - - // Tool calls — iterate ALL entries, not just first() - if let Some(tool_calls) = - d.get("tool_calls").and_then(|tc| tc.as_array()) - { - for tc in tool_calls { - let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| { - tracing::warn!("[stream] tool call delta missing index, defaulting to 0"); - 0 - }) as usize; - let id = tc - .get("id") - .and_then(|i| i.as_str()) - .map(std::string::ToString::to_string); - let name = tc - .get("function") - .and_then(|f| f.get("name")) - .and_then(|n| n.as_str()) - .map(std::string::ToString::to_string); - let args_delta = tc - .get("function") - .and_then(|f| f.get("arguments")) - .and_then(|a| a.as_str()) - .unwrap_or("") - .to_string(); - d_events.push(StreamEvent::ToolCallDelta { - index, - id, - name, - arguments_delta: args_delta, - }); - } - } - - // Finish reason - if let Some(reason) = - choice.get("finish_reason").and_then(|r| r.as_str()) - { - if reason == "stop" || reason == "tool_calls" { - d_events.push(StreamEvent::Done); - } - } - } - } - } else if let Some(content) = delta.get("content").and_then(|c| c.as_str()) { - d_events.push(StreamEvent::Token(content.to_string())); - } - } - d_events - } - _ => vec![], - }; - - events.append(&mut other_events); - events - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn feed_parses_single_token_chunk() { - let mut p = SseParser::new(); - let events = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n"); - assert_eq!(events.len(), 1); - match &events[0] { - StreamEvent::Token(t) => assert_eq!(t, "hello"), - other => panic!("expected Token, got {other:?}"), - } - } - - #[test] - fn feed_handles_chunk_split_mid_line() { - let mut p = SseParser::new(); - let e1 = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"partial"); - assert!( - e1.is_empty(), - "no event until the line and blank separator complete" - ); - let e2 = p.feed("\"}}]}\n\n"); - assert_eq!(e2.len(), 1); - match &e2[0] { - StreamEvent::Token(t) => assert_eq!(t, "partial"), - other => panic!("expected Token, got {other:?}"), - } - } - - #[test] - fn feed_emits_done_on_done_sentinel() { - let mut p = SseParser::new(); - let events = p.feed("data: [DONE]\n\n"); - assert_eq!(events.len(), 1); - assert!(matches!(events[0], StreamEvent::Done)); - } - - #[test] - fn feed_emits_done_on_finish_reason_stop() { - let mut p = SseParser::new(); - let events = p.feed("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n"); - assert_eq!(events.len(), 1); - assert!(matches!(events[0], StreamEvent::Done)); - } - - #[test] - fn feed_parses_tool_call_delta() { - let mut p = SseParser::new(); - let events = p.feed( - "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"bash\",\"arguments\":\"{\\\"cmd\\\"\"}}]}}]}\n\n", - ); - assert_eq!(events.len(), 1); - match &events[0] { - StreamEvent::ToolCallDelta { - index, - id, - name, - arguments_delta, - } => { - assert_eq!(*index, 0); - assert_eq!(id.as_deref(), Some("call_1")); - assert_eq!(name.as_deref(), Some("bash")); - assert_eq!(arguments_delta, "{\"cmd\""); - } - other => panic!("expected ToolCallDelta, got {other:?}"), - } - } - - #[test] - fn feed_parses_usage_chunk() { - let mut p = SseParser::new(); - let events = p.feed( - "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n", - ); - assert_eq!(events.len(), 1); - match &events[0] { - StreamEvent::Usage { - prompt_tokens, - completion_tokens, - total_tokens, - } => { - assert_eq!(*prompt_tokens, 10); - assert_eq!(*completion_tokens, 5); - assert_eq!(*total_tokens, 15); - } - other => panic!("expected Usage, got {other:?}"), - } - } - - #[test] - fn feed_parses_usage_and_content_bundled_chunk() { - let mut p = SseParser::new(); - let events = p.feed( - "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n", - ); - assert_eq!(events.len(), 2); - match (&events[0], &events[1]) { - ( - StreamEvent::Usage { - prompt_tokens, - completion_tokens, - total_tokens, - }, - StreamEvent::Token(t), - ) => { - assert_eq!(*prompt_tokens, 10); - assert_eq!(*completion_tokens, 5); - assert_eq!(*total_tokens, 15); - assert_eq!(t, "hello"); - } - other => panic!("expected [Usage, Token], got {other:?}"), - } - } - - #[test] - fn feed_ignores_empty_data_lines() { - let mut p = SseParser::new(); - let events = p.feed(": comment\n\n"); - assert!(events.is_empty()); - } - - #[test] - fn feed_multiple_events_across_one_chunk() { - let mut p = SseParser::new(); - let chunk = "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"b\"}}]}\n\n"; - let events = p.feed(chunk); - assert_eq!(events.len(), 2); - match (&events[0], &events[1]) { - (StreamEvent::Token(a), StreamEvent::Token(b)) => { - assert_eq!(a, "a"); - assert_eq!(b, "b"); - } - other => panic!("expected two Tokens, got {other:?}"), - } - } -} +pub use zesdex_entities::{SseParser, StreamEvent}; diff --git a/crates/zesdex-backend/src/app/state/misc.rs b/crates/zesdex-backend/src/app/state/misc.rs index a56ebbd..ce6b88c 100644 --- a/crates/zesdex-backend/src/app/state/misc.rs +++ b/crates/zesdex-backend/src/app/state/misc.rs @@ -1,9 +1,9 @@ //! Application-level "miscellaneous" state: scroll, input buffer, //! overlay stack, toasts, editor, and autocomplete. +use super::types::Overlay; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; -use super::types::Overlay; /// A shared, async-writable cache of directory entries, used to avoid /// re-reading a directory every render frame. @@ -134,7 +134,6 @@ const COMMANDS: &[&str] = &[ "/model", "/model ls", "/model add", - "/todo", "/usage", "/compact", @@ -211,7 +210,10 @@ impl InputState { return None; } let boundary_ok = at_pos == 0 - || before_cursor[..at_pos].chars().next_back().is_some_and(char::is_whitespace); + || before_cursor[..at_pos] + .chars() + .next_back() + .is_some_and(char::is_whitespace); if !boundary_ok { return None; } @@ -225,8 +227,8 @@ impl InputState { /// if none, close and return → otherwise fuzzy-match `query` against /// `files` via `nucleo-matcher`, keep the top 10 by score. pub fn open_mention_autocomplete(&mut self, files: &[String]) { - use nucleo_matcher::{Config, Matcher}; use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; + use nucleo_matcher::{Config, Matcher}; let Some((start, query)) = self.mention_query_at_cursor() else { self.close_autocomplete(); return; @@ -234,7 +236,11 @@ impl InputState { let mut matcher = Matcher::new(Config::DEFAULT.match_paths()); let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart); let matched_files = pattern.match_list(files.iter(), &mut matcher); - self.autocomplete_candidates = matched_files.into_iter().take(10).map(|(f, _)| f.clone()).collect(); + self.autocomplete_candidates = matched_files + .into_iter() + .take(10) + .map(|(f, _)| f.clone()) + .collect(); self.autocomplete_kind = AutocompleteKind::FileMention; self.mention_start = start; self.autocomplete_idx = 0; @@ -245,11 +251,17 @@ impl InputState { /// Wraps around at the boundaries. pub fn cycle_autocomplete(&mut self, forward: bool) { let n = self.autocomplete_candidates.len(); - if n == 0 { return; } + if n == 0 { + return; + } if forward { self.autocomplete_idx = (self.autocomplete_idx + 1) % n; } else { - self.autocomplete_idx = if self.autocomplete_idx == 0 { n - 1 } else { self.autocomplete_idx - 1 }; + self.autocomplete_idx = if self.autocomplete_idx == 0 { + n - 1 + } else { + self.autocomplete_idx - 1 + }; } } @@ -261,7 +273,11 @@ impl InputState { /// /// Return: `true` if a candidate was selected, `false` if none existed. pub fn select_autocomplete(&mut self) -> bool { - let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx).cloned() else { + let Some(candidate) = self + .autocomplete_candidates + .get(self.autocomplete_idx) + .cloned() + else { return false; }; match self.autocomplete_kind { @@ -282,7 +298,8 @@ impl InputState { return false; } let replacement = format!("@{candidate} "); - self.buffer.replace_range(self.mention_start..self.cursor, &replacement); + self.buffer + .replace_range(self.mention_start..self.cursor, &replacement); self.cursor = self.mention_start + replacement.len(); } } @@ -406,8 +423,6 @@ pub struct MiscState { pub selected_index: usize, pub editor: Option, pub api_connected: bool, - #[allow(dead_code)] - pub api_context_length: Option, pub tick_count: u64, pub todo_content: String, pub lesson_running: bool, @@ -427,7 +442,6 @@ impl MiscState { selected_index: 0, editor: None, api_connected: false, - api_context_length: None, tick_count: 0, todo_content: String::new(), lesson_running: false, @@ -443,7 +457,12 @@ impl MiscState { /// /// Return: the expired toasts (after removal). pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec { - let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect(); + let expired: Vec<_> = self + .toasts + .iter() + .filter(|t| t.expired(now_ms)) + .cloned() + .collect(); self.toasts.retain(|t| !t.expired(now_ms)); expired } @@ -463,13 +482,19 @@ mod tests { #[test] fn mention_at_buffer_start_triggers() { let input = input_with("@mai", 4); - assert_eq!(input.mention_query_at_cursor(), Some((0, "mai".to_string()))); + assert_eq!( + input.mention_query_at_cursor(), + Some((0, "mai".to_string())) + ); } #[test] fn mention_after_space_mid_sentence_triggers() { let input = input_with("look at @read", 13); - assert_eq!(input.mention_query_at_cursor(), Some((8, "read".to_string()))); + assert_eq!( + input.mention_query_at_cursor(), + Some((8, "read".to_string())) + ); } #[test] diff --git a/crates/zesdex-backend/src/app/state/rest.rs b/crates/zesdex-backend/src/app/state/rest.rs index 15b0e2b..b7c14ef 100644 --- a/crates/zesdex-backend/src/app/state/rest.rs +++ b/crates/zesdex-backend/src/app/state/rest.rs @@ -17,12 +17,12 @@ use crate::app::mcp::manager::McpManager; use crate::app::workflow::engine::WorkflowEngine; use zesdex_cms::domain::app_config::AppConfig; use zesdex_cms::domain::edit_log::EditLog; -use zesdex_cms::domain::repository::EditLogRepository; -use zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository; use zesdex_cms::domain::repository::AppConfigRepository; +use zesdex_cms::domain::repository::EditLogRepository; use zesdex_cms::domain::repository::SettingsRepository; use zesdex_cms::domain::settings::Settings; use zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository; +use zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository; use zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository; /// A single transcript entry rendered in the TUI chat pane. @@ -51,7 +51,6 @@ impl ChatMessageDisplay { /// other module. #[derive(Clone)] pub struct AppStateRest { - pub settings: Settings, pub app_config: AppConfig, pub workspace_roots: Vec, @@ -91,7 +90,11 @@ impl AppStateRest { /// Why: falls back to `memory_dir` itself (with a warning) when it has /// no parent, and to an empty session id when the dir name can't be /// read, so construction never fails. - pub fn new(workspace_roots: Vec, session_dir: &std::path::Path, memory_dir: PathBuf) -> Self { + pub fn new( + workspace_roots: Vec, + session_dir: &std::path::Path, + memory_dir: PathBuf, + ) -> Self { let store_base_dir = zesdex_entities::seaorm::common::store::Store::new().base_dir; let settings = JsonSettingsRepository::new() .load(&store_base_dir) @@ -99,18 +102,27 @@ impl AppStateRest { let app_config = JsonAppConfigRepository::new() .load(&store_base_dir) .unwrap_or_default(); - let worktrees_dir = memory_dir.parent().unwrap_or_else(|| { - tracing::warn!("[state] memory_dir '{}' has no parent, using it for worktrees", memory_dir.display()); - &memory_dir - }).join("worktrees"); + let worktrees_dir = memory_dir + .parent() + .unwrap_or_else(|| { + tracing::warn!( + "[state] memory_dir '{}' has no parent, using it for worktrees", + memory_dir.display() + ); + &memory_dir + }) + .join("worktrees"); let dir_cache = DirCache::new(); - let session_id = session_dir - .file_name().map_or_else(|| { - tracing::warn!("[state] session_dir has no file_name component, using empty session_id"); + let session_id = session_dir.file_name().map_or_else( + || { + tracing::warn!( + "[state] session_dir has no file_name component, using empty session_id" + ); String::new() - }, |n| n.to_string_lossy().to_string()); + }, + |n| n.to_string_lossy().to_string(), + ); let mut state = AppStateRest { - settings, app_config, workspace_roots, @@ -123,10 +135,15 @@ impl AppStateRest { abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), dir_cache: Arc::new(RwLock::new(dir_cache)), mention_index: MentionIndex::new(), - edit_log: JsonlEditLogRepository::new().open(session_dir).unwrap_or_else(|e| { - tracing::warn!("[state] failed to open edit log at '{}': {e}", session_dir.display()); - EditLog::new() - }), + edit_log: JsonlEditLogRepository::new() + .open(session_dir) + .unwrap_or_else(|e| { + tracing::warn!( + "[state] failed to open edit log at '{}': {e}", + session_dir.display() + ); + EditLog::new() + }), session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())), workflow_engine: WorkflowEngine::new(), mcp_manager: McpManager::new(), @@ -149,7 +166,9 @@ impl AppStateRest { let mut hasher = sha2::Sha256::new(); hasher.update(abs_root.to_string_lossy().as_bytes()); let hash_hex = hex::encode(hasher.finalize()); - let folder_name = abs_root.file_name().map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string()); + let folder_name = abs_root + .file_name() + .map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string()); let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]); let history_dir = base_dir.join("history"); let _ = std::fs::create_dir_all(&history_dir); @@ -194,9 +213,14 @@ impl AppStateRest { } // Wrap the msg_queue in a static-lifetime closure for use as ProgressFn. - let progress: provisioner::ProgressFn = Some(&|msg: &str| push_msg(&msg_queue, msg)); + let progress: provisioner::ProgressFn = + Some(&|msg: &str| push_msg(&msg_queue, msg)); - let report = |msg: &str| { if let Some(f) = &progress { f(msg); }}; + let report = |msg: &str| { + if let Some(f) = &progress { + f(msg); + } + }; report("LSP: provisioning servers..."); let results = provisioner::provision_all_with_progress(progress); @@ -204,18 +228,29 @@ impl AppStateRest { let connected = provisioner::auto_connect(&lsp_mgr, &results); for name in &connected { tracing::info!("LSP: {} connected", name); - let m = format!("LSP: {name} connected ✓"); push_msg(&msg_queue, &m); + let m = format!("LSP: {name} connected ✓"); + push_msg(&msg_queue, &m); } for r in &results { - if let ProvisionResult::Failed { language, server_name, reason, .. } = r { + if let ProvisionResult::Failed { + language, + server_name, + reason, + .. + } = r + { tracing::warn!("LSP {} ({}): {}", server_name, language, reason); - let m = format!("LSP: {server_name} ({language}) ✗ - {reason}"); push_msg(&msg_queue, &m); + let m = format!("LSP: {server_name} ({language}) ✗ - {reason}"); + push_msg(&msg_queue, &m); } } if connected.is_empty() { - let m = "LSP: no servers available — install manually or check prerequisites".to_string(); push_msg(&msg_queue, &m); + let m = "LSP: no servers available — install manually or check prerequisites" + .to_string(); + push_msg(&msg_queue, &m); } else { - let m = format!("LSP: {} server(s) connected", connected.len()); push_msg(&msg_queue, &m); + let m = format!("LSP: {} server(s) connected", connected.len()); + push_msg(&msg_queue, &m); } }); } @@ -259,7 +294,11 @@ impl AppStateRest { } let rel = entry.path().strip_prefix(root).unwrap_or(entry.path()); let rel_str = rel.display().to_string(); - let formatted = if i == 0 { rel_str } else { format!("[{i}]{rel_str}") }; + let formatted = if i == 0 { + rel_str + } else { + format!("[{i}]{rel_str}") + }; paths.push(formatted); if paths.len() >= MAX_MENTION_ENTRIES { break 'roots; @@ -275,10 +314,13 @@ impl AppStateRest { /// Return: `false` (and logs a warning) if the mutex is poisoned, rather /// than propagating a panic. pub fn turn_in_flight(&self) -> bool { - self.turn_in_flight.lock().map_or_else(|_| { - tracing::warn!("[state] turn_in_flight mutex poisoned"); - false - }, |g| *g) + self.turn_in_flight.lock().map_or_else( + |_| { + tracing::warn!("[state] turn_in_flight mutex poisoned"); + false + }, + |g| *g, + ) } /// Shut down every running LSP server process. @@ -317,14 +359,28 @@ impl AppStateRest { /// `session_dir` itself -- logging a warning at each step down, so this /// never fails even on a shallow path. pub fn store_base_dir(&self) -> std::path::PathBuf { - self.session_dir.parent() - .and_then(|p| p.parent()).map_or_else(|| { - tracing::warn!("[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display()); - self.session_dir.parent().map_or_else(|| { - tracing::warn!("[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display()); - self.session_dir.clone() - }, std::path::Path::to_path_buf) - }, std::path::Path::to_path_buf) + self.session_dir + .parent() + .and_then(|p| p.parent()) + .map_or_else( + || { + tracing::warn!( + "[state] session_dir '{}' has no grandparent, using parent", + self.session_dir.display() + ); + self.session_dir.parent().map_or_else( + || { + tracing::warn!( + "[state] session_dir '{}' has no parent at all, using itself", + self.session_dir.display() + ); + self.session_dir.clone() + }, + std::path::Path::to_path_buf, + ) + }, + std::path::Path::to_path_buf, + ) } /// Build a `ToolCtx` for tool calls originating from the main agent. diff --git a/crates/zesdex-backend/src/app/state/runtime.rs b/crates/zesdex-backend/src/app/state/runtime.rs index c169594..ee4e2f6 100644 --- a/crates/zesdex-backend/src/app/state/runtime.rs +++ b/crates/zesdex-backend/src/app/state/runtime.rs @@ -4,20 +4,7 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; -/// Cumulative token/latency counters for a session, persisted alongside it. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] -pub struct UsageStats { - pub tokens_in: u64, - pub tokens_out: u64, - #[serde(default)] - pub last_tokens_in: u64, - #[serde(default)] - pub last_tokens_out: u64, - pub api_calls: u64, - pub review_tokens: u64, - pub total_ms: u64, -} - +pub use zesdex_entities::seaorm::common::usage::UsageStats; /// Mutable, serializable state for one session: chat history, tool /// results, pending tools, background jobs, and lesson/review counters /// shown in the TUI status bar. @@ -55,16 +42,7 @@ pub struct SessionRuntime { pub hive_mind_converged: bool, } -/// Record of one completed tool invocation, kept for transcript/history. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCallResult { - pub tool_call_id: String, - pub tool_name: String, - pub output: String, - pub is_error: bool, - pub duration_ms: u64, -} - +pub use zesdex_entities::seaorm::common::tool_result::ToolCallResult; /// A tool call awaiting execution, along with which execution model /// (inline, deferred, async) it should run under. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/zesdex-backend/src/app/subagent/engine.rs b/crates/zesdex-backend/src/app/subagent/engine.rs index ba40975..b5c430a 100644 --- a/crates/zesdex-backend/src/app/subagent/engine.rs +++ b/crates/zesdex-backend/src/app/subagent/engine.rs @@ -7,14 +7,14 @@ //! bash exfiltration and destructive-pattern detection) so that subagents //! are not a weaker link than the main agent. -use std::fmt::Write; -use sha2::Digest; -use tokio::sync::mpsc; +use super::context::SubagentContext; +use super::event::SubagentEvent; use crate::dto::chat::message::ChatMessage; use crate::dto::provider::request::ToolDef; use crate::tool::{all_tools, tool_defs, tool_is_risky}; -use super::context::SubagentContext; -use super::event::SubagentEvent; +use sha2::Digest; +use std::fmt::Write; +use tokio::sync::mpsc; use zesdex_cms::domain::repository::AppConfigRepository; use zesdex_cms::domain::repository::EditLogRepository; use zesdex_cms::domain::repository::SettingsRepository; @@ -29,7 +29,9 @@ use zesdex_cms::domain::repository::SettingsRepository; /// `build_subagent_context`'s default for non-reviewer roles). /// /// Return: `(tool impls, schema defs)` for the subagent to use. -fn build_subagent_tools(allowed_tools: &[String]) -> (Vec>, Vec) { +fn build_subagent_tools( + allowed_tools: &[String], +) -> (Vec>, Vec) { let all = all_tools(); let filtered: Vec> = if allowed_tools.is_empty() { all.into_iter() @@ -62,28 +64,44 @@ fn build_subagent_tools(allowed_tools: &[String]) -> (Vec (String, String, Option, String) { let store_base_dir = zesdex_entities::seaorm::common::store::Store::new().base_dir; - let settings = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new() - .load(&store_base_dir) - .unwrap_or_default(); - let app_config = zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository::new() - .load(&store_base_dir) - .unwrap_or_default(); + let settings = + zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new() + .load(&store_base_dir) + .unwrap_or_default(); + let app_config = + zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository::new() + .load(&store_base_dir) + .unwrap_or_default(); - let mut api_key = settings.api_keys.get(&settings.provider).cloned().unwrap_or_else(|| { - tracing::warn!("[subagent] no API key for provider '{}' in settings, trying env/default", settings.provider); - String::new() - }); + let mut api_key = settings + .api_keys + .get(&settings.provider) + .cloned() + .unwrap_or_else(|| { + tracing::warn!( + "[subagent] no API key for provider '{}' in settings, trying env/default", + settings.provider + ); + String::new() + }); let model = settings.model.clone(); - let base_url = app_config.providers.get(&settings.provider) + let base_url = app_config + .providers + .get(&settings.provider) .map(|p| p.api_base.clone()); if api_key.is_empty() { if let Some(provider_cfg) = app_config.providers.get(&settings.provider) { - api_key = provider_cfg.api_key_env.as_ref() + api_key = provider_cfg + .api_key_env + .as_ref() .and_then(|env| std::env::var(env).ok()) .or_else(|| provider_cfg.default_api_key.clone()) .unwrap_or_else(|| { - tracing::warn!("[subagent] all API key resolution paths exhausted for '{}'", settings.provider); + tracing::warn!( + "[subagent] all API key resolution paths exhausted for '{}'", + settings.provider + ); String::new() }); } @@ -109,43 +127,82 @@ fn require_api_key(api_key: &str, provider: &str) -> anyhow::Result<()> { // ─── Subagent-level tool gating (mirrors Harness checks) ─── const STUB_PATTERNS: &[&str] = &[ - "todo!()", "todo!(", - "unimplemented!()", "unimplemented!(", - "FIXME", "fixme:", "XXX:", "PLACEHOLDER", - "REPLACE_ME", "stub_value", "stub_function", - "fake_response", "fake_data", - "not implemented", "not yet implemented", - "to be implemented", "to be done", + "todo!()", + "todo!(", + "unimplemented!()", + "unimplemented!(", + "FIXME", + "fixme:", + "XXX:", + "PLACEHOLDER", + "REPLACE_ME", + "stub_value", + "stub_function", + "fake_response", + "fake_data", + "not implemented", + "not yet implemented", + "to be implemented", + "to be done", ]; const DENIAL_PATTERNS: &[&str] = &[ - "// skip", "// skipping", "// skipping for now", - "// for now just", "// punt", "// hack:", - "// workaround:", "// cba", "// later", - "// do later", "// ignore for now", "// disable", - "// bypass", "// quick fix", "// temp fix", - "// temporary fix", "// temp:", "// temporary:", + "// skip", + "// skipping", + "// skipping for now", + "// for now just", + "// punt", + "// hack:", + "// workaround:", + "// cba", + "// later", + "// do later", + "// ignore for now", + "// disable", + "// bypass", + "// quick fix", + "// temp fix", + "// temporary fix", + "// temp:", + "// temporary:", "// noop", ]; const ASSUMPTION_PATTERNS: &[&str] = &[ - "// assume", "// probably", "// guess", - "// should work", "// hopefully", "// i think", - "// should be fine", "// likely", + "// assume", + "// probably", + "// guess", + "// should work", + "// hopefully", + "// i think", + "// should be fine", + "// likely", ]; const EXFIL_PATTERNS: &[&str] = &[ - "curl ", "wget ", "nc -e ", "ncat ", "/dev/tcp/", - "base64 -d |", "base64 --decode |", - "openssl s_client", "ssh -R ", - "scp /", "rsync /", + "curl ", + "wget ", + "nc -e ", + "ncat ", + "/dev/tcp/", + "base64 -d |", + "base64 --decode |", + "openssl s_client", + "ssh -R ", + "scp /", + "rsync /", ]; const SENSITIVE_PATH_PATTERNS: &[&str] = &[ - ".ssh/id_rsa", ".ssh/id_ed25519", - ".aws/credentials", ".aws/config", - ".kube/config", ".docker/config.json", - "/etc/shadow", "/etc/passwd", "/proc/self/environ", + ".ssh/id_rsa", + ".ssh/id_ed25519", + ".aws/credentials", + ".aws/config", + ".kube/config", + ".docker/config.json", + "/etc/shadow", + "/etc/passwd", + "/proc/self/environ", ]; const MIN_REASON_LEN: usize = 8; @@ -157,10 +214,7 @@ const MIN_REASON_LEN: usize = 8; /// assumption language, bash exfiltration, destructive commands, sensitive /// path reads — regardless of the allowed-tools list. Tools that are not /// risky only get the basic allowlist check. -fn gate_subagent_tool_call( - tool_name: &str, - args: &serde_json::Value, -) -> Option { +fn gate_subagent_tool_call(tool_name: &str, args: &serde_json::Value) -> Option { // File-mutating tools: write / edit / delete if matches!(tool_name, "write" | "edit" | "delete") { if let Some(path) = args.get("path").and_then(|v| v.as_str()) { @@ -204,10 +258,16 @@ fn gate_subagent_tool_call( return Some("content contains stub/placeholder pattern; production code must be fully implemented".to_string()); } if contains_any(content, DENIAL_PATTERNS) { - return Some("content contains denial/punt pattern; implement properly instead of skipping".to_string()); + return Some( + "content contains denial/punt pattern; implement properly instead of skipping" + .to_string(), + ); } if contains_any(content, ASSUMPTION_PATTERNS) { - return Some("content contains assumption pattern; verify against data instead of guessing".to_string()); + return Some( + "content contains assumption pattern; verify against data instead of guessing" + .to_string(), + ); } } @@ -231,7 +291,9 @@ fn gate_subagent_tool_call( if !is_standard { for pat in EXFIL_PATTERNS { if cmd.contains(pat) { - return Some(format!("potential data-exfiltration command blocked (matched '{pat}')")); + return Some(format!( + "potential data-exfiltration command blocked (matched '{pat}')" + )); } } } @@ -240,9 +302,21 @@ fn gate_subagent_tool_call( return Some(format!("refused to read/write sensitive path '{pat}'")); } } - let dangerous = ["rm -rf /", "rm -rf --no-preserve-root", "rm -rf ~", - "rm -fr /", "mkfs.", "dd if=", ":(){", "> /dev/sda", - "chmod -R 000 /", "shutdown ", "poweroff ", "reboot ", "halt "]; + let dangerous = [ + "rm -rf /", + "rm -rf --no-preserve-root", + "rm -rf ~", + "rm -fr /", + "mkfs.", + "dd if=", + ":(){", + "> /dev/sda", + "chmod -R 000 /", + "shutdown ", + "poweroff ", + "reboot ", + "halt ", + ]; for pat in &dangerous { if cmd.contains(pat) { return Some(format!("destructive command pattern blocked: {pat}")); @@ -289,7 +363,9 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String { for entry in walker.flatten() { let path = entry.path(); if let Ok(rel) = path.strip_prefix(root) { - if rel.as_os_str().is_empty() { continue; } + if rel.as_os_str().is_empty() { + continue; + } let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir()); let prefix = if is_dir { "[DIR] " } else { " " }; writeln!(out, " {}{}", prefix, rel.display()).unwrap(); @@ -331,8 +407,10 @@ fn format_subagent_progress(prefix: &str, text: &str) -> String { /// /// Return: the concatenated text output, or an `anyhow::Error` if the LLM /// call fails at any step. -#[allow(clippy::too_many_lines)] -pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender) -> anyhow::Result { +pub fn run_subagent( + ctx: &SubagentContext, + tx: &mpsc::Sender, +) -> anyhow::Result { let mut output = String::new(); let mut messages: Vec = Vec::new(); @@ -370,17 +448,23 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender) -> // surfaces past a buried WARN log. if let Err(error) = require_api_key(&api_key, &provider) { let error = error.to_string(); - let _ = tx.blocking_send(SubagentEvent::StepFailed { step: 0, error: error.clone() }); + let _ = tx.blocking_send(SubagentEvent::StepFailed { + step: 0, + error: error.clone(), + }); anyhow::bail!(error); } let client = crate::service::provider::LlmClient::new(api_key, model, base_url); for step in 0..ctx.max_steps { - // Check abort flag before each LLM call so a stuck subagent can // be cancelled from the parent (mirrors main agent behaviour). - if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) { + if ctx + .abort_flag + .as_ref() + .is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) + { let _ = tx.blocking_send(SubagentEvent::StepFailed { step, error: "subagent aborted by parent".to_string(), @@ -403,7 +487,11 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender) -> Some(4096), |event| -> bool { // Check abort on every SSE event for responsive cancellation. - if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) { + if ctx + .abort_flag + .as_ref() + .is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) + { return false; // signals provider to abort } match event { @@ -417,7 +505,11 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender) -> let prog = format_subagent_progress("replying", ¤t_token); let _ = tx_clone.blocking_send(SubagentEvent::Progress(prog)); } - crate::app::runtime::stream::StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => { + crate::app::runtime::stream::StreamEvent::Usage { + prompt_tokens, + completion_tokens, + .. + } => { // Capture usage so the drain thread can route it // to the parent's `UsageStats::review_tokens`. // Last writer wins — providers send exactly one @@ -433,7 +525,10 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender) -> let (response, returned_usage) = match stream_result { Ok(result) => result, Err(e) => { - let is_abort = ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) + let is_abort = ctx + .abort_flag + .as_ref() + .is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) || e.to_string().contains("aborted"); let _ = tx.blocking_send(SubagentEvent::StepFailed { step, @@ -459,7 +554,8 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender) -> // subagent never tells the parent about the tokens consumed. let (mut tok_in, mut tok_out) = returned_usage.unwrap_or((0, 0)); if tok_in == 0 { - let prompt_chars: usize = messages.iter() + let prompt_chars: usize = messages + .iter() .filter_map(|m| m.content.as_deref()) .map(str::len) .sum(); @@ -475,7 +571,10 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender) -> }); let has_tool_calls = response.tool_calls.is_some() - && response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty()); + && response + .tool_calls + .as_ref() + .is_some_and(|tc| !tc.is_empty()); let content = response.content.clone().unwrap_or_default(); @@ -608,7 +707,8 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender) -> for (tool_call, result) in results_vec { let tool_name = &tool_call.function.name; - let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments); + let args = + crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments); let _ = tx.blocking_send(SubagentEvent::ToolCall { tool: tool_name.clone(), @@ -617,24 +717,28 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender) -> match result { Ok(output_text) => { - messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone())); + messages.push(ChatMessage::tool_result( + tool_call.id.clone(), + output_text.clone(), + )); let _ = tx.blocking_send(SubagentEvent::ToolResult { tool: tool_name.clone(), args: args.clone(), }); - - let is_readonly = tool_name == "read" - || tool_name == "view_file" - || tool_name == "grep" - || tool_name == "grep_search" - || tool_name == "glob" - || tool_name == "dir_list" + + let is_readonly = tool_name == "read" + || tool_name == "view_file" + || tool_name == "grep" + || tool_name == "grep_search" + || tool_name == "glob" + || tool_name == "dir_list" || tool_name == "list_dir"; - + if is_readonly { if let Some(ref findings) = ctx.workflow_findings { if let Ok(mut f) = findings.lock() { - let args_json = serde_json::to_string(&args).unwrap_or_default(); + let args_json = + serde_json::to_string(&args).unwrap_or_default(); let mut shared_text = output_text; if shared_text.len() > 50_000 { shared_text.truncate(50_000); diff --git a/crates/zesdex-backend/src/app/subagent/spawn.rs b/crates/zesdex-backend/src/app/subagent/spawn.rs index 6fba4b5..d9db83c 100644 --- a/crates/zesdex-backend/src/app/subagent/spawn.rs +++ b/crates/zesdex-backend/src/app/subagent/spawn.rs @@ -41,16 +41,9 @@ impl AgentDefinition { } /// Builder method: set the maximum step count for this agent. - #[allow(dead_code)] pub fn with_max_steps(mut self, steps: usize) -> Self { self.max_steps = Some(steps); self } - /// Builder method: set the temperature for this agent. - #[allow(dead_code)] - pub fn with_temperature(mut self, temp: f32) -> Self { - self.temperature = Some(temp); - self - } } diff --git a/crates/zesdex-backend/src/app/workflow/docs.rs b/crates/zesdex-backend/src/app/workflow/docs.rs index 11b1b3d..c3b9e7a 100644 --- a/crates/zesdex-backend/src/app/workflow/docs.rs +++ b/crates/zesdex-backend/src/app/workflow/docs.rs @@ -7,9 +7,9 @@ //! Core Intelligence can omit or reshape — and always runs after any //! hive-mind convergence completes. use crate::app::workflow::hive_mind::NodeReport; -use zesdex_cms::domain::memory::Memory; use std::fmt::Write as _; use std::path::{Path, PathBuf}; +use zesdex_cms::domain::memory::Memory; /// Write a markdown report of one hive-mind convergence to /// `/docs/runs/-.md`. diff --git a/crates/zesdex-backend/src/app/workflow/engine.rs b/crates/zesdex-backend/src/app/workflow/engine.rs index 7a01834..9d97b72 100644 --- a/crates/zesdex-backend/src/app/workflow/engine.rs +++ b/crates/zesdex-backend/src/app/workflow/engine.rs @@ -210,20 +210,23 @@ fn format_tool_call_progress(prefix: &str, tool: &str, args: &serde_json::Value) /// a stuck stage from blocking the entire pipeline forever. /// /// Return: the agent's text output, or an error on failure. -fn spawn_single_agent( - agent_id: &str, - agent_name: &str, - prompt: &str, - role: &str, - allowed_tools: Option>, - findings_snapshot: &[String], - findings: &Arc>>, - abort_flag: &Option>, - live: Option<&LiveStateFn>, - session_dir: &std::path::Path, - workspaces: &[std::path::PathBuf], - timeout_ms: Option, -) -> anyhow::Result { +/// Bundled context for spawning a single subagent. +pub(crate) struct SpawnCtx<'a> { + pub agent_id: &'a str, + pub agent_name: &'a str, + pub prompt: &'a str, + pub role: &'a str, + pub allowed_tools: Option>, + pub findings_snapshot: &'a [String], + pub findings: &'a Arc>>, + pub abort_flag: &'a Option>, + pub live: Option<&'a LiveStateFn>, + pub session_dir: &'a std::path::Path, + pub workspaces: &'a [std::path::PathBuf], + pub timeout_ms: Option, +} + +fn spawn_single_agent(sp: SpawnCtx<'_>) -> anyhow::Result { use crate::app::subagent::context::build_subagent_context; use crate::app::subagent::engine::run_subagent; use crate::app::subagent::spawn::AgentDefinition; @@ -233,10 +236,10 @@ fn spawn_single_agent( // Notify UI: this agent is now running. // Pass both the unique agent_id (UUID for stable key) and agent_name // (human-readable display name, e.g. a hive-mind node designation). - if let Some(f) = live { + if let Some(f) = &sp.live { f( - agent_id.to_string(), - agent_name.to_string(), + sp.agent_id.to_string(), + sp.agent_name.to_string(), AgentStatus { state: AgentState::Running, started_at: Some(started_at), @@ -247,20 +250,20 @@ fn spawn_single_agent( ); } - let mut def = AgentDefinition::new(agent_name.to_string(), role.to_string()); - if let Some(tools) = allowed_tools { - def = def.with_allowed_tools(tools); + let mut def = AgentDefinition::new(sp.agent_name.to_string(), sp.role.to_string()); + if let Some(tools) = &sp.allowed_tools { + def = def.with_allowed_tools(tools.clone()); } let mut ctx = build_subagent_context(&def); - ctx.session_dir = session_dir.to_path_buf(); - ctx.workspaces = workspaces.to_vec(); + ctx.session_dir = sp.session_dir.to_path_buf(); + ctx.workspaces = sp.workspaces.to_vec(); - let findings_section = if findings_snapshot.is_empty() { + let findings_section = if sp.findings_snapshot.is_empty() { String::new() } else { format!( "\n\nFindings from sibling drones in this Hive run:\n{}", - findings_snapshot + sp.findings_snapshot .iter() .enumerate() .map(|(i, f)| format!("{}. {}", i + 1, f)) @@ -269,20 +272,20 @@ fn spawn_single_agent( ) }; - ctx.system_prompt = format!("{prompt}{findings_section}"); + ctx.system_prompt = format!("{}{}", sp.prompt, findings_section); // Link the shared findings Arc so note_finding calls within this // subagent write into the same vec visible to sibling agents. - ctx.workflow_findings = Some(findings.clone()); - ctx.abort_flag.clone_from(abort_flag); + ctx.workflow_findings = Some(sp.findings.clone()); + ctx.abort_flag.clone_from(sp.abort_flag); // Create an mpsc channel and drain events in a background thread. // The drain thread also pushes intra-division progress updates to the // live callback (current tool being executed), so the TUI panel shows // real-time "editing X" or "running build" instead of just "Running…". let (tx, rx) = tokio::sync::mpsc::channel(64); - let drain_agent_id = agent_id.to_string(); - let drain_agent_name = agent_name.to_string(); - let drain_live = live.cloned(); + let drain_agent_id = sp.agent_id.to_string(); + let drain_agent_name = sp.agent_name.to_string(); + let drain_live = sp.live.cloned(); let drain_started_at = started_at; let _drain_thread = std::thread::spawn(move || { use crate::app::subagent::event::SubagentEvent; @@ -380,11 +383,12 @@ fn spawn_single_agent( }); // Check abort before even starting the subagent. - if abort_flag + if sp + .abort_flag .as_ref() .is_some_and(|f| f.load(Ordering::SeqCst)) { - anyhow::bail!("subagent '{agent_name}' aborted before start"); + anyhow::bail!("subagent '{}' aborted before start", sp.agent_name); } // Run subagent on a separate thread so the abort flag can be polled. @@ -393,14 +397,14 @@ fn spawn_single_agent( let (done_tx, done_rx) = std::sync::mpsc::channel::>(); let bg_ctx = ctx; let bg_tx = tx; - let bg_name = agent_name.to_string(); - let bg_abort = abort_flag.clone(); + let bg_name = sp.agent_name.to_string(); + let bg_abort = sp.abort_flag.clone(); std::thread::spawn(move || { let _ = done_tx.send(run_subagent(&bg_ctx, &bg_tx)); }); let poll_interval = Duration::from_millis(200); - let result = if let Some(timeout) = timeout_ms { + let result = if let Some(timeout) = sp.timeout_ms { let deadline = Duration::from_millis(timeout); let mut elapsed = Duration::ZERO; loop { @@ -431,7 +435,7 @@ fn spawn_single_agent( let completed_at = chrono::Utc::now().timestamp_millis(); // Notify UI: agent completed or failed - if let Some(f) = live { + if let Some(f) = &sp.live { let summary_from = |text: &str| { text.lines() .next() @@ -444,8 +448,8 @@ fn spawn_single_agent( Ok(text) => { let summary = summary_from(text); f( - agent_id.to_string(), - agent_name.to_string(), + sp.agent_id.to_string(), + sp.agent_name.to_string(), AgentStatus { state: AgentState::Completed, started_at: Some(started_at), @@ -457,8 +461,8 @@ fn spawn_single_agent( } Err(e) => { f( - agent_id.to_string(), - agent_name.to_string(), + sp.agent_id.to_string(), + sp.agent_name.to_string(), AgentStatus { state: AgentState::Failed, started_at: Some(started_at), @@ -476,6 +480,20 @@ fn spawn_single_agent( type ParallelResult = (usize, anyhow::Result>); +/// Bundled context for executing a script primitive. +pub(crate) struct PrimitiveCtx<'a> { + pub primitive: &'a ScriptPrimitive, + pub args: &'a HashMap, + pub concurrency_cap: usize, + pub continue_on_error: bool, + pub abort_flag: &'a Option>, + pub live: Option<&'a LiveStateFn>, + pub session_dir: &'a std::path::Path, + pub workspaces: &'a [std::path::PathBuf], + pub findings: &'a Arc>>, + pub timeout_ms: Option, +} + /// Recursively execute a `ScriptPrimitive` tree, respecting an overall /// concurrency cap for parallel branches. /// @@ -497,22 +515,11 @@ type ParallelResult = (usize, anyhow::Result>); /// /// Return: a `Vec` of all agent outputs (or error strings) in /// the order they were submitted. -pub fn execute_primitive( - primitive: &ScriptPrimitive, - args: &HashMap, - concurrency_cap: usize, - continue_on_error: bool, - abort_flag: &Option>, - live: Option<&LiveStateFn>, - session_dir: &std::path::Path, - workspaces: &[std::path::PathBuf], - findings: &Arc>>, - timeout_ms: Option, -) -> anyhow::Result> { - match primitive { +pub fn execute_primitive(pc: PrimitiveCtx<'_>) -> anyhow::Result> { + match pc.primitive { ScriptPrimitive::Agent(prompt) => { - let mut resolved_args = args.clone(); - let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default(); + let mut resolved_args = pc.args.clone(); + let findings_snapshot = pc.findings.lock().map(|f| f.clone()).unwrap_or_default(); if !resolved_args.contains_key("findings") { let formatted_findings = if findings_snapshot.is_empty() { "None".to_string() @@ -529,23 +536,23 @@ pub fn execute_primitive( let resolved = resolve_template(prompt, &resolved_args); let agent_id = uuid::Uuid::new_v4().to_string(); let agent_name = resolved.chars().take(40).collect::(); - match spawn_single_agent( - &agent_id, - &agent_name, - &resolved, - "coder", - None, - &findings_snapshot, - findings, - abort_flag, - live, - session_dir, - workspaces, - timeout_ms, - ) { + match spawn_single_agent(SpawnCtx { + agent_id: &agent_id, + agent_name: &agent_name, + prompt: &resolved, + role: "coder", + allowed_tools: None, + findings_snapshot: &findings_snapshot, + findings: pc.findings, + abort_flag: pc.abort_flag, + live: pc.live, + session_dir: pc.session_dir, + workspaces: pc.workspaces, + timeout_ms: pc.timeout_ms, + }) { Ok(text) => Ok(vec![text]), Err(e) => { - if continue_on_error { + if pc.continue_on_error { Ok(vec![format!("agent error: {}", e)]) } else { Err(e) @@ -559,8 +566,8 @@ pub fn execute_primitive( node_id, tool_scope, } => { - let mut resolved_args = args.clone(); - let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default(); + let mut resolved_args = pc.args.clone(); + let findings_snapshot = pc.findings.lock().map(|f| f.clone()).unwrap_or_default(); if !resolved_args.contains_key("findings") { let formatted_findings = if findings_snapshot.is_empty() { "None".to_string() @@ -580,20 +587,20 @@ pub fn execute_primitive( tracing::debug!("[hive] deploying drone {node_id}: {truncated}"); let agent_name = format!("{node_id}: {truncated}"); let allowed_tools = crate::app::subagent::division::tool_scope::tools_for(tool_scope); - match spawn_single_agent( - &agent_id, - &agent_name, - &resolved, - node_id, - Some(allowed_tools), - &findings_snapshot, - findings, - abort_flag, - live, - session_dir, - workspaces, - timeout_ms, - ) { + match spawn_single_agent(SpawnCtx { + agent_id: &agent_id, + agent_name: &agent_name, + prompt: &resolved, + role: node_id, + allowed_tools: Some(allowed_tools), + findings_snapshot: &findings_snapshot, + findings: pc.findings, + abort_flag: pc.abort_flag, + live: pc.live, + session_dir: pc.session_dir, + workspaces: pc.workspaces, + timeout_ms: pc.timeout_ms, + }) { Ok(text) => { tracing::debug!( "[hive] drone {node_id} completed — merging into collective state" @@ -604,14 +611,14 @@ pub fn execute_primitive( // still running (via read_findings) or any drone spawned // afterward sees this immediately, making the collective // state genuinely continuous rather than batch-synced. - if let Ok(mut f) = findings.lock() { + if let Ok(mut f) = pc.findings.lock() { f.push(format!("[{node_id}]: {text}")); } Ok(vec![text]) } Err(e) => { tracing::warn!("[hive] drone {node_id} failed: {e}"); - if continue_on_error { + if pc.continue_on_error { Ok(vec![format!("drone error: {}", e)]) } else { Err(e) @@ -626,7 +633,7 @@ pub fn execute_primitive( // independent subagents work simultaneously. // Each branch shares the same `findings` Arc so note_finding // calls within any branch are visible to all other branches. - let semaphore = Arc::new(Semaphore::new(concurrency_cap.max(1))); + let semaphore = Arc::new(Semaphore::new(pc.concurrency_cap.max(1))); let results: Arc>> = Arc::new(Mutex::new(Vec::new())); let handles: Vec<_> = scripts @@ -634,31 +641,32 @@ pub fn execute_primitive( .enumerate() .map(|(idx, script)| { let script = script.clone(); - let args = args.clone(); + let args = pc.args.clone(); let sem = Arc::clone(&semaphore); let results = Arc::clone(&results); - let cap = concurrency_cap; - let abort = abort_flag.clone(); - let live_clone = live.cloned(); - let session_dir = session_dir.to_path_buf(); - let workspaces = workspaces.to_vec(); - let findings = Arc::clone(findings); - let to = timeout_ms; + let cap = pc.concurrency_cap; + let continue_on_error = pc.continue_on_error; + let abort = pc.abort_flag.clone(); + let live_clone = pc.live.cloned(); + let session_dir = pc.session_dir.to_path_buf(); + let workspaces = pc.workspaces.to_vec(); + let findings = Arc::clone(pc.findings); + let to = pc.timeout_ms; std::thread::spawn(move || { let _permit = sem.acquire(); - let result = execute_primitive( - &script, - &args, - cap, + let result = execute_primitive(PrimitiveCtx { + primitive: &script, + args: &args, + concurrency_cap: cap, continue_on_error, - &abort, - live_clone.as_ref(), - &session_dir, - &workspaces, - &findings, - to, - ); + abort_flag: &abort, + live: live_clone.as_ref(), + session_dir: &session_dir, + workspaces: &workspaces, + findings: &findings, + timeout_ms: to, + }); if let Ok(mut locked) = results.lock() { locked.push((idx, result)); } @@ -699,31 +707,32 @@ pub fn execute_primitive( for (idx, script) in scripts.iter().enumerate() { // Check abort before each pipeline stage so we don't // launch the next division after the user cancelled. - if abort_flag + if pc + .abort_flag .as_ref() .is_some_and(|f| f.load(Ordering::SeqCst)) { - if continue_on_error { + if pc.continue_on_error { all.push(format!("pipeline aborted at stage {idx}")); break; } anyhow::bail!("pipeline aborted by user at stage {idx}"); } - match execute_primitive( - script, - args, - concurrency_cap, - continue_on_error, - abort_flag, - live, - session_dir, - workspaces, - findings, - timeout_ms, - ) { + match execute_primitive(PrimitiveCtx { + primitive: script, + args: pc.args, + concurrency_cap: pc.concurrency_cap, + continue_on_error: pc.continue_on_error, + abort_flag: pc.abort_flag, + live: pc.live, + session_dir: pc.session_dir, + workspaces: pc.workspaces, + findings: pc.findings, + timeout_ms: pc.timeout_ms, + }) { Ok(outputs) => all.extend(outputs), Err(e) => { - if continue_on_error { + if pc.continue_on_error { all.push(format!("pipeline stage {idx} error: {e}")); } else { return Err(e); @@ -737,18 +746,18 @@ pub fn execute_primitive( ScriptPrimitive::Phase { name: _name, script, - } => execute_primitive( - script, - args, - concurrency_cap, - continue_on_error, - abort_flag, - live, - session_dir, - workspaces, - findings, - timeout_ms, - ), + } => execute_primitive(PrimitiveCtx { + primitive: script, + args: pc.args, + concurrency_cap: pc.concurrency_cap, + continue_on_error: pc.continue_on_error, + abort_flag: pc.abort_flag, + live: pc.live, + session_dir: pc.session_dir, + workspaces: pc.workspaces, + findings: pc.findings, + timeout_ms: pc.timeout_ms, + }), } } @@ -792,18 +801,18 @@ pub fn run_workflow_tracked( }; let findings = Arc::new(Mutex::new(Vec::new())); - let results = execute_primitive( - &script.script, + let results = execute_primitive(PrimitiveCtx { + primitive: &script.script, args, concurrency_cap, - script.options.continue_on_error, + continue_on_error: script.options.continue_on_error, abort_flag, live, session_dir, workspaces, - &findings, - script.options.timeout_ms, - )?; + findings: &findings, + timeout_ms: script.options.timeout_ms, + })?; let summary = if results.is_empty() { "workflow completed with no output".to_string() diff --git a/crates/zesdex-backend/src/app/workflow/hive_mind.rs b/crates/zesdex-backend/src/app/workflow/hive_mind.rs index ccda91e..a056faa 100644 --- a/crates/zesdex-backend/src/app/workflow/hive_mind.rs +++ b/crates/zesdex-backend/src/app/workflow/hive_mind.rs @@ -25,7 +25,7 @@ //! Synthesis node reads the complete collective state and converges it //! into one unified voice — returned to LO and persisted to docs/runs/*.md. //! ``` -use crate::app::workflow::engine::{execute_primitive, AgentStatus, LiveStateFn}; +use crate::app::workflow::engine::{execute_primitive, AgentStatus, LiveStateFn, PrimitiveCtx}; use crate::app::workflow::script::ScriptPrimitive; use serde::Deserialize; use std::collections::HashMap; @@ -211,18 +211,18 @@ fn execute_cycle( let args: HashMap = HashMap::new(); let abort_owned = ctx.abort_flag.cloned(); - let results = execute_primitive( - &cycle_primitive, - &args, - directives.len().clamp(1, ctx.max_cycle_concurrency), - true, - &abort_owned, - ctx.live, - ctx.session_dir, - ctx.workspaces, - ctx.collective_state, - ctx.node_timeout_ms, - )?; + let results = execute_primitive(PrimitiveCtx { + primitive: &cycle_primitive, + args: &args, + concurrency_cap: directives.len().clamp(1, ctx.max_cycle_concurrency), + continue_on_error: true, + abort_flag: &abort_owned, + live: ctx.live, + session_dir: ctx.session_dir, + workspaces: ctx.workspaces, + findings: ctx.collective_state, + timeout_ms: ctx.node_timeout_ms, + })?; let mut reports = Vec::new(); for (node_id, output) in node_ids.iter().zip(results.iter()) { @@ -280,9 +280,10 @@ pub fn run_hive_mind( } let store_base_dir = zesdex_entities::seaorm::common::store::Store::new().base_dir; - let settings = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new() - .load(&store_base_dir) - .unwrap_or_default(); + let settings = + zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new() + .load(&store_base_dir) + .unwrap_or_default(); let node_timeout_ms = Some(settings.hive_mind_node_timeout_ms); let max_cycle_concurrency = settings.workflow_max_concurrency.max(1); @@ -404,18 +405,18 @@ fn synthesize_consensus( let args: HashMap = HashMap::new(); let abort_owned: Option> = abort_flag.cloned(); - let results = execute_primitive( - &synthesis, - &args, - 1, - false, - &abort_owned, + let results = execute_primitive(PrimitiveCtx { + primitive: &synthesis, + args: &args, + concurrency_cap: 1, + continue_on_error: false, + abort_flag: &abort_owned, live, session_dir, workspaces, - collective_state, - node_timeout_ms, - )?; + findings: collective_state, + timeout_ms: node_timeout_ms, + })?; Ok(results.into_iter().next().unwrap_or_default()) } diff --git a/crates/zesdex-backend/src/dto/mod.rs b/crates/zesdex-backend/src/dto/mod.rs index 62c89a5..199d1c6 100644 --- a/crates/zesdex-backend/src/dto/mod.rs +++ b/crates/zesdex-backend/src/dto/mod.rs @@ -16,8 +16,8 @@ pub mod chat { pub mod provider { pub mod request { - pub use zesdex_dto::provider::request::*; pub use zesdex_dto::provider::request::ChatCompletionRequest as ChatRequest; + pub use zesdex_dto::provider::request::*; } pub mod response { pub use zesdex_dto::provider::response::ChatCompletionResponse as ChatResponse; diff --git a/crates/zesdex-backend/src/main.rs b/crates/zesdex-backend/src/main.rs index 7c9685e..851bc70 100644 --- a/crates/zesdex-backend/src/main.rs +++ b/crates/zesdex-backend/src/main.rs @@ -1,4 +1,9 @@ -#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] +#![allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + clippy::cast_possible_wrap +)] //! Zesdex binary entry point. //! //! Parses `--daemon` / `--attach ` flags to select one of three @@ -6,25 +11,27 @@ //! attach-only TUI client), sets up file logging, and runs the //! corresponding event loop. +use anyhow::Result; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; use std::io; use std::io::Write; use std::sync::Mutex; -use anyhow::Result; -use crossterm::execute; -use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; -use ratatui::backend::CrosstermBackend; -use ratatui::Terminal; -use zesdex_iam::domain::repository::{SessionLockRepository, SessionRepository}; use zesdex_cms::domain::repository::SettingsRepository; +use zesdex_iam::domain::repository::{SessionLockRepository, SessionRepository}; mod app; mod controller; mod dto; mod ipc; mod model; +mod resources; mod service; mod tool; -mod resources; mod view; /// RAII guard that releases a session lock on drop, restoring the @@ -55,7 +62,8 @@ impl Drop for SessionL fn main() -> Result<()> { let args: Vec = std::env::args().collect(); let is_daemon = args.iter().any(|a| a == "--daemon"); - let attach_session = args.iter() + let attach_session = args + .iter() .position(|a| a == "--attach") .and_then(|i| args.get(i + 1).cloned()); @@ -65,11 +73,14 @@ fn main() -> Result<()> { let _ = std::fs::create_dir_all(&log_dir); let log_path = log_dir.join("zesdex.log"); let log_file = std::fs::OpenOptions::new() - .create(true).append(true).open(&log_path) + .create(true) + .append(true) + .open(&log_path) .unwrap_or_else(|_| { // Fallback: /dev/null so the TUI isn't corrupted by stderr writes std::fs::OpenOptions::new() - .write(true).open("/dev/null") + .write(true) + .open("/dev/null") .expect("cannot open /dev/null") }); @@ -119,7 +130,10 @@ fn run_single_process() -> Result<()> { if !lock_repo.try_lock(&session_dir)? { anyhow::bail!("session already active (another zesdex process holds the lock for this session directory)"); } - let _session_lock_guard = SessionLockGuard { lock_repo: &lock_repo, session_dir: session_dir.clone() }; + let _session_lock_guard = SessionLockGuard { + lock_repo: &lock_repo, + session_dir: session_dir.clone(), + }; let workspace_roots = vec![std::env::current_dir()?]; let mut state = app::state::rest::AppStateRest::new( @@ -128,10 +142,11 @@ fn run_single_process() -> Result<()> { store.memory_dir, ); state.spawn_mention_index_build(); - let session_repo = zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new(); - state.sessions = session_repo.list_sessions(&store.base_dir).unwrap_or_default(); - - + let session_repo = + zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new(); + state.sessions = session_repo + .list_sessions(&store.base_dir) + .unwrap_or_default(); let _rt = tokio::runtime::Runtime::new()?; @@ -223,25 +238,34 @@ fn key_action_to_code(action: &ipc::protocol::KeyAction) -> crossterm::event::Ke /// /// Why: the client never shares memory with the daemon, so every action /// on the daemon side is followed by a full state push rather than a diff. -fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &app::state::rest::AppStateRest) -> Result<()> { - use ipc::protocol::{DaemonFrame, MessageEntry, ToastEntry, StatePayload}; +fn send_daemon_update( + conn: &mut ipc::conn::Connection, + state: &app::state::rest::AppStateRest, +) -> Result<()> { + use ipc::protocol::{DaemonFrame, MessageEntry, StatePayload, ToastEntry}; - let messages: Vec = state.transcript_cache.messages.iter().map(|m| { - MessageEntry { + let messages: Vec = state + .transcript_cache + .messages + .iter() + .map(|m| MessageEntry { role: format!("{:?}", m.role), content: m.content.clone(), timestamp: m.timestamp, - } - }).collect(); + }) + .collect(); - let toasts: Vec = state.misc.toasts.iter().map(|t| { - ToastEntry { + let toasts: Vec = state + .misc + .toasts + .iter() + .map(|t| ToastEntry { kind: format!("{:?}", t.kind), message: t.message.clone(), created_at: t.created_at, lifetime_ms: t.lifetime_ms, - } - }).collect(); + }) + .collect(); let overlay = if state.misc.overlay.is_active() { Some(format!("{:?}", state.misc.overlay)) @@ -283,8 +307,10 @@ fn apply_client_update( state.session_id = payload.session_id; state.dirty = payload.dirty; - state.transcript_cache.messages = payload.messages.into_iter().map(|m| { - app::state::rest::ChatMessageDisplay { + state.transcript_cache.messages = payload + .messages + .into_iter() + .map(|m| app::state::rest::ChatMessageDisplay { role: match m.role.as_str() { "Assistant" => crate::dto::chat::message::Role::Assistant, "System" => crate::dto::chat::message::Role::System, @@ -293,8 +319,8 @@ fn apply_client_update( }, content: m.content, timestamp: m.timestamp, - } - }).collect(); + }) + .collect(); state.transcript_cache.dirty = true; state.misc.overlay = match payload.overlay.as_deref() { @@ -304,7 +330,6 @@ fn apply_client_update( Some("Bash") => Overlay::Bash, Some("QuitConfirm") => Overlay::QuitConfirm, - Some("KeyInput") => Overlay::KeyInput, Some("Editor") => Overlay::Editor, Some("Effort") => Overlay::Effort, @@ -320,8 +345,10 @@ fn apply_client_update( _ => Overlay::None, }; - state.misc.toasts = payload.toasts.into_iter().map(|t| { - Toast { + state.misc.toasts = payload + .toasts + .into_iter() + .map(|t| Toast { kind: match t.kind.as_str() { "Success" => ToastKind::Success, "Warning" => ToastKind::Warning, @@ -332,8 +359,8 @@ fn apply_client_update( message: t.message, created_at: t.created_at, lifetime_ms: t.lifetime_ms, - } - }).collect(); + }) + .collect(); state.input.buffer = payload.input_buffer; state.input.cursor = payload.input_cursor; @@ -356,7 +383,7 @@ fn handle_daemon_client( mut conn: ipc::conn::Connection, state: &mut app::state::rest::AppStateRest, ) -> Result<()> { - use app::runtime::actions::{Action, apply_action}; + use app::runtime::actions::{apply_action, Action}; use ipc::protocol::ClientRequest; let mut running = true; @@ -367,15 +394,24 @@ fn handle_daemon_client( ClientRequest::Tick => { apply_action(state, Action::Tick); } - ClientRequest::KeyPress { key, ctrl, alt, shift } => { + ClientRequest::KeyPress { + key, + ctrl, + alt, + shift, + } => { let mut modifiers = crossterm::event::KeyModifiers::NONE; - if ctrl { modifiers |= crossterm::event::KeyModifiers::CONTROL; } - if alt { modifiers |= crossterm::event::KeyModifiers::ALT; } - if shift { modifiers |= crossterm::event::KeyModifiers::SHIFT; } - let key_event = crossterm::event::KeyEvent::new( - key_action_to_code(&key), - modifiers, - ); + if ctrl { + modifiers |= crossterm::event::KeyModifiers::CONTROL; + } + if alt { + modifiers |= crossterm::event::KeyModifiers::ALT; + } + if shift { + modifiers |= crossterm::event::KeyModifiers::SHIFT; + } + let key_event = + crossterm::event::KeyEvent::new(key_action_to_code(&key), modifiers); let actions = controller::input::handle_key(key_event, state); for action in actions { apply_action(state, action); @@ -455,7 +491,10 @@ fn run_daemon() -> Result<()> { if !lock_repo.try_lock(&session_dir)? { anyhow::bail!("session already active (another zesdex process holds the lock for this session directory)"); } - let _session_lock_guard = SessionLockGuard { lock_repo: &lock_repo, session_dir: session_dir.clone() }; + let _session_lock_guard = SessionLockGuard { + lock_repo: &lock_repo, + session_dir: session_dir.clone(), + }; let workspace_roots = vec![std::env::current_dir()?]; let mut state = app::state::rest::AppStateRest::new( @@ -464,8 +503,11 @@ fn run_daemon() -> Result<()> { store.memory_dir, ); state.spawn_mention_index_build(); - let session_repo = zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new(); - state.sessions = session_repo.list_sessions(&store.base_dir).unwrap_or_default(); + let session_repo = + zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new(); + state.sessions = session_repo + .list_sessions(&store.base_dir) + .unwrap_or_default(); let _rt = tokio::runtime::Runtime::new()?; @@ -492,8 +534,9 @@ fn run_daemon() -> Result<()> { } eprintln!("daemon: client disconnected, waiting for next connection..."); - let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new() - .save(&state.store_base_dir(), &state.settings); + let _ = + zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new() + .save(&state.store_base_dir(), &state.settings); } let _ = std::fs::remove_file(&socket_path); @@ -514,7 +557,10 @@ fn setup_attach_client( app::state::rest::AppStateRest, )> { let store = model::store::Store::new(); - let socket_path = store.base_dir.join("run").join(format!("{session_id}.sock")); + let socket_path = store + .base_dir + .join("run") + .join(format!("{session_id}.sock")); let addr = socket_path.to_string_lossy().to_string(); let client = ipc::client::IpcClient::connect_unix(&addr)?; @@ -530,11 +576,8 @@ fn setup_attach_client( let workspace_roots = vec![std::env::current_dir()?]; let session_dir = store.base_dir.join("sessions").join(session_id); std::fs::create_dir_all(&session_dir)?; - let mut client_state = app::state::rest::AppStateRest::new( - workspace_roots, - &session_dir, - store.memory_dir, - ); + let mut client_state = + app::state::rest::AppStateRest::new(workspace_roots, &session_dir, store.memory_dir); client_state.session_id = session_id.to_string(); Ok((client, terminal, client_state)) @@ -551,21 +594,17 @@ fn handle_daemon_frame( } Some(ipc::protocol::DaemonFrame::StreamToken(_token)) => {} Some(ipc::protocol::DaemonFrame::SystemNote { kind: _, message }) => { - client_state.push_toast( - app::state::types::Toast::new( - app::state::types::ToastKind::Info, - message, - ), - ); + client_state.push_toast(app::state::types::Toast::new( + app::state::types::ToastKind::Info, + message, + )); } Some(ipc::protocol::DaemonFrame::ClipboardCopy(text)) => { let _ = write_osc52(&mut io::stdout(), &text); - client_state.push_toast( - app::state::types::Toast::new( - app::state::types::ToastKind::Success, - "Copied to clipboard".to_string(), - ), - ); + client_state.push_toast(app::state::types::Toast::new( + app::state::types::ToastKind::Success, + "Copied to clipboard".to_string(), + )); } Some(ipc::protocol::DaemonFrame::Closed) | None => { client_state.quit = true; @@ -719,10 +758,10 @@ fn run_loop_inner( state: &mut app::state::rest::AppStateRest, terminal: &mut Terminal>, ) -> Result<()> { - use std::time::Duration; - use crossterm::event::{Event, KeyEventKind, MouseEventKind}; + use app::runtime::actions::{apply_action, Action}; use controller::input::handle_key; - use app::runtime::actions::{Action, apply_action}; + use crossterm::event::{Event, KeyEventKind, MouseEventKind}; + use std::time::Duration; loop { if state.quit { diff --git a/crates/zesdex-backend/src/model/agent_def/builtin.rs b/crates/zesdex-backend/src/model/agent_def/builtin.rs index 4767910..e4e1a28 100644 --- a/crates/zesdex-backend/src/model/agent_def/builtin.rs +++ b/crates/zesdex-backend/src/model/agent_def/builtin.rs @@ -14,13 +14,11 @@ use crate::app::subagent::spawn::AgentDefinition; /// researcher, planner). pub fn builtin_agents() -> Vec { vec![ - AgentDefinition::new( - "coder".to_string(), - "coder".to_string(), - ).with_system_prompt( - "You are a coding agent. Write correct, idiomatic Rust code.".to_string() - ).with_allowed_tools( - vec![ + AgentDefinition::new("coder".to_string(), "coder".to_string()) + .with_system_prompt( + "You are a coding agent. Write correct, idiomatic Rust code.".to_string(), + ) + .with_allowed_tools(vec![ "read".to_string(), "write".to_string(), "edit".to_string(), @@ -35,16 +33,14 @@ pub fn builtin_agents() -> Vec { "lsp_references".to_string(), "lsp_completion".to_string(), "lsp_disconnect".to_string(), - ] - ).with_max_steps(usize::MAX), - - AgentDefinition::new( - "reviewer".to_string(), - "reviewer".to_string(), - ).with_system_prompt( - "You are a code reviewer. Focus on correctness, safety, and performance.".to_string() - ).with_allowed_tools( - vec![ + ]) + .with_max_steps(usize::MAX), + AgentDefinition::new("reviewer".to_string(), "reviewer".to_string()) + .with_system_prompt( + "You are a code reviewer. Focus on correctness, safety, and performance." + .to_string(), + ) + .with_allowed_tools(vec![ "read".to_string(), "grep".to_string(), "glob".to_string(), @@ -54,39 +50,34 @@ pub fn builtin_agents() -> Vec { "lsp_hover".to_string(), "lsp_definition".to_string(), "lsp_references".to_string(), - ] - ).with_max_steps(usize::MAX), - - AgentDefinition::new( - "researcher".to_string(), - "researcher".to_string(), - ).with_system_prompt( - "You are a research agent. Search for information and summarize findings.".to_string() - ).with_allowed_tools( - vec![ + ]) + .with_max_steps(usize::MAX), + AgentDefinition::new("researcher".to_string(), "researcher".to_string()) + .with_system_prompt( + "You are a research agent. Search for information and summarize findings." + .to_string(), + ) + .with_allowed_tools(vec![ "read".to_string(), "grep".to_string(), "glob".to_string(), "bash".to_string(), "search_web".to_string(), "fetch_url".to_string(), - ] - ).with_max_steps(usize::MAX), - - AgentDefinition::new( - "planner".to_string(), - "planner".to_string(), - ).with_system_prompt( - "You are a planning agent. Break down tasks into clear steps.".to_string() - ).with_allowed_tools( - vec![ + ]) + .with_max_steps(usize::MAX), + AgentDefinition::new("planner".to_string(), "planner".to_string()) + .with_system_prompt( + "You are a planning agent. Break down tasks into clear steps.".to_string(), + ) + .with_allowed_tools(vec![ "read".to_string(), "write".to_string(), "edit".to_string(), "bash".to_string(), "todo_write".to_string(), "todo_finish".to_string(), - ] - ).with_max_steps(usize::MAX), + ]) + .with_max_steps(usize::MAX), ] } diff --git a/crates/zesdex-backend/src/model/agent_def/session.rs b/crates/zesdex-backend/src/model/agent_def/session.rs index 5b7f685..8055698 100644 --- a/crates/zesdex-backend/src/model/agent_def/session.rs +++ b/crates/zesdex-backend/src/model/agent_def/session.rs @@ -1,8 +1,8 @@ #![allow(dead_code)] //! Load, save, add, and remove agent definitions scoped to a single //! session (`/agents.json`). -use std::path::Path; use crate::app::subagent::spawn::AgentDefinition; +use std::path::Path; /// Load agent definitions saved for a specific session. /// @@ -21,12 +21,10 @@ pub fn load_session_agents(session_dir: &Path) -> Vec { return Vec::new(); } match std::fs::read_to_string(&agents_file) { - Ok(content) => { - serde_json::from_str(&content).unwrap_or_else(|e| { - tracing::warn!("[session] failed to parse agents.json: {}", e); - Vec::new() - }) - } + Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| { + tracing::warn!("[session] failed to parse agents.json: {}", e); + Vec::new() + }), Err(_) => Vec::new(), } } diff --git a/crates/zesdex-backend/src/model/mod.rs b/crates/zesdex-backend/src/model/mod.rs index 6cbe64d..c2e2cb5 100644 --- a/crates/zesdex-backend/src/model/mod.rs +++ b/crates/zesdex-backend/src/model/mod.rs @@ -5,6 +5,6 @@ pub mod store { pub use zesdex_entities::seaorm::common::store::*; } +pub mod agent_def; /// Local modules not extracted to workspace crates pub mod msglog; -pub mod agent_def; diff --git a/crates/zesdex-backend/src/service/provider.rs b/crates/zesdex-backend/src/service/provider.rs index 2069861..dff4565 100644 --- a/crates/zesdex-backend/src/service/provider.rs +++ b/crates/zesdex-backend/src/service/provider.rs @@ -107,6 +107,7 @@ impl LlmClient { stop: None, stream_options: None, tool_choice: None, + top_p: None, }; let url = format!("{}/chat/completions", self.base_url); @@ -143,12 +144,9 @@ impl LlmClient { } let data: crate::dto::provider::response::ChatResponse = resp.json()?; - let usage = data.usage.map(|u| { - ( - u64::from(u.prompt_tokens), - u64::from(u.completion_tokens), - ) - }); + let usage = data + .usage + .map(|u| (u64::from(u.prompt_tokens), u64::from(u.completion_tokens))); let message = data .choices .into_iter() @@ -204,6 +202,7 @@ impl LlmClient { include_usage: true, }), tool_choice: None, + top_p: None, }; let url = format!("{}/chat/completions", self.base_url); diff --git a/crates/zesdex-backend/src/tool/bash_tools.rs b/crates/zesdex-backend/src/tool/bash_tools.rs index f17cf88..b643eb7 100644 --- a/crates/zesdex-backend/src/tool/bash_tools.rs +++ b/crates/zesdex-backend/src/tool/bash_tools.rs @@ -2,7 +2,7 @@ //! and `bash_kill`. Both take a `job_id` produced by `bash` with `run_in_background=true`. use super::Tool; use super::ToolCtx; -use anyhow::{anyhow, Result}; +use anyhow::Result; use serde_json::{json, Value}; /// Tool: fetch buffered output from a background bash job by `job_id`. @@ -31,11 +31,7 @@ impl Tool for BashOutput { } fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let job_id = args - .get("job_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: job_id"))? - .to_string(); + let job_id = crate::tool::arg_str(args, "job_id")?; // Validate that job_id looks like a UUID to prevent injection // into the global job registry. if !is_valid_job_id(&job_id) { @@ -74,11 +70,7 @@ impl Tool for BashKill { } fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let job_id = args - .get("job_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: job_id"))? - .to_string(); + let job_id = crate::tool::arg_str(args, "job_id")?; if !is_valid_job_id(&job_id) { anyhow::bail!("invalid job_id format: expected UUID"); } diff --git a/crates/zesdex-backend/src/tool/fs/delete.rs b/crates/zesdex-backend/src/tool/fs/delete.rs index 223fd07..f3a750f 100644 --- a/crates/zesdex-backend/src/tool/fs/delete.rs +++ b/crates/zesdex-backend/src/tool/fs/delete.rs @@ -2,7 +2,7 @@ use super::super::resolve_path; use super::super::Tool; use super::super::ToolCtx; -use super::helpers::arg_str; +use crate::tool::arg_str; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use std::fs; diff --git a/crates/zesdex-backend/src/tool/fs/edit.rs b/crates/zesdex-backend/src/tool/fs/edit.rs index c2d19ec..b1799d9 100644 --- a/crates/zesdex-backend/src/tool/fs/edit.rs +++ b/crates/zesdex-backend/src/tool/fs/edit.rs @@ -9,7 +9,8 @@ use super::super::check_graduated_checks; use super::super::resolve_path; use super::super::Tool; use super::super::ToolCtx; -use super::helpers::{self, arg_str}; +use super::helpers; +use crate::tool::arg_str; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use similar::TextDiff; diff --git a/crates/zesdex-backend/src/tool/fs/helpers.rs b/crates/zesdex-backend/src/tool/fs/helpers.rs index a4fe968..56f2d0a 100644 --- a/crates/zesdex-backend/src/tool/fs/helpers.rs +++ b/crates/zesdex-backend/src/tool/fs/helpers.rs @@ -1,19 +1,8 @@ //! Shared helpers for filesystem tools: extracting string arguments from JSON //! and producing user-friendly "not found" diagnostics. -use anyhow::{anyhow, Result}; -use serde_json::Value; + use std::path::Path; -/// Extract a required string argument from a JSON args map. -/// -/// Return: the value as `String` if present and a string type; `Err` if missing -/// or of a different JSON type (null, number, boolean, array, object). -pub fn arg_str(args: &Value, name: &str) -> Result { - args.get(name) - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string) - .ok_or_else(|| anyhow!("missing required argument: {name}")) -} /// Produce a user-friendly diagnostic string when a path doesn't resolve or exist. /// @@ -70,35 +59,7 @@ mod tests { use super::*; use serde_json::json; - #[test] - fn test_arg_str_found() { - let args = json!({"key": "value"}); - assert_eq!(arg_str(&args, "key").unwrap(), "value"); - } - #[test] - fn test_arg_str_missing() { - let args = json!({"other": "value"}); - assert!(arg_str(&args, "key").is_err()); - } - - #[test] - fn test_arg_str_empty_string() { - let args = json!({"key": ""}); - assert_eq!(arg_str(&args, "key").unwrap(), ""); - } - - #[test] - fn test_arg_str_wrong_type() { - let args = json!({"key": 42}); - assert!(arg_str(&args, "key").is_err()); - } - - #[test] - fn test_arg_str_null() { - let args = json!({"key": null}); - assert!(arg_str(&args, "key").is_err()); - } #[test] fn test_truncate_diff_under_limit_unchanged() { diff --git a/crates/zesdex-backend/src/tool/fs/read.rs b/crates/zesdex-backend/src/tool/fs/read.rs index dc7f04e..262652c 100644 --- a/crates/zesdex-backend/src/tool/fs/read.rs +++ b/crates/zesdex-backend/src/tool/fs/read.rs @@ -8,7 +8,8 @@ use super::super::resolve_path; use super::super::Tool; use super::super::ToolCtx; -use super::helpers::{arg_str, not_found_help}; +use super::helpers::not_found_help; +use crate::tool::arg_str; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use std::fs; diff --git a/crates/zesdex-backend/src/tool/fs/write.rs b/crates/zesdex-backend/src/tool/fs/write.rs index 2fcbcd1..b89c4e6 100644 --- a/crates/zesdex-backend/src/tool/fs/write.rs +++ b/crates/zesdex-backend/src/tool/fs/write.rs @@ -3,7 +3,8 @@ use super::super::check_graduated_checks; use super::super::resolve_path; use super::super::Tool; use super::super::ToolCtx; -use super::helpers::{self, arg_str}; +use super::helpers; +use crate::tool::arg_str; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use similar::TextDiff; diff --git a/crates/zesdex-backend/src/tool/git_cred.rs b/crates/zesdex-backend/src/tool/git_cred.rs index 534d7e5..28fbf37 100644 --- a/crates/zesdex-backend/src/tool/git_cred.rs +++ b/crates/zesdex-backend/src/tool/git_cred.rs @@ -40,22 +40,11 @@ impl Tool for GitCred { /// /// Return: combined stdout+stderr on success; error with stderr on non-zero exit. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let operation = args - .get("operation") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: operation"))?; - let output = Command::new("git") - .arg("credential") - .arg(operation) - .output() - .map_err(|e| anyhow!("git credential failed: {e}"))?; - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - Ok(format!("{stdout}{stderr}")) - } else { - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - anyhow::bail!("git credential '{}' failed: {}", operation, stderr.trim()) - } + let operation = crate::tool::arg_str(args, "operation")?; + let mut cmd = Command::new("git"); + cmd.arg("credential").arg(&operation); + + crate::tool::execute_cmd(&mut cmd) + .map_err(|e| anyhow!("git credential '{}' failed: {}", operation, e)) } } diff --git a/crates/zesdex-backend/src/tool/git_operator.rs b/crates/zesdex-backend/src/tool/git_operator.rs index 9ba0696..9818aaf 100644 --- a/crates/zesdex-backend/src/tool/git_operator.rs +++ b/crates/zesdex-backend/src/tool/git_operator.rs @@ -53,11 +53,7 @@ impl Tool for GitOperator { /// Return: trimmed combined output on success; error including exit code and /// stderr on failure. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let operation = args - .get("operation") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: operation"))? - .to_string(); + let operation = crate::tool::arg_str(args, "operation")?; let arg_list: Vec = args .get("args") .and_then(|v| v.as_array()) @@ -73,27 +69,11 @@ impl Tool for GitOperator { let cmd_for_filter = format!("git {} {}", operation, arg_list.join(" ")); crate::tool::shell_filter::git::check_git_destructive(&cmd_for_filter) .map_err(|e| anyhow!("blocked: {e}"))?; - let output = Command::new("git") - .arg(&operation) - .args(&arg_list) - .output() - .map_err(|e| anyhow!("git {operation} failed: {e}"))?; - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let combined = if stderr.is_empty() { - stdout.trim().to_string() - } else { - format!("{}\n{}", stdout.trim(), stderr.trim()) - }; - if output.status.success() { - Ok(combined) - } else { - anyhow::bail!( - "git {} failed (exit {}): {}", - operation, - output.status.code().unwrap_or(-1), - stderr.trim() - ) - } + let mut cmd = Command::new("git"); + cmd.arg(&operation) + .args(&arg_list); + + crate::tool::execute_cmd(&mut cmd) + .map_err(|e| anyhow!("git {operation} failed: {e}")) } } diff --git a/crates/zesdex-backend/src/tool/git_worktree.rs b/crates/zesdex-backend/src/tool/git_worktree.rs index eb2eb52..e714db1 100644 --- a/crates/zesdex-backend/src/tool/git_worktree.rs +++ b/crates/zesdex-backend/src/tool/git_worktree.rs @@ -42,45 +42,24 @@ impl Tool for GitWorktree { /// Return: success message with combined output on success; error including exit /// code and stderr on failure. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let name = args - .get("name") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: name"))? - .to_string(); + let name = crate::tool::arg_str(args, "name")?; if name.contains('/') || name.contains('\\') || name.contains("..") { anyhow::bail!("worktree name must not contain path separators or '..'"); } - let base_ref = args - .get("base_ref") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: base_ref"))? - .to_string(); + let base_ref = crate::tool::arg_str(args, "base_ref")?; let worktree_path = ctx.worktrees_dir.join(&name); std::fs::create_dir_all(&worktree_path) .map_err(|e| anyhow!("failed to create worktree directory: {e}"))?; - let output = Command::new("git") - .args(["worktree", "add", "--checkout"]) + let mut cmd = Command::new("git"); + cmd.args(["worktree", "add", "--checkout"]) .arg(worktree_path.display().to_string()) - .arg(&base_ref) - .output() + .arg(&base_ref); + + let output = crate::tool::execute_cmd(&mut cmd) .map_err(|e| anyhow!("git worktree add failed: {e}"))?; - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let combined = if stderr.is_empty() { - stdout.trim().to_string() - } else { - format!("{}\n{}", stdout.trim(), stderr.trim()) - }; - if output.status.success() { - Ok(format!( - "created worktree '{name}' from '{base_ref}'\n{combined}" - )) - } else { - anyhow::bail!( - "git worktree add failed (exit {}): {}", - output.status.code().unwrap_or(-1), - stderr.trim() - ) - } + + Ok(format!( + "created worktree '{name}' from '{base_ref}'\n{output}" + )) } } diff --git a/crates/zesdex-backend/src/tool/lsp/mod.rs b/crates/zesdex-backend/src/tool/lsp/mod.rs index 2796c4e..872910d 100644 --- a/crates/zesdex-backend/src/tool/lsp/mod.rs +++ b/crates/zesdex-backend/src/tool/lsp/mod.rs @@ -51,18 +51,9 @@ impl Tool for LspConnect { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let name = args - .get("name") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: name"))?; - let command = args - .get("command") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: command"))?; - let language_id = args - .get("language_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: language_id"))?; + let name = crate::tool::arg_str(args, "name")?; + let command = crate::tool::arg_str(args, "command")?; + let language_id = crate::tool::arg_str(args, "language_id")?; let extra_args: Vec = args .get("args") .and_then(|v| v.as_array()) @@ -77,17 +68,17 @@ impl Tool for LspConnect { .lsp_manager .lock() .map_err(|e| anyhow!("LSP manager lock error: {e}"))?; - manager.connect(command, &extra_args, language_id)?; + manager.connect(&command, &extra_args, &language_id)?; // Auto-register this server's known extensions so lsp_diagnostics / // lsp_hover / lsp_completion / lsp_definition / lsp_references can // auto-detect it later without an explicit `server` argument. - let known_exts = known_extensions_for(language_id); + let known_exts = known_extensions_for(&language_id); if !known_exts.is_empty() { - manager.register_extensions(language_id, known_exts); + manager.register_extensions(&language_id, known_exts); } - let client_arc = manager.get_client(language_id); + let client_arc = manager.get_client(&language_id); let caps = client_arc .and_then(|c| { c.lock() @@ -138,18 +129,12 @@ impl Tool for LspDiagnostics { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel_path = args - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: path"))?; - let text = args - .get("text") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: text"))?; - let server_name = resolve_server_name(ctx, args, rel_path)?; + let rel_path = crate::tool::arg_str(args, "path")?; + let text = crate::tool::arg_str(args, "text")?; + let server_name = resolve_server_name(ctx, args, &rel_path)?; let server_name = server_name.as_str(); - let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?; + let abs_path = crate::tool::resolve_path(&ctx.workspaces, &rel_path)?; let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let manager = ctx @@ -168,7 +153,7 @@ impl Tool for LspDiagnostics { .lock() .map_err(|e| anyhow!("LSP client lock error: {e}"))?; - match client.collect_diagnostics(&uri, &language_id, text) { + match client.collect_diagnostics(&uri, &language_id, &text) { Ok(diags) => { let diags_array = diags.as_array().cloned().unwrap_or_default(); if diags_array.is_empty() { @@ -279,52 +264,12 @@ impl Tool for LspHover { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel_path = args - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: path"))?; - let line = args - .get("line") - .and_then(serde_json::Value::as_i64) - .ok_or_else(|| anyhow!("missing required argument: line"))? as u32; - let column = - args.get("column") - .and_then(serde_json::Value::as_i64) - .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; - let server_name = resolve_server_name(ctx, args, rel_path)?; - let server_name = server_name.as_str(); - - let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?; - let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); - - let file_content = std::fs::read_to_string(&abs_path) - .map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?; - - let manager = ctx - .lsp_manager - .lock() - .map_err(|e| anyhow!("LSP manager lock error: {e}"))?; - let language_id = manager.get_language_id(server_name).unwrap_or_else(|| { - args.get("language_id") - .and_then(|v| v.as_str()) - .unwrap_or("plaintext") - .to_string() + let result = run_lsp_query(ctx, args, |client, uri, line, column| { + client.hover(uri, line, column) }); - let client_arc = manager.get_client(server_name).ok_or_else(|| { - anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.") - })?; - drop(manager); - - let mut client = client_arc - .lock() - .map_err(|e| anyhow!("LSP client lock error: {e}"))?; - - client.did_open(&uri, &language_id, 1, &file_content)?; - let result = client.hover(&uri, line, column); - let _ = client.did_close(&uri); match result { - Ok(hover_result) => { + Ok((hover_result, _line, _column)) => { if hover_result == Value::Null { return Ok("No hover information available at this position.".to_string()); } @@ -424,49 +369,12 @@ impl Tool for LspCompletion { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel_path = args - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: path"))?; - let line = args - .get("line") - .and_then(serde_json::Value::as_i64) - .ok_or_else(|| anyhow!("missing required argument: line"))? as u32; - let column = - args.get("column") - .and_then(serde_json::Value::as_i64) - .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; - let server_name = resolve_server_name(ctx, args, rel_path)?; - let server_name = server_name.as_str(); - - let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?; - let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); - - let file_content = std::fs::read_to_string(&abs_path) - .map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?; - - let manager = ctx - .lsp_manager - .lock() - .map_err(|e| anyhow!("LSP manager lock error: {e}"))?; - let language_id = manager - .get_language_id(server_name) - .unwrap_or_else(|| "plaintext".to_string()); - let client_arc = manager.get_client(server_name).ok_or_else(|| { - anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.") - })?; - drop(manager); - - let mut client = client_arc - .lock() - .map_err(|e| anyhow!("LSP client lock error: {e}"))?; - - client.did_open(&uri, &language_id, 1, &file_content)?; - let result = client.completion(&uri, line, column); - let _ = client.did_close(&uri); + let result = run_lsp_query(ctx, args, |client, uri, line, column| { + client.completion(uri, line, column) + }); match result { - Ok(completion_result) => { + Ok((completion_result, line, column)) => { let items = if let Some(items) = completion_result.as_array() { items.clone() } else if let Some(arr) = completion_result.get("items").and_then(|v| v.as_array()) @@ -576,49 +484,12 @@ impl Tool for LspDefinition { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel_path = args - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: path"))?; - let line = args - .get("line") - .and_then(serde_json::Value::as_i64) - .ok_or_else(|| anyhow!("missing required argument: line"))? as u32; - let column = - args.get("column") - .and_then(serde_json::Value::as_i64) - .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; - let server_name = resolve_server_name(ctx, args, rel_path)?; - let server_name = server_name.as_str(); - - let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?; - let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); - - let file_content = std::fs::read_to_string(&abs_path) - .map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?; - - let manager = ctx - .lsp_manager - .lock() - .map_err(|e| anyhow!("LSP manager lock error: {e}"))?; - let language_id = manager - .get_language_id(server_name) - .unwrap_or_else(|| "plaintext".to_string()); - let client_arc = manager.get_client(server_name).ok_or_else(|| { - anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.") - })?; - drop(manager); - - let mut client = client_arc - .lock() - .map_err(|e| anyhow!("LSP client lock error: {e}"))?; - - client.did_open(&uri, &language_id, 1, &file_content)?; - let result = client.goto_definition(&uri, line, column); - let _ = client.did_close(&uri); + let result = run_lsp_query(ctx, args, |client, uri, line, column| { + client.goto_definition(uri, line, column) + }); match result { - Ok(def_result) => { + Ok((def_result, _line, _column)) => { if def_result == Value::Null { return Ok("No definition found at this position.".to_string()); } @@ -696,49 +567,12 @@ impl Tool for LspReferences { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel_path = args - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: path"))?; - let line = args - .get("line") - .and_then(serde_json::Value::as_i64) - .ok_or_else(|| anyhow!("missing required argument: line"))? as u32; - let column = - args.get("column") - .and_then(serde_json::Value::as_i64) - .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; - let server_name = resolve_server_name(ctx, args, rel_path)?; - let server_name = server_name.as_str(); - - let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?; - let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); - - let file_content = std::fs::read_to_string(&abs_path) - .map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?; - - let manager = ctx - .lsp_manager - .lock() - .map_err(|e| anyhow!("LSP manager lock error: {e}"))?; - let language_id = manager - .get_language_id(server_name) - .unwrap_or_else(|| "plaintext".to_string()); - let client_arc = manager.get_client(server_name).ok_or_else(|| { - anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.") - })?; - drop(manager); - - let mut client = client_arc - .lock() - .map_err(|e| anyhow!("LSP client lock error: {e}"))?; - - client.did_open(&uri, &language_id, 1, &file_content)?; - let result = client.references(&uri, line, column); - let _ = client.did_close(&uri); + let result = run_lsp_query(ctx, args, |client, uri, line, column| { + client.references(uri, line, column) + }); match result { - Ok(ref_result) => { + Ok((ref_result, _line, _column)) => { let locations = ref_result.as_array().cloned().unwrap_or_default(); if locations.is_empty() { return Ok("No references found for this symbol.".to_string()); @@ -794,17 +628,14 @@ impl Tool for LspDisconnect { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let name = args - .get("name") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: name"))?; + let name = crate::tool::arg_str(args, "name")?; let mut manager = ctx .lsp_manager .lock() .map_err(|e| anyhow!("LSP manager lock error: {e}"))?; - if manager.disconnect(name) { + if manager.disconnect(&name) { Ok(format!("Disconnected from LSP server '{name}'")) } else { Err(anyhow!("LSP server '{name}' not found")) @@ -842,6 +673,55 @@ fn known_extensions_for(language_id: &str) -> &[&'static str] { /// a server connected without an explicit `register_extensions` call. Returns /// `None` if the path has no extension, the lock is poisoned, or no /// connected server's language is known to use that extension. +fn run_lsp_query(ctx: &ToolCtx, args: &Value, op: F) -> Result<(R, u32, u32)> +where + F: FnOnce(&mut crate::app::lsp::LspClient, &str, u32, u32) -> Result, +{ + let rel_path = crate::tool::arg_str(args, "path")?; + let line = args + .get("line") + .and_then(serde_json::Value::as_i64) + .ok_or_else(|| anyhow::anyhow!("missing required argument: line"))? as u32; + let column = + args.get("column") + .and_then(serde_json::Value::as_i64) + .ok_or_else(|| anyhow::anyhow!("missing required argument: column"))? as u32; + let server_name = resolve_server_name(ctx, args, &rel_path)?; + + let abs_path = crate::tool::resolve_path(&ctx.workspaces, &rel_path)?; + let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); + + let file_content = std::fs::read_to_string(&abs_path) + .map_err(|e| anyhow::anyhow!("failed to read file '{rel_path}': {e}"))?; + + let manager = ctx + .lsp_manager + .lock() + .map_err(|e| anyhow::anyhow!("LSP manager lock error: {e}"))?; + let language_id = manager + .get_language_id(&server_name) + .unwrap_or_else(|| { + args.get("language_id") + .and_then(|v| v.as_str()) + .unwrap_or("plaintext") + .to_string() + }); + let client_arc = manager.get_client(&server_name).ok_or_else(|| { + anyhow::anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.") + })?; + drop(manager); + + let mut client = client_arc + .lock() + .map_err(|e| anyhow::anyhow!("LSP client lock error: {e}"))?; + + client.did_open(&uri, &language_id, 1, &file_content)?; + let result = op(&mut client, &uri, line, column); + let _ = client.did_close(&uri); + + result.map(|r| (r, line, column)) +} + fn auto_detect_server(ctx: &ToolCtx, path: &str) -> Option { let ext = std::path::Path::new(path) .extension() diff --git a/crates/zesdex-backend/src/tool/memory/forget.rs b/crates/zesdex-backend/src/tool/memory/forget.rs index 45375c9..27327ee 100644 --- a/crates/zesdex-backend/src/tool/memory/forget.rs +++ b/crates/zesdex-backend/src/tool/memory/forget.rs @@ -1,10 +1,10 @@ //! Tool for deleting a persisted memory entry by name. use super::super::Tool; use super::super::ToolCtx; -use zesdex_cms::domain::repository::MemoryRepository; -use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; +use zesdex_cms::domain::repository::MemoryRepository; +use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository; /// Tool that removes a single memory entry from `ctx.memory_dir` by exact name. pub struct Forget; @@ -38,12 +38,10 @@ impl Tool for Forget { /// Return: confirmation message on success; error if the memory does not exist /// or the file could not be removed. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let name = args - .get("name") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: name"))?; + let name = crate::tool::arg_str(args, "name")?; - MarkdownMemoryRepository::new().delete(&ctx.memory_dir, name) + MarkdownMemoryRepository::new() + .delete(&ctx.memory_dir, &name) .map_err(|e| anyhow!("failed to remove memory '{name}': {e}"))?; Ok(format!("removed memory '{name}'")) diff --git a/crates/zesdex-backend/src/tool/memory/recall.rs b/crates/zesdex-backend/src/tool/memory/recall.rs index e50b4e2..c44a9d1 100644 --- a/crates/zesdex-backend/src/tool/memory/recall.rs +++ b/crates/zesdex-backend/src/tool/memory/recall.rs @@ -1,11 +1,11 @@ //! Tool for reading a single memory entry or listing the whole memory index. use super::super::Tool; use super::super::ToolCtx; -use zesdex_cms::domain::repository::MemoryRepository; -use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use std::fmt::Write; +use zesdex_cms::domain::repository::MemoryRepository; +use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository; /// Tool that reads one memory entry by name, or lists all entries when name is omitted. pub struct Recall; @@ -42,7 +42,8 @@ impl Tool for Recall { if name.is_empty() { return Ok(list_all(ctx)); } - let memory = MarkdownMemoryRepository::new().load(&ctx.memory_dir, name) + let memory = MarkdownMemoryRepository::new() + .load(&ctx.memory_dir, name) .map_err(|e| anyhow!("memory '{name}' not found: {e}"))?; Ok(format!( "---\nname: {}\ndescription: {}\nkind: {}\nlifecycle: {}\n---\n\n{}", @@ -61,7 +62,9 @@ impl Tool for Recall { /// /// Return: `Ok` with the formatted index (never fails; missing dir yields "(no memory entries)"). fn list_all(ctx: &ToolCtx) -> String { - let names = MarkdownMemoryRepository::new().list(&ctx.memory_dir).unwrap_or_default(); + let names = MarkdownMemoryRepository::new() + .list(&ctx.memory_dir) + .unwrap_or_default(); if names.is_empty() { return "(no memory entries)".to_string(); } diff --git a/crates/zesdex-backend/src/tool/memory/remember.rs b/crates/zesdex-backend/src/tool/memory/remember.rs index e01e4c4..b77d8a4 100644 --- a/crates/zesdex-backend/src/tool/memory/remember.rs +++ b/crates/zesdex-backend/src/tool/memory/remember.rs @@ -1,11 +1,11 @@ //! Tool for saving a new memory entry to persistent project memory. use super::super::Tool; use super::super::ToolCtx; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; use zesdex_cms::domain::memory::Memory; use zesdex_cms::domain::repository::MemoryRepository; use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository; -use anyhow::{anyhow, Result}; -use serde_json::{json, Value}; /// Tool that writes a new `Memory` entry (name/description/content/kind) to disk. pub struct Remember; @@ -56,24 +56,12 @@ impl Tool for Remember { /// /// Return: confirmation string on success; error if name is invalid or the write fails. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let name = args - .get("name") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: name"))?; - let description = args - .get("description") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: description"))?; - let content = args - .get("content") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: content"))?; - let kind = args - .get("kind") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: kind"))?; + let name = crate::tool::arg_str(args, "name")?; + let description = crate::tool::arg_str(args, "description")?; + let content = crate::tool::arg_str(args, "content")?; + let kind = crate::tool::arg_str(args, "kind")?; - if Memory::slugify(name).is_none() { + if Memory::slugify(&name).is_none() { anyhow::bail!("invalid memory name: must produce a valid slug (alphanumeric + hyphens, 1-80 chars)"); } @@ -93,7 +81,8 @@ impl Tool for Remember { provenances: vec![], }; - MarkdownMemoryRepository::new().save(&ctx.memory_dir, &memory) + MarkdownMemoryRepository::new() + .save(&ctx.memory_dir, &memory) .map_err(|e| anyhow!("failed to save memory '{name}': {e}"))?; Ok(format!("saved memory '{name}' ({kind})")) diff --git a/crates/zesdex-backend/src/tool/mod.rs b/crates/zesdex-backend/src/tool/mod.rs index 9a0437e..7efc10c 100644 --- a/crates/zesdex-backend/src/tool/mod.rs +++ b/crates/zesdex-backend/src/tool/mod.rs @@ -237,6 +237,37 @@ pub fn tool_defs(tools: &[Box]) -> Vec Result { + 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. +/// +/// Return: `Ok(output)` on success, `Err(combined)` on non-zero exit or failure. +pub fn execute_cmd(cmd: &mut std::process::Command) -> Result { + 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 combined = if stderr.is_empty() { + stdout + } else { + format!("{}\n{}", stdout, stderr).trim().to_string() + }; + if output.status.success() { + Ok(combined) + } else { + let code = output.status.code().unwrap_or(-1); + 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. /// @@ -305,10 +336,41 @@ pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use serde_json::json; #[test] fn tool_ctx_builder_defaults_abort_flag_to_none() { let ctx = ToolCtx::builder().build(); assert!(ctx.abort_flag.is_none()); } + + #[test] + fn test_arg_str_found() { + let args = json!({"key": "value"}); + assert_eq!(arg_str(&args, "key").unwrap(), "value"); + } + + #[test] + fn test_arg_str_missing() { + let args = json!({"other": "value"}); + assert!(arg_str(&args, "key").is_err()); + } + + #[test] + fn test_arg_str_empty_string() { + let args = json!({"key": ""}); + assert_eq!(arg_str(&args, "key").unwrap(), ""); + } + + #[test] + fn test_arg_str_wrong_type() { + let args = json!({"key": 42}); + assert!(arg_str(&args, "key").is_err()); + } + + #[test] + fn test_arg_str_null() { + let args = json!({"key": null}); + assert!(arg_str(&args, "key").is_err()); + } } diff --git a/crates/zesdex-backend/src/tool/plan.rs b/crates/zesdex-backend/src/tool/plan.rs index 71abc34..2985be0 100644 --- a/crates/zesdex-backend/src/tool/plan.rs +++ b/crates/zesdex-backend/src/tool/plan.rs @@ -1,7 +1,7 @@ //! Plan-mode signaling tools: entering plan mode with a proposal, and confirming readiness. use super::Tool; use super::ToolCtx; -use anyhow::{anyhow, Result}; +use anyhow::Result; use serde_json::{json, Value}; /// Tool the model calls to present a step-by-step plan and enter plan mode. @@ -38,14 +38,8 @@ impl Tool for PlanEnter { /// /// Return: fixed acknowledgement string on success; error if either arg is missing. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let _ = args - .get("plan") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: plan"))?; - let _ = args - .get("sign_off") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: sign_off"))?; + let _ = crate::tool::arg_str(args, "plan")?; + let _ = crate::tool::arg_str(args, "sign_off")?; Ok("plan recorded".to_string()) } } @@ -79,10 +73,7 @@ impl Tool for PlanReady { /// /// Return: fixed "ready to execute" string on success; error if `confirmation` is missing. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let _ = args - .get("confirmation") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: confirmation"))?; + let _ = crate::tool::arg_str(args, "confirmation")?; Ok("ready to execute".to_string()) } } diff --git a/crates/zesdex-backend/src/tool/search.rs b/crates/zesdex-backend/src/tool/search.rs index f954866..afc4e6e 100644 --- a/crates/zesdex-backend/src/tool/search.rs +++ b/crates/zesdex-backend/src/tool/search.rs @@ -48,16 +48,8 @@ impl Tool for Grep { /// /// Return: "no matches found" if empty, else a header + `path:line:text` rows. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let pattern = args - .get("pattern") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: pattern"))? - .to_string(); - let rel = args - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: path"))? - .to_string(); + let pattern = crate::tool::arg_str(args, "pattern")?; + let rel = crate::tool::arg_str(args, "path")?; let path = resolve_path(&ctx.workspaces, &rel)?; if !path.exists() { anyhow::bail!("path '{rel}' does not exist"); @@ -137,16 +129,8 @@ impl Tool for Glob { /// /// Return: sorted newline-joined matches; "no files match" sentinel if empty. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let pat_str = args - .get("pattern") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: pattern"))? - .to_string(); - let rel = args - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: path"))? - .to_string(); + let pat_str = crate::tool::arg_str(args, "pattern")?; + let rel = crate::tool::arg_str(args, "path")?; let root = resolve_path(&ctx.workspaces, &rel)?; if !root.exists() || !root.is_dir() { anyhow::bail!("path '{rel}' is not a valid directory"); diff --git a/crates/zesdex-backend/src/tool/shell.rs b/crates/zesdex-backend/src/tool/shell.rs index 7807e01..7ef4b43 100644 --- a/crates/zesdex-backend/src/tool/shell.rs +++ b/crates/zesdex-backend/src/tool/shell.rs @@ -64,11 +64,7 @@ impl Tool for Bash { /// Return: exit-code + elapsed-seconds summary line (plus captured output) for /// foreground runs, or the job ID for background runs. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let cmd = args - .get("command") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: command"))? - .to_string(); + let cmd = crate::tool::arg_str(args, "command")?; let timeout_ms = args .get("timeout") .and_then(serde_json::Value::as_u64) diff --git a/crates/zesdex-backend/src/tool/shell_filter/credentials.rs b/crates/zesdex-backend/src/tool/shell_filter/credentials.rs index 653ea96..495e0d6 100644 --- a/crates/zesdex-backend/src/tool/shell_filter/credentials.rs +++ b/crates/zesdex-backend/src/tool/shell_filter/credentials.rs @@ -5,7 +5,6 @@ //! module is kept for callers that DO want to block credential reads (e.g. //! a future sandboxed/untrusted-tool execution path) and is covered by its //! own inline tests below. -use anyhow::Result; /// Reject shell commands whose lowercased form contains any known credential-read pattern. /// @@ -19,59 +18,6 @@ use anyhow::Result; /// model could insert quotes between characters to bypass substring matching. /// /// Return: `Ok(())` if no pattern matches; error naming the offending pattern otherwise. -pub fn check_credential_read(cmd: &str) -> Result<()> { - let patterns = [ - // SSH key files - "cat ~/.ssh", - "cat /home/", - ".ssh/id_rsa", - ".ssh/id_ed25519", - ".ssh/id_ecdsa", - ".ssh/id_dsa", - ".ssh/authorized_keys", - ".ssh/known_hosts", - // Git / generic credential files - ".git-credentials", - ".netrc", - // Cloud credentials - "aws/credentials", - "gcloud/credentials", - ".config/gcloud", - ".config/gh", - // Container/K8s credentials - ".docker/config.json", - ".kube/config", - ".npmrc", - // Token/key patterns in command strings - "token=", - "secret=", - "api_key=", - "api-key=", - "password=", - "ghp_", - "ghs_", - "sk-", - "akia", - "bearer ", - // Environment variable dumpers - " env", - "printenv", - "/proc/self/environ", - ]; - let cmd_lower = cmd.to_lowercase(); - let cmd_no_quotes: String = cmd_lower.chars() - .filter(|&c| c != '\'' && c != '"') - .collect(); - // Also check against ANSI-C quoting normalization so that - // $'cat\u0020~/.ssh/id_rsa' does not bypass the filter. - let cmd_normalized = super::normalize_ansi_c_quoting(&cmd_no_quotes); - for pattern in &patterns { - if cmd_lower.contains(pattern) || cmd_no_quotes.contains(pattern) || cmd_normalized.contains(pattern) { - anyhow::bail!("credential read blocked: '{}'", pattern); - } - } - Ok(()) -} #[cfg(test)] mod tests { diff --git a/crates/zesdex-backend/src/tool/shell_filter/git.rs b/crates/zesdex-backend/src/tool/shell_filter/git.rs index 5c0fc96..1e30a5e 100644 --- a/crates/zesdex-backend/src/tool/shell_filter/git.rs +++ b/crates/zesdex-backend/src/tool/shell_filter/git.rs @@ -44,10 +44,7 @@ pub fn check_git_destructive(cmd: &str) -> Result<()> { "push --tags --force", ]; let cmd_lower = cmd.to_lowercase(); - let cmd_no_quotes: String = cmd_lower - .chars() - .filter(|&c| c != '\'' && c != '"') - .collect(); + let cmd_no_quotes = super::strip_quotes(&cmd_lower); // Normalize ANSI-C quoting ($'...') which can encode spaces and // special characters as escape sequences (e.g. $'push\u0020--force' // → "push --force"), bypassing the raw substring matching above. diff --git a/crates/zesdex-backend/src/tool/shell_filter/mod.rs b/crates/zesdex-backend/src/tool/shell_filter/mod.rs index 3aba5eb..0f1ff43 100644 --- a/crates/zesdex-backend/src/tool/shell_filter/mod.rs +++ b/crates/zesdex-backend/src/tool/shell_filter/mod.rs @@ -1,5 +1,11 @@ //! Pre-execution safety filters applied to shell commands before they're spawned. pub mod git; +pub mod credentials; + +/// Strip single and double quotes from a string. +pub(crate) fn strip_quotes(s: &str) -> String { + s.chars().filter(|&c| c != '\'' && c != '"').collect() +} /// Decode ANSI-C quoted strings ($'...') found in `input`, replacing /// them with their unquoted, escape-decoded equivalents. diff --git a/crates/zesdex-backend/src/tool/spawn.rs b/crates/zesdex-backend/src/tool/spawn.rs index 2b38875..d077588 100644 --- a/crates/zesdex-backend/src/tool/spawn.rs +++ b/crates/zesdex-backend/src/tool/spawn.rs @@ -9,6 +9,7 @@ //! Also provides a pipeline variant: `spawn_pipeline` runs agents //! sequentially so each stage sees the previous stage's findings. use super::{Tool, ToolCtx}; +use crate::app::workflow::engine::PrimitiveCtx; use crate::app::workflow::script::{ScriptOptions, ScriptPrimitive, WorkflowScript}; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; @@ -114,18 +115,18 @@ impl Tool for SpawnAgents { // spawn_agents or workflow_run invocations. let findings: Arc>> = Arc::new(Mutex::new(Vec::new())); let no_abort: Option> = None; - let results = crate::app::workflow::engine::execute_primitive( - &wf.script, - &HashMap::new(), - max_concurrency, - true, - &no_abort, - live.as_ref(), - &ctx.session_dir, - &ctx.workspaces, - &findings, - None, // no per-agent timeout for spawn_agents - )?; + let results = crate::app::workflow::engine::execute_primitive(PrimitiveCtx { + primitive: &wf.script, + args: &HashMap::new(), + concurrency_cap: max_concurrency, + continue_on_error: true, + abort_flag: &no_abort, + live: live.as_ref(), + session_dir: &ctx.session_dir, + workspaces: &ctx.workspaces, + findings: &findings, + timeout_ms: None, + })?; Ok(format_results(&results, "parallel")) } } @@ -210,18 +211,18 @@ impl Tool for SpawnPipeline { // other concurrent spawn_agents / spawn_pipeline / workflow_run. let findings: Arc>> = Arc::new(Mutex::new(Vec::new())); let no_abort: Option> = None; - let results = crate::app::workflow::engine::execute_primitive( - &wf.script, - &HashMap::new(), - 1, - false, - &no_abort, - live.as_ref(), - &ctx.session_dir, - &ctx.workspaces, - &findings, - None, // no per-agent timeout for spawn_pipeline - )?; + let results = crate::app::workflow::engine::execute_primitive(PrimitiveCtx { + primitive: &wf.script, + args: &HashMap::new(), + concurrency_cap: 1, + continue_on_error: false, + abort_flag: &no_abort, + live: live.as_ref(), + session_dir: &ctx.session_dir, + workspaces: &ctx.workspaces, + findings: &findings, + timeout_ms: None, + })?; Ok(format_results(&results, "pipeline")) } } diff --git a/crates/zesdex-backend/src/tool/utility/cd.rs b/crates/zesdex-backend/src/tool/utility/cd.rs index 8936d62..c88c345 100644 --- a/crates/zesdex-backend/src/tool/utility/cd.rs +++ b/crates/zesdex-backend/src/tool/utility/cd.rs @@ -1,7 +1,7 @@ //! `cd` tool: verify and resolve a workspace-relative directory path. use super::super::Tool; use super::super::ToolCtx; -use anyhow::{anyhow, Result}; +use anyhow::Result; use serde_json::{json, Value}; /// Tool that resolves a workspace-relative path and reports whether it exists and is a dir. @@ -40,12 +40,9 @@ impl Tool for Cd { /// Return: canonical path on success; explicit "does not exist" / "not a directory" /// message (still `Ok`) so the model can react without treating it as an error. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel = args - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: path"))?; + let rel = crate::tool::arg_str(args, "path")?; - let path = super::super::resolve_path(&ctx.workspaces, rel)?; + let path = super::super::resolve_path(&ctx.workspaces, &rel)?; if !path.exists() { return Ok(format!( diff --git a/crates/zesdex-backend/src/tool/utility/dir_cache_update.rs b/crates/zesdex-backend/src/tool/utility/dir_cache_update.rs index 8c9d67a..81512f9 100644 --- a/crates/zesdex-backend/src/tool/utility/dir_cache_update.rs +++ b/crates/zesdex-backend/src/tool/utility/dir_cache_update.rs @@ -51,12 +51,9 @@ impl Tool for DirCacheUpdate { /// Return: a confirmation string with the entry count, or an error if /// the `path` argument is missing or the temp runtime fails to start. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel = args - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: path"))?; + let rel = crate::tool::arg_str(args, "path")?; - let path = super::super::resolve_path(&ctx.workspaces, rel)?; + let path = super::super::resolve_path(&ctx.workspaces, &rel)?; if !path.exists() { return Ok(format!( diff --git a/crates/zesdex-backend/src/tool/utility/dir_list.rs b/crates/zesdex-backend/src/tool/utility/dir_list.rs index 432d1f9..b2abe6b 100644 --- a/crates/zesdex-backend/src/tool/utility/dir_list.rs +++ b/crates/zesdex-backend/src/tool/utility/dir_list.rs @@ -52,12 +52,9 @@ impl Tool for DirList { /// Return: header + newline-joined entry names, or an error if the /// `path` argument is missing or `read_dir` fails outright. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let rel = args - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: path"))?; + let rel = crate::tool::arg_str(args, "path")?; - let path = super::super::resolve_path(&ctx.workspaces, rel)?; + let path = super::super::resolve_path(&ctx.workspaces, &rel)?; if !path.exists() { return Ok(format!( diff --git a/crates/zesdex-backend/src/tool/utility/todowrite.rs b/crates/zesdex-backend/src/tool/utility/todowrite.rs index bf34577..d1c9e8b 100644 --- a/crates/zesdex-backend/src/tool/utility/todowrite.rs +++ b/crates/zesdex-backend/src/tool/utility/todowrite.rs @@ -51,10 +51,7 @@ impl Tool for Todowrite { /// Return: confirmation string echoing the added task, or an error /// if the `task` argument is missing or the file can't be opened/written. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let task = args - .get("task") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: task"))?; + let task = crate::tool::arg_str(args, "task")?; let path: PathBuf = ctx.session_dir.join("todo.md"); let now = chrono::Utc::now(); diff --git a/crates/zesdex-backend/src/tool/workflow.rs b/crates/zesdex-backend/src/tool/workflow.rs index 1d84c43..54c2865 100644 --- a/crates/zesdex-backend/src/tool/workflow.rs +++ b/crates/zesdex-backend/src/tool/workflow.rs @@ -58,13 +58,10 @@ impl Tool for WorkflowRun { /// Return: the workflow engine's output string, or an error if the /// script argument is missing or fails to parse as JSON. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let script_str = args - .get("script") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: script"))?; + let script_str = crate::tool::arg_str(args, "script")?; let workflow_script: crate::app::workflow::script::WorkflowScript = - serde_json::from_str(script_str) + serde_json::from_str(&script_str) .map_err(|e| anyhow!("failed to parse workflow script: {e}"))?; let workflow_args: std::collections::HashMap = args @@ -125,10 +122,7 @@ impl Tool for NoteFinding { /// Return: confirmation string containing up to the first 80 chars /// of the recorded text. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let text = args - .get("text") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: text"))?; + let text = crate::tool::arg_str(args, "text")?; if let Some(ref findings) = ctx.workflow_findings { if let Ok(mut f) = findings.lock() { @@ -211,10 +205,7 @@ impl Tool for HiveMind { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { - let request = args - .get("request") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow!("missing required argument: request"))?; + let request = crate::tool::arg_str(args, "request")?; let cycles_value = args .get("cycles") @@ -228,7 +219,7 @@ impl Tool for HiveMind { // itself (guaranteed, even if synthesis fails) — do not write it // again here. let (consensus, _reports) = crate::app::workflow::hive_mind::run_hive_mind( - request, + &request, &plan, &ctx.session_dir, &ctx.workspaces, diff --git a/crates/zesdex-backend/src/view/markdown.rs b/crates/zesdex-backend/src/view/markdown.rs index 5423e9d..e168363 100644 --- a/crates/zesdex-backend/src/view/markdown.rs +++ b/crates/zesdex-backend/src/view/markdown.rs @@ -15,15 +15,17 @@ //! are the exception: every line gets its `" "` prefix independently //! and consistently, so there's no first-line-only misalignment there. +use super::theme::Theme; use ratatui::style::{Modifier, Style}; use ratatui::text::Span; -use super::theme::Theme; /// Apply the "tool output" dim/italic style, or pass `style` through /// unchanged, depending on `dim`. fn apply_dim(style: Style, dim: bool) -> Style { if dim { - Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC) + Style::default() + .fg(Theme::TEXT_DIM) + .add_modifier(Modifier::ITALIC) } else { style } @@ -59,7 +61,6 @@ fn diff_line_style(line: &str) -> Option