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;
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() {
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"});
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
);
}
}