roboticus-agent 0.9.8

Agent core with ReAct loop, policy engine, injection defense, memory system, and skill loader
Documentation
mod filesystem;
mod execution;
mod introspection;
mod data;

pub use filesystem::*;
pub use execution::*;
pub use introspection::*;
pub use data::*;

use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};

use async_trait::async_trait;
use serde_json::Value;

use roboticus_core::{InputAuthority, RiskLevel};

pub(crate) const MAX_FILE_BYTES: usize = 1024 * 1024;
pub(crate) const MAX_SEARCH_RESULTS: usize = 100;
pub(crate) const MAX_WALK_FILES: usize = 5000;

pub(crate) fn workspace_root_from_ctx(ctx: &ToolContext) -> std::result::Result<PathBuf, ToolError> {
    std::fs::canonicalize(&ctx.workspace_root).map_err(|e| ToolError {
        message: format!(
            "failed to resolve workspace root '{}': {e}",
            ctx.workspace_root.display()
        ),
    })
}

pub(crate) fn validate_rel_path(rel: &Path) -> std::result::Result<(), ToolError> {
    if rel.is_absolute() {
        return Err(ToolError {
            message: "absolute paths are not allowed".into(),
        });
    }
    if rel.components().any(|c| matches!(c, Component::ParentDir)) {
        return Err(ToolError {
            message: "path traversal ('..') is not allowed".into(),
        });
    }
    Ok(())
}

pub(crate) fn normalize_workspace_rel_path(root: &Path, raw: &str) -> std::result::Result<PathBuf, ToolError> {
    if raw.is_empty() || raw == "." {
        return Ok(PathBuf::from("."));
    }

    if raw == "~" || raw.starts_with("~/") {
        let home = std::env::var("HOME").map_err(|_| ToolError {
            message: "cannot expand '~' because HOME is not set".into(),
        })?;
        let suffix = if raw == "~" {
            ""
        } else {
            raw.trim_start_matches("~/")
        };
        let expanded = Path::new(&home).join(suffix);
        return absolutize_into_workspace_rel(root, &expanded);
    }

    let as_path = Path::new(raw);
    if as_path.is_absolute() {
        return absolutize_into_workspace_rel(root, as_path);
    }

    validate_rel_path(as_path)?;
    Ok(as_path.to_path_buf())
}

pub(crate) fn absolutize_into_workspace_rel(
    root: &Path,
    absolute_or_expanded: &Path,
) -> std::result::Result<PathBuf, ToolError> {
    if let Ok(stripped) = absolute_or_expanded.strip_prefix(root) {
        let rel = if stripped.as_os_str().is_empty() {
            PathBuf::from(".")
        } else {
            stripped.to_path_buf()
        };
        validate_rel_path(&rel)?;
        return Ok(rel);
    }

    if absolute_or_expanded.exists() {
        let canonical = std::fs::canonicalize(absolute_or_expanded).map_err(|e| ToolError {
            message: format!(
                "failed to resolve '{}': {e}",
                absolute_or_expanded.display()
            ),
        })?;
        if let Ok(stripped) = canonical.strip_prefix(root) {
            let rel = if stripped.as_os_str().is_empty() {
                PathBuf::from(".")
            } else {
                stripped.to_path_buf()
            };
            validate_rel_path(&rel)?;
            return Ok(rel);
        }
    }

    Err(ToolError {
        message: format!(
            "path is outside workspace root: {}",
            absolute_or_expanded.display()
        ),
    })
}

pub(crate) fn resolve_workspace_path(
    root: &Path,
    rel: &str,
    allow_nonexistent: bool,
) -> std::result::Result<PathBuf, ToolError> {
    let rel_path = normalize_workspace_rel_path(root, rel)?;
    let joined = root.join(rel_path);
    if joined.exists() {
        let canonical = std::fs::canonicalize(&joined).map_err(|e| ToolError {
            message: format!("failed to resolve '{}': {e}", joined.display()),
        })?;
        if !canonical.starts_with(root) {
            return Err(ToolError {
                message: "resolved path escapes workspace root".into(),
            });
        }
        return Ok(canonical);
    }

    if !allow_nonexistent {
        return Err(ToolError {
            message: format!("path does not exist: {}", joined.display()),
        });
    }

    if let Some(parent) = joined.parent() {
        let mut existing_ancestor = parent;
        while !existing_ancestor.exists() {
            existing_ancestor = existing_ancestor.parent().ok_or_else(|| ToolError {
                message: "unable to resolve existing parent for target path".into(),
            })?;
        }
        let canonical_parent = std::fs::canonicalize(existing_ancestor).map_err(|e| ToolError {
            message: format!(
                "failed to resolve parent '{}': {e}",
                existing_ancestor.display()
            ),
        })?;
        if !canonical_parent.starts_with(root) {
            return Err(ToolError {
                message: "target path escapes workspace root".into(),
            });
        }
    }

    Ok(joined)
}

pub(crate) fn walk_workspace_files(
    base: &Path,
    out: &mut Vec<PathBuf>,
    count: &mut usize,
) -> std::result::Result<(), ToolError> {
    if *count >= MAX_WALK_FILES {
        return Ok(());
    }
    let rd = std::fs::read_dir(base).map_err(|e| ToolError {
        message: format!("failed to read directory '{}': {e}", base.display()),
    })?;
    for entry in rd {
        if *count >= MAX_WALK_FILES {
            break;
        }
        let entry = entry.map_err(|e| ToolError {
            message: format!("failed to read directory entry: {e}"),
        })?;
        let name = entry.file_name();
        let name = name.to_string_lossy();
        let skip_dir = name.starts_with('.') || name == "node_modules";
        let path = entry.path();
        let ftype = entry.file_type().map_err(|e| ToolError {
            message: format!("failed to inspect '{}': {e}", path.display()),
        })?;
        if ftype.is_symlink() {
            continue;
        }
        if ftype.is_dir() {
            if skip_dir {
                continue;
            }
            walk_workspace_files(&path, out, count)?;
        } else if ftype.is_file() {
            out.push(path);
            *count += 1;
        }
    }
    Ok(())
}

pub(crate) fn wildcard_match_segment(pattern: &str, candidate: &str) -> bool {
    let p: Vec<char> = pattern.chars().collect();
    let s: Vec<char> = candidate.chars().collect();
    let (mut pi, mut si) = (0usize, 0usize);
    let (mut star, mut match_i) = (None::<usize>, 0usize);

    while si < s.len() {
        if pi < p.len() && (p[pi] == '?' || p[pi] == s[si]) {
            pi += 1;
            si += 1;
        } else if pi < p.len() && p[pi] == '*' {
            star = Some(pi);
            pi += 1;
            match_i = si;
        } else if let Some(star_idx) = star {
            pi = star_idx + 1;
            match_i += 1;
            si = match_i;
        } else {
            return false;
        }
    }

    while pi < p.len() && p[pi] == '*' {
        pi += 1;
    }

    pi == p.len()
}

pub(crate) fn wildcard_match(pattern: &str, candidate: &str) -> bool {
    fn rec(
        p: &[&str],
        c: &[&str],
        pi: usize,
        ci: usize,
        memo: &mut std::collections::HashMap<(usize, usize), bool>,
    ) -> bool {
        if let Some(v) = memo.get(&(pi, ci)) {
            return *v;
        }

        let out = if pi == p.len() {
            ci == c.len()
        } else if p[pi] == "**" {
            // Collapse consecutive ** tokens.
            let mut next_pi = pi + 1;
            while next_pi < p.len() && p[next_pi] == "**" {
                next_pi += 1;
            }
            if next_pi == p.len() {
                true
            } else {
                (ci..=c.len()).any(|next_ci| rec(p, c, next_pi, next_ci, memo))
            }
        } else if ci < c.len() && wildcard_match_segment(p[pi], c[ci]) {
            rec(p, c, pi + 1, ci + 1, memo)
        } else {
            false
        };

        memo.insert((pi, ci), out);
        out
    }

    let pattern_norm = pattern.replace('\\', "/");
    let candidate_norm = candidate.replace('\\', "/");
    let p: Vec<&str> = pattern_norm.split('/').filter(|s| !s.is_empty()).collect();
    let c: Vec<&str> = candidate_norm
        .split('/')
        .filter(|s| !s.is_empty())
        .collect();
    rec(&p, &c, 0, 0, &mut std::collections::HashMap::new())
}

#[async_trait]
pub trait Tool: Send + Sync {
    fn name(&self) -> &str;
    fn description(&self) -> &str;
    fn risk_level(&self) -> RiskLevel;
    fn parameters_schema(&self) -> Value;
    async fn execute(
        &self,
        params: Value,
        _ctx: &ToolContext,
    ) -> std::result::Result<ToolResult, ToolError>;
}

#[derive(Debug, Clone)]
pub struct ToolContext {
    pub session_id: String,
    pub agent_id: String,
    pub agent_name: String,
    pub authority: InputAuthority,
    pub workspace_root: PathBuf,
    /// The channel through which the current message arrived (e.g. "api", "telegram", "discord").
    /// `None` when channel is unknown or the tool was invoked outside a channel context.
    pub channel: Option<String>,
    /// Optional database handle for tools that need to query runtime state
    /// (e.g. subagent status, task lists, delivery queue depth).
    pub db: Option<roboticus_db::Database>,
}

#[derive(Debug, Clone)]
pub struct ToolResult {
    pub output: String,
    pub metadata: Option<Value>,
}

#[derive(Debug, Clone, thiserror::Error)]
#[error("ToolError: {message}")]
pub struct ToolError {
    pub message: String,
}

impl ToolError {
    /// Convert into `RoboticusError::Tool` with an explicit tool name.
    pub fn into_roboticus(self, tool: &str) -> roboticus_core::error::RoboticusError {
        roboticus_core::error::RoboticusError::Tool {
            tool: tool.to_owned(),
            message: self.message,
        }
    }
}

pub struct ToolRegistry {
    tools: HashMap<String, Box<dyn Tool>>,
}

impl ToolRegistry {
    pub fn new() -> Self {
        Self {
            tools: HashMap::new(),
        }
    }

    pub fn register(&mut self, tool: Box<dyn Tool>) {
        self.tools.insert(tool.name().to_string(), tool);
    }

    pub fn get(&self, name: &str) -> Option<&dyn Tool> {
        self.tools.get(name).map(|t| t.as_ref())
    }

    pub fn list(&self) -> Vec<&dyn Tool> {
        self.tools.values().map(|t| t.as_ref()).collect()
    }
}

impl Default for ToolRegistry {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
#[path = "tests.rs"]
mod tests;