471 lines
19 KiB
Rust
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)),
|
|
}
|
|
}
|