roboticus-agent 0.11.4

Agent core with ReAct loop, policy engine, injection defense, memory system, and skill loader
Documentation
use super::{
    Tool, ToolContext, ToolError, ToolResult, resolve_workspace_path_with_allowed,
    workspace_root_from_ctx,
};
use async_trait::async_trait;
use roboticus_core::RiskLevel;
use serde_json::Value;
use std::time::Instant;
use tokio::process::Command;

pub struct EchoTool;

#[async_trait]
impl Tool for EchoTool {
    fn name(&self) -> &str {
        "echo"
    }

    fn description(&self) -> &str {
        "Echoes input back as output"
    }

    fn risk_level(&self) -> RiskLevel {
        RiskLevel::Safe
    }

    fn parameters_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "message": { "type": "string" }
            },
            "required": ["message"]
        })
    }

    async fn execute(
        &self,
        params: Value,
        _ctx: &ToolContext,
    ) -> std::result::Result<ToolResult, ToolError> {
        let message = params
            .get("message")
            .and_then(|v| v.as_str())
            .ok_or_else(|| ToolError {
                message: "missing 'message' parameter".into(),
            })?;

        Ok(ToolResult {
            output: message.to_string(),
            metadata: None,
        })
    }
}

/// Tool wrapper around `ScriptRunner` for executing skill scripts via the ToolRegistry.
pub struct ScriptRunnerTool {
    runner: crate::script_runner::ScriptRunner,
}

impl ScriptRunnerTool {
    pub fn new(
        config: roboticus_core::config::SkillsConfig,
        fs_security: roboticus_core::config::FilesystemSecurityConfig,
    ) -> Self {
        Self {
            runner: crate::script_runner::ScriptRunner::new(config, fs_security),
        }
    }
}

pub(crate) fn classify_script_runner_error(message: &str) -> &'static str {
    let lower = message.to_ascii_lowercase();
    if lower.contains("timed out") {
        "SCRIPT_TIMEOUT"
    } else if lower.contains("absolute script paths are not allowed")
        || lower.contains("escapes skills_dir")
        || lower.contains("not a file")
        || lower.contains("world-writable")
    {
        "SCRIPT_PATH_INVALID"
    } else if lower.contains("not in whitelist") || lower.contains("cannot infer interpreter") {
        "SCRIPT_INTERPRETER_DENIED"
    } else if lower.contains("failed to spawn") {
        "SCRIPT_SPAWN_FAILED"
    } else {
        "SCRIPT_RUNTIME_ERROR"
    }
}

#[async_trait]
impl Tool for ScriptRunnerTool {
    fn name(&self) -> &str {
        "run_script"
    }

    fn description(&self) -> &str {
        "Execute a whitelisted skill script with sandboxed environment"
    }

    fn risk_level(&self) -> RiskLevel {
        RiskLevel::Caution
    }

    fn parameters_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "path": { "type": "string", "description": "Path to the script file" },
                "args": { "type": "array", "items": { "type": "string" }, "description": "Arguments to pass" }
            },
            "required": ["path"]
        })
    }

    async fn execute(
        &self,
        params: Value,
        _ctx: &ToolContext,
    ) -> std::result::Result<ToolResult, ToolError> {
        let path = params
            .get("path")
            .and_then(|v| v.as_str())
            .ok_or_else(|| ToolError {
                message: "missing 'path' parameter".into(),
            })?;

        let args: Vec<String> = params
            .get("args")
            .and_then(|v| v.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
                    .collect()
            })
            .unwrap_or_default();

        let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
        let script_path = std::path::Path::new(path);

        match self.runner.execute(script_path, &arg_refs).await {
            Ok(result) => {
                if result.exit_code != 0 {
                    return Err(ToolError {
                        message: format!(
                            "SCRIPT_EXIT_NONZERO: script exited with code {}: {}",
                            result.exit_code, result.stderr
                        ),
                    });
                }
                Ok(ToolResult {
                    output: result.stdout,
                    metadata: Some(serde_json::json!({
                        "adapter": "script_runner",
                        "schema_version": 1,
                        "status": "ok",
                        "error_class": null,
                        "exit_code": result.exit_code,
                        "duration_ms": result.duration_ms,
                    })),
                })
            }
            Err(e) => {
                let msg = e.to_string();
                let class = classify_script_runner_error(&msg);
                Err(ToolError {
                    message: format!("{class}: {msg}"),
                })
            }
        }
    }
}

pub struct BashTool;

#[async_trait]
impl Tool for BashTool {
    fn name(&self) -> &str {
        "bash"
    }

    fn description(&self) -> &str {
        "Execute a shell command. Runs in the workspace root by default, but cwd can be set to any configured allowed path."
    }

    fn risk_level(&self) -> RiskLevel {
        RiskLevel::Dangerous
    }

    fn parameters_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "command": { "type": "string", "description": "Shell command to execute" },
                "cwd": { "type": "string", "description": "Working directory under workspace root", "default": "." },
                "timeout_seconds": { "type": "integer", "minimum": 1, "maximum": 120, "default": 20 }
            },
            "required": ["command"]
        })
    }

    async fn execute(
        &self,
        params: Value,
        ctx: &ToolContext,
    ) -> std::result::Result<ToolResult, ToolError> {
        let command = params
            .get("command")
            .and_then(|v| v.as_str())
            .ok_or_else(|| ToolError {
                message: "missing 'command' parameter".into(),
            })?;
        let cwd_raw = params.get("cwd").and_then(|v| v.as_str()).unwrap_or(".");
        let timeout_seconds = params
            .get("timeout_seconds")
            .and_then(|v| v.as_u64())
            .unwrap_or(20)
            .clamp(1, 120);

        let root = workspace_root_from_ctx(ctx)?;
        let cwd =
            resolve_workspace_path_with_allowed(&root, cwd_raw, false, &ctx.tool_allowed_paths)?;
        let started = Instant::now();
        let output = tokio::time::timeout(
            std::time::Duration::from_secs(timeout_seconds),
            Command::new("bash")
                .arg("-lc")
                .arg(command)
                .current_dir(&cwd)
                .output(),
        )
        .await
        .map_err(|_| ToolError {
            message: format!("command timed out after {timeout_seconds}s"),
        })?
        .map_err(|e| ToolError {
            message: format!("failed to run bash command: {e}"),
        })?;

        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
        let exit_code = output.status.code().unwrap_or(-1);
        if !output.status.success() {
            return Err(ToolError {
                message: format!("command exited with code {exit_code}: {stderr}"),
            });
        }
        Ok(ToolResult {
            output: stdout,
            metadata: Some(serde_json::json!({
                "exit_code": exit_code,
                "duration_ms": started.elapsed().as_millis() as u64,
                "cwd": cwd.display().to_string(),
            })),
        })
    }
}