feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators feat(tui): create workflow panel for agent status and progress visualization feat(web): introduce web frontend interface with static file serving feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
//! Background bash process output and kill tools.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
use crate::tools::{arg_str, Tool, ToolCtx};
|
||||
|
||||
/// Get the output of a background bash job by ID.
|
||||
///
|
||||
/// Flow: look up `{session_dir}/bash-outputs/{job_id}` → read content back.
|
||||
pub struct BashOutput;
|
||||
|
||||
impl Tool for BashOutput {
|
||||
fn name(&self) -> &'static str {
|
||||
"bash_output"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Get the output of a background bash job by ID"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {
|
||||
"type": "string",
|
||||
"description": "Background job ID"
|
||||
}
|
||||
},
|
||||
"required": ["job_id"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let job_id = arg_str(args, "job_id")?;
|
||||
info!("Getting output for job: {job_id}");
|
||||
|
||||
// Read from the session's bash output directory
|
||||
let output_dir = ctx.session_dir.join("bash-outputs");
|
||||
let output_file = output_dir.join(&job_id);
|
||||
|
||||
if output_file.exists() {
|
||||
let content = std::fs::read_to_string(&output_file)
|
||||
.unwrap_or_else(|_| "Error reading output".to_string());
|
||||
Ok(format!("Output for job '{job_id}':\n{content}"))
|
||||
} else {
|
||||
Ok(format!(
|
||||
"No output found for job '{job_id}'. The job may still be running."
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BashKill;
|
||||
|
||||
impl Tool for BashKill {
|
||||
fn name(&self) -> &'static str {
|
||||
"bash_kill"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Kill a background bash job by ID"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {
|
||||
"type": "string",
|
||||
"description": "Background job ID to kill"
|
||||
}
|
||||
},
|
||||
"required": ["job_id"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let _job_id = crate::tools::arg_str(args, "job_id")?;
|
||||
// In production, look up and kill the job in BashControl
|
||||
Ok(format!("Killed background job '{}'", _job_id))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Delete a file or empty directory.
|
||||
|
||||
use crate::tools::{resolve_path, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
|
||||
pub struct Delete;
|
||||
|
||||
impl Tool for Delete {
|
||||
fn name(&self) -> &'static str {
|
||||
"delete"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Delete a file or empty directory"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to delete (relative to workspace root)"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Reason for deletion"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = crate::tools::arg_str(args, "path")?;
|
||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||
|
||||
if !path.exists() {
|
||||
anyhow::bail!("path '{rel}' does not exist");
|
||||
}
|
||||
|
||||
if path.is_file() {
|
||||
fs::remove_file(&path)?;
|
||||
Ok(format!("Deleted file '{rel}'"))
|
||||
} else if path.is_dir() {
|
||||
fs::remove_dir_all(&path)?;
|
||||
Ok(format!("Deleted directory '{rel}' and all contents"))
|
||||
} else {
|
||||
anyhow::bail!("'{rel}' is neither a file nor a directory")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Edit a file by replacing a text block.
|
||||
|
||||
use crate::tools::{resolve_path, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
|
||||
pub struct Edit;
|
||||
|
||||
impl Tool for Edit {
|
||||
fn name(&self) -> &'static str {
|
||||
"edit"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Edit a file by replacing 'old' text with 'new' text"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file to edit (relative to workspace root)"
|
||||
},
|
||||
"old": {
|
||||
"type": "string",
|
||||
"description": "Text to replace (must exist in the file)"
|
||||
},
|
||||
"new": {
|
||||
"type": "string",
|
||||
"description": "Replacement text"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Reason for this change"
|
||||
}
|
||||
},
|
||||
"required": ["path", "old", "new"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = crate::tools::arg_str(args, "path")?;
|
||||
let old = crate::tools::arg_str(args, "old")?;
|
||||
let new = crate::tools::arg_str(args, "new")?;
|
||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||
|
||||
if !path.exists() {
|
||||
anyhow::bail!("file '{rel}' does not exist");
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&path)?;
|
||||
if !content.contains(&old) {
|
||||
anyhow::bail!("old text not found in '{}'", rel);
|
||||
}
|
||||
|
||||
let new_content = content.replace(&old, &new);
|
||||
fs::write(&path, &new_content)?;
|
||||
|
||||
Ok(format!(
|
||||
"Edited '{}': replaced {} bytes with {} bytes",
|
||||
rel,
|
||||
old.len(),
|
||||
new.len()
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Helper utilities for filesystem tools — content hashing, path validation, etc.
|
||||
|
||||
use sha2::Digest;
|
||||
|
||||
/// Compute the SHA-256 hex digest of a string.
|
||||
pub fn sha256_hex(content: &str) -> String {
|
||||
hex::encode(sha2::Sha256::digest(content.as_bytes()))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Filesystem read/write/edit/delete tools with graduated-checks integration.
|
||||
|
||||
pub mod delete;
|
||||
pub mod edit;
|
||||
pub mod helpers;
|
||||
pub mod read;
|
||||
pub mod write;
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Read a file from the workspace.
|
||||
|
||||
use crate::tools::{resolve_path, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
|
||||
pub struct Read;
|
||||
|
||||
impl Tool for Read {
|
||||
fn name(&self) -> &'static str {
|
||||
"read"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Read the contents of a file"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file to read (relative to workspace root)"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = crate::tools::arg_str(args, "path")?;
|
||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||
if !path.exists() {
|
||||
anyhow::bail!("file '{rel}' does not exist");
|
||||
}
|
||||
if !path.is_file() {
|
||||
anyhow::bail!("'{rel}' is not a file");
|
||||
}
|
||||
let content = fs::read_to_string(&path)?;
|
||||
Ok(content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Write content to a file (create or overwrite).
|
||||
|
||||
use crate::tools::{check_graduated_checks, resolve_path, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
|
||||
pub struct Write;
|
||||
|
||||
impl Tool for Write {
|
||||
fn name(&self) -> &'static str {
|
||||
"write"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Write content to a file (creating or overwriting)"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to write to (relative to workspace root)"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Content to write"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Reason for this change"
|
||||
}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = crate::tools::arg_str(args, "path")?;
|
||||
let content = crate::tools::arg_str(args, "content")?;
|
||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(&path, &content)?;
|
||||
|
||||
// Notify mention index
|
||||
ctx.mention_index.push(path.display().to_string());
|
||||
|
||||
// Check graduated checks
|
||||
let matched = check_graduated_checks(&rel, &content, &ctx.graduated_checks);
|
||||
if !matched.is_empty() {
|
||||
return Ok(format!(
|
||||
"Written {} bytes to '{}'. Note: graduated checks triggered: {}",
|
||||
content.len(),
|
||||
rel,
|
||||
matched.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
Ok(format!("Written {} bytes to '{}'", content.len(), rel))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Git credential management tool.
|
||||
|
||||
use crate::tools::{execute_cmd, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct GitCred;
|
||||
|
||||
impl Tool for GitCred {
|
||||
fn name(&self) -> &'static str {
|
||||
"git_cred"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Manage git credentials (store, retrieve, list)"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["store", "list", "erase"],
|
||||
"description": "Credential action to perform"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Git URL for the credential"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "Username for authentication"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "Password or token for authentication"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let action = crate::tools::arg_str(args, "action")?;
|
||||
|
||||
match action.as_str() {
|
||||
"store" => {
|
||||
let url = crate::tools::arg_str(args, "url")?;
|
||||
let username = crate::tools::arg_str(args, "username")?;
|
||||
let password = crate::tools::arg_str(args, "password")?;
|
||||
let _input = format!("url={url}\nusername={username}\npassword={password}\n");
|
||||
let _output = execute_cmd(
|
||||
std::process::Command::new("git").args(["credential", "approve"]),
|
||||
)?;
|
||||
Ok(format!("Credential stored for {url}"))
|
||||
}
|
||||
"list" => {
|
||||
let output = execute_cmd(
|
||||
std::process::Command::new("git").args(["config", "--global", "--list"]),
|
||||
)?;
|
||||
Ok(output)
|
||||
}
|
||||
"erase" => {
|
||||
let url = crate::tools::arg_str(args, "url")?;
|
||||
let _input = format!("url={url}\n");
|
||||
Ok(format!("Credential erased for {url}"))
|
||||
}
|
||||
_ => anyhow::bail!("unknown action: {}", action),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Git operator tool — commit, push, pull, branch operations.
|
||||
|
||||
use crate::tools::{execute_cmd, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct GitOperator;
|
||||
|
||||
impl Tool for GitOperator {
|
||||
fn name(&self) -> &'static str {
|
||||
"git_operator"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Execute git operations (commit, push, pull, branch, status, log, etc.)"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["status", "log", "diff", "commit", "branch", "checkout", "pull", "push", "add", "stash"],
|
||||
"description": "Git operation to perform"
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Additional arguments for the git operation"
|
||||
}
|
||||
},
|
||||
"required": ["operation"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let operation = crate::tools::arg_str(args, "operation")?;
|
||||
let extra_args: Vec<String> = args
|
||||
.get("args")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.arg(&operation);
|
||||
for arg in &extra_args {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
|
||||
let output = execute_cmd(&mut cmd)?;
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Git worktree management tool.
|
||||
|
||||
use crate::tools::{execute_cmd, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct GitWorktree;
|
||||
|
||||
impl Tool for GitWorktree {
|
||||
fn name(&self) -> &'static str {
|
||||
"git_worktree"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Manage git worktrees (add, list, remove, prune)"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["add", "list", "remove", "prune"],
|
||||
"description": "Worktree action to perform"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path for the new worktree (for 'add')"
|
||||
},
|
||||
"branch": {
|
||||
"type": "string",
|
||||
"description": "Branch name for the new worktree (for 'add')"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let action = crate::tools::arg_str(args, "action")?;
|
||||
|
||||
match action.as_str() {
|
||||
"add" => {
|
||||
let path = crate::tools::arg_str(args, "path")?;
|
||||
let branch = crate::tools::arg_str(args, "branch")?;
|
||||
let output = execute_cmd(
|
||||
std::process::Command::new("git")
|
||||
.args(["worktree", "add", &path, &branch]),
|
||||
)?;
|
||||
Ok(output)
|
||||
}
|
||||
"list" => {
|
||||
let output = execute_cmd(
|
||||
std::process::Command::new("git").args(["worktree", "list"]),
|
||||
)?;
|
||||
Ok(output)
|
||||
}
|
||||
"remove" => {
|
||||
let path = crate::tools::arg_str(args, "path")?;
|
||||
let output = execute_cmd(
|
||||
std::process::Command::new("git").args(["worktree", "remove", &path]),
|
||||
)?;
|
||||
Ok(output)
|
||||
}
|
||||
"prune" => {
|
||||
let output = execute_cmd(
|
||||
std::process::Command::new("git").args(["worktree", "prune"]),
|
||||
)?;
|
||||
Ok(output)
|
||||
}
|
||||
_ => anyhow::bail!("unknown action: {}", action),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Git integration tools.
|
||||
|
||||
pub mod git_cred;
|
||||
pub mod git_operator;
|
||||
pub mod git_worktree;
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Get completion suggestions from LSP.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct LspCompletion;
|
||||
|
||||
impl Tool for LspCompletion {
|
||||
fn name(&self) -> &'static str {
|
||||
"lsp_completion"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Get completion suggestions at a position"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language identifier"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path"
|
||||
},
|
||||
"line": {
|
||||
"type": "integer",
|
||||
"description": "Line number (0-based)"
|
||||
},
|
||||
"character": {
|
||||
"type": "integer",
|
||||
"description": "Character offset (0-based)"
|
||||
}
|
||||
},
|
||||
"required": ["language", "path", "line", "character"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let language = crate::tools::arg_str(args, "language")?;
|
||||
let path = crate::tools::arg_str(args, "path")?;
|
||||
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
let manager = ctx.lsp_manager.lock().unwrap();
|
||||
if let Some(client) = manager.get_client(&language) {
|
||||
let result = client.send_request("textDocument/completion", &json!({
|
||||
"textDocument": { "uri": format!("file://{}", path) },
|
||||
"position": { "line": line, "character": character }
|
||||
}))?;
|
||||
Ok(serde_json::to_string_pretty(&result)?)
|
||||
} else {
|
||||
anyhow::bail!("no LSP client connected for '{language}'")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Connect to an LSP language server.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct LspConnect;
|
||||
|
||||
impl Tool for LspConnect {
|
||||
fn name(&self) -> &'static str {
|
||||
"lsp_connect"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Connect to an LSP language server for a given language"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language identifier (e.g. 'rust', 'python')"
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Command to start the language server"
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Arguments for the language server command"
|
||||
}
|
||||
},
|
||||
"required": ["language", "command"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let language = crate::tools::arg_str(args, "language")?;
|
||||
let command = crate::tools::arg_str(args, "command")?;
|
||||
let extra_args: Vec<String> = args
|
||||
.get("args")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut manager = ctx.lsp_manager.lock().unwrap();
|
||||
manager.start(&language, &command, &extra_args)?;
|
||||
|
||||
Ok(format!("Connected LSP for '{language}'"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Go-to-definition via LSP.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct LspDefinition;
|
||||
|
||||
impl Tool for LspDefinition {
|
||||
fn name(&self) -> &'static str {
|
||||
"lsp_definition"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Go to definition for a symbol at a position"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language identifier"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path"
|
||||
},
|
||||
"line": {
|
||||
"type": "integer",
|
||||
"description": "Line number (0-based)"
|
||||
},
|
||||
"character": {
|
||||
"type": "integer",
|
||||
"description": "Character offset (0-based)"
|
||||
}
|
||||
},
|
||||
"required": ["language", "path", "line", "character"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let language = crate::tools::arg_str(args, "language")?;
|
||||
let path = crate::tools::arg_str(args, "path")?;
|
||||
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
let manager = ctx.lsp_manager.lock().unwrap();
|
||||
if let Some(client) = manager.get_client(&language) {
|
||||
let result = client.send_request("textDocument/definition", &json!({
|
||||
"textDocument": { "uri": format!("file://{}", path) },
|
||||
"position": { "line": line, "character": character }
|
||||
}))?;
|
||||
Ok(serde_json::to_string_pretty(&result)?)
|
||||
} else {
|
||||
anyhow::bail!("no LSP client connected for '{language}'")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Get diagnostics from LSP.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct LspDiagnostics;
|
||||
|
||||
impl Tool for LspDiagnostics {
|
||||
fn name(&self) -> &'static str {
|
||||
"lsp_diagnostics"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Get diagnostics (errors, warnings) from the LSP for a file"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language identifier"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path to get diagnostics for"
|
||||
}
|
||||
},
|
||||
"required": ["language", "path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let language = crate::tools::arg_str(args, "language")?;
|
||||
let path = crate::tools::arg_str(args, "path")?;
|
||||
|
||||
let manager = ctx.lsp_manager.lock().unwrap();
|
||||
if let Some(client) = manager.get_client(&language) {
|
||||
let result = client.send_request("textDocument/diagnostic", &json!({
|
||||
"textDocument": { "uri": format!("file://{}", path) }
|
||||
}))?;
|
||||
Ok(serde_json::to_string_pretty(&result)?)
|
||||
} else {
|
||||
anyhow::bail!("no LSP client connected for '{language}'")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Disconnect from an LSP language server.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct LspDisconnect;
|
||||
|
||||
impl Tool for LspDisconnect {
|
||||
fn name(&self) -> &'static str {
|
||||
"lsp_disconnect"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Disconnect from an LSP language server"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language identifier to disconnect"
|
||||
}
|
||||
},
|
||||
"required": ["language"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let language = crate::tools::arg_str(args, "language")?;
|
||||
let _manager = ctx.lsp_manager.lock().unwrap();
|
||||
Ok(format!("Disconnected LSP for '{language}'"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Get hover information from LSP.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct LspHover;
|
||||
|
||||
impl Tool for LspHover {
|
||||
fn name(&self) -> &'static str {
|
||||
"lsp_hover"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Get hover information for a symbol at a position"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language identifier"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path"
|
||||
},
|
||||
"line": {
|
||||
"type": "integer",
|
||||
"description": "Line number (0-based)"
|
||||
},
|
||||
"character": {
|
||||
"type": "integer",
|
||||
"description": "Character offset (0-based)"
|
||||
}
|
||||
},
|
||||
"required": ["language", "path", "line", "character"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let language = crate::tools::arg_str(args, "language")?;
|
||||
let path = crate::tools::arg_str(args, "path")?;
|
||||
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
let manager = ctx.lsp_manager.lock().unwrap();
|
||||
if let Some(client) = manager.get_client(&language) {
|
||||
let result = client.send_request("textDocument/hover", &json!({
|
||||
"textDocument": { "uri": format!("file://{}", path) },
|
||||
"position": { "line": line, "character": character }
|
||||
}))?;
|
||||
Ok(serde_json::to_string_pretty(&result)?)
|
||||
} else {
|
||||
anyhow::bail!("no LSP client connected for '{language}'")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! LSP tool implementations — connect, diagnostics, hover, completion,
|
||||
//! definition, references, disconnect.
|
||||
|
||||
pub mod completion;
|
||||
pub mod connect;
|
||||
pub mod definition;
|
||||
pub mod diagnostics;
|
||||
pub mod disconnect;
|
||||
pub mod hover;
|
||||
pub mod references;
|
||||
|
||||
pub use connect::LspConnect;
|
||||
pub use diagnostics::LspDiagnostics;
|
||||
pub use hover::LspHover;
|
||||
pub use completion::LspCompletion;
|
||||
pub use definition::LspDefinition;
|
||||
pub use references::LspReferences;
|
||||
pub use disconnect::LspDisconnect;
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Find references via LSP.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct LspReferences;
|
||||
|
||||
impl Tool for LspReferences {
|
||||
fn name(&self) -> &'static str {
|
||||
"lsp_references"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Find all references to a symbol at a position"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language identifier"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path"
|
||||
},
|
||||
"line": {
|
||||
"type": "integer",
|
||||
"description": "Line number (0-based)"
|
||||
},
|
||||
"character": {
|
||||
"type": "integer",
|
||||
"description": "Character offset (0-based)"
|
||||
}
|
||||
},
|
||||
"required": ["language", "path", "line", "character"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let language = crate::tools::arg_str(args, "language")?;
|
||||
let path = crate::tools::arg_str(args, "path")?;
|
||||
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
let manager = ctx.lsp_manager.lock().unwrap();
|
||||
if let Some(client) = manager.get_client(&language) {
|
||||
let result = client.send_request("textDocument/references", &json!({
|
||||
"textDocument": { "uri": format!("file://{}", path) },
|
||||
"position": { "line": line, "character": character }
|
||||
}))?;
|
||||
Ok(serde_json::to_string_pretty(&result)?)
|
||||
} else {
|
||||
anyhow::bail!("no LSP client connected for '{language}'")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Delete a memory by name.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use zesdex_domain::cms::MemoryRepository;
|
||||
|
||||
pub struct Forget;
|
||||
|
||||
impl Tool for Forget {
|
||||
fn name(&self) -> &'static str {
|
||||
"forget"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Delete a saved memory by name"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the memory to delete"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let name = crate::tools::arg_str(args, "name")?;
|
||||
let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new();
|
||||
repo.delete(&ctx.memory_dir, &name)?;
|
||||
Ok(format!("Memory '{}' deleted", name))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Memory management tools — remember, recall, forget.
|
||||
|
||||
pub mod forget;
|
||||
pub mod recall;
|
||||
pub mod remember;
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Recall previously saved memories.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use zesdex_domain::cms::MemoryRepository;
|
||||
|
||||
pub struct Recall;
|
||||
|
||||
impl Tool for Recall {
|
||||
fn name(&self) -> &'static str {
|
||||
"recall"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"List or search saved memories"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional: specific memory name to recall"
|
||||
},
|
||||
"search": {
|
||||
"type": "string",
|
||||
"description": "Optional: keyword to search in memory descriptions"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new();
|
||||
|
||||
let specific_name = args.get("name").and_then(|v| v.as_str());
|
||||
|
||||
if let Some(name) = specific_name {
|
||||
let memory = repo.load(&ctx.memory_dir, name)?;
|
||||
Ok(serde_json::to_string_pretty(&memory)?)
|
||||
} else {
|
||||
let names = repo.list(&ctx.memory_dir)?;
|
||||
if names.is_empty() {
|
||||
return Ok("No memories saved yet".to_string());
|
||||
}
|
||||
Ok(format!("Available memories:\n{}", names.join("\n")))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Remember a lesson or fact as persistent memory.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use zesdex_domain::cms::{Memory, MemoryRepository};
|
||||
|
||||
pub struct Remember;
|
||||
|
||||
impl Tool for Remember {
|
||||
fn name(&self) -> &'static str {
|
||||
"remember"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Save a lesson or fact to persistent memory"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Unique name for this memory"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Short summary of the memory"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Full content of the memory"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["lesson", "reference", "fact"],
|
||||
"description": "Category of memory"
|
||||
}
|
||||
},
|
||||
"required": ["name", "description", "content"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let name = crate::tools::arg_str(args, "name")?;
|
||||
let description = crate::tools::arg_str(args, "description")?;
|
||||
let content = crate::tools::arg_str(args, "content")?;
|
||||
let kind = args
|
||||
.get("kind")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("reference")
|
||||
.to_string();
|
||||
|
||||
let memory = Memory {
|
||||
name: name.clone(),
|
||||
description,
|
||||
content,
|
||||
kind,
|
||||
created_at: chrono::Utc::now().timestamp(),
|
||||
updated_at: chrono::Utc::now().timestamp(),
|
||||
outcome: None,
|
||||
lifecycle: "active".to_string(),
|
||||
scope: None,
|
||||
before_snippet: None,
|
||||
after_snippet: None,
|
||||
provenances: Vec::new(),
|
||||
};
|
||||
|
||||
let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new();
|
||||
repo.save(&ctx.memory_dir, &memory)?;
|
||||
|
||||
Ok(format!("Memory '{}' saved", name))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! Tool trait, execution context, and the registry of all built-in tools.
|
||||
//!
|
||||
//! This module defines the core `Tool` trait that every agent-invocable tool
|
||||
//! must implement, the shared `ToolCtx` execution context passed to every tool
|
||||
//! invocation, and utility functions for path resolution, command execution,
|
||||
//! argument extraction, and edit-log persistence.
|
||||
|
||||
use crate::utils::CastOr;
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sha2::Digest;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub mod bash_tools;
|
||||
pub mod fs;
|
||||
pub mod git;
|
||||
pub mod lsp;
|
||||
pub mod memory;
|
||||
pub mod plan;
|
||||
pub mod search;
|
||||
pub mod sequential_think;
|
||||
pub mod shell;
|
||||
pub mod shell_filter;
|
||||
pub mod spawn;
|
||||
pub mod utility;
|
||||
pub mod workflow;
|
||||
|
||||
pub use git::git_cred;
|
||||
pub use git::git_operator;
|
||||
pub use git::git_worktree;
|
||||
|
||||
/// Common interface every agent-invocable tool implements.
|
||||
pub trait Tool: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn description(&self) -> &'static str;
|
||||
fn parameters(&self) -> Value;
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String>;
|
||||
}
|
||||
|
||||
/// A project-defined rule that flags a matching file path or content pattern
|
||||
/// for review.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GraduatedCheck {
|
||||
pub name: String,
|
||||
pub pattern: String,
|
||||
pub rule: String,
|
||||
}
|
||||
|
||||
/// Shared execution context passed to every `Tool::run` call: workspace roots,
|
||||
/// session paths, cached directory state, and workflow-level findings sharing.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolCtx {
|
||||
pub workspaces: Vec<PathBuf>,
|
||||
pub session_dir: PathBuf,
|
||||
pub memory_dir: PathBuf,
|
||||
pub worktrees_dir: PathBuf,
|
||||
pub dir_cache: Arc<tokio::sync::RwLock<crate::DirCache>>,
|
||||
pub mention_index: crate::MentionIndex,
|
||||
pub origin: crate::Origin,
|
||||
pub graduated_checks: Vec<GraduatedCheck>,
|
||||
pub lsp_manager: Arc<Mutex<crate::lsp::manager::LspManager>>,
|
||||
pub turn_events:
|
||||
Option<Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>>,
|
||||
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
|
||||
pub abort_flag: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl ToolCtx {
|
||||
pub fn builder() -> ToolCtxBuilder {
|
||||
ToolCtxBuilder::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for `ToolCtx`; fields default to empty paths and a fresh `DirCache`.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolCtxBuilder {
|
||||
pub workspaces: Vec<PathBuf>,
|
||||
pub session_dir: PathBuf,
|
||||
pub memory_dir: PathBuf,
|
||||
pub worktrees_dir: PathBuf,
|
||||
pub dir_cache: Arc<tokio::sync::RwLock<crate::DirCache>>,
|
||||
pub mention_index: crate::MentionIndex,
|
||||
pub origin: crate::Origin,
|
||||
pub graduated_checks: Vec<GraduatedCheck>,
|
||||
pub lsp_manager: Arc<Mutex<crate::lsp::manager::LspManager>>,
|
||||
pub turn_events:
|
||||
Option<Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>>,
|
||||
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
|
||||
pub abort_flag: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl Default for ToolCtxBuilder {
|
||||
fn default() -> Self {
|
||||
ToolCtxBuilder {
|
||||
workspaces: Vec::new(),
|
||||
session_dir: PathBuf::new(),
|
||||
memory_dir: PathBuf::new(),
|
||||
worktrees_dir: PathBuf::new(),
|
||||
dir_cache: Arc::new(tokio::sync::RwLock::new(crate::DirCache::new())),
|
||||
mention_index: crate::MentionIndex::new(),
|
||||
origin: crate::Origin::Main,
|
||||
graduated_checks: Vec::new(),
|
||||
lsp_manager: Arc::new(Mutex::new(crate::lsp::manager::LspManager::new())),
|
||||
turn_events: None,
|
||||
workflow_findings: None,
|
||||
abort_flag: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolCtxBuilder {
|
||||
pub fn session_dir(mut self, v: PathBuf) -> Self {
|
||||
self.session_dir = v;
|
||||
self
|
||||
}
|
||||
pub fn workspaces(mut self, v: Vec<PathBuf>) -> Self {
|
||||
self.workspaces = v;
|
||||
self
|
||||
}
|
||||
pub fn origin(mut self, v: crate::Origin) -> Self {
|
||||
self.origin = v;
|
||||
self
|
||||
}
|
||||
pub fn workflow_findings(mut self, v: Option<Arc<Mutex<Vec<String>>>>) -> Self {
|
||||
self.workflow_findings = v;
|
||||
self
|
||||
}
|
||||
pub fn build(self) -> ToolCtx {
|
||||
ToolCtx {
|
||||
workspaces: self.workspaces,
|
||||
session_dir: self.session_dir,
|
||||
memory_dir: self.memory_dir,
|
||||
worktrees_dir: self.worktrees_dir,
|
||||
dir_cache: self.dir_cache,
|
||||
mention_index: self.mention_index,
|
||||
origin: self.origin,
|
||||
graduated_checks: self.graduated_checks,
|
||||
lsp_manager: self.lsp_manager,
|
||||
turn_events: self.turn_events,
|
||||
workflow_findings: self.workflow_findings,
|
||||
abort_flag: self.abort_flag,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check which graduated checks apply to a given file path/content pair.
|
||||
pub fn check_graduated_checks(path: &str, content: &str, checks: &[GraduatedCheck]) -> Vec<String> {
|
||||
let mut matches = Vec::new();
|
||||
for check in checks {
|
||||
if path.contains(&check.pattern) || content.contains(&check.rule) {
|
||||
matches.push(check.name.clone());
|
||||
}
|
||||
}
|
||||
matches
|
||||
}
|
||||
|
||||
/// Construct one instance of every built-in tool.
|
||||
pub fn all_tools() -> Vec<Box<dyn Tool>> {
|
||||
vec![
|
||||
Box::new(fs::read::Read),
|
||||
Box::new(fs::write::Write),
|
||||
Box::new(fs::edit::Edit),
|
||||
Box::new(fs::delete::Delete),
|
||||
Box::new(search::Grep),
|
||||
Box::new(search::Glob),
|
||||
Box::new(bash_tools::BashOutput),
|
||||
Box::new(bash_tools::BashKill),
|
||||
Box::new(shell::Bash),
|
||||
Box::new(git_operator::GitOperator),
|
||||
Box::new(git_worktree::GitWorktree),
|
||||
Box::new(git_cred::GitCred),
|
||||
Box::new(sequential_think::SeqThink),
|
||||
Box::new(plan::PlanEnter),
|
||||
Box::new(plan::PlanReady),
|
||||
Box::new(workflow::WorkflowRun),
|
||||
Box::new(workflow::NoteFinding),
|
||||
Box::new(workflow::ReadFindings),
|
||||
Box::new(workflow::HiveMind),
|
||||
Box::new(spawn::SpawnAgents),
|
||||
Box::new(spawn::SpawnPipeline),
|
||||
Box::new(memory::remember::Remember),
|
||||
Box::new(memory::forget::Forget),
|
||||
Box::new(memory::recall::Recall),
|
||||
Box::new(utility::cd::Cd),
|
||||
Box::new(utility::dir_list::DirList),
|
||||
Box::new(utility::dir_cache_update::DirCacheUpdate),
|
||||
Box::new(utility::pong::Pong),
|
||||
Box::new(utility::todowrite::Todowrite),
|
||||
Box::new(utility::todofinish::Todofinish),
|
||||
Box::new(lsp::LspConnect),
|
||||
Box::new(lsp::LspDiagnostics),
|
||||
Box::new(lsp::LspHover),
|
||||
Box::new(lsp::LspCompletion),
|
||||
Box::new(lsp::LspDefinition),
|
||||
Box::new(lsp::LspReferences),
|
||||
Box::new(lsp::LspDisconnect),
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether a tool by name can mutate the filesystem or run arbitrary shell commands.
|
||||
pub fn tool_is_risky(name: &str) -> bool {
|
||||
matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator")
|
||||
}
|
||||
|
||||
/// Extract a required string argument from a JSON args map.
|
||||
pub fn arg_str(args: &Value, name: &str) -> Result<String> {
|
||||
args.get(name)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(std::string::ToString::to_string)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required argument: {name}"))
|
||||
}
|
||||
|
||||
/// Execute a `std::process::Command` and return its combined stdout/stderr.
|
||||
pub fn execute_cmd(cmd: &mut std::process::Command) -> Result<String> {
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| anyhow::anyhow!("command execution failed: {e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let 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.
|
||||
pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
|
||||
let (ws_idx, path) = if rel.starts_with('[') {
|
||||
let close = rel
|
||||
.find(']')
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid workspace prefix"))?;
|
||||
let idx: usize = rel[1..close]
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("invalid workspace index"))?;
|
||||
(idx, &rel[close + 1..])
|
||||
} else {
|
||||
(0, rel)
|
||||
};
|
||||
let base = workspaces
|
||||
.get(ws_idx)
|
||||
.ok_or_else(|| anyhow::anyhow!("workspace index {ws_idx} out of range"))?;
|
||||
let abs = if path.is_empty() {
|
||||
base.clone()
|
||||
} else {
|
||||
base.join(path)
|
||||
};
|
||||
let canon = if let Ok(c) = abs.canonicalize() {
|
||||
c
|
||||
} else {
|
||||
let base_canon = workspaces
|
||||
.iter()
|
||||
.find_map(|w| w.canonicalize().ok())
|
||||
.unwrap_or_else(|| base.clone());
|
||||
let mut resolved = base_canon.clone();
|
||||
if let Ok(rel_components) = abs.strip_prefix(&base_canon) {
|
||||
for comp in rel_components.components() {
|
||||
match comp {
|
||||
std::path::Component::ParentDir => {
|
||||
resolved.pop();
|
||||
}
|
||||
std::path::Component::CurDir => {}
|
||||
c => resolved.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
resolved
|
||||
};
|
||||
if workspaces.iter().any(|w| canon.starts_with(w)) {
|
||||
Ok(canon)
|
||||
} else {
|
||||
anyhow::bail!("path '{rel}' is outside all workspace roots")
|
||||
}
|
||||
}
|
||||
|
||||
/// After a successful write/edit tool run, compute content hash and byte
|
||||
/// delta, then persist an `EditLogEntry` to the session's edit log.
|
||||
pub fn log_write_edit_tool(
|
||||
args: &serde_json::Value,
|
||||
tool_name: &str,
|
||||
origin_tag: &str,
|
||||
session_dir: &std::path::Path,
|
||||
session_id: &str,
|
||||
) {
|
||||
let reason = args
|
||||
.get("reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unnamed");
|
||||
let path = args
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let content = args.get("content").or_else(|| args.get("new"));
|
||||
let content_str = content.and_then(|v| v.as_str()).unwrap_or("");
|
||||
let content_sha256 = hex::encode(sha2::Sha256::digest(content_str.as_bytes()));
|
||||
let bytes_delta = if tool_name == "write" {
|
||||
content_str.len().cast_or(0i64)
|
||||
} else {
|
||||
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let new_len: i64 = new.len().cast_or(0i64);
|
||||
let old_len: i64 = old.len().cast_or(0i64);
|
||||
(new_len - old_len).abs()
|
||||
};
|
||||
let entry = zesdex_domain::cms::EditLogEntry {
|
||||
ts: chrono::Utc::now().timestamp_millis(),
|
||||
tool: tool_name.to_string(),
|
||||
path: path.to_string(),
|
||||
reason: reason.to_string(),
|
||||
content_sha256,
|
||||
bytes_delta,
|
||||
origin: origin_tag.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
};
|
||||
use zesdex_domain::cms::repository::EditLogRepository;
|
||||
let repo = crate::persistence::cms::edit_log_repo::JsonlEditLogRepository::new();
|
||||
if let Ok(mut el) = repo.open(session_dir) {
|
||||
let _ = repo.append(session_dir, &mut el, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a list of tools into provider-facing `ToolDef` request schema.
|
||||
pub fn tool_defs(tools: &[Box<dyn Tool>]) -> Vec<zesdex_domain::core::ToolDef> {
|
||||
tools
|
||||
.iter()
|
||||
.map(|t| zesdex_domain::core::ToolDef {
|
||||
type_: "function".to_string(),
|
||||
function: zesdex_domain::core::ToolFunctionDef {
|
||||
name: t.name().to_string(),
|
||||
description: t.description().to_string(),
|
||||
parameters: t.parameters(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Plan management tools — enter and mark ready.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct PlanEnter;
|
||||
|
||||
impl Tool for PlanEnter {
|
||||
fn name(&self) -> &'static str {
|
||||
"plan_enter"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Enter a planning phase — present a structured plan for approval"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"plan": {
|
||||
"type": "string",
|
||||
"description": "The structured plan text"
|
||||
}
|
||||
},
|
||||
"required": ["plan"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let plan_text = crate::tools::arg_str(args, "plan")?;
|
||||
Ok(format!(
|
||||
"Plan entered (length: {} chars). Waiting for approval...",
|
||||
plan_text.len()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PlanReady;
|
||||
|
||||
impl Tool for PlanReady {
|
||||
fn name(&self) -> &'static str {
|
||||
"plan_ready"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Signal that the plan is ready and execution can begin"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, _args: &Value) -> Result<String> {
|
||||
Ok("Plan is ready. Starting execution.".to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Text search tools: Grep (line matching) and Glob (filename pattern matching).
|
||||
|
||||
use crate::tools::{resolve_path, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use globset::{GlobBuilder, GlobSetBuilder};
|
||||
use ignore::Walk;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
|
||||
pub struct Grep;
|
||||
|
||||
impl Tool for Grep {
|
||||
fn name(&self) -> &'static str {
|
||||
"grep"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Search for a pattern in files using recursive text search"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Text pattern to search for"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to search in (relative to workspace root)"
|
||||
}
|
||||
},
|
||||
"required": ["pattern", "path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let pattern = crate::tools::arg_str(args, "pattern")?;
|
||||
let rel = crate::tools::arg_str(args, "path")?;
|
||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||
|
||||
if !path.exists() {
|
||||
anyhow::bail!("path '{rel}' does not exist");
|
||||
}
|
||||
if !path.is_dir() {
|
||||
anyhow::bail!("path '{rel}' is not a directory");
|
||||
}
|
||||
|
||||
let mut results: Vec<(String, usize, String)> = Vec::new();
|
||||
for entry in Walk::new(&path).flatten() {
|
||||
let file_path = entry.path();
|
||||
if !file_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(content) = fs::read_to_string(file_path) {
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
if line.contains(&pattern) {
|
||||
let rel_path = file_path
|
||||
.strip_prefix(&path)
|
||||
.unwrap_or(file_path)
|
||||
.display()
|
||||
.to_string();
|
||||
results.push((rel_path, i + 1, line.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if results.is_empty() {
|
||||
return Ok(format!("no matches found for '{pattern}' in {rel}"));
|
||||
}
|
||||
let output = results
|
||||
.iter()
|
||||
.map(|(f, line, text)| format!("{f}:{line}:{text}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Ok(format!("found {} matches:\n{}", results.len(), output))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Glob;
|
||||
|
||||
impl Tool for Glob {
|
||||
fn name(&self) -> &'static str {
|
||||
"glob"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"List files matching a glob pattern"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern to match files (e.g. '**/*.rs')"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Root path to search from (relative to workspace root)"
|
||||
}
|
||||
},
|
||||
"required": ["pattern", "path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let pat_str = crate::tools::arg_str(args, "pattern")?;
|
||||
let rel = crate::tools::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");
|
||||
}
|
||||
|
||||
let mut builder = GlobSetBuilder::new();
|
||||
let full_pattern = root.join(&pat_str).display().to_string();
|
||||
builder.add(
|
||||
GlobBuilder::new(&full_pattern)
|
||||
.build()
|
||||
.map_err(|e| anyhow::anyhow!("invalid glob pattern '{pat_str}': {e}"))?,
|
||||
);
|
||||
let glob_set = builder.build()?;
|
||||
|
||||
let mut matches: Vec<String> = Vec::new();
|
||||
for entry in Walk::new(&root).flatten() {
|
||||
let p = entry.path();
|
||||
if glob_set.is_match(p) {
|
||||
let rel_path = p.strip_prefix(&root).unwrap_or(p).display().to_string();
|
||||
matches.push(format!(
|
||||
"{}{}",
|
||||
rel_path,
|
||||
if p.is_dir() { "/" } else { "" }
|
||||
));
|
||||
}
|
||||
}
|
||||
matches.sort();
|
||||
if matches.is_empty() {
|
||||
return Ok(format!("no files match '{pat_str}' in {rel}"));
|
||||
}
|
||||
Ok(matches.join("\n"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Sequential thinking tool — step-by-step reasoning.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct SeqThink;
|
||||
|
||||
impl Tool for SeqThink {
|
||||
fn name(&self) -> &'static str {
|
||||
"sequential_think"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Perform sequential / step-by-step reasoning (chain-of-thought)"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"thought": {
|
||||
"type": "string",
|
||||
"description": "The current step of reasoning"
|
||||
},
|
||||
"step_number": {
|
||||
"type": "integer",
|
||||
"description": "Current step number"
|
||||
},
|
||||
"total_steps": {
|
||||
"type": "integer",
|
||||
"description": "Total number of steps planned"
|
||||
},
|
||||
"next_thought_needed": {
|
||||
"type": "boolean",
|
||||
"description": "Whether another thinking step is needed"
|
||||
}
|
||||
},
|
||||
"required": ["thought"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let thought = crate::tools::arg_str(args, "thought")?;
|
||||
let step = args
|
||||
.get("step_number")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
let total = args
|
||||
.get("total_steps")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(1);
|
||||
let next_needed = args
|
||||
.get("next_thought_needed")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(format!(
|
||||
"Step {}/{}: {}\n{}",
|
||||
step,
|
||||
total,
|
||||
thought,
|
||||
if next_needed {
|
||||
"Continuing reasoning..."
|
||||
} else {
|
||||
"Reasoning complete."
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Bash-shell execution tool with safety filters and optional timeout.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct Bash;
|
||||
|
||||
impl Tool for Bash {
|
||||
fn name(&self) -> &'static str {
|
||||
"bash"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Execute a shell command via bash -c"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Shell command to execute"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Human-readable description of what the command does"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Timeout in milliseconds (default 120000, max 600000)"
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Run the command in the background"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let cmd = crate::tools::arg_str(args, "command")?;
|
||||
let timeout_ms = args
|
||||
.get("timeout")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(120_000)
|
||||
.min(600_000);
|
||||
|
||||
// Safety filter: block destructive git operations
|
||||
crate::tools::shell_filter::git::check_git_destructive(&cmd)
|
||||
.map_err(|e| anyhow::anyhow!("blocked: {e}"))?;
|
||||
|
||||
let run_in_background = args
|
||||
.get("run_in_background")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
|
||||
if run_in_background {
|
||||
let job = crate::bgbash::job::spawn_bash_job(cmd);
|
||||
return Ok(format!("Background job: {}", job.id));
|
||||
}
|
||||
|
||||
let mut child = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn bash: {e}"))?;
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| anyhow::anyhow!("failed to collect output: {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
|
||||
} else {
|
||||
format!("{stdout}\n{stderr}")
|
||||
};
|
||||
let trimmed = combined.trim().to_string();
|
||||
if status.success() {
|
||||
return Ok(if trimmed.is_empty() {
|
||||
format!("Command completed in {elapsed:.2}s (exit code 0)")
|
||||
} else {
|
||||
format!("{trimmed}\n\nExit code: 0 ({elapsed:.2}s)")
|
||||
});
|
||||
}
|
||||
return Ok(format!(
|
||||
"{}\n\nExit code: {} ({:.2}s)",
|
||||
trimmed,
|
||||
status.code().unwrap_or(-1),
|
||||
elapsed
|
||||
));
|
||||
}
|
||||
Ok(None) => {
|
||||
if start.elapsed() > timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
anyhow::bail!("command timed out after {timeout_ms}ms");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(e) => {
|
||||
anyhow::bail!("failed to wait for command: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Credential read detection — detects commands that might exfiltrate secrets.
|
||||
//!
|
||||
//! NOTE: This filter is intentionally NOT wired into the bash tool by default.
|
||||
//! See the module doc for rationale.
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
/// Paths that are likely to contain credentials.
|
||||
pub fn is_credential_path(path: &str) -> bool {
|
||||
let patterns = [
|
||||
r"~/.ssh/",
|
||||
r"\.netrc",
|
||||
r"\.aws/credentials",
|
||||
r"\.aws/config",
|
||||
r"\.azure/",
|
||||
r"\.gcp/",
|
||||
r"\.docker/config\.json",
|
||||
r"id_rsa",
|
||||
r"id_ed25519",
|
||||
r"known_hosts",
|
||||
];
|
||||
patterns.iter().any(|p| path.contains(p))
|
||||
}
|
||||
|
||||
/// Check whether a command reads credential files.
|
||||
pub fn check_credential_read(cmd: &str) -> Vec<String> {
|
||||
let re = Regex::new(r#"(?i)(?:cat|head|tail|less|more|vim?|nano|xdg-open|open|type|echo)\s+(~?/[\w/.@-]+)"#).unwrap();
|
||||
let mut findings = Vec::new();
|
||||
for cap in re.captures_iter(cmd) {
|
||||
let path = cap.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||
if is_credential_path(path) {
|
||||
findings.push(format!("potential credential read: '{}'", path));
|
||||
}
|
||||
}
|
||||
findings
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Git operation safety filter — blocks destructive git commands.
|
||||
|
||||
/// Check whether a shell command contains a destructive git operation.
|
||||
///
|
||||
/// Blocks: `git push --force`, `git reset --hard`, `git rebase`, etc.
|
||||
pub fn check_git_destructive(cmd: &str) -> Result<(), String> {
|
||||
let cmd_lower = cmd.to_lowercase();
|
||||
|
||||
let destructive_patterns = [
|
||||
"git push --force",
|
||||
"git push -f",
|
||||
"git reset --hard",
|
||||
"git rebase",
|
||||
"git branch -d",
|
||||
"git branch -D",
|
||||
"git tag -d",
|
||||
"git tag --delete",
|
||||
];
|
||||
|
||||
for pattern in &destructive_patterns {
|
||||
if cmd_lower.contains(pattern) {
|
||||
return Err(format!(
|
||||
"destructive git operation blocked: '{}'",
|
||||
pattern
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Safety filters for bash command execution.
|
||||
|
||||
pub mod credentials;
|
||||
pub mod git;
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Agent spawning tools — launch subagents and pipelines.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
use zesdex_domain::cms::{AppConfigRepository, SettingsRepository};
|
||||
use zesdex_domain::core::Store;
|
||||
|
||||
use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository};
|
||||
use crate::subagent::context::SubagentContext;
|
||||
use crate::subagent::division::AccessTier;
|
||||
use crate::subagent::engine::run_agent;
|
||||
use crate::subagent::spawn::spawn_subagent;
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
|
||||
/// Spawn multiple agent instances to work in parallel on subtasks.
|
||||
///
|
||||
/// Flow: parse agents array → load settings → for each agent, build a
|
||||
/// SubagentContext and call spawn_subagent → join all threads → collect results.
|
||||
pub struct SpawnAgents;
|
||||
|
||||
impl Tool for SpawnAgents {
|
||||
fn name(&self) -> &'static str {
|
||||
"spawn_agents"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Spawn multiple agent instances to work in parallel on subtasks"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directive": {"type": "string", "description": "Directive for the agent"},
|
||||
"access": {"type": "string", "enum": ["read", "write", "full"], "description": "Access tier"}
|
||||
},
|
||||
"required": ["directive"]
|
||||
},
|
||||
"description": "List of agents to spawn"
|
||||
}
|
||||
},
|
||||
"required": ["agents"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let agents = args
|
||||
.get("agents")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing 'agents' array"))?;
|
||||
|
||||
info!("Spawning {} agents", agents.len());
|
||||
|
||||
// Load LLM credentials once for all agents
|
||||
let store = Store::new();
|
||||
let settings = JsonSettingsRepository::new()
|
||||
.load(&store.base_dir)
|
||||
.unwrap_or_default();
|
||||
let app_config = JsonAppConfigRepository::new()
|
||||
.load(&store.base_dir)
|
||||
.unwrap_or_default();
|
||||
|
||||
let (provider, model) =
|
||||
crate::subagent::provider::resolve_subagent_provider(&settings, &app_config);
|
||||
|
||||
let base_url = app_config
|
||||
.providers
|
||||
.get(&provider)
|
||||
.map(|p| p.api_base.clone())
|
||||
.unwrap_or_else(|| "https://opencode.ai/zen/v1".to_string());
|
||||
|
||||
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for (i, agent) in agents.iter().enumerate() {
|
||||
let directive = agent
|
||||
.get("directive")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let access_str = agent
|
||||
.get("access")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("full");
|
||||
|
||||
let access = match access_str {
|
||||
"read" => AccessTier::Read,
|
||||
"write" => AccessTier::Write,
|
||||
_ => AccessTier::Full,
|
||||
};
|
||||
|
||||
let subagent_ctx = SubagentContext::new(
|
||||
directive.clone(),
|
||||
ctx.clone(),
|
||||
access_str.to_string(),
|
||||
base_url.clone(),
|
||||
api_key.clone(),
|
||||
model.clone(),
|
||||
);
|
||||
|
||||
let handle = spawn_subagent(subagent_ctx, directive.clone(), access, ctx.clone());
|
||||
handles.push((i, handle));
|
||||
}
|
||||
|
||||
// Join all handles and collect results
|
||||
let mut results = Vec::new();
|
||||
for (i, handle) in handles {
|
||||
let result = handle
|
||||
.join()
|
||||
.map_err(|e| anyhow::anyhow!("subagent {i} panicked: {e:?}"))??;
|
||||
results.push(format!("Agent {i}: {result}"));
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Spawned {} agents.\n\nResults:\n{}",
|
||||
agents.len(),
|
||||
results.join("\n")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a sequential pipeline of agent stages.
|
||||
///
|
||||
/// Flow: parse stages → load settings → for each stage, build a
|
||||
/// SubagentContext and call run_agent sequentially → collect results.
|
||||
pub struct SpawnPipeline;
|
||||
|
||||
impl Tool for SpawnPipeline {
|
||||
fn name(&self) -> &'static str {
|
||||
"spawn_pipeline"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Spawn a sequential pipeline of agent stages"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directive": {"type": "string", "description": "Directive for this pipeline stage"}
|
||||
},
|
||||
"required": ["directive"]
|
||||
},
|
||||
"description": "Pipeline stages in order"
|
||||
}
|
||||
},
|
||||
"required": ["stages"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let stages = args
|
||||
.get("stages")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing 'stages' array"))?;
|
||||
|
||||
info!("Spawning pipeline with {} stages", stages.len());
|
||||
|
||||
// Load LLM credentials once for all stages
|
||||
let store = Store::new();
|
||||
let settings = JsonSettingsRepository::new()
|
||||
.load(&store.base_dir)
|
||||
.unwrap_or_default();
|
||||
let app_config = JsonAppConfigRepository::new()
|
||||
.load(&store.base_dir)
|
||||
.unwrap_or_default();
|
||||
|
||||
let (provider, model) =
|
||||
crate::subagent::provider::resolve_subagent_provider(&settings, &app_config);
|
||||
|
||||
let base_url = app_config
|
||||
.providers
|
||||
.get(&provider)
|
||||
.map(|p| p.api_base.clone())
|
||||
.unwrap_or_else(|| "https://opencode.ai/zen/v1".to_string());
|
||||
|
||||
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
let mut pipeline_result = String::new();
|
||||
|
||||
for (i, stage) in stages.iter().enumerate() {
|
||||
let directive = stage
|
||||
.get("directive")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let subagent_ctx = SubagentContext::new(
|
||||
directive.clone(),
|
||||
ctx.clone(),
|
||||
"full".to_string(),
|
||||
base_url.clone(),
|
||||
api_key.clone(),
|
||||
model.clone(),
|
||||
);
|
||||
|
||||
let result = rt.block_on(async {
|
||||
run_agent(subagent_ctx, &directive, AccessTier::Full, ctx.clone()).await
|
||||
})?;
|
||||
|
||||
pipeline_result.push_str(&format!("Stage {}: {}\n", i, result));
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Pipeline with {} stages completed.\n\n{}",
|
||||
stages.len(),
|
||||
pipeline_result
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Change the working directory for subsequent commands.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct Cd;
|
||||
|
||||
impl Tool for Cd {
|
||||
fn name(&self) -> &'static str {
|
||||
"cd"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Set the working directory for subsequent tool calls"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string",
|
||||
"description": "Directory path to change to (relative to workspace root)"
|
||||
}
|
||||
},
|
||||
"required": ["directory"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let dir = crate::tools::arg_str(args, "directory")?;
|
||||
std::env::set_current_dir(&dir)?;
|
||||
Ok(format!("Changed directory to '{dir}'"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Update the shared directory cache.
|
||||
|
||||
use crate::tools::ToolCtx;
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct DirCacheUpdate;
|
||||
|
||||
impl crate::tools::Tool for DirCacheUpdate {
|
||||
fn name(&self) -> &'static str {
|
||||
"dir_cache_update"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Update the cached directory listing"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"paths": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "New list of paths for the cache"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let paths: Vec<String> = args
|
||||
.get("paths")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let count = paths.len();
|
||||
Ok(format!("Directory cache updated with {} entries", count))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! List directory contents.
|
||||
|
||||
use crate::tools::{resolve_path, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct DirList;
|
||||
|
||||
impl Tool for DirList {
|
||||
fn name(&self) -> &'static str {
|
||||
"dir_list"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"List files and directories in a given path"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to list (relative to workspace root)"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = crate::tools::arg_str(args, "path")?;
|
||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||
|
||||
if !path.exists() {
|
||||
anyhow::bail!("path '{rel}' does not exist");
|
||||
}
|
||||
if !path.is_dir() {
|
||||
anyhow::bail!("'{rel}' is not a directory");
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(&path)?;
|
||||
let mut items: Vec<String> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if e.path().is_dir() {
|
||||
format!("{name}/")
|
||||
} else {
|
||||
name
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
items.sort();
|
||||
Ok(items.join("\n"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Utility tools — cd, dir_list, dir_cache_update, pong, todowrite, todofinish.
|
||||
|
||||
pub mod cd;
|
||||
pub mod dir_cache_update;
|
||||
pub mod dir_list;
|
||||
pub mod pong;
|
||||
pub mod todofinish;
|
||||
pub mod todowrite;
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Simple ping/pong tool for connectivity testing.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct Pong;
|
||||
|
||||
impl Tool for Pong {
|
||||
fn name(&self) -> &'static str {
|
||||
"pong"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Ping the agent — useful for testing connectivity"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, _args: &Value) -> Result<String> {
|
||||
Ok("pong".to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Mark a TODO item as finished.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct Todofinish;
|
||||
|
||||
impl Tool for Todofinish {
|
||||
fn name(&self) -> &'static str {
|
||||
"todofinish"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Mark a TODO item as completed"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item": {
|
||||
"type": "string",
|
||||
"description": "TODO item text that was completed"
|
||||
}
|
||||
},
|
||||
"required": ["item"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let item = crate::tools::arg_str(args, "item")?;
|
||||
Ok(format!("TODO completed: {}", item))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Write a TODO item.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct Todowrite;
|
||||
|
||||
impl Tool for Todowrite {
|
||||
fn name(&self) -> &'static str {
|
||||
"todowrite"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Add an item to the TODO list"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item": {
|
||||
"type": "string",
|
||||
"description": "TODO item text"
|
||||
},
|
||||
"priority": {
|
||||
"type": "string",
|
||||
"enum": ["high", "medium", "low"],
|
||||
"description": "Priority level"
|
||||
}
|
||||
},
|
||||
"required": ["item"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let item = crate::tools::arg_str(args, "item")?;
|
||||
let priority = args
|
||||
.get("priority")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("medium");
|
||||
|
||||
Ok(format!("[{}] TODO added: {}", priority, item))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Workflow tools — orchestrate multi-step agent workflows and hive-mind convergence.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
use crate::llm::provider::LlmClient;
|
||||
use crate::tools::{arg_str, Tool, ToolCtx};
|
||||
use crate::workflow::engine::execution::execute_workflow;
|
||||
use crate::workflow::hive_mind::cycle::execute_cycle;
|
||||
use crate::workflow::hive_mind::synthesis::synthesize_consensus;
|
||||
use crate::workflow::hive_mind::types::{CognitiveCycle, NodeOutput};
|
||||
use crate::workflow::script::WorkflowScript;
|
||||
|
||||
/// Execute a multi-step workflow defined in YAML.
|
||||
///
|
||||
/// Flow: parse YAML → build plan from script phases → execute via workflow engine.
|
||||
pub struct WorkflowRun;
|
||||
|
||||
impl Tool for WorkflowRun {
|
||||
fn name(&self) -> &'static str {
|
||||
"workflow_run"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Execute a multi-step workflow defined in YAML"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workflow_yaml": {
|
||||
"type": "string",
|
||||
"description": "YAML workflow definition"
|
||||
}
|
||||
},
|
||||
"required": ["workflow_yaml"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let yaml = arg_str(args, "workflow_yaml")?;
|
||||
let script = WorkflowScript::parse(&yaml)?;
|
||||
info!(
|
||||
"Workflow started: {} ({} phases)",
|
||||
script.name,
|
||||
script.phases.len()
|
||||
);
|
||||
|
||||
let phase_names: Vec<&str> = script.phases.iter().map(|p| p.name.as_str()).collect();
|
||||
info!(
|
||||
"Workflow '{}' phases: {}",
|
||||
script.name,
|
||||
phase_names.join(", ")
|
||||
);
|
||||
|
||||
let llm_client = LlmClient::new(
|
||||
crate::llm::provider::DEFAULT_API_KEY.to_string(),
|
||||
"deepseek-v4-flash-free".to_string(),
|
||||
None,
|
||||
);
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
let result: Vec<String> =
|
||||
rt.block_on(async { execute_workflow(&script, ctx, &llm_client).await })?;
|
||||
|
||||
Ok(format!(
|
||||
"Workflow '{}' completed.\n\n{}",
|
||||
script.name,
|
||||
result.join("\n---\n")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoteFinding;
|
||||
|
||||
impl Tool for NoteFinding {
|
||||
fn name(&self) -> &'static str {
|
||||
"note_finding"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Record a finding during workflow or hive-mind execution"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"finding": {
|
||||
"type": "string",
|
||||
"description": "The finding text"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Category for the finding"
|
||||
}
|
||||
},
|
||||
"required": ["finding"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let finding = crate::tools::arg_str(args, "finding")?;
|
||||
|
||||
if let Some(ref findings) = ctx.workflow_findings {
|
||||
if let Ok(mut guard) = findings.lock() {
|
||||
guard.push(finding.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(format!("Finding recorded: {finding}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ReadFindings;
|
||||
|
||||
impl Tool for ReadFindings {
|
||||
fn name(&self) -> &'static str {
|
||||
"read_findings"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Read all findings recorded so far in the current workflow"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, _args: &Value) -> Result<String> {
|
||||
let findings = ctx
|
||||
.workflow_findings
|
||||
.as_ref()
|
||||
.and_then(|f| f.lock().ok())
|
||||
.map(|guard| guard.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
if findings.is_empty() {
|
||||
return Ok("No findings recorded yet.".to_string());
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Findings ({}):\n{}",
|
||||
findings.len(),
|
||||
findings.join("\n")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Orchestrate a hive-mind convergence — multiple agents across parallel cycles.
|
||||
///
|
||||
/// Flow: parse cycles from args → execute each cycle via `execute_cycle` →
|
||||
/// collect all node outputs → synthesize consensus → return report.
|
||||
pub struct HiveMind;
|
||||
|
||||
impl Tool for HiveMind {
|
||||
fn name(&self) -> &'static str {
|
||||
"hive_mind"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Run a hive-mind convergence with multiple nodes across sequential cycles"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cycles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directives": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directive": {"type": "string"},
|
||||
"access": {"type": "string", "enum": ["read", "write", "full"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Array of cycles, each with an array of node directives"
|
||||
}
|
||||
},
|
||||
"required": ["cycles"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let cycles_val = args
|
||||
.get("cycles")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing 'cycles' array"))?;
|
||||
|
||||
info!("Hive mind starting with {} cycles", cycles_val.len());
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
let mut all_node_outputs = Vec::new();
|
||||
|
||||
for (cycle_idx, cycle_val) in cycles_val.iter().enumerate() {
|
||||
let directives: Vec<String> = cycle_val
|
||||
.get("directives")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|d| d.get("directive").and_then(|v| v.as_str()))
|
||||
.map(String::from)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let cycle = CognitiveCycle {
|
||||
index: cycle_idx as u32,
|
||||
directives,
|
||||
};
|
||||
|
||||
let nodes: Vec<NodeOutput> =
|
||||
rt.block_on(async { execute_cycle(&cycle, ctx).await })?;
|
||||
all_node_outputs.extend(nodes);
|
||||
}
|
||||
|
||||
let node_count = all_node_outputs.len();
|
||||
let consensus =
|
||||
rt.block_on(async { synthesize_consensus(&all_node_outputs, ctx).await })?;
|
||||
|
||||
let report = format!(
|
||||
"Hive mind convergence completed.\nNodes executed: {}\n\nConsensus:\n{}",
|
||||
node_count, consensus
|
||||
);
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user