Files
openfang/crates/openfang-runtime/src/tool_runner.rs
T
77ed954d18 wecom channel adapter
* feat: Add WeCom (WeChat Work) channel adapter

- Add wecom.rs channel adapter implementation
- Add WeComConfig in config.rs
- Register WeCom adapter in channel_bridge.rs

WeCom channel supports:
- Inbound messages via callback webhook
- Outbound messages via WeCom API
- Access token caching and auto-refresh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: handle WeCom callbacks and preserve hand extension tools

* fix: render WeCom replies as plain text

* fix: resolve clippy warnings in wecom adapter

- Remove unused WECOM_API_HOST constant
- Fix needless borrow in send_text call
- Replace assert_eq!(bool, true) with assert!()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: cargo fmt for wecom-related files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: cargo fmt --all

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: upgrade quinn-proto and add cargo audit ignore list

- Upgrade quinn-proto 0.11.13 → 0.11.14 (RUSTSEC-2026-0037 DoS fix)
- Add .cargo/audit.toml to ignore unmaintainable transitive deps
  (tauri GTK3 bindings, time pinned by mac-notification-sys, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:50:43 +03:00

3989 lines
152 KiB
Rust

//! Built-in tool execution.
//!
//! Provides filesystem, web, shell, and inter-agent tools. Agent tools
//! (agent_send, agent_spawn, etc.) require a KernelHandle to be passed in.
use crate::kernel_handle::KernelHandle;
use crate::mcp;
use crate::web_search::{parse_ddg_results, WebToolsContext};
use openfang_skills::registry::SkillRegistry;
use openfang_types::taint::{TaintLabel, TaintSink, TaintedValue};
use openfang_types::tool::{ToolDefinition, ToolResult};
use openfang_types::tool_compat::normalize_tool_name;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tracing::{debug, warn};
/// Maximum inter-agent call depth to prevent infinite recursion (A->B->C->...).
const MAX_AGENT_CALL_DEPTH: u32 = 5;
/// Check if a shell command should be blocked by taint tracking.
///
/// Layer 1: Shell metacharacter injection (backticks, `$(`, `${`, etc.)
/// Layer 2: Heuristic patterns for injected external data (piped curl, base64, eval)
///
/// This implements the TaintSink::shell_exec() policy from SOTA 2.
fn check_taint_shell_exec(command: &str) -> Option<String> {
// Layer 1: Block shell metacharacters that enable command injection.
// Uses the same validator as subprocess_sandbox and docker_sandbox.
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command) {
return Some(format!("Shell metacharacter injection blocked: {reason}"));
}
// Layer 2: Heuristic patterns for injected external URLs / base64 payloads
let suspicious_patterns = ["curl ", "wget ", "| sh", "| bash", "base64 -d", "eval "];
for pattern in &suspicious_patterns {
if command.contains(pattern) {
let mut labels = HashSet::new();
labels.insert(TaintLabel::ExternalNetwork);
let tainted = TaintedValue::new(command, labels, "llm_tool_call");
if let Err(violation) = tainted.check_sink(&TaintSink::shell_exec()) {
warn!(command = crate::str_utils::safe_truncate_str(command, 80), %violation, "Shell taint check failed");
return Some(violation.to_string());
}
}
}
None
}
/// Check if a URL should be blocked by taint tracking before network fetch.
///
/// Blocks URLs that appear to contain API keys, tokens, or other secrets
/// in query parameters (potential data exfiltration). Implements TaintSink::net_fetch().
fn check_taint_net_fetch(url: &str) -> Option<String> {
let exfil_patterns = [
"api_key=",
"apikey=",
"token=",
"secret=",
"password=",
"Authorization:",
];
for pattern in &exfil_patterns {
if url.to_lowercase().contains(&pattern.to_lowercase()) {
let mut labels = HashSet::new();
labels.insert(TaintLabel::Secret);
let tainted = TaintedValue::new(url, labels, "llm_tool_call");
if let Err(violation) = tainted.check_sink(&TaintSink::net_fetch()) {
warn!(url = crate::str_utils::safe_truncate_str(url, 80), %violation, "Net fetch taint check failed");
return Some(violation.to_string());
}
}
}
None
}
tokio::task_local! {
/// Tracks the current inter-agent call depth within a task.
static AGENT_CALL_DEPTH: std::cell::Cell<u32>;
/// Canvas max HTML size in bytes (set from kernel config at loop start).
pub static CANVAS_MAX_BYTES: usize;
}
/// Get the current inter-agent call depth from the task-local context.
/// Returns 0 if called outside an agent task.
pub fn current_agent_depth() -> u32 {
AGENT_CALL_DEPTH.try_with(|d| d.get()).unwrap_or(0)
}
/// Execute a tool by name with the given input, returning a ToolResult.
///
/// The optional `kernel` handle enables inter-agent tools. If `None`,
/// agent tools will return an error indicating the kernel is not available.
///
/// `allowed_tools` enforces capability-based security: if provided, only
/// tools in the list may execute. This prevents an LLM from hallucinating
/// tool names outside the agent's capability grants.
#[allow(clippy::too_many_arguments)]
pub async fn execute_tool(
tool_use_id: &str,
tool_name: &str,
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
allowed_tools: Option<&[String]>,
caller_agent_id: Option<&str>,
skill_registry: Option<&SkillRegistry>,
mcp_connections: Option<&tokio::sync::Mutex<Vec<mcp::McpConnection>>>,
web_ctx: Option<&WebToolsContext>,
browser_ctx: Option<&crate::browser::BrowserManager>,
allowed_env_vars: Option<&[String]>,
workspace_root: Option<&Path>,
media_engine: Option<&crate::media_understanding::MediaEngine>,
exec_policy: Option<&openfang_types::config::ExecPolicy>,
tts_engine: Option<&crate::tts::TtsEngine>,
docker_config: Option<&openfang_types::config::DockerSandboxConfig>,
process_manager: Option<&crate::process_manager::ProcessManager>,
) -> ToolResult {
// Normalize the tool name through compat mappings so LLM-hallucinated aliases
// (e.g. "fs-write" → "file_write") resolve to the canonical OpenFang name.
let tool_name = normalize_tool_name(tool_name);
// Capability enforcement: reject tools not in the allowed list
if let Some(allowed) = allowed_tools {
if !allowed.iter().any(|t| t == tool_name) {
warn!(tool_name, "Capability denied: tool not in allowed list");
return ToolResult {
tool_use_id: tool_use_id.to_string(),
content: format!(
"Permission denied: agent does not have capability to use tool '{tool_name}'"
),
is_error: true,
};
}
}
// Approval gate: check if this tool requires human approval before execution
if let Some(kh) = kernel {
if kh.requires_approval(tool_name) {
let agent_id_str = caller_agent_id.unwrap_or("unknown");
let input_str = input.to_string();
let summary = format!(
"{}: {}",
tool_name,
openfang_types::truncate_str(&input_str, 200)
);
match kh.request_approval(agent_id_str, tool_name, &summary).await {
Ok(true) => {
debug!(tool_name, "Approval granted — proceeding with execution");
}
Ok(false) => {
warn!(tool_name, "Approval denied — blocking tool execution");
return ToolResult {
tool_use_id: tool_use_id.to_string(),
content: format!(
"Execution denied: '{}' requires human approval and was denied or timed out. The operation was not performed.",
tool_name
),
is_error: true,
};
}
Err(e) => {
warn!(tool_name, error = %e, "Approval system error");
return ToolResult {
tool_use_id: tool_use_id.to_string(),
content: format!("Approval system error: {e}"),
is_error: true,
};
}
}
}
}
debug!(tool_name, "Executing tool");
let result = match tool_name {
// Filesystem tools
"file_read" => tool_file_read(input, workspace_root).await,
"file_write" => tool_file_write(input, workspace_root).await,
"file_list" => tool_file_list(input, workspace_root).await,
"apply_patch" => tool_apply_patch(input, workspace_root).await,
// Web tools (upgraded: multi-provider search, SSRF-protected fetch)
"web_fetch" => {
// Taint check: block URLs containing secrets/PII from being exfiltrated
let url = input["url"].as_str().unwrap_or("");
if let Some(violation) = check_taint_net_fetch(url) {
return ToolResult {
tool_use_id: tool_use_id.to_string(),
content: format!("Taint violation: {violation}"),
is_error: true,
};
}
let method = input["method"].as_str().unwrap_or("GET");
let headers = input.get("headers").and_then(|v| v.as_object());
let body = input["body"].as_str();
if let Some(ctx) = web_ctx {
ctx.fetch
.fetch_with_options(url, method, headers, body)
.await
} else {
tool_web_fetch_legacy(input).await
}
}
"web_search" => {
if let Some(ctx) = web_ctx {
let query = input["query"].as_str().unwrap_or("");
let max_results = input["max_results"].as_u64().unwrap_or(5) as usize;
ctx.search.search(query, max_results).await
} else {
tool_web_search_legacy(input).await
}
}
// Shell tool — metacharacter check + exec policy + taint check
"shell_exec" => {
let command = input["command"].as_str().unwrap_or("");
// SECURITY: Always check for shell metacharacters, even in Full mode.
// These enable command injection regardless of exec policy.
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command)
{
return ToolResult {
tool_use_id: tool_use_id.to_string(),
content: format!(
"shell_exec blocked: command contains {reason}. \
Shell metacharacters are never allowed."
),
is_error: true,
};
}
// Exec policy enforcement (allowlist / deny / full)
if let Some(policy) = exec_policy {
if let Err(reason) =
crate::subprocess_sandbox::validate_command_allowlist(command, policy)
{
return ToolResult {
tool_use_id: tool_use_id.to_string(),
content: format!(
"shell_exec blocked: {reason}. Current exec_policy.mode = '{:?}'. \
To allow shell commands, set exec_policy.mode = 'full' in the agent manifest or config.toml.",
policy.mode
),
is_error: true,
};
}
}
// Skip heuristic taint patterns for Full exec policy (e.g. hand agents that need curl)
let is_full_exec = exec_policy
.is_some_and(|p| p.mode == openfang_types::config::ExecSecurityMode::Full);
if !is_full_exec {
if let Some(violation) = check_taint_shell_exec(command) {
return ToolResult {
tool_use_id: tool_use_id.to_string(),
content: format!("Taint violation: {violation}"),
is_error: true,
};
}
}
tool_shell_exec(
input,
allowed_env_vars.unwrap_or(&[]),
workspace_root,
exec_policy,
)
.await
}
// Inter-agent tools (require kernel handle)
"agent_send" => tool_agent_send(input, kernel).await,
"agent_spawn" => tool_agent_spawn(input, kernel, caller_agent_id).await,
"agent_list" => tool_agent_list(kernel),
"agent_kill" => tool_agent_kill(input, kernel),
// Shared memory tools
"memory_store" => tool_memory_store(input, kernel),
"memory_recall" => tool_memory_recall(input, kernel),
// Collaboration tools
"agent_find" => tool_agent_find(input, kernel),
"task_post" => tool_task_post(input, kernel, caller_agent_id).await,
"task_claim" => tool_task_claim(kernel, caller_agent_id).await,
"task_complete" => tool_task_complete(input, kernel).await,
"task_list" => tool_task_list(input, kernel).await,
"event_publish" => tool_event_publish(input, kernel).await,
// Scheduling tools
"schedule_create" => tool_schedule_create(input, kernel).await,
"schedule_list" => tool_schedule_list(kernel).await,
"schedule_delete" => tool_schedule_delete(input, kernel).await,
// Knowledge graph tools
"knowledge_add_entity" => tool_knowledge_add_entity(input, kernel).await,
"knowledge_add_relation" => tool_knowledge_add_relation(input, kernel).await,
"knowledge_query" => tool_knowledge_query(input, kernel).await,
// Image analysis tool
"image_analyze" => tool_image_analyze(input).await,
// Media understanding tools
"media_describe" => tool_media_describe(input, media_engine).await,
"media_transcribe" => tool_media_transcribe(input, media_engine).await,
// Image generation tool
"image_generate" => tool_image_generate(input, workspace_root).await,
// TTS/STT tools
"text_to_speech" => tool_text_to_speech(input, tts_engine, workspace_root).await,
"speech_to_text" => tool_speech_to_text(input, media_engine, workspace_root).await,
// Docker sandbox tool
"docker_exec" => {
tool_docker_exec(input, docker_config, workspace_root, caller_agent_id).await
}
// Location tool
"location_get" => tool_location_get().await,
// System time tool
"system_time" => Ok(tool_system_time()),
// Cron scheduling tools
"cron_create" => tool_cron_create(input, kernel, caller_agent_id).await,
"cron_list" => tool_cron_list(kernel, caller_agent_id).await,
"cron_cancel" => tool_cron_cancel(input, kernel).await,
// Channel send tool (proactive outbound messaging)
"channel_send" => tool_channel_send(input, kernel, workspace_root).await,
// Persistent process tools
"process_start" => tool_process_start(input, process_manager, caller_agent_id).await,
"process_poll" => tool_process_poll(input, process_manager).await,
"process_write" => tool_process_write(input, process_manager).await,
"process_kill" => tool_process_kill(input, process_manager).await,
"process_list" => tool_process_list(process_manager, caller_agent_id).await,
// Hand tools (curated autonomous capability packages)
"hand_list" => tool_hand_list(kernel).await,
"hand_activate" => tool_hand_activate(input, kernel).await,
"hand_status" => tool_hand_status(input, kernel).await,
"hand_deactivate" => tool_hand_deactivate(input, kernel).await,
// A2A outbound tools (cross-instance agent communication)
"a2a_discover" => tool_a2a_discover(input).await,
"a2a_send" => tool_a2a_send(input, kernel).await,
// Browser automation tools
"browser_navigate" => {
let url = input["url"].as_str().unwrap_or("");
if let Some(violation) = check_taint_net_fetch(url) {
return ToolResult {
tool_use_id: tool_use_id.to_string(),
content: format!("Taint violation: {violation}"),
is_error: true,
};
}
match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_navigate(input, mgr, aid).await
}
None => Err(
"Browser tools not available. Ensure Chrome/Chromium is installed.".to_string(),
),
}
}
"browser_click" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_click(input, mgr, aid).await
}
None => {
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
}
},
"browser_type" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_type(input, mgr, aid).await
}
None => {
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
}
},
"browser_screenshot" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_screenshot(input, mgr, aid).await
}
None => {
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
}
},
"browser_read_page" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_read_page(input, mgr, aid).await
}
None => {
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
}
},
"browser_close" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_close(input, mgr, aid).await
}
None => {
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
}
},
"browser_scroll" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_scroll(input, mgr, aid).await
}
None => {
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
}
},
"browser_wait" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_wait(input, mgr, aid).await
}
None => {
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
}
},
"browser_run_js" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_run_js(input, mgr, aid).await
}
None => {
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
}
},
"browser_back" => match browser_ctx {
Some(mgr) => {
let aid = caller_agent_id.unwrap_or("default");
crate::browser::tool_browser_back(input, mgr, aid).await
}
None => {
Err("Browser tools not available. Ensure Chrome/Chromium is installed.".to_string())
}
},
// Canvas / A2UI tool
"canvas_present" => tool_canvas_present(input, workspace_root).await,
other => {
// Fallback 1: MCP tools (mcp_{server}_{tool} prefix)
if mcp::is_mcp_tool(other) {
if let Some(mcp_conns) = mcp_connections {
let mut conns = mcp_conns.lock().await;
let known_names: Vec<String> =
conns.iter().map(|c| c.name().to_string()).collect();
let known_refs: Vec<&str> = known_names.iter().map(|s| s.as_str()).collect();
if let Some(server_name) =
mcp::extract_mcp_server_from_known(other, &known_refs)
{
if let Some(conn) = conns.iter_mut().find(|c| c.name() == server_name) {
debug!(
tool = other,
server = server_name,
"Dispatching to MCP server"
);
match conn.call_tool(other, input).await {
Ok(content) => Ok(content),
Err(e) => Err(format!("MCP tool call failed: {e}")),
}
} else {
Err(format!("MCP server '{server_name}' not connected"))
}
} else {
Err(format!("Invalid MCP tool name: {other}"))
}
} else {
Err(format!("MCP not available for tool: {other}"))
}
}
// Fallback 2: Skill registry tool providers
else if let Some(registry) = skill_registry {
if let Some(skill) = registry.find_tool_provider(other) {
debug!(tool = other, skill = %skill.manifest.skill.name, "Dispatching to skill");
match openfang_skills::loader::execute_skill_tool(
&skill.manifest,
&skill.path,
other,
input,
)
.await
{
Ok(skill_result) => {
let content = serde_json::to_string(&skill_result.output)
.unwrap_or_else(|_| skill_result.output.to_string());
if skill_result.is_error {
Err(content)
} else {
Ok(content)
}
}
Err(e) => Err(format!("Skill execution failed: {e}")),
}
} else {
Err(format!("Unknown tool: {other}"))
}
} else {
Err(format!("Unknown tool: {other}"))
}
}
};
match result {
Ok(content) => ToolResult {
tool_use_id: tool_use_id.to_string(),
content,
is_error: false,
},
Err(err) => ToolResult {
tool_use_id: tool_use_id.to_string(),
content: format!("Error: {err}"),
is_error: true,
},
}
}
/// Get definitions for all built-in tools.
pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
vec![
// --- Filesystem tools ---
ToolDefinition {
name: "file_read".to_string(),
description: "Read the contents of a file. Paths are relative to the agent workspace.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "The file path to read" }
},
"required": ["path"]
}),
},
ToolDefinition {
name: "file_write".to_string(),
description: "Write content to a file. Paths are relative to the agent workspace.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "The file path to write to" },
"content": { "type": "string", "description": "The content to write" }
},
"required": ["path", "content"]
}),
},
ToolDefinition {
name: "file_list".to_string(),
description: "List files in a directory. Paths are relative to the agent workspace.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "The directory path to list" }
},
"required": ["path"]
}),
},
ToolDefinition {
name: "apply_patch".to_string(),
description: "Apply a multi-hunk diff patch to add, update, move, or delete files. Use this for targeted edits instead of full file overwrites.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"patch": {
"type": "string",
"description": "The patch in *** Begin Patch / *** End Patch format. Use *** Add File:, *** Update File:, *** Delete File: markers. Hunks use @@ headers with space (context), - (remove), + (add) prefixed lines."
}
},
"required": ["patch"]
}),
},
// --- Web tools ---
ToolDefinition {
name: "web_fetch".to_string(),
description: "Fetch a URL with SSRF protection. Supports GET/POST/PUT/PATCH/DELETE. For GET, HTML is converted to Markdown. For other methods, returns raw response body.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"url": { "type": "string", "description": "The URL to fetch (http/https only)" },
"method": { "type": "string", "enum": ["GET","POST","PUT","PATCH","DELETE"], "description": "HTTP method (default: GET)" },
"headers": { "type": "object", "description": "Custom HTTP headers as key-value pairs" },
"body": { "type": "string", "description": "Request body for POST/PUT/PATCH" }
},
"required": ["url"]
}),
},
ToolDefinition {
name: "web_search".to_string(),
description: "Search the web using multiple providers (Tavily, Brave, Perplexity, DuckDuckGo) with automatic fallback. Returns structured results with titles, URLs, and snippets.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "The search query" },
"max_results": { "type": "integer", "description": "Maximum number of results to return (default: 5, max: 20)" }
},
"required": ["query"]
}),
},
// --- Shell tool ---
ToolDefinition {
name: "shell_exec".to_string(),
description: "Execute a shell command and return its output.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "The command to execute" },
"timeout_seconds": { "type": "integer", "description": "Timeout in seconds (default: 30)" }
},
"required": ["command"]
}),
},
// --- Inter-agent tools ---
ToolDefinition {
name: "agent_send".to_string(),
description: "Send a message to another agent and receive their response. Accepts UUID or agent name. Use agent_find first to discover agents.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"agent_id": { "type": "string", "description": "The target agent's UUID or name" },
"message": { "type": "string", "description": "The message to send to the agent" }
},
"required": ["agent_id", "message"]
}),
},
ToolDefinition {
name: "agent_spawn".to_string(),
description: "Spawn a new agent from a TOML manifest. Returns the new agent's ID and name.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"manifest_toml": {
"type": "string",
"description": "The agent manifest in TOML format (must include name, module, [model], and [capabilities])"
}
},
"required": ["manifest_toml"]
}),
},
ToolDefinition {
name: "agent_list".to_string(),
description: "List all currently running agents with their IDs, names, states, and models.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "agent_kill".to_string(),
description: "Kill (terminate) another agent by its ID.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"agent_id": { "type": "string", "description": "The agent's UUID to kill" }
},
"required": ["agent_id"]
}),
},
// --- Shared memory tools ---
ToolDefinition {
name: "memory_store".to_string(),
description: "Store a value in shared memory accessible by all agents. Use for cross-agent coordination and data sharing.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"key": { "type": "string", "description": "The storage key" },
"value": { "type": "string", "description": "The value to store (JSON-encode objects/arrays, or pass a plain string)" }
},
"required": ["key", "value"]
}),
},
ToolDefinition {
name: "memory_recall".to_string(),
description: "Recall a value from shared memory by key.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"key": { "type": "string", "description": "The storage key to recall" }
},
"required": ["key"]
}),
},
// --- Collaboration tools ---
ToolDefinition {
name: "agent_find".to_string(),
description: "Discover agents by name, tag, tool, or description. Use to find specialists before delegating work.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search query (matches agent name, tags, tools, description)" }
},
"required": ["query"]
}),
},
ToolDefinition {
name: "task_post".to_string(),
description: "Post a task to the shared task queue for another agent to pick up.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"title": { "type": "string", "description": "Short task title" },
"description": { "type": "string", "description": "Detailed task description" },
"assigned_to": { "type": "string", "description": "Agent name or ID to assign the task to (optional)" }
},
"required": ["title", "description"]
}),
},
ToolDefinition {
name: "task_claim".to_string(),
description: "Claim the next available task from the task queue assigned to you or unassigned.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "task_complete".to_string(),
description: "Mark a previously claimed task as completed with a result.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"task_id": { "type": "string", "description": "The task ID to complete" },
"result": { "type": "string", "description": "The result or outcome of the task" }
},
"required": ["task_id", "result"]
}),
},
ToolDefinition {
name: "task_list".to_string(),
description: "List tasks in the shared queue, optionally filtered by status (pending, in_progress, completed).".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"status": { "type": "string", "description": "Filter by status: pending, in_progress, completed (optional)" }
}
}),
},
ToolDefinition {
name: "event_publish".to_string(),
description: "Publish a custom event that can trigger proactive agents. Use to broadcast signals to the agent fleet.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"event_type": { "type": "string", "description": "Type identifier for the event (e.g., 'code_review_requested')" },
"payload": { "type": "object", "description": "JSON payload data for the event" }
},
"required": ["event_type"]
}),
},
// --- Scheduling tools ---
ToolDefinition {
name: "schedule_create".to_string(),
description: "Schedule a recurring task using natural language or cron syntax. Examples: 'every 5 minutes', 'daily at 9am', 'weekdays at 6pm', '0 */5 * * *'.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"description": { "type": "string", "description": "What this schedule does (e.g., 'Check for new emails')" },
"schedule": { "type": "string", "description": "Natural language or cron expression (e.g., 'every 5 minutes', 'daily at 9am', '0 */5 * * *')" },
"agent": { "type": "string", "description": "Agent name or ID to run this task (optional, defaults to self)" }
},
"required": ["description", "schedule"]
}),
},
ToolDefinition {
name: "schedule_list".to_string(),
description: "List all scheduled tasks with their IDs, descriptions, schedules, and next run times.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "schedule_delete".to_string(),
description: "Remove a scheduled task by its ID.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"id": { "type": "string", "description": "The schedule ID to remove" }
},
"required": ["id"]
}),
},
// --- Knowledge graph tools ---
ToolDefinition {
name: "knowledge_add_entity".to_string(),
description: "Add an entity to the knowledge graph. Entities represent people, organizations, projects, concepts, locations, tools, etc.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "Display name of the entity" },
"entity_type": { "type": "string", "description": "Type: person, organization, project, concept, event, location, document, tool, or a custom type" },
"properties": { "type": "object", "description": "Arbitrary key-value properties (optional)" }
},
"required": ["name", "entity_type"]
}),
},
ToolDefinition {
name: "knowledge_add_relation".to_string(),
description: "Add a relation between two entities in the knowledge graph.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"source": { "type": "string", "description": "Source entity ID or name" },
"relation": { "type": "string", "description": "Relation type: works_at, knows_about, related_to, depends_on, owned_by, created_by, located_in, part_of, uses, produces, or a custom type" },
"target": { "type": "string", "description": "Target entity ID or name" },
"confidence": { "type": "number", "description": "Confidence score 0.0-1.0 (default: 1.0)" },
"properties": { "type": "object", "description": "Arbitrary key-value properties (optional)" }
},
"required": ["source", "relation", "target"]
}),
},
ToolDefinition {
name: "knowledge_query".to_string(),
description: "Query the knowledge graph. Filter by source entity, relation type, and/or target entity. Returns matching entity-relation-entity triples.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"source": { "type": "string", "description": "Filter by source entity name or ID (optional)" },
"relation": { "type": "string", "description": "Filter by relation type (optional)" },
"target": { "type": "string", "description": "Filter by target entity name or ID (optional)" },
"max_depth": { "type": "integer", "description": "Maximum traversal depth (default: 1)" }
}
}),
},
// --- Image analysis tool ---
ToolDefinition {
name: "image_analyze".to_string(),
description: "Analyze an image file — returns format, dimensions, file size, and a base64 preview. For vision-model analysis, include a prompt.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to the image file" },
"prompt": { "type": "string", "description": "Optional prompt for vision analysis (e.g., 'Describe what you see')" }
},
"required": ["path"]
}),
},
// --- Location tool ---
ToolDefinition {
name: "location_get".to_string(),
description: "Get approximate geographic location based on IP address. Returns city, country, coordinates, and timezone.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
// --- Browser automation tools ---
ToolDefinition {
name: "browser_navigate".to_string(),
description: "Navigate a browser to a URL. Returns the page title and readable content as markdown. Opens a persistent browser session.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"url": { "type": "string", "description": "The URL to navigate to (http/https only)" }
},
"required": ["url"]
}),
},
ToolDefinition {
name: "browser_click".to_string(),
description: "Click an element on the current browser page by CSS selector or visible text. Returns the resulting page state.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"selector": { "type": "string", "description": "CSS selector (e.g., '#submit-btn', '.add-to-cart') or visible text to click" }
},
"required": ["selector"]
}),
},
ToolDefinition {
name: "browser_type".to_string(),
description: "Type text into an input field on the current browser page.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"selector": { "type": "string", "description": "CSS selector for the input field (e.g., 'input[name=\"email\"]', '#search-box')" },
"text": { "type": "string", "description": "The text to type into the field" }
},
"required": ["selector", "text"]
}),
},
ToolDefinition {
name: "browser_screenshot".to_string(),
description: "Take a screenshot of the current browser page. Returns a base64-encoded PNG image.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "browser_read_page".to_string(),
description: "Read the current browser page content as structured markdown. Use after clicking or navigating to see the updated page.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "browser_close".to_string(),
description: "Close the browser session. The browser will also auto-close when the agent loop ends.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "browser_scroll".to_string(),
description: "Scroll the browser page. Use this to see content below the fold or navigate long pages.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"direction": { "type": "string", "description": "Scroll direction: 'up', 'down', 'left', 'right' (default: 'down')" },
"amount": { "type": "integer", "description": "Pixels to scroll (default: 600)" }
}
}),
},
ToolDefinition {
name: "browser_wait".to_string(),
description: "Wait for a CSS selector to appear on the page. Useful for dynamic content that loads asynchronously.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"selector": { "type": "string", "description": "CSS selector to wait for" },
"timeout_ms": { "type": "integer", "description": "Max wait time in milliseconds (default: 5000, max: 30000)" }
},
"required": ["selector"]
}),
},
ToolDefinition {
name: "browser_run_js".to_string(),
description: "Run JavaScript on the current browser page and return the result. For advanced interactions that other browser tools cannot handle.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"expression": { "type": "string", "description": "JavaScript expression to run in the page context" }
},
"required": ["expression"]
}),
},
ToolDefinition {
name: "browser_back".to_string(),
description: "Go back to the previous page in browser history.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
// --- Media understanding tools ---
ToolDefinition {
name: "media_describe".to_string(),
description: "Describe an image using a vision-capable LLM. Auto-selects the best available provider (Anthropic, OpenAI, or Gemini). Returns a text description of the image content.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to the image file (relative to workspace)" },
"prompt": { "type": "string", "description": "Optional prompt to guide the description (e.g., 'Extract all text from this image')" }
},
"required": ["path"]
}),
},
ToolDefinition {
name: "media_transcribe".to_string(),
description: "Transcribe audio to text using speech-to-text. Auto-selects the best available provider (Groq Whisper or OpenAI Whisper). Returns the transcript.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to the audio file (relative to workspace). Supported: mp3, wav, ogg, flac, m4a, webm." },
"language": { "type": "string", "description": "Optional ISO-639-1 language code (e.g., 'en', 'es', 'ja')" }
},
"required": ["path"]
}),
},
// --- Image generation tool ---
ToolDefinition {
name: "image_generate".to_string(),
description: "Generate images from a text prompt using DALL-E 3, DALL-E 2, or GPT-Image-1. Requires OPENAI_API_KEY. Generated images are saved to the workspace output/ directory.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"prompt": { "type": "string", "description": "Text description of the image to generate (max 4000 chars)" },
"model": { "type": "string", "description": "Model to use: 'dall-e-3' (default), 'dall-e-2', or 'gpt-image-1'" },
"size": { "type": "string", "description": "Image size: '1024x1024' (default), '1024x1792', '1792x1024', '256x256', '512x512'" },
"quality": { "type": "string", "description": "Quality: 'hd' (default for dall-e-3) or 'standard'" },
"count": { "type": "integer", "description": "Number of images to generate (1-4, default: 1). DALL-E 3 only supports 1." }
},
"required": ["prompt"]
}),
},
// --- Cron scheduling tools ---
ToolDefinition {
name: "cron_create".to_string(),
description: "Create a scheduled/cron job. Supports one-shot (at), recurring (every N seconds), and cron expressions. Max 50 jobs per agent.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "Job name (max 128 chars, alphanumeric + spaces/hyphens/underscores)" },
"schedule": {
"type": "object",
"description": "Schedule: {\"kind\":\"at\",\"at\":\"2025-01-01T00:00:00Z\"} or {\"kind\":\"every\",\"every_secs\":300} or {\"kind\":\"cron\",\"expr\":\"0 */6 * * *\"}"
},
"action": {
"type": "object",
"description": "Action: {\"kind\":\"system_event\",\"text\":\"...\"} or {\"kind\":\"agent_turn\",\"message\":\"...\",\"timeout_secs\":300}"
},
"delivery": {
"type": "object",
"description": "Delivery target: {\"kind\":\"none\"} or {\"kind\":\"channel\",\"channel\":\"telegram\"} or {\"kind\":\"last_channel\"}"
},
"one_shot": { "type": "boolean", "description": "If true, auto-delete after execution. Default: false" }
},
"required": ["name", "schedule", "action"]
}),
},
ToolDefinition {
name: "cron_list".to_string(),
description: "List all scheduled/cron jobs for the current agent.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "cron_cancel".to_string(),
description: "Cancel a scheduled/cron job by its ID.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"job_id": { "type": "string", "description": "The UUID of the cron job to cancel" }
},
"required": ["job_id"]
}),
},
// --- Channel send tool (proactive outbound messaging) ---
ToolDefinition {
name: "channel_send".to_string(),
description: "Send a message or media to a user on a configured channel (email, telegram, slack, etc). For email: recipient is the email address; optionally set subject. For media: set image_url, file_url, or file_path to send an image or file instead of (or alongside) text. Use thread_id to reply in a specific thread/topic.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"channel": { "type": "string", "description": "Channel adapter name (e.g., 'email', 'telegram', 'slack', 'discord')" },
"recipient": { "type": "string", "description": "Platform-specific recipient identifier (email address, user ID, etc.)" },
"subject": { "type": "string", "description": "Optional subject line (used for email; ignored for other channels)" },
"message": { "type": "string", "description": "The message body to send (required for text, optional caption for media)" },
"image_url": { "type": "string", "description": "URL of an image to send (supported on Telegram, Discord, Slack)" },
"file_url": { "type": "string", "description": "URL of a file to send as attachment" },
"file_path": { "type": "string", "description": "Local file path to send as attachment (reads from disk; use instead of file_url for local files)" },
"filename": { "type": "string", "description": "Filename for file attachments (defaults to the basename of file_path, or 'file')" },
"thread_id": { "type": "string", "description": "Thread/topic ID to reply in (e.g., Telegram message_thread_id, Slack thread_ts)" }
},
"required": ["channel", "recipient"]
}),
},
// --- Hand tools (curated autonomous capability packages) ---
ToolDefinition {
name: "hand_list".to_string(),
description: "List available Hands (curated autonomous packages) and their activation status.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "hand_activate".to_string(),
description: "Activate a Hand — spawns a specialized autonomous agent with curated tools and skills.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"hand_id": { "type": "string", "description": "The ID of the hand to activate (e.g. 'researcher', 'clip', 'browser')" },
"config": { "type": "object", "description": "Optional configuration overrides for the hand's settings" }
},
"required": ["hand_id"]
}),
},
ToolDefinition {
name: "hand_status".to_string(),
description: "Check the status and metrics of an active Hand.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"hand_id": { "type": "string", "description": "The ID of the hand to check status for" }
},
"required": ["hand_id"]
}),
},
ToolDefinition {
name: "hand_deactivate".to_string(),
description: "Deactivate a running Hand and stop its agent.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"instance_id": { "type": "string", "description": "The UUID of the hand instance to deactivate" }
},
"required": ["instance_id"]
}),
},
// --- A2A outbound tools ---
ToolDefinition {
name: "a2a_discover".to_string(),
description: "Discover an external A2A agent by fetching its agent card from a URL. Returns the agent's name, description, skills, and supported protocols.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"url": { "type": "string", "description": "Base URL of the remote OpenFang/A2A-compatible agent (e.g., 'https://agent.example.com')" }
},
"required": ["url"]
}),
},
ToolDefinition {
name: "a2a_send".to_string(),
description: "Send a task/message to an external A2A agent and get the response. Use agent_name to send to a previously discovered agent, or agent_url for direct addressing.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"message": { "type": "string", "description": "The task/message to send to the remote agent" },
"agent_url": { "type": "string", "description": "Direct URL of the remote agent's A2A endpoint" },
"agent_name": { "type": "string", "description": "Name of a previously discovered A2A agent (looked up from kernel)" },
"session_id": { "type": "string", "description": "Optional session ID for multi-turn conversations" }
},
"required": ["message"]
}),
},
// --- TTS/STT tools ---
ToolDefinition {
name: "text_to_speech".to_string(),
description: "Convert text to speech audio. Auto-selects OpenAI or ElevenLabs. Saves audio to workspace output/ directory.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"text": { "type": "string", "description": "The text to convert to speech (max 4096 chars)" },
"voice": { "type": "string", "description": "Voice name: 'alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer' (default: 'alloy')" },
"format": { "type": "string", "description": "Output format: 'mp3', 'opus', 'aac', 'flac' (default: 'mp3')" }
},
"required": ["text"]
}),
},
ToolDefinition {
name: "speech_to_text".to_string(),
description: "Transcribe audio to text using speech-to-text. Auto-selects Groq Whisper or OpenAI Whisper. Supported formats: mp3, wav, ogg, flac, m4a, webm.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to the audio file (relative to workspace)" },
"language": { "type": "string", "description": "Optional ISO-639-1 language code (e.g., 'en', 'es', 'ja')" }
},
"required": ["path"]
}),
},
// --- Docker sandbox tool ---
ToolDefinition {
name: "docker_exec".to_string(),
description: "Execute a command inside a Docker container sandbox. Provides OS-level isolation with resource limits, network isolation, and capability dropping. Requires Docker to be installed and docker.enabled=true.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "The command to execute inside the container" }
},
"required": ["command"]
}),
},
// --- Persistent process tools ---
ToolDefinition {
name: "process_start".to_string(),
description: "Start a long-running process (REPL, server, watcher). Returns a process_id for subsequent poll/write/kill operations. Max 5 processes per agent.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "The executable to run (e.g. 'python', 'node', 'npm')" },
"args": {
"type": "array",
"items": { "type": "string" },
"description": "Command-line arguments (e.g. ['-i'] for interactive Python)"
}
},
"required": ["command"]
}),
},
ToolDefinition {
name: "process_poll".to_string(),
description: "Read accumulated stdout/stderr from a running process. Non-blocking: returns whatever output has buffered since the last poll.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"process_id": { "type": "string", "description": "The process ID returned by process_start" }
},
"required": ["process_id"]
}),
},
ToolDefinition {
name: "process_write".to_string(),
description: "Write data to a running process's stdin. A newline is appended automatically if not present.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"process_id": { "type": "string", "description": "The process ID returned by process_start" },
"data": { "type": "string", "description": "The data to write to stdin" }
},
"required": ["process_id", "data"]
}),
},
ToolDefinition {
name: "process_kill".to_string(),
description: "Terminate a running process and clean up its resources.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"process_id": { "type": "string", "description": "The process ID returned by process_start" }
},
"required": ["process_id"]
}),
},
ToolDefinition {
name: "process_list".to_string(),
description: "List all running processes for the current agent, including their IDs, commands, uptime, and alive status.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
// --- System time tool ---
ToolDefinition {
name: "system_time".to_string(),
description: "Get the current date, time, and timezone. Returns ISO 8601 timestamp, Unix epoch seconds, and timezone info.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
},
// --- Canvas / A2UI tool ---
ToolDefinition {
name: "canvas_present".to_string(),
description: "Present an interactive HTML canvas to the user. The HTML is sanitized (no scripts, no event handlers) and saved to the workspace. The dashboard will render it in a panel. Use for rich data visualizations, formatted reports, or interactive UI.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"html": { "type": "string", "description": "The HTML content to present. Must not contain <script> tags, event handlers, or javascript: URLs." },
"title": { "type": "string", "description": "Optional title for the canvas panel" }
},
"required": ["html"]
}),
},
]
}
// ---------------------------------------------------------------------------
// Filesystem tools
// ---------------------------------------------------------------------------
/// SECURITY: Reject path traversal attempts. Forbids `..` components in file paths.
fn validate_path(path: &str) -> Result<&str, String> {
for component in std::path::Path::new(path).components() {
if matches!(component, std::path::Component::ParentDir) {
return Err("Path traversal denied: '..' components are forbidden".to_string());
}
}
Ok(path)
}
/// Resolve a file path through the workspace sandbox (if available) or legacy validation.
fn resolve_file_path(raw_path: &str, workspace_root: Option<&Path>) -> Result<PathBuf, String> {
if let Some(root) = workspace_root {
crate::workspace_sandbox::resolve_sandbox_path(raw_path, root)
} else {
let _ = validate_path(raw_path)?;
Ok(PathBuf::from(raw_path))
}
}
async fn tool_file_read(
input: &serde_json::Value,
workspace_root: Option<&Path>,
) -> Result<String, String> {
let raw_path = input["path"].as_str().ok_or("Missing 'path' parameter")?;
let resolved = resolve_file_path(raw_path, workspace_root)?;
tokio::fs::read_to_string(&resolved)
.await
.map_err(|e| format!("Failed to read file: {e}"))
}
async fn tool_file_write(
input: &serde_json::Value,
workspace_root: Option<&Path>,
) -> Result<String, String> {
let raw_path = input["path"].as_str().ok_or("Missing 'path' parameter")?;
let resolved = resolve_file_path(raw_path, workspace_root)?;
let content = input["content"]
.as_str()
.ok_or("Missing 'content' parameter")?;
if let Some(parent) = resolved.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to create directories: {e}"))?;
}
tokio::fs::write(&resolved, content)
.await
.map_err(|e| format!("Failed to write file: {e}"))?;
Ok(format!(
"Successfully wrote {} bytes to {}",
content.len(),
resolved.display()
))
}
async fn tool_file_list(
input: &serde_json::Value,
workspace_root: Option<&Path>,
) -> Result<String, String> {
let raw_path = input["path"].as_str().ok_or("Missing 'path' parameter")?;
let resolved = resolve_file_path(raw_path, workspace_root)?;
let mut entries = tokio::fs::read_dir(&resolved)
.await
.map_err(|e| format!("Failed to list directory: {e}"))?;
let mut files = Vec::new();
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| format!("Failed to read entry: {e}"))?
{
let name = entry.file_name().to_string_lossy().to_string();
let metadata = entry.metadata().await;
let suffix = match metadata {
Ok(m) if m.is_dir() => "/",
_ => "",
};
files.push(format!("{name}{suffix}"));
}
files.sort();
Ok(files.join("\n"))
}
// ---------------------------------------------------------------------------
// Patch tool
// ---------------------------------------------------------------------------
async fn tool_apply_patch(
input: &serde_json::Value,
workspace_root: Option<&Path>,
) -> Result<String, String> {
let patch_str = input["patch"].as_str().ok_or("Missing 'patch' parameter")?;
let root = workspace_root.ok_or("apply_patch requires a workspace root")?;
let ops = crate::apply_patch::parse_patch(patch_str)?;
let result = crate::apply_patch::apply_patch(&ops, root).await;
if result.is_ok() {
Ok(result.summary())
} else {
Err(format!(
"Patch partially applied: {}. Errors: {}",
result.summary(),
result.errors.join("; ")
))
}
}
// ---------------------------------------------------------------------------
// Web tools
// ---------------------------------------------------------------------------
/// Legacy web fetch (no SSRF protection, no readability). Used when WebToolsContext is unavailable.
async fn tool_web_fetch_legacy(input: &serde_json::Value) -> Result<String, String> {
let url = input["url"].as_str().ok_or("Missing 'url' parameter")?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
let resp = client
.get(url)
.send()
.await
.map_err(|e| format!("HTTP request failed: {e}"))?;
let status = resp.status();
// Reject responses larger than 10MB to prevent memory exhaustion
if let Some(len) = resp.content_length() {
if len > 10 * 1024 * 1024 {
return Err(format!("Response too large: {len} bytes (max 10MB)"));
}
}
let body = resp
.text()
.await
.map_err(|e| format!("Failed to read response body: {e}"))?;
let max_len = 50_000;
let truncated = if body.len() > max_len {
format!(
"{}... [truncated, {} total bytes]",
crate::str_utils::safe_truncate_str(&body, max_len),
body.len()
)
} else {
body
};
Ok(format!("HTTP {status}\n\n{truncated}"))
}
/// Legacy web search via DuckDuckGo HTML only. Used when WebToolsContext is unavailable.
async fn tool_web_search_legacy(input: &serde_json::Value) -> Result<String, String> {
let query = input["query"].as_str().ok_or("Missing 'query' parameter")?;
let max_results = input["max_results"].as_u64().unwrap_or(5) as usize;
debug!(query, "Executing web search via DuckDuckGo HTML");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
let resp = client
.get("https://html.duckduckgo.com/html/")
.query(&[("q", query)])
.header("User-Agent", "Mozilla/5.0 (compatible; OpenFangAgent/0.1)")
.send()
.await
.map_err(|e| format!("Search request failed: {e}"))?;
let body = resp
.text()
.await
.map_err(|e| format!("Failed to read search response: {e}"))?;
// Parse DuckDuckGo HTML results
let results = parse_ddg_results(&body, max_results);
if results.is_empty() {
return Ok(format!("No results found for '{query}'."));
}
let mut output = format!("Search results for '{query}':\n\n");
for (i, (title, url, snippet)) in results.iter().enumerate() {
output.push_str(&format!(
"{}. {}\n URL: {}\n {}\n\n",
i + 1,
title,
url,
snippet
));
}
Ok(output)
}
// ---------------------------------------------------------------------------
// Shell tool
// ---------------------------------------------------------------------------
async fn tool_shell_exec(
input: &serde_json::Value,
allowed_env: &[String],
workspace_root: Option<&Path>,
exec_policy: Option<&openfang_types::config::ExecPolicy>,
) -> Result<String, String> {
let command = input["command"]
.as_str()
.ok_or("Missing 'command' parameter")?;
// Use LLM-specified timeout, or fall back to exec policy timeout, or default 30s
let policy_timeout = exec_policy.map(|p| p.timeout_secs).unwrap_or(30);
let timeout_secs = input["timeout_seconds"].as_u64().unwrap_or(policy_timeout);
// SECURITY: Determine execution strategy based on exec policy.
//
// In Allowlist mode (default): Use direct execution via shlex argv splitting.
// This avoids invoking a shell interpreter, which eliminates an entire class
// of injection attacks (encoding tricks, $IFS, glob expansion, etc.).
//
// In Full mode: User explicitly opted into unrestricted shell access,
// so we use sh -c / cmd /C as before.
let use_direct_exec = exec_policy
.map(|p| p.mode == openfang_types::config::ExecSecurityMode::Allowlist)
.unwrap_or(true); // Default to safe mode
let mut cmd = if use_direct_exec {
// SAFE PATH: Split command into argv using POSIX shell lexer rules,
// then execute the binary directly — no shell interpreter involved.
let argv = shlex::split(command).ok_or_else(|| {
"Command contains unmatched quotes or invalid shell syntax".to_string()
})?;
if argv.is_empty() {
return Err("Empty command after parsing".to_string());
}
let mut c = tokio::process::Command::new(&argv[0]);
if argv.len() > 1 {
c.args(&argv[1..]);
}
c
} else {
// UNSAFE PATH: Full mode — user explicitly opted in to shell interpretation.
// Shell resolution: prefer sh (Git Bash/MSYS2) on Windows.
#[cfg(windows)]
let git_sh: Option<&str> = {
const SH_PATHS: &[&str] = &[
"C:\\Program Files\\Git\\usr\\bin\\sh.exe",
"C:\\Program Files (x86)\\Git\\usr\\bin\\sh.exe",
];
SH_PATHS
.iter()
.copied()
.find(|p| std::path::Path::new(p).exists())
};
let (shell, shell_arg) = if cfg!(windows) {
#[cfg(windows)]
{
if let Some(sh) = git_sh {
(sh, "-c")
} else {
("cmd", "/C")
}
}
#[cfg(not(windows))]
{
("sh", "-c")
}
} else {
("sh", "-c")
};
let mut c = tokio::process::Command::new(shell);
c.arg(shell_arg).arg(command);
c
};
// Set working directory to agent workspace so files are created there
if let Some(ws) = workspace_root {
cmd.current_dir(ws);
}
// SECURITY: Isolate environment to prevent credential leakage.
// Hand settings may grant access to specific provider API keys.
crate::subprocess_sandbox::sandbox_command(&mut cmd, allowed_env);
// Ensure UTF-8 output on Windows
#[cfg(windows)]
cmd.env("PYTHONIOENCODING", "utf-8");
// Prevent child from inheriting stdin (avoids blocking on Windows)
cmd.stdin(std::process::Stdio::null());
let result =
tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), cmd.output()).await;
match result {
Ok(Ok(output)) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let exit_code = output.status.code().unwrap_or(-1);
// Truncate very long outputs to prevent memory issues
let max_output = 100_000;
let stdout_str = if stdout.len() > max_output {
format!(
"{}...\n[truncated, {} total bytes]",
crate::str_utils::safe_truncate_str(&stdout, max_output),
stdout.len()
)
} else {
stdout.to_string()
};
let stderr_str = if stderr.len() > max_output {
format!(
"{}...\n[truncated, {} total bytes]",
crate::str_utils::safe_truncate_str(&stderr, max_output),
stderr.len()
)
} else {
stderr.to_string()
};
Ok(format!(
"Exit code: {exit_code}\n\nSTDOUT:\n{stdout_str}\nSTDERR:\n{stderr_str}"
))
}
Ok(Err(e)) => Err(format!("Failed to execute command: {e}")),
Err(_) => Err(format!("Command timed out after {timeout_secs}s")),
}
}
// ---------------------------------------------------------------------------
// Inter-agent tools
// ---------------------------------------------------------------------------
fn require_kernel(
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<&Arc<dyn KernelHandle>, String> {
kernel.ok_or_else(|| {
"Kernel handle not available. Inter-agent tools require a running kernel.".to_string()
})
}
async fn tool_agent_send(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let agent_id = input["agent_id"]
.as_str()
.ok_or("Missing 'agent_id' parameter")?;
let message = input["message"]
.as_str()
.ok_or("Missing 'message' parameter")?;
// Check + increment inter-agent call depth
let current_depth = AGENT_CALL_DEPTH.try_with(|d| d.get()).unwrap_or(0);
if current_depth >= MAX_AGENT_CALL_DEPTH {
return Err(format!(
"Inter-agent call depth exceeded (max {}). \
A->B->C chain is too deep. Use the task queue instead.",
MAX_AGENT_CALL_DEPTH
));
}
AGENT_CALL_DEPTH
.scope(std::cell::Cell::new(current_depth + 1), async {
kh.send_to_agent(agent_id, message).await
})
.await
}
async fn tool_agent_spawn(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
parent_id: Option<&str>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let manifest_toml = input["manifest_toml"]
.as_str()
.ok_or("Missing 'manifest_toml' parameter")?;
let (id, name) = kh.spawn_agent(manifest_toml, parent_id).await?;
Ok(format!(
"Agent spawned successfully.\n ID: {id}\n Name: {name}"
))
}
fn tool_agent_list(kernel: Option<&Arc<dyn KernelHandle>>) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let agents = kh.list_agents();
if agents.is_empty() {
return Ok("No agents currently running.".to_string());
}
let mut output = format!("Running agents ({}):\n", agents.len());
for a in &agents {
output.push_str(&format!(
" - {} (id: {}, state: {}, model: {}:{})\n",
a.name, a.id, a.state, a.model_provider, a.model_name
));
}
Ok(output)
}
fn tool_agent_kill(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let agent_id = input["agent_id"]
.as_str()
.ok_or("Missing 'agent_id' parameter")?;
kh.kill_agent(agent_id)?;
Ok(format!("Agent {agent_id} killed successfully."))
}
// ---------------------------------------------------------------------------
// Shared memory tools
// ---------------------------------------------------------------------------
fn tool_memory_store(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let key = input["key"].as_str().ok_or("Missing 'key' parameter")?;
let value = input.get("value").ok_or("Missing 'value' parameter")?;
kh.memory_store(key, value.clone())?;
Ok(format!("Stored value under key '{key}'."))
}
fn tool_memory_recall(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let key = input["key"].as_str().ok_or("Missing 'key' parameter")?;
match kh.memory_recall(key)? {
Some(val) => Ok(serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string())),
None => Ok(format!("No value found for key '{key}'.")),
}
}
// ---------------------------------------------------------------------------
// Collaboration tools
// ---------------------------------------------------------------------------
fn tool_agent_find(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let query = input["query"].as_str().ok_or("Missing 'query' parameter")?;
let agents = kh.find_agents(query);
if agents.is_empty() {
return Ok(format!("No agents found matching '{query}'."));
}
let result: Vec<serde_json::Value> = agents
.iter()
.map(|a| {
serde_json::json!({
"id": a.id,
"name": a.name,
"state": a.state,
"description": a.description,
"tags": a.tags,
"tools": a.tools,
"model": format!("{}:{}", a.model_provider, a.model_name),
})
})
.collect();
serde_json::to_string_pretty(&result).map_err(|e| format!("Serialize error: {e}"))
}
async fn tool_task_post(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
caller_agent_id: Option<&str>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let title = input["title"].as_str().ok_or("Missing 'title' parameter")?;
let description = input["description"]
.as_str()
.ok_or("Missing 'description' parameter")?;
let assigned_to = input["assigned_to"].as_str();
let task_id = kh
.task_post(title, description, assigned_to, caller_agent_id)
.await?;
Ok(format!("Task created with ID: {task_id}"))
}
async fn tool_task_claim(
kernel: Option<&Arc<dyn KernelHandle>>,
caller_agent_id: Option<&str>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let agent_id = caller_agent_id.unwrap_or("");
match kh.task_claim(agent_id).await? {
Some(task) => {
serde_json::to_string_pretty(&task).map_err(|e| format!("Serialize error: {e}"))
}
None => Ok("No tasks available.".to_string()),
}
}
async fn tool_task_complete(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let task_id = input["task_id"]
.as_str()
.ok_or("Missing 'task_id' parameter")?;
let result = input["result"]
.as_str()
.ok_or("Missing 'result' parameter")?;
kh.task_complete(task_id, result).await?;
Ok(format!("Task {task_id} marked as completed."))
}
async fn tool_task_list(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let status = input["status"].as_str();
let tasks = kh.task_list(status).await?;
if tasks.is_empty() {
return Ok("No tasks found.".to_string());
}
serde_json::to_string_pretty(&tasks).map_err(|e| format!("Serialize error: {e}"))
}
async fn tool_event_publish(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let event_type = input["event_type"]
.as_str()
.ok_or("Missing 'event_type' parameter")?;
let payload = input
.get("payload")
.cloned()
.unwrap_or(serde_json::json!({}));
kh.publish_event(event_type, payload).await?;
Ok(format!("Event '{event_type}' published successfully."))
}
// ---------------------------------------------------------------------------
// Knowledge graph tools
// ---------------------------------------------------------------------------
fn parse_entity_type(s: &str) -> openfang_types::memory::EntityType {
use openfang_types::memory::EntityType;
match s.to_lowercase().as_str() {
"person" => EntityType::Person,
"organization" | "org" => EntityType::Organization,
"project" => EntityType::Project,
"concept" => EntityType::Concept,
"event" => EntityType::Event,
"location" => EntityType::Location,
"document" | "doc" => EntityType::Document,
"tool" => EntityType::Tool,
other => EntityType::Custom(other.to_string()),
}
}
fn parse_relation_type(s: &str) -> openfang_types::memory::RelationType {
use openfang_types::memory::RelationType;
match s.to_lowercase().as_str() {
"works_at" | "worksat" => RelationType::WorksAt,
"knows_about" | "knowsabout" | "knows" => RelationType::KnowsAbout,
"related_to" | "relatedto" | "related" => RelationType::RelatedTo,
"depends_on" | "dependson" | "depends" => RelationType::DependsOn,
"owned_by" | "ownedby" => RelationType::OwnedBy,
"created_by" | "createdby" => RelationType::CreatedBy,
"located_in" | "locatedin" => RelationType::LocatedIn,
"part_of" | "partof" => RelationType::PartOf,
"uses" => RelationType::Uses,
"produces" => RelationType::Produces,
other => RelationType::Custom(other.to_string()),
}
}
async fn tool_knowledge_add_entity(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let name = input["name"].as_str().ok_or("Missing 'name' parameter")?;
let entity_type_str = input["entity_type"]
.as_str()
.ok_or("Missing 'entity_type' parameter")?;
let properties = input
.get("properties")
.and_then(|v| v.as_object())
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
let entity = openfang_types::memory::Entity {
id: String::new(), // kernel/store assigns a real ID
entity_type: parse_entity_type(entity_type_str),
name: name.to_string(),
properties,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let id = kh.knowledge_add_entity(entity).await?;
Ok(format!("Entity '{name}' added with ID: {id}"))
}
async fn tool_knowledge_add_relation(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let source = input["source"]
.as_str()
.ok_or("Missing 'source' parameter")?;
let relation_str = input["relation"]
.as_str()
.ok_or("Missing 'relation' parameter")?;
let target = input["target"]
.as_str()
.ok_or("Missing 'target' parameter")?;
let confidence = input["confidence"].as_f64().unwrap_or(1.0) as f32;
let properties = input
.get("properties")
.and_then(|v| v.as_object())
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
let relation = openfang_types::memory::Relation {
source: source.to_string(),
relation: parse_relation_type(relation_str),
target: target.to_string(),
properties,
confidence,
created_at: chrono::Utc::now(),
};
let id = kh.knowledge_add_relation(relation).await?;
Ok(format!(
"Relation '{source}' --[{relation_str}]--> '{target}' added with ID: {id}"
))
}
async fn tool_knowledge_query(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let source = input["source"].as_str().map(|s| s.to_string());
let target = input["target"].as_str().map(|s| s.to_string());
let relation = input["relation"].as_str().map(parse_relation_type);
let max_depth = input["max_depth"].as_u64().unwrap_or(1) as u32;
let pattern = openfang_types::memory::GraphPattern {
source,
relation,
target,
max_depth,
};
let matches = kh.knowledge_query(pattern).await?;
if matches.is_empty() {
return Ok("No matching knowledge graph entries found.".to_string());
}
let mut output = format!("Found {} match(es):\n", matches.len());
for m in &matches {
output.push_str(&format!(
"\n {} ({:?}) --[{:?} ({:.0}%)]--> {} ({:?})",
m.source.name,
m.source.entity_type,
m.relation.relation,
m.relation.confidence * 100.0,
m.target.name,
m.target.entity_type,
));
}
Ok(output)
}
// ---------------------------------------------------------------------------
// Scheduling tools
// ---------------------------------------------------------------------------
/// Parse a natural language schedule into a cron expression.
fn parse_schedule_to_cron(input: &str) -> Result<String, String> {
let input = input.trim().to_lowercase();
// If it already looks like a cron expression (5 space-separated fields), pass through
let parts: Vec<&str> = input.split_whitespace().collect();
if parts.len() == 5
&& parts
.iter()
.all(|p| p.chars().all(|c| c.is_ascii_digit() || "*/,-".contains(c)))
{
return Ok(input);
}
// Natural language patterns
if let Some(rest) = input.strip_prefix("every ") {
if rest == "minute" || rest == "1 minute" {
return Ok("* * * * *".to_string());
}
if let Some(mins) = rest.strip_suffix(" minutes") {
let n: u32 = mins
.trim()
.parse()
.map_err(|_| format!("Invalid number in '{input}'"))?;
if n == 0 || n > 59 {
return Err(format!("Minutes must be 1-59, got {n}"));
}
return Ok(format!("*/{n} * * * *"));
}
if rest == "hour" || rest == "1 hour" {
return Ok("0 * * * *".to_string());
}
if let Some(hrs) = rest.strip_suffix(" hours") {
let n: u32 = hrs
.trim()
.parse()
.map_err(|_| format!("Invalid number in '{input}'"))?;
if n == 0 || n > 23 {
return Err(format!("Hours must be 1-23, got {n}"));
}
return Ok(format!("0 */{n} * * *"));
}
if rest == "day" || rest == "1 day" {
return Ok("0 0 * * *".to_string());
}
if rest == "week" || rest == "1 week" {
return Ok("0 0 * * 0".to_string());
}
}
// "daily at Xam/pm"
if let Some(time_str) = input.strip_prefix("daily at ") {
let hour = parse_time_to_hour(time_str)?;
return Ok(format!("0 {hour} * * *"));
}
// "weekdays at Xam/pm"
if let Some(time_str) = input.strip_prefix("weekdays at ") {
let hour = parse_time_to_hour(time_str)?;
return Ok(format!("0 {hour} * * 1-5"));
}
// "weekends at Xam/pm"
if let Some(time_str) = input.strip_prefix("weekends at ") {
let hour = parse_time_to_hour(time_str)?;
return Ok(format!("0 {hour} * * 0,6"));
}
// "hourly" / "daily" / "weekly" / "monthly"
match input.as_str() {
"hourly" => return Ok("0 * * * *".to_string()),
"daily" => return Ok("0 0 * * *".to_string()),
"weekly" => return Ok("0 0 * * 0".to_string()),
"monthly" => return Ok("0 0 1 * *".to_string()),
_ => {}
}
Err(format!(
"Could not parse schedule '{input}'. Try: 'every 5 minutes', 'daily at 9am', 'weekdays at 6pm', or a cron expression like '0 */5 * * *'"
))
}
/// Parse a time string like "9am", "6pm", "14:00", "9:30am" into an hour (0-23).
fn parse_time_to_hour(s: &str) -> Result<u32, String> {
let s = s.trim().to_lowercase();
// Handle "9am", "6pm", "12pm", "12am"
if let Some(h) = s.strip_suffix("am") {
let hour: u32 = h.trim().parse().map_err(|_| format!("Invalid time: {s}"))?;
return match hour {
12 => Ok(0),
1..=11 => Ok(hour),
_ => Err(format!("Invalid hour: {hour}")),
};
}
if let Some(h) = s.strip_suffix("pm") {
let hour: u32 = h.trim().parse().map_err(|_| format!("Invalid time: {s}"))?;
return match hour {
12 => Ok(12),
1..=11 => Ok(hour + 12),
_ => Err(format!("Invalid hour: {hour}")),
};
}
// Handle "14:00" or "9:30"
if let Some((h, _m)) = s.split_once(':') {
let hour: u32 = h.trim().parse().map_err(|_| format!("Invalid time: {s}"))?;
if hour > 23 {
return Err(format!("Hour must be 0-23, got {hour}"));
}
return Ok(hour);
}
// Plain number
let hour: u32 = s.parse().map_err(|_| format!("Invalid time: {s}"))?;
if hour > 23 {
return Err(format!("Hour must be 0-23, got {hour}"));
}
Ok(hour)
}
const SCHEDULES_KEY: &str = "__openfang_schedules";
async fn tool_schedule_create(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let description = input["description"]
.as_str()
.ok_or("Missing 'description' parameter")?;
let schedule_str = input["schedule"]
.as_str()
.ok_or("Missing 'schedule' parameter")?;
let agent = input["agent"].as_str().unwrap_or("");
let cron_expr = parse_schedule_to_cron(schedule_str)?;
let schedule_id = uuid::Uuid::new_v4().to_string();
let entry = serde_json::json!({
"id": schedule_id,
"description": description,
"schedule_input": schedule_str,
"cron": cron_expr,
"agent": agent,
"created_at": chrono::Utc::now().to_rfc3339(),
"enabled": true,
});
// Load existing schedules from shared memory
let mut schedules: Vec<serde_json::Value> = match kh.memory_recall(SCHEDULES_KEY)? {
Some(serde_json::Value::Array(arr)) => arr,
_ => Vec::new(),
};
schedules.push(entry);
kh.memory_store(SCHEDULES_KEY, serde_json::Value::Array(schedules))?;
Ok(format!(
"Schedule created:\n ID: {schedule_id}\n Description: {description}\n Cron: {cron_expr}\n Original: {schedule_str}"
))
}
async fn tool_schedule_list(kernel: Option<&Arc<dyn KernelHandle>>) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let schedules: Vec<serde_json::Value> = match kh.memory_recall(SCHEDULES_KEY)? {
Some(serde_json::Value::Array(arr)) => arr,
_ => Vec::new(),
};
if schedules.is_empty() {
return Ok("No scheduled tasks.".to_string());
}
let mut output = format!("Scheduled tasks ({}):\n\n", schedules.len());
for s in &schedules {
let enabled = s["enabled"].as_bool().unwrap_or(true);
let status = if enabled { "active" } else { "paused" };
output.push_str(&format!(
" [{status}] {}{}\n Cron: {} | Agent: {}\n Created: {}\n\n",
s["id"].as_str().unwrap_or("?"),
s["description"].as_str().unwrap_or("?"),
s["cron"].as_str().unwrap_or("?"),
s["agent"].as_str().unwrap_or("(self)"),
s["created_at"].as_str().unwrap_or("?"),
));
}
Ok(output)
}
async fn tool_schedule_delete(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let id = input["id"].as_str().ok_or("Missing 'id' parameter")?;
let mut schedules: Vec<serde_json::Value> = match kh.memory_recall(SCHEDULES_KEY)? {
Some(serde_json::Value::Array(arr)) => arr,
_ => Vec::new(),
};
let before = schedules.len();
schedules.retain(|s| s["id"].as_str() != Some(id));
if schedules.len() == before {
return Err(format!("Schedule '{id}' not found."));
}
kh.memory_store(SCHEDULES_KEY, serde_json::Value::Array(schedules))?;
Ok(format!("Schedule '{id}' deleted."))
}
// ---------------------------------------------------------------------------
// Cron scheduling tools (delegated to kernel via KernelHandle trait)
// ---------------------------------------------------------------------------
async fn tool_cron_create(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
caller_agent_id: Option<&str>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let agent_id = caller_agent_id.ok_or("Agent ID required for cron_create")?;
kh.cron_create(agent_id, input.clone()).await
}
async fn tool_cron_list(
kernel: Option<&Arc<dyn KernelHandle>>,
caller_agent_id: Option<&str>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let agent_id = caller_agent_id.ok_or("Agent ID required for cron_list")?;
let jobs = kh.cron_list(agent_id).await?;
serde_json::to_string_pretty(&jobs).map_err(|e| format!("Failed to serialize cron jobs: {e}"))
}
async fn tool_cron_cancel(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let job_id = input["job_id"]
.as_str()
.ok_or("Missing 'job_id' parameter")?;
kh.cron_cancel(job_id).await?;
Ok(format!("Cron job '{job_id}' cancelled."))
}
// ---------------------------------------------------------------------------
// Channel send tool (proactive outbound messaging via configured adapters)
// ---------------------------------------------------------------------------
async fn tool_channel_send(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
workspace_root: Option<&Path>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let channel = input["channel"]
.as_str()
.ok_or("Missing 'channel' parameter")?
.trim()
.to_lowercase();
let recipient_input = input["recipient"]
.as_str()
.map(|s| s.trim().to_string())
.unwrap_or_default();
// If recipient is empty, resolve from channel's default_chat_id config.
let recipient = if recipient_input.is_empty() {
let default_id = kh.get_channel_default_recipient(&channel).await;
match default_id {
Some(id) => id,
None => {
return Err(format!(
"Missing 'recipient' parameter. Set default_chat_id in [channels.{channel}] config \
or pass recipient explicitly."
))
}
}
} else {
recipient_input
};
let recipient = recipient.as_str();
let thread_id = input["thread_id"].as_str().filter(|s| !s.is_empty());
// Check for media content (image_url, file_url, or file_path)
let image_url = input["image_url"].as_str().filter(|s| !s.is_empty());
let file_url = input["file_url"].as_str().filter(|s| !s.is_empty());
let file_path = input["file_path"].as_str().filter(|s| !s.is_empty());
if let Some(url) = image_url {
let caption = input["message"].as_str().filter(|s| !s.is_empty());
return kh
.send_channel_media(&channel, recipient, "image", url, caption, None, thread_id)
.await;
}
if let Some(url) = file_url {
let caption = input["message"].as_str().filter(|s| !s.is_empty());
let filename = input["filename"].as_str();
return kh
.send_channel_media(
&channel, recipient, "file", url, caption, filename, thread_id,
)
.await;
}
// Local file attachment: read from disk and send as FileData
if let Some(raw_path) = file_path {
let resolved = resolve_file_path(raw_path, workspace_root)?;
let data = tokio::fs::read(&resolved)
.await
.map_err(|e| format!("Failed to read file '{}': {e}", resolved.display()))?;
// Derive filename from the path if not explicitly provided
let filename = input["filename"]
.as_str()
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or_else(|| {
resolved
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_string()
});
// Determine MIME type from extension
let ext = resolved
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let mime_type = match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"pdf" => "application/pdf",
"txt" => "text/plain",
"csv" => "text/csv",
"json" => "application/json",
"xml" => "application/xml",
"zip" => "application/zip",
"gz" | "gzip" => "application/gzip",
"tar" => "application/x-tar",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"mp4" => "video/mp4",
"doc" => "application/msword",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xls" => "application/vnd.ms-excel",
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
_ => "application/octet-stream",
};
return kh
.send_channel_file_data(&channel, recipient, data, &filename, mime_type, thread_id)
.await;
}
// Text-only message
let message = input["message"]
.as_str()
.ok_or("Missing 'message' parameter (required for text messages)")?;
if message.is_empty() {
return Err("Message cannot be empty".to_string());
}
// For email channels, validate email format and prepend subject
let final_message = if channel == "email" {
if !recipient.contains('@') || !recipient.contains('.') {
return Err(format!("Invalid email address: '{recipient}'"));
}
if let Some(subject) = input["subject"].as_str() {
if !subject.is_empty() {
format!("Subject: {subject}\n\n{message}")
} else {
message.to_string()
}
} else {
message.to_string()
}
} else {
message.to_string()
};
kh.send_channel_message(&channel, recipient, &final_message, thread_id)
.await
}
// ---------------------------------------------------------------------------
// Hand tools (delegated to kernel via KernelHandle trait)
// ---------------------------------------------------------------------------
async fn tool_hand_list(kernel: Option<&Arc<dyn KernelHandle>>) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let hands = kh.hand_list().await?;
if hands.is_empty() {
return Ok(
"No Hands available. Install hands to enable curated autonomous packages.".to_string(),
);
}
let mut lines = vec!["Available Hands:".to_string(), String::new()];
for h in &hands {
let icon = h["icon"].as_str().unwrap_or("");
let name = h["name"].as_str().unwrap_or("?");
let id = h["id"].as_str().unwrap_or("?");
let status = h["status"].as_str().unwrap_or("unknown");
let desc = h["description"].as_str().unwrap_or("");
let status_marker = match status {
"Active" => "[ACTIVE]",
"Paused" => "[PAUSED]",
_ => "[available]",
};
lines.push(format!("{} {} ({}) {}", icon, name, id, status_marker));
if !desc.is_empty() {
lines.push(format!(" {}", desc));
}
if let Some(iid) = h["instance_id"].as_str() {
lines.push(format!(" Instance: {}", iid));
}
lines.push(String::new());
}
Ok(lines.join("\n"))
}
async fn tool_hand_activate(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let hand_id = input["hand_id"]
.as_str()
.ok_or("Missing 'hand_id' parameter")?;
let config: std::collections::HashMap<String, serde_json::Value> =
if let Some(obj) = input["config"].as_object() {
obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
} else {
std::collections::HashMap::new()
};
let result = kh.hand_activate(hand_id, config).await?;
let instance_id = result["instance_id"].as_str().unwrap_or("?");
let agent_name = result["agent_name"].as_str().unwrap_or("?");
let status = result["status"].as_str().unwrap_or("?");
Ok(format!(
"Hand '{}' activated!\n Instance: {}\n Agent: {} ({})",
hand_id, instance_id, agent_name, status
))
}
async fn tool_hand_status(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let hand_id = input["hand_id"]
.as_str()
.ok_or("Missing 'hand_id' parameter")?;
let result = kh.hand_status(hand_id).await?;
let icon = result["icon"].as_str().unwrap_or("");
let name = result["name"].as_str().unwrap_or(hand_id);
let status = result["status"].as_str().unwrap_or("unknown");
let instance_id = result["instance_id"].as_str().unwrap_or("?");
let agent_name = result["agent_name"].as_str().unwrap_or("?");
let activated = result["activated_at"].as_str().unwrap_or("?");
Ok(format!(
"{} {}{}\n Instance: {}\n Agent: {}\n Activated: {}",
icon, name, status, instance_id, agent_name, activated
))
}
async fn tool_hand_deactivate(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let instance_id = input["instance_id"]
.as_str()
.ok_or("Missing 'instance_id' parameter")?;
kh.hand_deactivate(instance_id).await?;
Ok(format!("Hand instance '{}' deactivated.", instance_id))
}
// ---------------------------------------------------------------------------
// A2A outbound tools (cross-instance agent communication)
// ---------------------------------------------------------------------------
/// Discover an external A2A agent by fetching its agent card.
async fn tool_a2a_discover(input: &serde_json::Value) -> Result<String, String> {
let url = input["url"].as_str().ok_or("Missing 'url' parameter")?;
// SSRF protection: block private/metadata IPs
if crate::web_fetch::check_ssrf(url).is_err() {
return Err("SSRF blocked: URL resolves to a private or metadata address".to_string());
}
let client = crate::a2a::A2aClient::new();
let card = client.discover(url).await?;
serde_json::to_string_pretty(&card).map_err(|e| format!("Serialization error: {e}"))
}
/// Send a task to an external A2A agent.
async fn tool_a2a_send(
input: &serde_json::Value,
kernel: Option<&Arc<dyn KernelHandle>>,
) -> Result<String, String> {
let kh = require_kernel(kernel)?;
let message = input["message"]
.as_str()
.ok_or("Missing 'message' parameter")?;
// Resolve agent URL: either directly provided or looked up by name
let url = if let Some(url) = input["agent_url"].as_str() {
// SSRF protection
if crate::web_fetch::check_ssrf(url).is_err() {
return Err("SSRF blocked: URL resolves to a private or metadata address".to_string());
}
url.to_string()
} else if let Some(name) = input["agent_name"].as_str() {
kh.get_a2a_agent_url(name)
.ok_or_else(|| format!("No known A2A agent with name '{name}'. Use a2a_discover first or provide agent_url directly."))?
} else {
return Err("Missing 'agent_url' or 'agent_name' parameter".to_string());
};
let session_id = input["session_id"].as_str();
let client = crate::a2a::A2aClient::new();
let task = client.send_task(&url, message, session_id).await?;
serde_json::to_string_pretty(&task).map_err(|e| format!("Serialization error: {e}"))
}
// ---------------------------------------------------------------------------
// Image analysis tool
// ---------------------------------------------------------------------------
async fn tool_image_analyze(input: &serde_json::Value) -> Result<String, String> {
let path = input["path"].as_str().ok_or("Missing 'path' parameter")?;
let prompt = input["prompt"].as_str().unwrap_or("");
let data = tokio::fs::read(path)
.await
.map_err(|e| format!("Failed to read image '{path}': {e}"))?;
let file_size = data.len();
// Detect image format from magic bytes
let format = detect_image_format(&data);
// Extract dimensions for common formats
let dimensions = extract_image_dimensions(&data, &format);
// Base64-encode (truncate for very large images in the response)
let base64_preview = if file_size <= 512 * 1024 {
// Under 512KB — include full base64
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(&data)
} else {
// Over 512KB — include first 64KB preview
use base64::Engine;
let preview_bytes = &data[..64 * 1024];
format!(
"{}... [truncated, {} total bytes]",
base64::engine::general_purpose::STANDARD.encode(preview_bytes),
file_size
)
};
let mut result = serde_json::json!({
"path": path,
"format": format,
"file_size_bytes": file_size,
"file_size_human": format_file_size(file_size),
});
if let Some((w, h)) = dimensions {
result["width"] = serde_json::json!(w);
result["height"] = serde_json::json!(h);
}
if !prompt.is_empty() {
result["prompt"] = serde_json::json!(prompt);
result["note"] = serde_json::json!(
"Vision analysis requires a vision-capable LLM. The base64 data is included for downstream processing."
);
}
result["base64_preview"] = serde_json::json!(base64_preview);
serde_json::to_string_pretty(&result).map_err(|e| format!("Serialize error: {e}"))
}
/// Detect image format from magic bytes.
fn detect_image_format(data: &[u8]) -> String {
if data.len() < 4 {
return "unknown".to_string();
}
if data.starts_with(b"\x89PNG") {
"png".to_string()
} else if data.starts_with(b"\xFF\xD8\xFF") {
"jpeg".to_string()
} else if data.starts_with(b"GIF8") {
"gif".to_string()
} else if data.starts_with(b"RIFF") && data.len() > 12 && &data[8..12] == b"WEBP" {
"webp".to_string()
} else if data.starts_with(b"BM") {
"bmp".to_string()
} else if data.starts_with(b"\x00\x00\x01\x00") {
"ico".to_string()
} else {
"unknown".to_string()
}
}
/// Extract image dimensions from common formats.
fn extract_image_dimensions(data: &[u8], format: &str) -> Option<(u32, u32)> {
match format {
"png" => {
// PNG: IHDR chunk starts at byte 16, width at 16-19, height at 20-23
if data.len() >= 24 {
let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
Some((w, h))
} else {
None
}
}
"gif" => {
// GIF: width at bytes 6-7, height at bytes 8-9 (little-endian)
if data.len() >= 10 {
let w = u16::from_le_bytes([data[6], data[7]]) as u32;
let h = u16::from_le_bytes([data[8], data[9]]) as u32;
Some((w, h))
} else {
None
}
}
"bmp" => {
// BMP: width at bytes 18-21, height at bytes 22-25 (little-endian)
if data.len() >= 26 {
let w = u32::from_le_bytes([data[18], data[19], data[20], data[21]]);
let h = u32::from_le_bytes([data[22], data[23], data[24], data[25]]);
Some((w, h))
} else {
None
}
}
"jpeg" => {
// JPEG: scan for SOF0 marker (0xFF 0xC0) to find dimensions
extract_jpeg_dimensions(data)
}
_ => None,
}
}
/// Extract JPEG dimensions by scanning for SOF markers.
fn extract_jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> {
let mut i = 2; // Skip SOI marker
while i + 1 < data.len() {
if data[i] != 0xFF {
i += 1;
continue;
}
let marker = data[i + 1];
// SOF0-SOF3 markers contain dimensions
if (0xC0..=0xC3).contains(&marker) && i + 9 < data.len() {
let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
return Some((w, h));
}
if i + 3 < data.len() {
let seg_len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
i += 2 + seg_len;
} else {
break;
}
}
None
}
/// Format file size in human-readable form.
fn format_file_size(bytes: usize) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
// ---------------------------------------------------------------------------
// Location tool
// ---------------------------------------------------------------------------
async fn tool_location_get() -> Result<String, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
// Use ip-api.com (free, no API key, JSON response)
let resp = client
.get("https://ip-api.com/json/?fields=status,message,country,regionName,city,zip,lat,lon,timezone,isp,query")
.header("User-Agent", "OpenFang/0.1")
.send()
.await
.map_err(|e| format!("Location request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("Location API returned {}", resp.status()));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse location response: {e}"))?;
if body["status"].as_str() != Some("success") {
let msg = body["message"].as_str().unwrap_or("Unknown error");
return Err(format!("Location lookup failed: {msg}"));
}
let result = serde_json::json!({
"lat": body["lat"],
"lon": body["lon"],
"city": body["city"],
"region": body["regionName"],
"country": body["country"],
"zip": body["zip"],
"timezone": body["timezone"],
"isp": body["isp"],
"ip": body["query"],
});
serde_json::to_string_pretty(&result).map_err(|e| format!("Serialize error: {e}"))
}
// ---------------------------------------------------------------------------
// System time tool
// ---------------------------------------------------------------------------
/// Return current date, time, timezone, and Unix epoch.
fn tool_system_time() -> String {
let now_utc = chrono::Utc::now();
let now_local = chrono::Local::now();
let result = serde_json::json!({
"utc": now_utc.to_rfc3339(),
"local": now_local.to_rfc3339(),
"unix_epoch": now_utc.timestamp(),
"timezone": now_local.format("%Z").to_string(),
"utc_offset": now_local.format("%:z").to_string(),
"date": now_local.format("%Y-%m-%d").to_string(),
"time": now_local.format("%H:%M:%S").to_string(),
"day_of_week": now_local.format("%A").to_string(),
});
serde_json::to_string_pretty(&result).unwrap_or_else(|_| now_utc.to_rfc3339())
}
// ---------------------------------------------------------------------------
// Media understanding tools
// ---------------------------------------------------------------------------
/// Describe an image using a vision-capable LLM provider.
async fn tool_media_describe(
input: &serde_json::Value,
media_engine: Option<&crate::media_understanding::MediaEngine>,
) -> Result<String, String> {
use base64::Engine;
let engine = media_engine.ok_or("Media engine not available. Check media configuration.")?;
let path = input["path"].as_str().ok_or("Missing 'path' parameter")?;
let _ = validate_path(path)?;
// Read image file
let data = tokio::fs::read(path)
.await
.map_err(|e| format!("Failed to read image file: {e}"))?;
// Detect MIME type from extension
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let mime = match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"svg" => "image/svg+xml",
_ => return Err(format!("Unsupported image format: .{ext}")),
};
let attachment = openfang_types::media::MediaAttachment {
media_type: openfang_types::media::MediaType::Image,
mime_type: mime.to_string(),
source: openfang_types::media::MediaSource::Base64 {
data: base64::engine::general_purpose::STANDARD.encode(&data),
mime_type: mime.to_string(),
},
size_bytes: data.len() as u64,
};
let understanding = engine.describe_image(&attachment).await?;
serde_json::to_string_pretty(&understanding).map_err(|e| format!("Serialize error: {e}"))
}
/// Transcribe audio to text using speech-to-text.
async fn tool_media_transcribe(
input: &serde_json::Value,
media_engine: Option<&crate::media_understanding::MediaEngine>,
) -> Result<String, String> {
use base64::Engine;
let engine = media_engine.ok_or("Media engine not available. Check media configuration.")?;
let path = input["path"].as_str().ok_or("Missing 'path' parameter")?;
let _ = validate_path(path)?;
// Read audio file
let data = tokio::fs::read(path)
.await
.map_err(|e| format!("Failed to read audio file: {e}"))?;
// Detect MIME type from extension
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let mime = match ext.as_str() {
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"ogg" => "audio/ogg",
"flac" => "audio/flac",
"m4a" => "audio/mp4",
"webm" => "audio/webm",
_ => return Err(format!("Unsupported audio format: .{ext}")),
};
let attachment = openfang_types::media::MediaAttachment {
media_type: openfang_types::media::MediaType::Audio,
mime_type: mime.to_string(),
source: openfang_types::media::MediaSource::Base64 {
data: base64::engine::general_purpose::STANDARD.encode(&data),
mime_type: mime.to_string(),
},
size_bytes: data.len() as u64,
};
let understanding = engine.transcribe_audio(&attachment).await?;
serde_json::to_string_pretty(&understanding).map_err(|e| format!("Serialize error: {e}"))
}
// ---------------------------------------------------------------------------
// Image generation tool
// ---------------------------------------------------------------------------
/// Generate images from a text prompt.
async fn tool_image_generate(
input: &serde_json::Value,
workspace_root: Option<&Path>,
) -> Result<String, String> {
let prompt = input["prompt"]
.as_str()
.ok_or("Missing 'prompt' parameter")?;
let model_str = input["model"].as_str().unwrap_or("dall-e-3");
let model = match model_str {
"dall-e-3" | "dalle3" | "dalle-3" => openfang_types::media::ImageGenModel::DallE3,
"dall-e-2" | "dalle2" | "dalle-2" => openfang_types::media::ImageGenModel::DallE2,
"gpt-image-1" | "gpt_image_1" => openfang_types::media::ImageGenModel::GptImage1,
_ => {
return Err(format!(
"Unknown image model: {model_str}. Use 'dall-e-3', 'dall-e-2', or 'gpt-image-1'."
))
}
};
let size = input["size"].as_str().unwrap_or("1024x1024").to_string();
let quality = input["quality"].as_str().unwrap_or("hd").to_string();
let count = input["count"].as_u64().unwrap_or(1).min(4) as u8;
let request = openfang_types::media::ImageGenRequest {
prompt: prompt.to_string(),
model,
size,
quality,
count,
};
let result = crate::image_gen::generate_image(&request).await?;
// Save images to workspace if available
let saved_paths = if let Some(workspace) = workspace_root {
match crate::image_gen::save_images_to_workspace(&result, workspace) {
Ok(paths) => paths,
Err(e) => {
warn!("Failed to save images to workspace: {e}");
Vec::new()
}
}
} else {
Vec::new()
};
// Also save to the uploads temp dir so the web UI can serve them via
// GET /api/uploads/{file_id}. Each image gets a UUID filename.
let mut image_urls: Vec<String> = Vec::new();
{
use base64::Engine;
let upload_dir = std::env::temp_dir().join("openfang_uploads");
let _ = std::fs::create_dir_all(&upload_dir);
for img in &result.images {
let file_id = uuid::Uuid::new_v4().to_string();
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&img.data_base64)
{
let path = upload_dir.join(&file_id);
if std::fs::write(&path, &decoded).is_ok() {
image_urls.push(format!("/api/uploads/{file_id}"));
}
}
}
}
// Build response — include image_urls so the dashboard can render <img> tags
let response = serde_json::json!({
"model": result.model,
"images_generated": result.images.len(),
"saved_to": saved_paths,
"revised_prompt": result.revised_prompt,
"image_urls": image_urls,
});
serde_json::to_string_pretty(&response).map_err(|e| format!("Serialize error: {e}"))
}
// ---------------------------------------------------------------------------
// TTS / STT tools
// ---------------------------------------------------------------------------
async fn tool_text_to_speech(
input: &serde_json::Value,
tts_engine: Option<&crate::tts::TtsEngine>,
workspace_root: Option<&Path>,
) -> Result<String, String> {
let engine =
tts_engine.ok_or("TTS engine not available. Ensure tts.enabled=true in config.")?;
let text = input["text"].as_str().ok_or("Missing 'text' parameter")?;
let voice = input["voice"].as_str();
let format = input["format"].as_str();
let result = engine.synthesize(text, voice, format).await?;
// Save audio to workspace
let saved_path = if let Some(workspace) = workspace_root {
let output_dir = workspace.join("output");
tokio::fs::create_dir_all(&output_dir)
.await
.map_err(|e| format!("Failed to create output dir: {e}"))?;
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
let filename = format!("tts_{timestamp}.{}", result.format);
let path = output_dir.join(&filename);
tokio::fs::write(&path, &result.audio_data)
.await
.map_err(|e| format!("Failed to write audio file: {e}"))?;
Some(path.display().to_string())
} else {
None
};
let response = serde_json::json!({
"saved_to": saved_path,
"format": result.format,
"provider": result.provider,
"duration_estimate_ms": result.duration_estimate_ms,
"size_bytes": result.audio_data.len(),
});
serde_json::to_string_pretty(&response).map_err(|e| format!("Serialize error: {e}"))
}
async fn tool_speech_to_text(
input: &serde_json::Value,
media_engine: Option<&crate::media_understanding::MediaEngine>,
workspace_root: Option<&Path>,
) -> Result<String, String> {
let engine = media_engine.ok_or("Media engine not available for speech-to-text")?;
let raw_path = input["path"].as_str().ok_or("Missing 'path' parameter")?;
let _language = input["language"].as_str();
let resolved = resolve_file_path(raw_path, workspace_root)?;
// Read the audio file
let data = tokio::fs::read(&resolved)
.await
.map_err(|e| format!("Failed to read audio file: {e}"))?;
// Determine MIME type from extension
let ext = resolved
.extension()
.and_then(|e| e.to_str())
.unwrap_or("mp3");
let mime_type = match ext {
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"ogg" => "audio/ogg",
"flac" => "audio/flac",
"m4a" => "audio/mp4",
"webm" => "audio/webm",
_ => "audio/mpeg",
};
use openfang_types::media::{MediaAttachment, MediaSource, MediaType};
let attachment = MediaAttachment {
media_type: MediaType::Audio,
mime_type: mime_type.to_string(),
source: MediaSource::Base64 {
data: {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(&data)
},
mime_type: mime_type.to_string(),
},
size_bytes: data.len() as u64,
};
let understanding = engine.transcribe_audio(&attachment).await?;
let response = serde_json::json!({
"transcript": understanding.description,
"provider": understanding.provider,
"model": understanding.model,
});
serde_json::to_string_pretty(&response).map_err(|e| format!("Serialize error: {e}"))
}
// ---------------------------------------------------------------------------
// Docker sandbox tool
// ---------------------------------------------------------------------------
async fn tool_docker_exec(
input: &serde_json::Value,
docker_config: Option<&openfang_types::config::DockerSandboxConfig>,
workspace_root: Option<&Path>,
caller_agent_id: Option<&str>,
) -> Result<String, String> {
let config = docker_config.ok_or("Docker sandbox not configured")?;
if !config.enabled {
return Err("Docker sandbox is disabled. Set docker.enabled=true in config.".into());
}
let command = input["command"]
.as_str()
.ok_or("Missing 'command' parameter")?;
let workspace = workspace_root.ok_or("Docker exec requires a workspace directory")?;
let agent_id = caller_agent_id.unwrap_or("default");
// Check Docker availability
if !crate::docker_sandbox::is_docker_available().await {
return Err(
"Docker is not available on this system. Install Docker to use docker_exec.".into(),
);
}
// Create sandbox container
let container = crate::docker_sandbox::create_sandbox(config, agent_id, workspace).await?;
// Execute command with timeout
let timeout = std::time::Duration::from_secs(config.timeout_secs);
let result = crate::docker_sandbox::exec_in_sandbox(&container, command, timeout).await;
// Always destroy the container after execution
if let Err(e) = crate::docker_sandbox::destroy_sandbox(&container).await {
warn!("Failed to destroy Docker sandbox: {e}");
}
let exec_result = result?;
let response = serde_json::json!({
"exit_code": exec_result.exit_code,
"stdout": exec_result.stdout,
"stderr": exec_result.stderr,
"container_id": container.container_id,
});
serde_json::to_string_pretty(&response).map_err(|e| format!("Serialize error: {e}"))
}
// ---------------------------------------------------------------------------
// Persistent process tools
// ---------------------------------------------------------------------------
/// Start a long-running process (REPL, server, watcher).
async fn tool_process_start(
input: &serde_json::Value,
pm: Option<&crate::process_manager::ProcessManager>,
caller_agent_id: Option<&str>,
) -> Result<String, String> {
let pm = pm.ok_or("Process manager not available")?;
let agent_id = caller_agent_id.unwrap_or("default");
let command = input["command"]
.as_str()
.ok_or("Missing 'command' parameter")?;
let args: Vec<String> = input["args"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let proc_id = pm.start(agent_id, command, &args).await?;
Ok(serde_json::json!({
"process_id": proc_id,
"status": "started"
})
.to_string())
}
/// Read accumulated stdout/stderr from a process (non-blocking drain).
async fn tool_process_poll(
input: &serde_json::Value,
pm: Option<&crate::process_manager::ProcessManager>,
) -> Result<String, String> {
let pm = pm.ok_or("Process manager not available")?;
let proc_id = input["process_id"]
.as_str()
.ok_or("Missing 'process_id' parameter")?;
let (stdout, stderr) = pm.read(proc_id).await?;
Ok(serde_json::json!({
"stdout": stdout,
"stderr": stderr,
})
.to_string())
}
/// Write data to a process's stdin.
async fn tool_process_write(
input: &serde_json::Value,
pm: Option<&crate::process_manager::ProcessManager>,
) -> Result<String, String> {
let pm = pm.ok_or("Process manager not available")?;
let proc_id = input["process_id"]
.as_str()
.ok_or("Missing 'process_id' parameter")?;
let data = input["data"].as_str().ok_or("Missing 'data' parameter")?;
// Always append newline if not present (common expectation for REPLs)
let data = if data.ends_with('\n') {
data.to_string()
} else {
format!("{data}\n")
};
pm.write(proc_id, &data).await?;
Ok(r#"{"status": "written"}"#.to_string())
}
/// Terminate a process.
async fn tool_process_kill(
input: &serde_json::Value,
pm: Option<&crate::process_manager::ProcessManager>,
) -> Result<String, String> {
let pm = pm.ok_or("Process manager not available")?;
let proc_id = input["process_id"]
.as_str()
.ok_or("Missing 'process_id' parameter")?;
pm.kill(proc_id).await?;
Ok(r#"{"status": "killed"}"#.to_string())
}
/// List processes for the current agent.
async fn tool_process_list(
pm: Option<&crate::process_manager::ProcessManager>,
caller_agent_id: Option<&str>,
) -> Result<String, String> {
let pm = pm.ok_or("Process manager not available")?;
let agent_id = caller_agent_id.unwrap_or("default");
let procs = pm.list(agent_id);
let list: Vec<serde_json::Value> = procs
.iter()
.map(|p| {
serde_json::json!({
"id": p.id,
"command": p.command,
"alive": p.alive,
"uptime_secs": p.uptime_secs,
})
})
.collect();
Ok(serde_json::Value::Array(list).to_string())
}
// ---------------------------------------------------------------------------
// Canvas / A2UI tool
// ---------------------------------------------------------------------------
/// Sanitize HTML for canvas presentation.
///
/// SECURITY: Strips dangerous elements and attributes to prevent XSS:
/// - Rejects <script>, <iframe>, <object>, <embed>, <applet> tags
/// - Strips all on* event attributes (onclick, onload, onerror, etc.)
/// - Strips javascript:, data:text/html, vbscript: URLs
/// - Enforces size limit
pub fn sanitize_canvas_html(html: &str, max_bytes: usize) -> Result<String, String> {
if html.is_empty() {
return Err("Empty HTML content".to_string());
}
if html.len() > max_bytes {
return Err(format!(
"HTML too large: {} bytes (max {})",
html.len(),
max_bytes
));
}
let lower = html.to_lowercase();
// Reject dangerous tags
let dangerous_tags = [
"<script", "</script", "<iframe", "</iframe", "<object", "</object", "<embed", "<applet",
"</applet",
];
for tag in &dangerous_tags {
if lower.contains(tag) {
return Err(format!("Forbidden HTML tag detected: {tag}"));
}
}
// Reject event handler attributes (on*)
// Match patterns like: onclick=, onload=, onerror=, onmouseover=, etc.
static EVENT_PATTERN: std::sync::LazyLock<regex_lite::Regex> =
std::sync::LazyLock::new(|| regex_lite::Regex::new(r"(?i)\bon[a-z]+\s*=").unwrap());
if EVENT_PATTERN.is_match(html) {
return Err(
"Forbidden event handler attribute detected (on* attributes are not allowed)"
.to_string(),
);
}
// Reject dangerous URL schemes
let dangerous_schemes = ["javascript:", "vbscript:", "data:text/html"];
for scheme in &dangerous_schemes {
if lower.contains(scheme) {
return Err(format!("Forbidden URL scheme detected: {scheme}"));
}
}
Ok(html.to_string())
}
/// Canvas presentation tool handler.
async fn tool_canvas_present(
input: &serde_json::Value,
workspace_root: Option<&Path>,
) -> Result<String, String> {
let html = input["html"].as_str().ok_or("Missing 'html' parameter")?;
let title = input["title"].as_str().unwrap_or("Canvas");
// Use configured max from task-local (set by agent_loop from KernelConfig), or default 512KB.
let max_bytes = CANVAS_MAX_BYTES.try_with(|v| *v).unwrap_or(512 * 1024);
let sanitized = sanitize_canvas_html(html, max_bytes)?;
// Generate canvas ID
let canvas_id = uuid::Uuid::new_v4().to_string();
// Save to workspace output directory
let output_dir = if let Some(root) = workspace_root {
root.join("output")
} else {
PathBuf::from("output")
};
let _ = tokio::fs::create_dir_all(&output_dir).await;
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let filename = format!(
"canvas_{timestamp}_{}.html",
crate::str_utils::safe_truncate_str(&canvas_id, 8)
);
let filepath = output_dir.join(&filename);
// Write the full HTML document
let full_html = format!(
"<!DOCTYPE html>\n<html>\n<head><meta charset=\"utf-8\"><title>{title}</title></head>\n<body>\n{sanitized}\n</body>\n</html>"
);
tokio::fs::write(&filepath, &full_html)
.await
.map_err(|e| format!("Failed to save canvas: {e}"))?;
let response = serde_json::json!({
"canvas_id": canvas_id,
"title": title,
"saved_to": filepath.to_string_lossy(),
"size_bytes": full_html.len(),
});
serde_json::to_string_pretty(&response).map_err(|e| format!("Serialize error: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builtin_tool_definitions() {
let tools = builtin_tool_definitions();
assert!(
tools.len() >= 39,
"Expected at least 39 tools, got {}",
tools.len()
);
let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
// Original 12
assert!(names.contains(&"file_read"));
assert!(names.contains(&"shell_exec"));
assert!(names.contains(&"agent_send"));
assert!(names.contains(&"agent_spawn"));
assert!(names.contains(&"agent_list"));
assert!(names.contains(&"agent_kill"));
assert!(names.contains(&"memory_store"));
assert!(names.contains(&"memory_recall"));
// 6 collaboration tools
assert!(names.contains(&"agent_find"));
assert!(names.contains(&"task_post"));
assert!(names.contains(&"task_claim"));
assert!(names.contains(&"task_complete"));
assert!(names.contains(&"task_list"));
assert!(names.contains(&"event_publish"));
// 5 new Phase 3 tools
assert!(names.contains(&"schedule_create"));
assert!(names.contains(&"schedule_list"));
assert!(names.contains(&"schedule_delete"));
assert!(names.contains(&"image_analyze"));
assert!(names.contains(&"location_get"));
assert!(names.contains(&"system_time"));
// 6 browser tools
assert!(names.contains(&"browser_navigate"));
assert!(names.contains(&"browser_click"));
assert!(names.contains(&"browser_type"));
assert!(names.contains(&"browser_screenshot"));
assert!(names.contains(&"browser_read_page"));
assert!(names.contains(&"browser_close"));
assert!(names.contains(&"browser_scroll"));
assert!(names.contains(&"browser_wait"));
assert!(names.contains(&"browser_run_js"));
assert!(names.contains(&"browser_back"));
// 3 media/image generation tools
assert!(names.contains(&"media_describe"));
assert!(names.contains(&"media_transcribe"));
assert!(names.contains(&"image_generate"));
// 3 cron tools
assert!(names.contains(&"cron_create"));
assert!(names.contains(&"cron_list"));
assert!(names.contains(&"cron_cancel"));
// 1 channel send tool
assert!(names.contains(&"channel_send"));
// 4 hand tools
assert!(names.contains(&"hand_list"));
assert!(names.contains(&"hand_activate"));
assert!(names.contains(&"hand_status"));
assert!(names.contains(&"hand_deactivate"));
// 3 voice/docker tools
assert!(names.contains(&"text_to_speech"));
assert!(names.contains(&"speech_to_text"));
assert!(names.contains(&"docker_exec"));
// Canvas tool
assert!(names.contains(&"canvas_present"));
}
#[test]
fn test_collaboration_tool_schemas() {
let tools = builtin_tool_definitions();
let collab_tools = [
"agent_find",
"task_post",
"task_claim",
"task_complete",
"task_list",
"event_publish",
];
for name in &collab_tools {
let tool = tools
.iter()
.find(|t| t.name == *name)
.unwrap_or_else(|| panic!("Tool '{}' not found", name));
// Verify each has a valid JSON schema
assert!(
tool.input_schema.is_object(),
"Tool '{}' schema should be an object",
name
);
assert_eq!(
tool.input_schema["type"], "object",
"Tool '{}' should have type=object",
name
);
}
}
#[tokio::test]
async fn test_file_read_missing() {
let bad_path = std::env::temp_dir()
.join("openfang_test_nonexistent_99999")
.join("file.txt");
let result = execute_tool(
"test-id",
"file_read",
&serde_json::json!({"path": bad_path.to_str().unwrap()}),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
assert!(
result.is_error,
"Expected error but got: {}",
result.content
);
}
#[tokio::test]
async fn test_file_read_path_traversal_blocked() {
let result = execute_tool(
"test-id",
"file_read",
&serde_json::json!({"path": "../../etc/passwd"}),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
assert!(result.is_error);
assert!(result.content.contains("traversal"));
}
#[tokio::test]
async fn test_file_write_path_traversal_blocked() {
let result = execute_tool(
"test-id",
"file_write",
&serde_json::json!({"path": "../../../tmp/evil.txt", "content": "pwned"}),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
assert!(result.is_error);
assert!(result.content.contains("traversal"));
}
#[tokio::test]
async fn test_file_list_path_traversal_blocked() {
let result = execute_tool(
"test-id",
"file_list",
&serde_json::json!({"path": "/foo/../../etc"}),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
assert!(result.is_error);
assert!(result.content.contains("traversal"));
}
#[tokio::test]
async fn test_web_search() {
let result = execute_tool(
"test-id",
"web_search",
&serde_json::json!({"query": "rust programming"}),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
// web_search now attempts a real fetch; may succeed or fail depending on network
assert!(!result.tool_use_id.is_empty());
}
#[tokio::test]
async fn test_unknown_tool() {
let result = execute_tool(
"test-id",
"nonexistent_tool",
&serde_json::json!({}),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
assert!(result.is_error);
assert!(result.content.contains("Unknown tool"));
}
#[tokio::test]
async fn test_agent_tools_without_kernel() {
let result = execute_tool(
"test-id",
"agent_list",
&serde_json::json!({}),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
assert!(result.is_error);
assert!(result.content.contains("Kernel handle not available"));
}
#[tokio::test]
async fn test_capability_enforcement_denied() {
let allowed = vec!["file_read".to_string(), "file_list".to_string()];
let result = execute_tool(
"test-id",
"shell_exec",
&serde_json::json!({"command": "ls"}),
None,
Some(&allowed),
None,
None,
None,
None,
None,
None,
None,
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
assert!(result.is_error);
assert!(result.content.contains("Permission denied"));
}
#[tokio::test]
async fn test_capability_enforcement_allowed() {
let allowed = vec!["file_read".to_string()];
// Use a cross-platform nonexistent path
let bad_path = std::env::temp_dir()
.join("openfang_test_nonexistent_12345")
.join("file.txt");
let result = execute_tool(
"test-id",
"file_read",
&serde_json::json!({"path": bad_path.to_str().unwrap()}),
None,
Some(&allowed),
None,
None,
None,
None,
None,
None,
None,
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
// Should fail for file-not-found, NOT for permission denied
assert!(
result.is_error,
"Expected error but got: {}",
result.content
);
assert!(
result.content.contains("Failed to read")
|| result.content.contains("not found")
|| result.content.contains("No such file"),
"Unexpected error: {}",
result.content
);
}
#[tokio::test]
async fn test_capability_enforcement_aliased_tool_name() {
// Agent has "file_write" in allowed tools, but LLM calls "fs-write".
// After normalization, this should pass the capability check.
let allowed = vec![
"file_read".to_string(),
"file_write".to_string(),
"file_list".to_string(),
"shell_exec".to_string(),
];
let result = execute_tool(
"test-id",
"fs-write", // LLM-hallucinated alias
&serde_json::json!({"path": "/nonexistent/file.txt", "content": "hello"}),
None,
Some(&allowed),
None,
None,
None,
None,
None,
None,
None,
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
// Should NOT be "Permission denied" — it should normalize to file_write
// and pass the capability check. It will fail for other reasons (path validation).
assert!(
!result.content.contains("Permission denied"),
"fs-write should normalize to file_write and pass capability check, got: {}",
result.content
);
}
#[tokio::test]
async fn test_capability_enforcement_aliased_denied() {
// Agent does NOT have file_write, and LLM calls "fs-write" — should be denied.
let allowed = vec!["file_read".to_string()];
let result = execute_tool(
"test-id",
"fs-write",
&serde_json::json!({"path": "/tmp/test.txt", "content": "hello"}),
None,
Some(&allowed),
None,
None,
None,
None,
None,
None,
None,
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
assert!(result.is_error);
assert!(
result.content.contains("Permission denied"),
"fs-write should normalize to file_write which is not in allowed list"
);
}
// --- Schedule parser tests ---
#[test]
fn test_parse_schedule_every_minutes() {
assert_eq!(
parse_schedule_to_cron("every 5 minutes").unwrap(),
"*/5 * * * *"
);
assert_eq!(
parse_schedule_to_cron("every 1 minute").unwrap(),
"* * * * *"
);
assert_eq!(parse_schedule_to_cron("every minute").unwrap(), "* * * * *");
assert_eq!(
parse_schedule_to_cron("every 30 minutes").unwrap(),
"*/30 * * * *"
);
}
#[test]
fn test_parse_schedule_every_hours() {
assert_eq!(parse_schedule_to_cron("every hour").unwrap(), "0 * * * *");
assert_eq!(parse_schedule_to_cron("every 1 hour").unwrap(), "0 * * * *");
assert_eq!(
parse_schedule_to_cron("every 2 hours").unwrap(),
"0 */2 * * *"
);
}
#[test]
fn test_parse_schedule_daily() {
assert_eq!(parse_schedule_to_cron("daily at 9am").unwrap(), "0 9 * * *");
assert_eq!(
parse_schedule_to_cron("daily at 6pm").unwrap(),
"0 18 * * *"
);
assert_eq!(
parse_schedule_to_cron("daily at 12am").unwrap(),
"0 0 * * *"
);
assert_eq!(
parse_schedule_to_cron("daily at 12pm").unwrap(),
"0 12 * * *"
);
}
#[test]
fn test_parse_schedule_weekdays() {
assert_eq!(
parse_schedule_to_cron("weekdays at 9am").unwrap(),
"0 9 * * 1-5"
);
assert_eq!(
parse_schedule_to_cron("weekends at 10am").unwrap(),
"0 10 * * 0,6"
);
}
#[test]
fn test_parse_schedule_shorthand() {
assert_eq!(parse_schedule_to_cron("hourly").unwrap(), "0 * * * *");
assert_eq!(parse_schedule_to_cron("daily").unwrap(), "0 0 * * *");
assert_eq!(parse_schedule_to_cron("weekly").unwrap(), "0 0 * * 0");
assert_eq!(parse_schedule_to_cron("monthly").unwrap(), "0 0 1 * *");
}
#[test]
fn test_parse_schedule_cron_passthrough() {
assert_eq!(
parse_schedule_to_cron("0 */5 * * *").unwrap(),
"0 */5 * * *"
);
assert_eq!(
parse_schedule_to_cron("30 9 * * 1-5").unwrap(),
"30 9 * * 1-5"
);
}
#[test]
fn test_parse_schedule_invalid() {
assert!(parse_schedule_to_cron("whenever I feel like it").is_err());
assert!(parse_schedule_to_cron("every 0 minutes").is_err());
}
// --- Image format detection tests ---
#[test]
fn test_detect_image_format_png() {
let data = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x10\x00\x00\x00\x10";
assert_eq!(detect_image_format(data), "png");
}
#[test]
fn test_detect_image_format_jpeg() {
let data = b"\xFF\xD8\xFF\xE0\x00\x10JFIF";
assert_eq!(detect_image_format(data), "jpeg");
}
#[test]
fn test_detect_image_format_gif() {
let data = b"GIF89a\x10\x00\x10\x00";
assert_eq!(detect_image_format(data), "gif");
}
#[test]
fn test_detect_image_format_bmp() {
let data = b"BM\x00\x00\x00\x00";
assert_eq!(detect_image_format(data), "bmp");
}
#[test]
fn test_detect_image_format_unknown() {
let data = b"\x00\x00\x00\x00";
assert_eq!(detect_image_format(data), "unknown");
}
#[test]
fn test_extract_png_dimensions() {
// Minimal PNG header: signature (8) + IHDR length (4) + "IHDR" (4) + width (4) + height (4)
let mut data = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; // signature
data.extend_from_slice(&[0x00, 0x00, 0x00, 0x0D]); // IHDR length
data.extend_from_slice(b"IHDR"); // chunk type
data.extend_from_slice(&640u32.to_be_bytes()); // width
data.extend_from_slice(&480u32.to_be_bytes()); // height
assert_eq!(extract_image_dimensions(&data, "png"), Some((640, 480)));
}
#[test]
fn test_extract_gif_dimensions() {
let mut data = b"GIF89a".to_vec();
data.extend_from_slice(&320u16.to_le_bytes()); // width
data.extend_from_slice(&240u16.to_le_bytes()); // height
assert_eq!(extract_image_dimensions(&data, "gif"), Some((320, 240)));
}
#[test]
fn test_format_file_size() {
assert_eq!(format_file_size(500), "500 B");
assert_eq!(format_file_size(1536), "1.5 KB");
assert_eq!(format_file_size(2 * 1024 * 1024), "2.0 MB");
}
#[tokio::test]
async fn test_image_analyze_missing_file() {
let result = execute_tool(
"test-id",
"image_analyze",
&serde_json::json!({"path": "/nonexistent/image.png"}),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
assert!(result.is_error);
assert!(result.content.contains("Failed to read"));
}
#[test]
fn test_depth_limit_constant() {
assert_eq!(MAX_AGENT_CALL_DEPTH, 5);
}
#[test]
fn test_depth_limit_first_call_succeeds() {
// Default depth is 0, which is < MAX_AGENT_CALL_DEPTH
let default_depth = AGENT_CALL_DEPTH.try_with(|d| d.get()).unwrap_or(0);
assert!(default_depth < MAX_AGENT_CALL_DEPTH);
}
#[test]
fn test_task_local_compiles() {
// Verify task_local macro works — just ensure the type exists
let cell = std::cell::Cell::new(0u32);
assert_eq!(cell.get(), 0);
}
#[tokio::test]
async fn test_schedule_tools_without_kernel() {
let result = execute_tool(
"test-id",
"schedule_list",
&serde_json::json!({}),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None, // media_engine
None, // exec_policy
None, // tts_engine
None, // docker_config
None, // process_manager
)
.await;
assert!(result.is_error);
assert!(result.content.contains("Kernel handle not available"));
}
// ─── Canvas / A2UI tests ────────────────────────────────────────
#[test]
fn test_sanitize_canvas_basic_html() {
let html = "<h1>Hello World</h1><p>This is a test.</p>";
let result = sanitize_canvas_html(html, 512 * 1024);
assert!(result.is_ok());
assert_eq!(result.unwrap(), html);
}
#[test]
fn test_sanitize_canvas_rejects_script() {
let html = "<div><script>alert('xss')</script></div>";
let result = sanitize_canvas_html(html, 512 * 1024);
assert!(result.is_err());
assert!(result.unwrap_err().contains("script"));
}
#[test]
fn test_sanitize_canvas_rejects_iframe() {
let html = "<iframe src='https://evil.com'></iframe>";
let result = sanitize_canvas_html(html, 512 * 1024);
assert!(result.is_err());
assert!(result.unwrap_err().contains("iframe"));
}
#[test]
fn test_sanitize_canvas_rejects_event_handler() {
let html = "<div onclick=\"alert('xss')\">click me</div>";
let result = sanitize_canvas_html(html, 512 * 1024);
assert!(result.is_err());
assert!(result.unwrap_err().contains("event handler"));
}
#[test]
fn test_sanitize_canvas_rejects_onload() {
let html = "<img src='x' onerror = \"alert(1)\">";
let result = sanitize_canvas_html(html, 512 * 1024);
assert!(result.is_err());
}
#[test]
fn test_sanitize_canvas_rejects_javascript_url() {
let html = "<a href=\"javascript:alert('xss')\">click</a>";
let result = sanitize_canvas_html(html, 512 * 1024);
assert!(result.is_err());
assert!(result.unwrap_err().contains("javascript:"));
}
#[test]
fn test_sanitize_canvas_rejects_data_html() {
let html = "<a href=\"data:text/html,<script>alert(1)</script>\">x</a>";
let result = sanitize_canvas_html(html, 512 * 1024);
assert!(result.is_err());
}
#[test]
fn test_sanitize_canvas_rejects_empty() {
let result = sanitize_canvas_html("", 512 * 1024);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Empty"));
}
#[test]
fn test_sanitize_canvas_size_limit() {
let html = "x".repeat(1024);
let result = sanitize_canvas_html(&html, 100);
assert!(result.is_err());
assert!(result.unwrap_err().contains("too large"));
}
#[tokio::test]
async fn test_canvas_present_tool() {
let input = serde_json::json!({
"html": "<h1>Test Canvas</h1><p>Hello world</p>",
"title": "Test"
});
let tmp = std::env::temp_dir().join("openfang_canvas_test");
let _ = std::fs::create_dir_all(&tmp);
let result = tool_canvas_present(&input, Some(tmp.as_path())).await;
assert!(result.is_ok());
let output: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
assert!(output["canvas_id"].is_string());
assert_eq!(output["title"], "Test");
// Cleanup
let _ = std::fs::remove_dir_all(&tmp);
}
}