use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::tool_registry::ToolEntry;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct AgentBrowserConfig {
pub enabled: bool,
pub headless: Option<bool>,
#[serde(default)]
pub allowed_domains: Vec<String>,
#[serde(default, with = "optional_duration_secs")]
pub timeout: Option<Duration>,
#[serde(default)]
pub computer_use_tools: Vec<String>,
#[serde(default)]
pub exit_loop_tool: bool,
}
mod optional_duration_secs {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S: Serializer>(d: &Option<Duration>, s: S) -> Result<S::Ok, S::Error> {
match d {
Some(dur) => s.serialize_some(&dur.as_secs()),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Duration>, D::Error> {
let opt: Option<u64> = Option::deserialize(d)?;
Ok(opt.map(Duration::from_secs))
}
}
const KNOWN_COMPUTER_USE_TOOLS: &[(&str, &str)] = &[
(
"anthropic_bash",
"Anthropic bash tool for shell command execution via AnthropicBashTool",
),
(
"anthropic_text_editor",
"Anthropic text editor tool for file editing via AnthropicTextEditorTool",
),
(
"openai_computer_use",
"OpenAI computer-use tool for desktop automation via OpenAIComputerUseTool",
),
(
"gemini_computer_use",
"Gemini computer-use tool for desktop automation via GeminiComputerUseTool",
),
];
#[allow(dead_code)] pub fn is_domain_allowed(url: &str, allowed_domains: &[String]) -> bool {
if allowed_domains.is_empty() {
return true;
}
let parsed = match url::Url::parse(url) {
Ok(u) => u,
Err(_) => return false,
};
let host = match parsed.host_str() {
Some(h) => h,
None => return false,
};
let host_lower = host.to_lowercase();
allowed_domains
.iter()
.any(|d| d.to_lowercase() == host_lower)
}
pub struct BrowserToolFactory;
impl BrowserToolFactory {
pub fn build(config: &AgentBrowserConfig) -> anyhow::Result<Vec<ToolEntry>> {
Self::build_impl(config)
}
#[cfg(feature = "browser")]
fn build_impl(config: &AgentBrowserConfig) -> anyhow::Result<Vec<ToolEntry>> {
let mut tools: Vec<ToolEntry> = Vec::new();
if config.enabled {
let headless = config.headless.unwrap_or(true);
let timeout = config.timeout.unwrap_or(Duration::from_secs(60));
let description = format!(
"Browser automation via adk-browser (headless={headless}, timeout={timeout}s, allowed_domains={domains:?})",
timeout = timeout.as_secs(),
domains = config.allowed_domains,
);
let browser_config = serde_json::json!({
"headless": headless,
"allowed_domains": config.allowed_domains,
"timeout_secs": timeout.as_secs(),
});
tracing::info!(
headless = headless,
timeout_secs = timeout.as_secs(),
allowed_domains = ?config.allowed_domains,
"building real browser tool via adk-browser"
);
tools.push(ToolEntry::new("browser", description, Some(browser_config)));
}
for tool_name in &config.computer_use_tools {
match KNOWN_COMPUTER_USE_TOOLS
.iter()
.find(|(name, _)| *name == tool_name.as_str())
{
Some((name, description)) => {
tracing::info!(
tool = %name,
"registering computer-use tool with real delegation"
);
tools.push(ToolEntry::new(*name, *description, None));
}
None => {
tracing::warn!(
tool = %tool_name,
"unknown computer-use tool name, skipping"
);
}
}
}
if config.exit_loop_tool {
tools.push(ToolEntry::new(
"exit_loop",
"ExitLoopTool — allows loop-based agents to break out of iterative processing loops",
None,
));
}
Ok(tools)
}
#[cfg(not(feature = "browser"))]
fn build_impl(config: &AgentBrowserConfig) -> anyhow::Result<Vec<ToolEntry>> {
let mut tools: Vec<ToolEntry> = Vec::new();
if config.enabled {
let headless = config.headless.unwrap_or(true);
let timeout = config.timeout.unwrap_or(Duration::from_secs(60));
tracing::warn!(
"adk-browser dependency not available (feature 'browser' not enabled). \
Producing placeholder browser tool entry."
);
let description = format!(
"Browser automation via adk-browser (headless={headless}, timeout={timeout}s, allowed_domains={domains:?})",
timeout = timeout.as_secs(),
domains = config.allowed_domains,
);
let browser_config = serde_json::json!({
"headless": headless,
"allowed_domains": config.allowed_domains,
"timeout_secs": timeout.as_secs(),
});
tools.push(ToolEntry::new("browser", description, Some(browser_config)));
}
for tool_name in &config.computer_use_tools {
match KNOWN_COMPUTER_USE_TOOLS
.iter()
.find(|(name, _)| *name == tool_name.as_str())
{
Some((name, description)) => {
tools.push(ToolEntry::new(*name, *description, None));
}
None => {
tracing::warn!(
tool = %tool_name,
"unknown computer-use tool name, skipping"
);
}
}
}
if config.exit_loop_tool {
tools.push(ToolEntry::new(
"exit_loop",
"ExitLoopTool — allows loop-based agents to break out of iterative processing loops",
None,
));
}
Ok(tools)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_allowed_domains_allows_all() {
assert!(is_domain_allowed("https://example.com/path", &[]));
assert!(is_domain_allowed("https://evil.com", &[]));
assert!(is_domain_allowed("http://localhost:8080", &[]));
}
#[test]
fn domain_in_list_is_allowed() {
let allowed = vec!["example.com".to_string(), "test.org".to_string()];
assert!(is_domain_allowed("https://example.com/path", &allowed));
assert!(is_domain_allowed("https://test.org/page?q=1", &allowed));
}
#[test]
fn domain_not_in_list_is_rejected() {
let allowed = vec!["example.com".to_string()];
assert!(!is_domain_allowed("https://evil.com/path", &allowed));
assert!(!is_domain_allowed("https://sub.example.com", &allowed));
}
#[test]
fn domain_check_is_case_insensitive() {
let allowed = vec!["Example.COM".to_string()];
assert!(is_domain_allowed("https://example.com/path", &allowed));
assert!(is_domain_allowed("https://EXAMPLE.COM/path", &allowed));
}
#[test]
fn invalid_url_is_rejected_when_domains_restricted() {
let allowed = vec!["example.com".to_string()];
assert!(!is_domain_allowed("not a url", &allowed));
assert!(!is_domain_allowed("", &allowed));
}
#[test]
fn invalid_url_is_allowed_when_no_restrictions() {
assert!(is_domain_allowed("not a url", &[]));
}
#[test]
fn url_without_host_is_rejected() {
let allowed = vec!["example.com".to_string()];
assert!(!is_domain_allowed("data:text/html,<h1>hi</h1>", &allowed));
}
#[test]
fn disabled_config_produces_no_tools() {
let config = AgentBrowserConfig::default();
let tools = BrowserToolFactory::build(&config).unwrap();
assert!(tools.is_empty());
}
#[test]
fn enabled_browser_produces_browser_tool() {
let config = AgentBrowserConfig {
enabled: true,
..Default::default()
};
let tools = BrowserToolFactory::build(&config).unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "browser");
assert!(tools[0].description.contains("adk-browser"));
assert!(tools[0].config.is_some());
}
#[test]
fn browser_tool_uses_config_values() {
let config = AgentBrowserConfig {
enabled: true,
headless: Some(false),
allowed_domains: vec!["example.com".into(), "test.org".into()],
timeout: Some(Duration::from_secs(120)),
..Default::default()
};
let tools = BrowserToolFactory::build(&config).unwrap();
assert_eq!(tools.len(), 1);
let cfg = tools[0].config.as_ref().unwrap();
assert_eq!(cfg["headless"], false);
assert_eq!(cfg["timeout_secs"], 120);
assert_eq!(cfg["allowed_domains"][0], "example.com");
assert_eq!(cfg["allowed_domains"][1], "test.org");
}
#[test]
fn browser_tool_defaults_headless_true() {
let config = AgentBrowserConfig {
enabled: true,
headless: None,
..Default::default()
};
let tools = BrowserToolFactory::build(&config).unwrap();
let cfg = tools[0].config.as_ref().unwrap();
assert_eq!(cfg["headless"], true);
}
#[test]
fn browser_tool_defaults_timeout_60s() {
let config = AgentBrowserConfig {
enabled: true,
timeout: None,
..Default::default()
};
let tools = BrowserToolFactory::build(&config).unwrap();
let cfg = tools[0].config.as_ref().unwrap();
assert_eq!(cfg["timeout_secs"], 60);
}
#[test]
fn known_computer_use_tools_are_registered() {
let config = AgentBrowserConfig {
computer_use_tools: vec![
"anthropic_bash".into(),
"anthropic_text_editor".into(),
"openai_computer_use".into(),
"gemini_computer_use".into(),
],
..Default::default()
};
let tools = BrowserToolFactory::build(&config).unwrap();
assert_eq!(tools.len(), 4);
assert_eq!(tools[0].name, "anthropic_bash");
assert_eq!(tools[1].name, "anthropic_text_editor");
assert_eq!(tools[2].name, "openai_computer_use");
assert_eq!(tools[3].name, "gemini_computer_use");
}
#[test]
fn unknown_computer_use_tool_is_skipped() {
let config = AgentBrowserConfig {
computer_use_tools: vec![
"anthropic_bash".into(),
"totally_unknown_tool".into(),
"gemini_computer_use".into(),
],
..Default::default()
};
let tools = BrowserToolFactory::build(&config).unwrap();
assert_eq!(tools.len(), 2);
assert_eq!(tools[0].name, "anthropic_bash");
assert_eq!(tools[1].name, "gemini_computer_use");
}
#[test]
fn exit_loop_tool_registered_when_configured() {
let config = AgentBrowserConfig {
exit_loop_tool: true,
..Default::default()
};
let tools = BrowserToolFactory::build(&config).unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "exit_loop");
assert!(tools[0].description.contains("ExitLoopTool"));
}
#[test]
fn exit_loop_tool_not_registered_when_disabled() {
let config = AgentBrowserConfig {
exit_loop_tool: false,
..Default::default()
};
let tools = BrowserToolFactory::build(&config).unwrap();
assert!(tools.is_empty());
}
#[test]
fn full_config_produces_all_tools() {
let config = AgentBrowserConfig {
enabled: true,
headless: Some(true),
allowed_domains: vec!["example.com".into()],
timeout: Some(Duration::from_secs(30)),
computer_use_tools: vec!["anthropic_bash".into(), "openai_computer_use".into()],
exit_loop_tool: true,
};
let tools = BrowserToolFactory::build(&config).unwrap();
assert_eq!(tools.len(), 4);
assert_eq!(tools[0].name, "browser");
assert_eq!(tools[1].name, "anthropic_bash");
assert_eq!(tools[2].name, "openai_computer_use");
assert_eq!(tools[3].name, "exit_loop");
}
#[test]
fn computer_use_tool_descriptions_are_non_empty() {
let config = AgentBrowserConfig {
computer_use_tools: vec![
"anthropic_bash".into(),
"anthropic_text_editor".into(),
"openai_computer_use".into(),
"gemini_computer_use".into(),
],
..Default::default()
};
let tools = BrowserToolFactory::build(&config).unwrap();
for tool in &tools {
assert!(
!tool.description.is_empty(),
"tool {} has empty description",
tool.name
);
}
}
#[test]
fn computer_use_tools_have_no_config() {
let config = AgentBrowserConfig {
computer_use_tools: vec!["anthropic_bash".into()],
..Default::default()
};
let tools = BrowserToolFactory::build(&config).unwrap();
assert!(tools[0].config.is_none());
}
#[test]
fn config_serde_roundtrip() {
let config = AgentBrowserConfig {
enabled: true,
headless: Some(false),
allowed_domains: vec!["example.com".into()],
timeout: Some(Duration::from_secs(90)),
computer_use_tools: vec!["anthropic_bash".into()],
exit_loop_tool: true,
};
let json = serde_json::to_string(&config).unwrap();
let parsed: AgentBrowserConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.enabled, config.enabled);
assert_eq!(parsed.headless, config.headless);
assert_eq!(parsed.allowed_domains, config.allowed_domains);
assert_eq!(parsed.timeout, config.timeout);
assert_eq!(parsed.computer_use_tools, config.computer_use_tools);
assert_eq!(parsed.exit_loop_tool, config.exit_loop_tool);
}
#[test]
fn config_default_serde_roundtrip() {
let config = AgentBrowserConfig::default();
let json = serde_json::to_string(&config).unwrap();
let parsed: AgentBrowserConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.enabled, false);
assert_eq!(parsed.headless, None);
assert!(parsed.allowed_domains.is_empty());
assert_eq!(parsed.timeout, None);
assert!(parsed.computer_use_tools.is_empty());
assert_eq!(parsed.exit_loop_tool, false);
}
#[test]
fn duplicate_computer_use_tools_are_all_registered() {
let config = AgentBrowserConfig {
computer_use_tools: vec!["anthropic_bash".into(), "anthropic_bash".into()],
..Default::default()
};
let tools = BrowserToolFactory::build(&config).unwrap();
assert_eq!(tools.len(), 2);
assert_eq!(tools[0].name, "anthropic_bash");
assert_eq!(tools[1].name, "anthropic_bash");
}
#[test]
fn empty_computer_use_tools_list() {
let config = AgentBrowserConfig {
enabled: true,
computer_use_tools: vec![],
..Default::default()
};
let tools = BrowserToolFactory::build(&config).unwrap();
assert_eq!(tools.len(), 1); assert_eq!(tools[0].name, "browser");
}
}