roboticus-agent 0.10.0

Agent core with ReAct loop, policy engine, injection defense, memory system, and skill loader
Documentation
use super::{Tool, ToolContext, ToolError, ToolResult};
use async_trait::async_trait;
use roboticus_browser::Browser;
use roboticus_browser::actions::{ActionResult, BrowserAction};
use roboticus_core::RiskLevel;
use serde_json::Value;
use std::sync::Arc;
use std::time::Instant;

/// Agent-facing tool that wraps the browser facade for LLM-driven web automation.
///
/// The LLM passes a JSON object with an `action` discriminator matching the
/// `BrowserAction` enum variants. Screenshots return base64 data; page reads
/// return extracted text. The tool is `RiskLevel::Caution` because it can
/// navigate to arbitrary URLs and execute JavaScript.
pub struct BrowserTool {
    browser: Arc<Browser>,
}

impl BrowserTool {
    pub fn new(browser: Arc<Browser>) -> Self {
        Self { browser }
    }
}

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

    fn description(&self) -> &str {
        "Control a headless browser. Actions: Navigate to a URL, Click an element by CSS selector, \
         Type text into an element, take a Screenshot, read the page text (ReadPage), \
         Evaluate JavaScript, manage cookies (GetCookies, ClearCookies), and navigate history \
         (GoBack, GoForward, Reload)."
    }

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

    fn parameters_schema(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "description": "Browser action to perform",
                    "enum": [
                        "Navigate", "Click", "Type", "Screenshot", "Pdf",
                        "Evaluate", "GetCookies", "ClearCookies", "ReadPage",
                        "GoBack", "GoForward", "Reload"
                    ]
                },
                "url": {
                    "type": "string",
                    "description": "URL to navigate to (Navigate action)"
                },
                "selector": {
                    "type": "string",
                    "description": "CSS selector for Click or Type actions"
                },
                "text": {
                    "type": "string",
                    "description": "Text to type (Type action)"
                },
                "expression": {
                    "type": "string",
                    "description": "JavaScript expression to evaluate (Evaluate action)"
                }
            },
            "required": ["action"]
        })
    }

    fn paired_skill(&self) -> Option<&str> {
        Some("browser")
    }

    async fn execute(
        &self,
        params: Value,
        _ctx: &ToolContext,
    ) -> std::result::Result<ToolResult, ToolError> {
        if !self.browser.is_running().await {
            return Err(ToolError {
                message: "browser is not running — ask the operator to start it first".into(),
            });
        }

        let action: BrowserAction =
            serde_json::from_value(params.clone()).map_err(|e| ToolError {
                message: format!(
                    "invalid browser action parameters: {e}. \
                     Expected an object with \"action\" and relevant fields."
                ),
            })?;

        let started = Instant::now();
        let result: ActionResult = self.browser.execute_action(&action).await;
        let duration_ms = started.elapsed().as_millis() as u64;

        if result.success {
            let output = match &result.data {
                Some(data) => serde_json::to_string_pretty(data).unwrap_or_default(),
                None => "action completed successfully".to_string(),
            };
            Ok(ToolResult {
                output,
                metadata: Some(serde_json::json!({
                    "action": result.action,
                    "duration_ms": duration_ms,
                })),
            })
        } else {
            Err(ToolError {
                message: format!(
                    "browser action '{}' failed: {}",
                    result.action,
                    result.error.unwrap_or_else(|| "unknown error".into())
                ),
            })
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn browser_tool_metadata() {
        let browser = Arc::new(Browser::new(
            roboticus_core::config::BrowserConfig::default(),
        ));
        let tool = BrowserTool::new(browser);
        assert_eq!(tool.name(), "browser");
        assert_eq!(tool.risk_level(), RiskLevel::Caution);
        assert_eq!(tool.paired_skill(), Some("browser"));

        let schema = tool.parameters_schema();
        let action_enum = schema["properties"]["action"]["enum"]
            .as_array()
            .expect("action enum should be an array");
        assert_eq!(
            action_enum.len(),
            12,
            "all 12 browser actions should be listed"
        );
    }

    #[tokio::test]
    async fn browser_not_running_returns_error() {
        let browser = Arc::new(Browser::new(
            roboticus_core::config::BrowserConfig::default(),
        ));
        let tool = BrowserTool::new(browser);
        let ctx = ToolContext {
            session_id: "test".into(),
            agent_id: "test".into(),
            agent_name: "test".into(),
            authority: roboticus_core::InputAuthority::Creator,
            workspace_root: std::path::PathBuf::from("/tmp"),
            tool_allowed_paths: vec![],
            channel: None,
            db: None,
            sandbox: super::super::ToolSandboxSnapshot::default(),
        };
        let params = serde_json::json!({"action": "Screenshot"});
        let result = tool.execute(params, &ctx).await;
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(
            err.message.contains("not running"),
            "should indicate browser not running: {}",
            err.message
        );
    }

    #[tokio::test]
    async fn invalid_action_returns_parse_error() {
        // Even though browser isn't running, invalid params should fail at parse
        // before the running check — but our impl checks running first.
        // So we test with a valid action name but bad structure.
        let browser = Arc::new(Browser::new(
            roboticus_core::config::BrowserConfig::default(),
        ));
        let tool = BrowserTool::new(browser);
        let ctx = ToolContext {
            session_id: "test".into(),
            agent_id: "test".into(),
            agent_name: "test".into(),
            authority: roboticus_core::InputAuthority::Creator,
            workspace_root: std::path::PathBuf::from("/tmp"),
            tool_allowed_paths: vec![],
            channel: None,
            db: None,
            sandbox: super::super::ToolSandboxSnapshot::default(),
        };
        // Navigate requires "url" field — missing it should fail at serde
        // But browser check comes first, so this will fail with "not running"
        let params = serde_json::json!({"action": "Navigate"});
        let result = tool.execute(params, &ctx).await;
        assert!(result.is_err());
    }

    #[test]
    fn browser_tool_description_mentions_key_actions() {
        let browser = Arc::new(Browser::new(
            roboticus_core::config::BrowserConfig::default(),
        ));
        let tool = BrowserTool::new(browser);
        let desc = tool.description();
        assert!(desc.contains("Navigate"), "should mention Navigate");
        assert!(desc.contains("Screenshot"), "should mention Screenshot");
        assert!(desc.contains("ReadPage"), "should mention ReadPage");
        assert!(desc.contains("Click"), "should mention Click");
        assert!(desc.contains("Evaluate"), "should mention Evaluate");
    }

    #[test]
    fn browser_tool_schema_has_required_action() {
        let browser = Arc::new(Browser::new(
            roboticus_core::config::BrowserConfig::default(),
        ));
        let tool = BrowserTool::new(browser);
        let schema = tool.parameters_schema();
        let required = schema["required"].as_array().unwrap();
        assert_eq!(required.len(), 1);
        assert_eq!(required[0], "action");
    }

    #[test]
    fn browser_tool_schema_has_all_properties() {
        let browser = Arc::new(Browser::new(
            roboticus_core::config::BrowserConfig::default(),
        ));
        let tool = BrowserTool::new(browser);
        let schema = tool.parameters_schema();
        let props = schema["properties"].as_object().unwrap();
        assert!(props.contains_key("action"));
        assert!(props.contains_key("url"));
        assert!(props.contains_key("selector"));
        assert!(props.contains_key("text"));
        assert!(props.contains_key("expression"));
        assert_eq!(props.len(), 5);
    }

    #[test]
    fn browser_tool_schema_type_is_object() {
        let browser = Arc::new(Browser::new(
            roboticus_core::config::BrowserConfig::default(),
        ));
        let tool = BrowserTool::new(browser);
        let schema = tool.parameters_schema();
        assert_eq!(schema["type"], "object");
    }

    #[tokio::test]
    async fn browser_not_running_error_message_is_helpful() {
        let browser = Arc::new(Browser::new(
            roboticus_core::config::BrowserConfig::default(),
        ));
        let tool = BrowserTool::new(browser);
        let ctx = ToolContext {
            session_id: "test".into(),
            agent_id: "test".into(),
            agent_name: "test".into(),
            authority: roboticus_core::InputAuthority::Creator,
            workspace_root: std::path::PathBuf::from("/tmp"),
            tool_allowed_paths: vec![],
            channel: None,
            db: None,
            sandbox: super::super::ToolSandboxSnapshot::default(),
        };
        let params = serde_json::json!({"action": "Navigate", "url": "https://example.com"});
        let err = tool.execute(params, &ctx).await.unwrap_err();
        assert!(
            err.message.contains("operator"),
            "error should mention operator: {}",
            err.message
        );
    }
}