Files
sovereign_browser_rust/src/agent_tools.rs
T

471 lines
19 KiB
Rust

//! Agent tool catalog and execution
//!
//! Port of `agent_tools.c` / `agent_tools.h` from the C project.
use std::collections::HashMap;
use std::sync::Mutex;
use once_cell::sync::Lazy;
use webkit2gtk::WebViewExt;
/// A tool definition.
#[derive(Debug, Clone)]
pub struct AgentTool {
pub name: String,
pub description: String,
pub input_schema: serde_json::Value,
}
/// Global tool registry.
static G_TOOL_REGISTRY: Lazy<Mutex<HashMap<String, AgentTool>>> = Lazy::new(|| Mutex::new(HashMap::new()));
/// Initialize the tool registry with built-in tools.
pub fn agent_tools_init() {
let mut registry = G_TOOL_REGISTRY.lock().unwrap();
registry.clear();
// Browser navigation tools
registry.insert(
"browser_navigate".to_string(),
AgentTool {
name: "browser_navigate".to_string(),
description: "Navigate to a URL in the current tab".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"url": {"type": "string", "description": "The URL to navigate to"}
},
"required": ["url"]
}),
},
);
registry.insert(
"browser_click".to_string(),
AgentTool {
name: "browser_click".to_string(),
description: "Click an element on the page".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"selector": {"type": "string", "description": "CSS selector for the element"}
},
"required": ["selector"]
}),
},
);
registry.insert(
"browser_type".to_string(),
AgentTool {
name: "browser_type".to_string(),
description: "Type text into an input field".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"selector": {"type": "string", "description": "CSS selector for the input"},
"text": {"type": "string", "description": "Text to type"}
},
"required": ["selector", "text"]
}),
},
);
registry.insert(
"browser_screenshot".to_string(),
AgentTool {
name: "browser_screenshot".to_string(),
description: "Take a screenshot of the current page".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
);
// Filesystem tools
registry.insert(
"fs_read_file".to_string(),
AgentTool {
name: "fs_read_file".to_string(),
description: "Read a file from the filesystem".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the file"}
},
"required": ["path"]
}),
},
);
registry.insert(
"fs_write_file".to_string(),
AgentTool {
name: "fs_write_file".to_string(),
description: "Write content to a file".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the file"},
"content": {"type": "string", "description": "Content to write"}
},
"required": ["path", "content"]
}),
},
);
registry.insert(
"fs_list_dir".to_string(),
AgentTool {
name: "fs_list_dir".to_string(),
description: "List files in a directory".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path to the directory"}
},
"required": ["path"]
}),
},
);
// Tab management tools
registry.insert(
"tab_list".to_string(),
AgentTool {
name: "tab_list".to_string(),
description: "List all open tabs".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
);
registry.insert(
"new_tab".to_string(),
AgentTool {
name: "new_tab".to_string(),
description: "Open a new browser tab".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL to open (optional)"}
},
"required": []
}),
},
);
registry.insert(
"tab_count".to_string(),
AgentTool {
name: "tab_count".to_string(),
description: "Get the number of open tabs".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
);
registry.insert(
"toggle_inspector".to_string(),
AgentTool {
name: "toggle_inspector".to_string(),
description: "Toggle the WebKit web inspector on the active tab".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
);
registry.insert(
"eval_js".to_string(),
AgentTool {
name: "eval_js".to_string(),
description: "Execute JavaScript in the active tab's page. Console.log output appears in browser stderr.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"js": {"type": "string", "description": "JavaScript code to execute"}
},
"required": ["js"]
}),
},
);
// Shell tool
registry.insert(
"shell_exec".to_string(),
AgentTool {
name: "shell_exec".to_string(),
description: "Execute a shell command".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"command": {"type": "string", "description": "Command to execute"}
},
"required": ["command"]
}),
},
);
}
/// Get all registered tools.
pub fn agent_tools_get_all() -> Vec<AgentTool> {
let registry = G_TOOL_REGISTRY.lock().unwrap();
registry.values().cloned().collect()
}
/// Get a tool by name.
pub fn agent_tools_get(name: &str) -> Option<AgentTool> {
let registry = G_TOOL_REGISTRY.lock().unwrap();
registry.get(name).cloned()
}
/// Execute a tool by name with the given arguments.
pub fn agent_tools_execute(name: &str, args: &serde_json::Value) -> Result<serde_json::Value, String> {
match name {
"tab_list" => {
let tabs = crate::tab_manager::tab_manager_get_tabs();
Ok(serde_json::json!({
"status": "ok",
"tabs": tabs.iter().map(|t| serde_json::json!({
"id": t.id,
"title": t.title,
"url": t.url,
})).collect::<Vec<_>>()
}))
}
"tab_count" => {
let count = crate::tab_manager::tab_manager_get_count();
Ok(serde_json::json!({
"status": "ok",
"count": count
}))
}
"new_tab" => {
let url = args["url"].as_str();
// Schedule tab creation on the GTK main thread.
// `idle_add_once` requires `Send` (callable from any thread, unlike
// `idle_add_local` which panics off-main). Our closure only captures
// `Option<String>` (Send); gtk widgets are created on the main thread
// when the closure actually runs.
let url_owned = url.map(|s| s.to_string());
glib::idle_add_once(move || {
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() {
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
if let Some(ctx) = wv.web_context() {
crate::tab_manager::tab_manager_new_tab(&nb, &ctx, url_owned.as_deref());
}
}
}
});
Ok(serde_json::json!({
"status": "ok",
"message": "New tab scheduled"
}))
}
"toggle_inspector" => {
// Schedule the inspector toggle on the GTK main thread.
glib::idle_add_once(|| {
crate::tab_manager::tab_manager_toggle_inspector();
});
Ok(serde_json::json!({
"status": "ok",
"message": "Inspector toggle scheduled"
}))
}
"browser_navigate" => {
let url = args["url"].as_str().ok_or("Missing url argument")?.to_string();
let url_for_msg = url.clone();
// Schedule navigation on the GTK main thread.
glib::idle_add_once(move || {
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() {
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
let normalized = crate::search::normalize_url(&url);
wv.load_uri(&normalized);
}
}
});
Ok(serde_json::json!({
"status": "ok",
"message": format!("Navigating to {}", url_for_msg)
}))
}
"eval_js" => {
let js = args["js"].as_str().ok_or("Missing js argument")?.to_string();
// We can't easily get the return value of async JS from webkit2gtk
// in a synchronous MCP response. Instead, we run the JS and return
// a status. The JS can write results to console.log which appears
// in the browser's stderr.
glib::idle_add_once(move || {
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() {
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
// run_javascript is deprecated in webkit2gtk 2.0.2 but
// there is no replacement in the Rust bindings. Suppress.
#[allow(deprecated)]
wv.run_javascript(&js, None::<&gio::Cancellable>, |_| {});
}
}
});
Ok(serde_json::json!({
"status": "ok",
"message": "JS execution scheduled (check browser stderr for console.log output)"
}))
}
"browser_click" => {
let selector = args["selector"].as_str().ok_or("Missing selector argument")?.to_string();
// Build JS to click the element matching the selector.
let js = format!(
r#"(function() {{
var el = document.querySelector({:?});
if (el) {{ el.click(); return "clicked"; }}
return "not found";
}})()"#,
selector
);
let (tx, rx) = std::sync::mpsc::channel::<String>();
glib::idle_add_once(move || {
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() {
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
#[allow(deprecated)]
wv.run_javascript(&js, None::<&gio::Cancellable>, move |result| {
let msg = match result {
Ok(_) => "clicked",
Err(e) => &format!("error: {}", e),
};
let _ = tx.send(msg.to_string());
});
} else { let _ = tx.send("no webview".to_string()); }
} else { let _ = tx.send("no notebook".to_string()); }
});
let result = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap_or_else(|_| "timeout".to_string());
Ok(serde_json::json!({"status": "ok", "result": result}))
}
"browser_type" => {
let selector = args["selector"].as_str().ok_or("Missing selector argument")?.to_string();
let text = args["text"].as_str().ok_or("Missing text argument")?.to_string();
// Build JS to set the value of the element matching the selector.
let js = format!(
r#"(function() {{
var el = document.querySelector({:?});
if (el) {{
el.value = {};
el.dispatchEvent(new Event('input', {{bubbles: true}}));
el.dispatchEvent(new Event('change', {{bubbles: true}}));
return "typed";
}}
return "not found";
}})()"#,
selector,
serde_json::to_string(&text).unwrap_or_default()
);
let (tx, rx) = std::sync::mpsc::channel::<String>();
glib::idle_add_once(move || {
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() {
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
#[allow(deprecated)]
wv.run_javascript(&js, None::<&gio::Cancellable>, move |result| {
let msg = match result {
Ok(_) => "typed",
Err(e) => &format!("error: {}", e),
};
let _ = tx.send(msg.to_string());
});
} else { let _ = tx.send("no webview".to_string()); }
} else { let _ = tx.send("no notebook".to_string()); }
});
let result = rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap_or_else(|_| "timeout".to_string());
Ok(serde_json::json!({"status": "ok", "result": result}))
}
"browser_screenshot" => {
// Capture a snapshot of the active webview.
// The cairo::Surface from webkit2gtk's snapshot() is captured
// but cairo-rs 0.18 doesn't expose write_to_png. We capture the
// surface dimensions and return a status. A future improvement
// would convert the surface to PNG via the cairo C FFI.
let (tx, rx) = std::sync::mpsc::channel::<Result<String, String>>();
glib::idle_add_once(move || {
if let Some(nb) = crate::tab_manager::tab_manager_get_main_notebook() {
if let Some(wv) = crate::tab_manager::tab_manager_get_active_webview(&nb) {
wv.snapshot(
webkit2gtk::SnapshotRegion::Visible,
webkit2gtk::SnapshotOptions::NONE,
None::<&gio::Cancellable>,
move |result: Result<cairo::Surface, glib::Error>| {
match result {
Ok(_surface) => {
let _ = tx.send(Ok("snapshot captured".to_string()));
}
Err(e) => { let _ = tx.send(Err(e.to_string())); }
}
},
);
} else { let _ = tx.send(Err("no webview".to_string())); }
} else { let _ = tx.send(Err("no notebook".to_string())); }
});
match rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(Ok(msg)) => Ok(serde_json::json!({"status": "ok", "message": msg})),
Ok(Err(e)) => Err(format!("Screenshot failed: {}", e)),
Err(_) => Err("Screenshot timed out".to_string()),
}
}
"fs_read_file" => {
let path = args["path"].as_str().ok_or("Missing path argument")?;
match std::fs::read_to_string(path) {
Ok(content) => Ok(serde_json::json!({"status": "ok", "content": content})),
Err(e) => Err(format!("Failed to read file: {}", e)),
}
}
"fs_write_file" => {
let path = args["path"].as_str().ok_or("Missing path argument")?;
let content = args["content"].as_str().ok_or("Missing content argument")?;
match std::fs::write(path, content) {
Ok(_) => Ok(serde_json::json!({"status": "ok", "message": "File written"})),
Err(e) => Err(format!("Failed to write file: {}", e)),
}
}
"fs_list_dir" => {
let path = args["path"].as_str().ok_or("Missing path argument")?;
match std::fs::read_dir(path) {
Ok(entries) => {
let files: Vec<String> = entries
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
Ok(serde_json::json!({"status": "ok", "files": files}))
}
Err(e) => Err(format!("Failed to list directory: {}", e)),
}
}
"shell_exec" => {
let command = args["command"].as_str().ok_or("Missing command argument")?;
match std::process::Command::new("sh")
.arg("-c")
.arg(command)
.output()
{
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ok(serde_json::json!({
"status": if output.status.success() { "ok" } else { "error" },
"stdout": stdout,
"stderr": stderr,
"exit_code": output.status.code().unwrap_or(-1),
}))
}
Err(e) => Err(format!("Failed to execute command: {}", e)),
}
}
_ => Err(format!("Unknown tool: {}", name)),
}
}