diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 806c309..548466a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -250,6 +250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -258,6 +259,18 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -271,7 +284,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -946,7 +962,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -1412,6 +1430,7 @@ dependencies = [ name = "tools" version = "0.1.0" dependencies = [ + "reqwest", "runtime", "serde", "serde_json", diff --git a/rust/crates/tools/.gitignore b/rust/crates/tools/.gitignore new file mode 100644 index 0000000..96da1ea --- /dev/null +++ b/rust/crates/tools/.gitignore @@ -0,0 +1 @@ +.clawd-agents/ diff --git a/rust/crates/tools/Cargo.toml b/rust/crates/tools/Cargo.toml index e1fb5bb..64768f4 100644 --- a/rust/crates/tools/Cargo.toml +++ b/rust/crates/tools/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [dependencies] runtime = { path = "../runtime" } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index e849990..5927e64 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -1,8 +1,12 @@ +use std::collections::BTreeSet; +use std::time::{Duration, Instant}; + +use reqwest::blocking::Client; use runtime::{ edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput, GrepSearchInput, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -140,23 +144,172 @@ pub fn mvp_tool_specs() -> Vec { "additionalProperties": false }), }, + ToolSpec { + name: "WebFetch", + description: + "Fetch a URL, convert it into readable text, and answer a prompt about it.", + input_schema: json!({ + "type": "object", + "properties": { + "url": { "type": "string", "format": "uri" }, + "prompt": { "type": "string" } + }, + "required": ["url", "prompt"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "WebSearch", + description: "Search the web for current information and return cited results.", + input_schema: json!({ + "type": "object", + "properties": { + "query": { "type": "string", "minLength": 2 }, + "allowed_domains": { + "type": "array", + "items": { "type": "string" } + }, + "blocked_domains": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["query"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "TodoWrite", + description: "Update the structured task list for the current session.", + input_schema: json!({ + "type": "object", + "properties": { + "todos": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": { "type": "string" }, + "activeForm": { "type": "string" }, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed"] + } + }, + "required": ["content", "activeForm", "status"], + "additionalProperties": false + } + } + }, + "required": ["todos"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "Skill", + description: "Load a local skill definition and its instructions.", + input_schema: json!({ + "type": "object", + "properties": { + "skill": { "type": "string" }, + "args": { "type": "string" } + }, + "required": ["skill"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "Agent", + description: "Launch a specialized agent task and persist its handoff metadata.", + input_schema: json!({ + "type": "object", + "properties": { + "description": { "type": "string" }, + "prompt": { "type": "string" }, + "subagent_type": { "type": "string" }, + "name": { "type": "string" }, + "model": { "type": "string" } + }, + "required": ["description", "prompt"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "ToolSearch", + description: "Search for deferred or specialized tools by exact name or keywords.", + input_schema: json!({ + "type": "object", + "properties": { + "query": { "type": "string" }, + "max_results": { "type": "integer", "minimum": 1 } + }, + "required": ["query"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "NotebookEdit", + description: "Replace, insert, or delete a cell in a Jupyter notebook.", + input_schema: json!({ + "type": "object", + "properties": { + "notebook_path": { "type": "string" }, + "cell_id": { "type": "string" }, + "new_source": { "type": "string" }, + "cell_type": { "type": "string", "enum": ["code", "markdown"] }, + "edit_mode": { "type": "string", "enum": ["replace", "insert", "delete"] } + }, + "required": ["notebook_path"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "Sleep", + description: "Wait for a specified duration without holding a shell process.", + input_schema: json!({ + "type": "object", + "properties": { + "duration_ms": { "type": "integer", "minimum": 0 } + }, + "required": ["duration_ms"], + "additionalProperties": false + }), + }, + ToolSpec { + name: "PowerShell", + description: "Execute a PowerShell command with optional timeout.", + input_schema: json!({ + "type": "object", + "properties": { + "command": { "type": "string" }, + "timeout": { "type": "integer", "minimum": 1 }, + "description": { "type": "string" }, + "run_in_background": { "type": "boolean" } + }, + "required": ["command"], + "additionalProperties": false + }), + }, ] } pub fn execute_tool(name: &str, input: &Value) -> Result { match name { "bash" => from_value::(input).and_then(run_bash), - "read_file" => from_value::(input).and_then(|input| run_read_file(&input)), - "write_file" => { - from_value::(input).and_then(|input| run_write_file(&input)) - } - "edit_file" => from_value::(input).and_then(|input| run_edit_file(&input)), - "glob_search" => { - from_value::(input).and_then(|input| run_glob_search(&input)) - } - "grep_search" => { - from_value::(input).and_then(|input| run_grep_search(&input)) - } + "read_file" => from_value::(input).and_then(run_read_file), + "write_file" => from_value::(input).and_then(run_write_file), + "edit_file" => from_value::(input).and_then(run_edit_file), + "glob_search" => from_value::(input).and_then(run_glob_search), + "grep_search" => from_value::(input).and_then(run_grep_search), + "WebFetch" => from_value::(input).and_then(run_web_fetch), + "WebSearch" => from_value::(input).and_then(run_web_search), + "TodoWrite" => from_value::(input).and_then(run_todo_write), + "Skill" => from_value::(input).and_then(run_skill), + "Agent" => from_value::(input).and_then(run_agent), + "ToolSearch" => from_value::(input).and_then(run_tool_search), + "NotebookEdit" => from_value::(input).and_then(run_notebook_edit), + "Sleep" => from_value::(input).and_then(run_sleep), + "PowerShell" => from_value::(input).and_then(run_powershell), _ => Err(format!("unsupported tool: {name}")), } } @@ -170,17 +323,15 @@ fn run_bash(input: BashCommandInput) -> Result { .map_err(|error| error.to_string()) } -fn run_read_file(input: &ReadFileInput) -> Result { - to_pretty_json( - read_file(&input.path, input.offset, input.limit).map_err(|error| io_to_string(&error))?, - ) +fn run_read_file(input: ReadFileInput) -> Result { + to_pretty_json(read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?) } -fn run_write_file(input: &WriteFileInput) -> Result { - to_pretty_json(write_file(&input.path, &input.content).map_err(|error| io_to_string(&error))?) +fn run_write_file(input: WriteFileInput) -> Result { + to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?) } -fn run_edit_file(input: &EditFileInput) -> Result { +fn run_edit_file(input: EditFileInput) -> Result { to_pretty_json( edit_file( &input.path, @@ -188,25 +339,59 @@ fn run_edit_file(input: &EditFileInput) -> Result { &input.new_string, input.replace_all.unwrap_or(false), ) - .map_err(|error| io_to_string(&error))?, + .map_err(io_to_string)?, ) } -fn run_glob_search(input: &GlobSearchInputValue) -> Result { - to_pretty_json( - glob_search(&input.pattern, input.path.as_deref()).map_err(|error| io_to_string(&error))?, - ) +fn run_glob_search(input: GlobSearchInputValue) -> Result { + to_pretty_json(glob_search(&input.pattern, input.path.as_deref()).map_err(io_to_string)?) } -fn run_grep_search(input: &GrepSearchInput) -> Result { - to_pretty_json(grep_search(input).map_err(|error| io_to_string(&error))?) +fn run_grep_search(input: GrepSearchInput) -> Result { + to_pretty_json(grep_search(&input).map_err(io_to_string)?) +} + +fn run_web_fetch(input: WebFetchInput) -> Result { + to_pretty_json(execute_web_fetch(&input)?) +} + +fn run_web_search(input: WebSearchInput) -> Result { + to_pretty_json(execute_web_search(&input)?) +} + +fn run_todo_write(input: TodoWriteInput) -> Result { + to_pretty_json(execute_todo_write(input)?) +} + +fn run_skill(input: SkillInput) -> Result { + to_pretty_json(execute_skill(input)?) +} + +fn run_agent(input: AgentInput) -> Result { + to_pretty_json(execute_agent(input)?) +} + +fn run_tool_search(input: ToolSearchInput) -> Result { + to_pretty_json(execute_tool_search(input)) +} + +fn run_notebook_edit(input: NotebookEditInput) -> Result { + to_pretty_json(execute_notebook_edit(input)?) +} + +fn run_sleep(input: SleepInput) -> Result { + to_pretty_json(execute_sleep(input)) +} + +fn run_powershell(input: PowerShellInput) -> Result { + to_pretty_json(execute_powershell(input).map_err(|error| error.to_string())?) } fn to_pretty_json(value: T) -> Result { serde_json::to_string_pretty(&value).map_err(|error| error.to_string()) } -fn io_to_string(error: &std::io::Error) -> String { +fn io_to_string(error: std::io::Error) -> String { error.to_string() } @@ -237,11 +422,1370 @@ struct GlobSearchInputValue { path: Option, } +#[derive(Debug, Deserialize)] +struct WebFetchInput { + url: String, + prompt: String, +} + +#[derive(Debug, Deserialize)] +struct WebSearchInput { + query: String, + allowed_domains: Option>, + blocked_domains: Option>, +} + +#[derive(Debug, Deserialize)] +struct TodoWriteInput { + todos: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +struct TodoItem { + content: String, + #[serde(rename = "activeForm")] + active_form: String, + status: TodoStatus, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum TodoStatus { + Pending, + InProgress, + Completed, +} + +#[derive(Debug, Deserialize)] +struct SkillInput { + skill: String, + args: Option, +} + +#[derive(Debug, Deserialize)] +struct AgentInput { + description: String, + prompt: String, + subagent_type: Option, + name: Option, + model: Option, +} + +#[derive(Debug, Deserialize)] +struct ToolSearchInput { + query: String, + max_results: Option, +} + +#[derive(Debug, Deserialize)] +struct NotebookEditInput { + notebook_path: String, + cell_id: Option, + new_source: Option, + cell_type: Option, + edit_mode: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum NotebookCellType { + Code, + Markdown, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum NotebookEditMode { + Replace, + Insert, + Delete, +} + +#[derive(Debug, Deserialize)] +struct SleepInput { + duration_ms: u64, +} + +#[derive(Debug, Deserialize)] +struct PowerShellInput { + command: String, + timeout: Option, + description: Option, + run_in_background: Option, +} + +#[derive(Debug, Serialize)] +struct WebFetchOutput { + bytes: usize, + code: u16, + #[serde(rename = "codeText")] + code_text: String, + result: String, + #[serde(rename = "durationMs")] + duration_ms: u128, + url: String, +} + +#[derive(Debug, Serialize)] +struct WebSearchOutput { + query: String, + results: Vec, + #[serde(rename = "durationSeconds")] + duration_seconds: f64, +} + +#[derive(Debug, Serialize)] +struct TodoWriteOutput { + #[serde(rename = "oldTodos")] + old_todos: Vec, + #[serde(rename = "newTodos")] + new_todos: Vec, + #[serde(rename = "verificationNudgeNeeded")] + verification_nudge_needed: Option, +} + +#[derive(Debug, Serialize)] +struct SkillOutput { + skill: String, + path: String, + args: Option, + description: Option, + prompt: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct AgentOutput { + #[serde(rename = "agentId")] + agent_id: String, + name: String, + description: String, + #[serde(rename = "subagentType")] + subagent_type: Option, + model: Option, + status: String, + #[serde(rename = "outputFile")] + output_file: String, + #[serde(rename = "manifestFile")] + manifest_file: String, + #[serde(rename = "createdAt")] + created_at: String, +} + +#[derive(Debug, Serialize)] +struct ToolSearchOutput { + matches: Vec, + query: String, + normalized_query: String, + #[serde(rename = "total_deferred_tools")] + total_deferred_tools: usize, + #[serde(rename = "pending_mcp_servers")] + pending_mcp_servers: Option>, +} + +#[derive(Debug, Serialize)] +struct NotebookEditOutput { + new_source: String, + cell_id: Option, + cell_type: Option, + language: String, + edit_mode: String, + error: Option, + notebook_path: String, + original_file: String, + updated_file: String, +} + +#[derive(Debug, Serialize)] +struct SleepOutput { + duration_ms: u64, + message: String, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +enum WebSearchResultItem { + SearchResult { + tool_use_id: String, + content: Vec, + }, + Commentary(String), +} + +#[derive(Debug, Serialize)] +struct SearchHit { + title: String, + url: String, +} + +fn execute_web_fetch(input: &WebFetchInput) -> Result { + let started = Instant::now(); + let client = build_http_client()?; + let request_url = normalize_fetch_url(&input.url)?; + let response = client + .get(request_url.clone()) + .send() + .map_err(|error| error.to_string())?; + + let status = response.status(); + let final_url = response.url().to_string(); + let code = status.as_u16(); + let code_text = status.canonical_reason().unwrap_or("Unknown").to_string(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = response.text().map_err(|error| error.to_string())?; + let bytes = body.len(); + let normalized = normalize_fetched_content(&body, &content_type); + let result = summarize_web_fetch(&final_url, &input.prompt, &normalized, &body, &content_type); + + Ok(WebFetchOutput { + bytes, + code, + code_text, + result, + duration_ms: started.elapsed().as_millis(), + url: final_url, + }) +} + +fn execute_web_search(input: &WebSearchInput) -> Result { + let started = Instant::now(); + let client = build_http_client()?; + let search_url = build_search_url(&input.query)?; + let response = client + .get(search_url) + .send() + .map_err(|error| error.to_string())?; + + let final_url = response.url().clone(); + let html = response.text().map_err(|error| error.to_string())?; + let mut hits = extract_search_hits(&html); + + if hits.is_empty() && final_url.host_str().is_some() { + hits = extract_search_hits_from_generic_links(&html); + } + + if let Some(allowed) = input.allowed_domains.as_ref() { + hits.retain(|hit| host_matches_list(&hit.url, allowed)); + } + if let Some(blocked) = input.blocked_domains.as_ref() { + hits.retain(|hit| !host_matches_list(&hit.url, blocked)); + } + + dedupe_hits(&mut hits); + hits.truncate(8); + + let summary = if hits.is_empty() { + format!("No web search results matched the query {:?}.", input.query) + } else { + let rendered_hits = hits + .iter() + .map(|hit| format!("- [{}]({})", hit.title, hit.url)) + .collect::>() + .join("\n"); + format!( + "Search results for {:?}. Include a Sources section in the final answer.\n{}", + input.query, rendered_hits + ) + }; + + Ok(WebSearchOutput { + query: input.query.clone(), + results: vec![ + WebSearchResultItem::Commentary(summary), + WebSearchResultItem::SearchResult { + tool_use_id: String::from("web_search_1"), + content: hits, + }, + ], + duration_seconds: started.elapsed().as_secs_f64(), + }) +} + +fn build_http_client() -> Result { + Client::builder() + .timeout(Duration::from_secs(20)) + .redirect(reqwest::redirect::Policy::limited(10)) + .user_agent("clawd-rust-tools/0.1") + .build() + .map_err(|error| error.to_string()) +} + +fn normalize_fetch_url(url: &str) -> Result { + let parsed = reqwest::Url::parse(url).map_err(|error| error.to_string())?; + if parsed.scheme() == "http" { + let host = parsed.host_str().unwrap_or_default(); + if host != "localhost" && host != "127.0.0.1" && host != "::1" { + let mut upgraded = parsed; + upgraded + .set_scheme("https") + .map_err(|_| String::from("failed to upgrade URL to https"))?; + return Ok(upgraded.to_string()); + } + } + Ok(parsed.to_string()) +} + +fn build_search_url(query: &str) -> Result { + if let Ok(base) = std::env::var("CLAWD_WEB_SEARCH_BASE_URL") { + let mut url = reqwest::Url::parse(&base).map_err(|error| error.to_string())?; + url.query_pairs_mut().append_pair("q", query); + return Ok(url); + } + + let mut url = reqwest::Url::parse("https://html.duckduckgo.com/html/") + .map_err(|error| error.to_string())?; + url.query_pairs_mut().append_pair("q", query); + Ok(url) +} + +fn normalize_fetched_content(body: &str, content_type: &str) -> String { + if content_type.contains("html") { + html_to_text(body) + } else { + body.trim().to_string() + } +} + +fn summarize_web_fetch( + url: &str, + prompt: &str, + content: &str, + raw_body: &str, + content_type: &str, +) -> String { + let lower_prompt = prompt.to_lowercase(); + let compact = collapse_whitespace(content); + + let detail = if lower_prompt.contains("title") { + extract_title(content, raw_body, content_type) + .map(|title| format!("Title: {title}")) + .unwrap_or_else(|| preview_text(&compact, 600)) + } else if lower_prompt.contains("summary") || lower_prompt.contains("summarize") { + preview_text(&compact, 900) + } else { + let preview = preview_text(&compact, 900); + format!("Prompt: {prompt}\nContent preview:\n{preview}") + }; + + format!("Fetched {url}\n{detail}") +} + +fn extract_title(content: &str, raw_body: &str, content_type: &str) -> Option { + if content_type.contains("html") { + let lowered = raw_body.to_lowercase(); + if let Some(start) = lowered.find("") { + let after = start + "<title>".len(); + if let Some(end_rel) = lowered[after..].find("") { + let title = + collapse_whitespace(&decode_html_entities(&raw_body[after..after + end_rel])); + if !title.is_empty() { + return Some(title); + } + } + } + } + + for line in content.lines() { + let trimmed = line.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + None +} + +fn html_to_text(html: &str) -> String { + let mut text = String::with_capacity(html.len()); + let mut in_tag = false; + let mut previous_was_space = false; + + for ch in html.chars() { + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + _ if in_tag => {} + '&' => { + text.push('&'); + previous_was_space = false; + } + ch if ch.is_whitespace() => { + if !previous_was_space { + text.push(' '); + previous_was_space = true; + } + } + _ => { + text.push(ch); + previous_was_space = false; + } + } + } + + collapse_whitespace(&decode_html_entities(&text)) +} + +fn decode_html_entities(input: &str) -> String { + input + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace(" ", " ") +} + +fn collapse_whitespace(input: &str) -> String { + input.split_whitespace().collect::>().join(" ") +} + +fn preview_text(input: &str, max_chars: usize) -> String { + if input.chars().count() <= max_chars { + return input.to_string(); + } + let shortened = input.chars().take(max_chars).collect::(); + format!("{}…", shortened.trim_end()) +} + +fn extract_search_hits(html: &str) -> Vec { + let mut hits = Vec::new(); + let mut remaining = html; + + while let Some(anchor_start) = remaining.find("result__a") { + let after_class = &remaining[anchor_start..]; + let Some(href_idx) = after_class.find("href=") else { + remaining = &after_class[1..]; + continue; + }; + let href_slice = &after_class[href_idx + 5..]; + let Some((url, rest)) = extract_quoted_value(href_slice) else { + remaining = &after_class[1..]; + continue; + }; + let Some(close_tag_idx) = rest.find('>') else { + remaining = &after_class[1..]; + continue; + }; + let after_tag = &rest[close_tag_idx + 1..]; + let Some(end_anchor_idx) = after_tag.find("") else { + remaining = &after_tag[1..]; + continue; + }; + let title = html_to_text(&after_tag[..end_anchor_idx]); + if let Some(decoded_url) = decode_duckduckgo_redirect(&url) { + hits.push(SearchHit { + title: title.trim().to_string(), + url: decoded_url, + }); + } + remaining = &after_tag[end_anchor_idx + 4..]; + } + + hits +} + +fn extract_search_hits_from_generic_links(html: &str) -> Vec { + let mut hits = Vec::new(); + let mut remaining = html; + + while let Some(anchor_start) = remaining.find("') else { + remaining = &after_anchor[2..]; + continue; + }; + let after_tag = &rest[close_tag_idx + 1..]; + let Some(end_anchor_idx) = after_tag.find("") else { + remaining = &after_anchor[2..]; + continue; + }; + let title = html_to_text(&after_tag[..end_anchor_idx]); + if title.trim().is_empty() { + remaining = &after_tag[end_anchor_idx + 4..]; + continue; + } + let decoded_url = decode_duckduckgo_redirect(&url).unwrap_or(url); + if decoded_url.starts_with("http://") || decoded_url.starts_with("https://") { + hits.push(SearchHit { + title: title.trim().to_string(), + url: decoded_url, + }); + } + remaining = &after_tag[end_anchor_idx + 4..]; + } + + hits +} + +fn extract_quoted_value(input: &str) -> Option<(String, &str)> { + let quote = input.chars().next()?; + if quote != '"' && quote != '\'' { + return None; + } + let rest = &input[quote.len_utf8()..]; + let end = rest.find(quote)?; + Some((rest[..end].to_string(), &rest[end + quote.len_utf8()..])) +} + +fn decode_duckduckgo_redirect(url: &str) -> Option { + if url.starts_with("http://") || url.starts_with("https://") { + return Some(html_entity_decode_url(url)); + } + + let joined = if url.starts_with("//") { + format!("https:{url}") + } else if url.starts_with('/') { + format!("https://duckduckgo.com{url}") + } else { + return None; + }; + + let parsed = reqwest::Url::parse(&joined).ok()?; + if parsed.path() == "/l/" || parsed.path() == "/l" { + for (key, value) in parsed.query_pairs() { + if key == "uddg" { + return Some(html_entity_decode_url(value.as_ref())); + } + } + } + Some(joined) +} + +fn html_entity_decode_url(url: &str) -> String { + decode_html_entities(url) +} + +fn host_matches_list(url: &str, domains: &[String]) -> bool { + let Ok(parsed) = reqwest::Url::parse(url) else { + return false; + }; + let Some(host) = parsed.host_str() else { + return false; + }; + let host = host.to_ascii_lowercase(); + domains.iter().any(|domain| { + let normalized = normalize_domain_filter(domain); + !normalized.is_empty() && (host == normalized || host.ends_with(&format!(".{normalized}"))) + }) +} + +fn normalize_domain_filter(domain: &str) -> String { + let trimmed = domain.trim(); + let candidate = reqwest::Url::parse(trimmed) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + .unwrap_or_else(|| trimmed.to_string()); + candidate + .trim() + .trim_start_matches('.') + .trim_end_matches('/') + .to_ascii_lowercase() +} + +fn dedupe_hits(hits: &mut Vec) { + let mut seen = BTreeSet::new(); + hits.retain(|hit| seen.insert(hit.url.clone())); +} + +fn execute_todo_write(input: TodoWriteInput) -> Result { + validate_todos(&input.todos)?; + let store_path = todo_store_path()?; + let old_todos = if store_path.exists() { + serde_json::from_str::>( + &std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())? + } else { + Vec::new() + }; + + let all_done = input + .todos + .iter() + .all(|todo| matches!(todo.status, TodoStatus::Completed)); + let persisted = if all_done { + Vec::new() + } else { + input.todos.clone() + }; + + if let Some(parent) = store_path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + std::fs::write( + &store_path, + serde_json::to_string_pretty(&persisted).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + + let verification_nudge_needed = (all_done + && input.todos.len() >= 3 + && !input + .todos + .iter() + .any(|todo| todo.content.to_lowercase().contains("verif"))) + .then_some(true); + + Ok(TodoWriteOutput { + old_todos, + new_todos: input.todos, + verification_nudge_needed, + }) +} + +fn execute_skill(input: SkillInput) -> Result { + let skill_path = resolve_skill_path(&input.skill)?; + let prompt = std::fs::read_to_string(&skill_path).map_err(|error| error.to_string())?; + let description = parse_skill_description(&prompt); + + Ok(SkillOutput { + skill: input.skill, + path: skill_path.display().to_string(), + args: input.args, + description, + prompt, + }) +} + +fn validate_todos(todos: &[TodoItem]) -> Result<(), String> { + if todos.is_empty() { + return Err(String::from("todos must not be empty")); + } + let in_progress = todos + .iter() + .filter(|todo| matches!(todo.status, TodoStatus::InProgress)) + .count(); + if in_progress > 1 { + return Err(String::from( + "exactly zero or one todo items may be in_progress", + )); + } + if todos.iter().any(|todo| todo.content.trim().is_empty()) { + return Err(String::from("todo content must not be empty")); + } + if todos.iter().any(|todo| todo.active_form.trim().is_empty()) { + return Err(String::from("todo activeForm must not be empty")); + } + Ok(()) +} + +fn todo_store_path() -> Result { + if let Ok(path) = std::env::var("CLAWD_TODO_STORE") { + return Ok(std::path::PathBuf::from(path)); + } + let cwd = std::env::current_dir().map_err(|error| error.to_string())?; + Ok(cwd.join(".clawd-todos.json")) +} + +fn resolve_skill_path(skill: &str) -> Result { + let requested = skill.trim().trim_start_matches('/').trim_start_matches('$'); + if requested.is_empty() { + return Err(String::from("skill must not be empty")); + } + + let mut candidates = Vec::new(); + if let Ok(codex_home) = std::env::var("CODEX_HOME") { + candidates.push(std::path::PathBuf::from(codex_home).join("skills")); + } + candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills")); + + for root in candidates { + let direct = root.join(requested).join("SKILL.md"); + if direct.exists() { + return Ok(direct); + } + + if let Ok(entries) = std::fs::read_dir(&root) { + for entry in entries.flatten() { + let path = entry.path().join("SKILL.md"); + if !path.exists() { + continue; + } + if entry + .file_name() + .to_string_lossy() + .eq_ignore_ascii_case(requested) + { + return Ok(path); + } + } + } + } + + Err(format!("unknown skill: {requested}")) +} + +fn execute_agent(input: AgentInput) -> Result { + if input.description.trim().is_empty() { + return Err(String::from("description must not be empty")); + } + if input.prompt.trim().is_empty() { + return Err(String::from("prompt must not be empty")); + } + + let agent_id = make_agent_id(); + let output_dir = agent_store_dir()?; + std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?; + let output_file = output_dir.join(format!("{agent_id}.md")); + let manifest_file = output_dir.join(format!("{agent_id}.json")); + let normalized_subagent_type = normalize_subagent_type(input.subagent_type.as_deref()); + let agent_name = input + .name + .as_deref() + .map(slugify_agent_name) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| slugify_agent_name(&input.description)); + let created_at = iso8601_now(); + + let output_contents = format!( + "# Agent Task + +- id: {} +- name: {} +- description: {} +- subagent_type: {} +- created_at: {} + +## Prompt + +{} +", + agent_id, agent_name, input.description, normalized_subagent_type, created_at, input.prompt + ); + std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?; + + let manifest = AgentOutput { + agent_id, + name: agent_name, + description: input.description, + subagent_type: Some(normalized_subagent_type), + model: input.model, + status: String::from("queued"), + output_file: output_file.display().to_string(), + manifest_file: manifest_file.display().to_string(), + created_at, + }; + std::fs::write( + &manifest_file, + serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + + Ok(manifest) +} + +fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput { + let deferred = deferred_tool_specs(); + let max_results = input.max_results.unwrap_or(5).max(1); + let query = input.query.trim().to_string(); + let normalized_query = normalize_tool_search_query(&query); + let matches = search_tool_specs(&query, max_results, &deferred); + + ToolSearchOutput { + matches, + query, + normalized_query, + total_deferred_tools: deferred.len(), + pending_mcp_servers: None, + } +} + +fn deferred_tool_specs() -> Vec { + mvp_tool_specs() + .into_iter() + .filter(|spec| { + !matches!( + spec.name, + "bash" | "read_file" | "write_file" | "edit_file" | "glob_search" | "grep_search" + ) + }) + .collect() +} + +fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec { + let lowered = query.to_lowercase(); + if let Some(selection) = lowered.strip_prefix("select:") { + return selection + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .filter_map(|wanted| { + let wanted = canonical_tool_token(wanted); + specs + .iter() + .find(|spec| canonical_tool_token(spec.name) == wanted) + .map(|spec| spec.name.to_string()) + }) + .take(max_results) + .collect(); + } + + let mut required = Vec::new(); + let mut optional = Vec::new(); + for term in lowered.split_whitespace() { + if let Some(rest) = term.strip_prefix('+') { + if !rest.is_empty() { + required.push(rest); + } + } else { + optional.push(term); + } + } + let terms = if required.is_empty() { + optional.clone() + } else { + required.iter().chain(optional.iter()).copied().collect() + }; + + let mut scored = specs + .iter() + .filter_map(|spec| { + let name = spec.name.to_lowercase(); + let canonical_name = canonical_tool_token(spec.name); + let normalized_description = normalize_tool_search_query(spec.description); + let haystack = format!( + "{name} {} {canonical_name}", + spec.description.to_lowercase() + ); + let normalized_haystack = format!("{canonical_name} {normalized_description}"); + if required.iter().any(|term| !haystack.contains(term)) { + return None; + } + + let mut score = 0_i32; + for term in &terms { + let canonical_term = canonical_tool_token(term); + if haystack.contains(term) { + score += 2; + } + if name == *term { + score += 8; + } + if name.contains(term) { + score += 4; + } + if canonical_name == canonical_term { + score += 12; + } + if normalized_haystack.contains(&canonical_term) { + score += 3; + } + } + + if score == 0 && !lowered.is_empty() { + return None; + } + Some((score, spec.name.to_string())) + }) + .collect::>(); + + scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); + scored + .into_iter() + .map(|(_, name)| name) + .take(max_results) + .collect() +} + +fn normalize_tool_search_query(query: &str) -> String { + query + .trim() + .split(|ch: char| ch.is_whitespace() || ch == ',') + .filter(|term| !term.is_empty()) + .map(canonical_tool_token) + .collect::>() + .join(" ") +} + +fn canonical_tool_token(value: &str) -> String { + let mut canonical = value + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::(); + if let Some(stripped) = canonical.strip_suffix("tool") { + canonical = stripped.to_string(); + } + canonical +} + +fn agent_store_dir() -> Result { + if let Ok(path) = std::env::var("CLAWD_AGENT_STORE") { + return Ok(std::path::PathBuf::from(path)); + } + let cwd = std::env::current_dir().map_err(|error| error.to_string())?; + if let Some(workspace_root) = cwd.ancestors().nth(2) { + return Ok(workspace_root.join(".clawd-agents")); + } + Ok(cwd.join(".clawd-agents")) +} + +fn make_agent_id() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("agent-{nanos}") +} + +fn slugify_agent_name(description: &str) -> String { + let mut out = description + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::(); + while out.contains("--") { + out = out.replace("--", "-"); + } + out.trim_matches('-').chars().take(32).collect() +} + +fn normalize_subagent_type(subagent_type: Option<&str>) -> String { + let trimmed = subagent_type.map(str::trim).unwrap_or_default(); + if trimmed.is_empty() { + return String::from("general-purpose"); + } + + match canonical_tool_token(trimmed).as_str() { + "general" | "generalpurpose" | "generalpurposeagent" => String::from("general-purpose"), + "explore" | "explorer" | "exploreagent" => String::from("Explore"), + "plan" | "planagent" => String::from("Plan"), + "verification" | "verificationagent" | "verify" | "verifier" => { + String::from("Verification") + } + "claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claude-code-guide"), + "statusline" | "statuslinesetup" => String::from("statusline-setup"), + _ => trimmed.to_string(), + } +} + +fn iso8601_now() -> String { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string() +} + +fn execute_notebook_edit(input: NotebookEditInput) -> Result { + let path = std::path::PathBuf::from(&input.notebook_path); + if path.extension().and_then(|ext| ext.to_str()) != Some("ipynb") { + return Err(String::from( + "File must be a Jupyter notebook (.ipynb file).", + )); + } + + let original_file = std::fs::read_to_string(&path).map_err(|error| error.to_string())?; + let mut notebook: serde_json::Value = + serde_json::from_str(&original_file).map_err(|error| error.to_string())?; + let language = notebook + .get("metadata") + .and_then(|metadata| metadata.get("kernelspec")) + .and_then(|kernelspec| kernelspec.get("language")) + .and_then(serde_json::Value::as_str) + .unwrap_or("python") + .to_string(); + let cells = notebook + .get_mut("cells") + .and_then(serde_json::Value::as_array_mut) + .ok_or_else(|| String::from("Notebook cells array not found"))?; + + let edit_mode = input.edit_mode.unwrap_or(NotebookEditMode::Replace); + let target_index = match input.cell_id.as_deref() { + Some(cell_id) => Some(resolve_cell_index(cells, Some(cell_id), edit_mode)?), + None if matches!( + edit_mode, + NotebookEditMode::Replace | NotebookEditMode::Delete + ) => + { + Some(resolve_cell_index(cells, None, edit_mode)?) + } + None => None, + }; + let resolved_cell_type = match edit_mode { + NotebookEditMode::Delete => None, + NotebookEditMode::Insert => Some(input.cell_type.unwrap_or(NotebookCellType::Code)), + NotebookEditMode::Replace => Some(input.cell_type.unwrap_or_else(|| { + target_index + .and_then(|index| cells.get(index)) + .and_then(cell_kind) + .unwrap_or(NotebookCellType::Code) + })), + }; + let new_source = require_notebook_source(input.new_source, edit_mode)?; + + let cell_id = match edit_mode { + NotebookEditMode::Insert => { + let resolved_cell_type = resolved_cell_type.expect("insert cell type"); + let new_id = make_cell_id(cells.len()); + let new_cell = build_notebook_cell(&new_id, resolved_cell_type, &new_source); + let insert_at = target_index.map_or(cells.len(), |index| index + 1); + cells.insert(insert_at, new_cell); + cells + .get(insert_at) + .and_then(|cell| cell.get("id")) + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + } + NotebookEditMode::Delete => { + let removed = cells.remove(target_index.expect("delete target index")); + removed + .get("id") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + } + NotebookEditMode::Replace => { + let resolved_cell_type = resolved_cell_type.expect("replace cell type"); + let cell = cells + .get_mut(target_index.expect("replace target index")) + .ok_or_else(|| String::from("Cell index out of range"))?; + cell["source"] = serde_json::Value::Array(source_lines(&new_source)); + cell["cell_type"] = serde_json::Value::String(match resolved_cell_type { + NotebookCellType::Code => String::from("code"), + NotebookCellType::Markdown => String::from("markdown"), + }); + match resolved_cell_type { + NotebookCellType::Code => { + if !cell.get("outputs").is_some_and(serde_json::Value::is_array) { + cell["outputs"] = json!([]); + } + if !cell.get("execution_count").is_some() { + cell["execution_count"] = serde_json::Value::Null; + } + } + NotebookCellType::Markdown => { + if let Some(object) = cell.as_object_mut() { + object.remove("outputs"); + object.remove("execution_count"); + } + } + } + cell.get("id") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + } + }; + + let updated_file = + serde_json::to_string_pretty(¬ebook).map_err(|error| error.to_string())?; + std::fs::write(&path, &updated_file).map_err(|error| error.to_string())?; + + Ok(NotebookEditOutput { + new_source, + cell_id, + cell_type: resolved_cell_type, + language, + edit_mode: format_notebook_edit_mode(edit_mode), + error: None, + notebook_path: path.display().to_string(), + original_file, + updated_file, + }) +} + +fn require_notebook_source( + source: Option, + edit_mode: NotebookEditMode, +) -> Result { + match edit_mode { + NotebookEditMode::Delete => Ok(source.unwrap_or_default()), + NotebookEditMode::Insert | NotebookEditMode::Replace => source + .ok_or_else(|| String::from("new_source is required for insert and replace edits")), + } +} + +fn build_notebook_cell(cell_id: &str, cell_type: NotebookCellType, source: &str) -> Value { + let mut cell = json!({ + "cell_type": match cell_type { + NotebookCellType::Code => "code", + NotebookCellType::Markdown => "markdown", + }, + "id": cell_id, + "metadata": {}, + "source": source_lines(source), + }); + if let Some(object) = cell.as_object_mut() { + match cell_type { + NotebookCellType::Code => { + object.insert(String::from("outputs"), json!([])); + object.insert(String::from("execution_count"), Value::Null); + } + NotebookCellType::Markdown => {} + } + } + cell +} + +fn cell_kind(cell: &serde_json::Value) -> Option { + cell.get("cell_type") + .and_then(serde_json::Value::as_str) + .map(|kind| { + if kind == "markdown" { + NotebookCellType::Markdown + } else { + NotebookCellType::Code + } + }) +} + +fn execute_sleep(input: SleepInput) -> SleepOutput { + std::thread::sleep(Duration::from_millis(input.duration_ms)); + SleepOutput { + duration_ms: input.duration_ms, + message: format!("Slept for {}ms", input.duration_ms), + } +} + +fn execute_powershell(input: PowerShellInput) -> std::io::Result { + let _ = &input.description; + let shell = detect_powershell_shell()?; + execute_shell_command( + shell, + &input.command, + input.timeout, + input.run_in_background, + ) +} + +fn detect_powershell_shell() -> std::io::Result<&'static str> { + if command_exists("pwsh") { + Ok("pwsh") + } else if command_exists("powershell") { + Ok("powershell") + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "PowerShell executable not found (expected `pwsh` or `powershell` in PATH)", + )) + } +} + +fn command_exists(command: &str) -> bool { + std::process::Command::new("sh") + .arg("-lc") + .arg(format!("command -v {command} >/dev/null 2>&1")) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +fn execute_shell_command( + shell: &str, + command: &str, + timeout: Option, + run_in_background: Option, +) -> std::io::Result { + if run_in_background.unwrap_or(false) { + let child = std::process::Command::new(shell) + .arg("-NoProfile") + .arg("-NonInteractive") + .arg("-Command") + .arg(command) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn()?; + return Ok(runtime::BashCommandOutput { + stdout: String::new(), + stderr: String::new(), + raw_output_path: None, + interrupted: false, + is_image: None, + background_task_id: Some(child.id().to_string()), + backgrounded_by_user: Some(true), + assistant_auto_backgrounded: Some(false), + dangerously_disable_sandbox: None, + return_code_interpretation: None, + no_output_expected: Some(true), + structured_content: None, + persisted_output_path: None, + persisted_output_size: None, + }); + } + + let mut process = std::process::Command::new(shell); + process + .arg("-NoProfile") + .arg("-NonInteractive") + .arg("-Command") + .arg(command); + process + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + if let Some(timeout_ms) = timeout { + let mut child = process.spawn()?; + let started = Instant::now(); + loop { + if let Some(status) = child.try_wait()? { + let output = child.wait_with_output()?; + return Ok(runtime::BashCommandOutput { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + raw_output_path: None, + interrupted: false, + is_image: None, + background_task_id: None, + backgrounded_by_user: None, + assistant_auto_backgrounded: None, + dangerously_disable_sandbox: None, + return_code_interpretation: status + .code() + .filter(|code| *code != 0) + .map(|code| format!("exit_code:{code}")), + no_output_expected: Some(output.stdout.is_empty() && output.stderr.is_empty()), + structured_content: None, + persisted_output_path: None, + persisted_output_size: None, + }); + } + if started.elapsed() >= Duration::from_millis(timeout_ms) { + let _ = child.kill(); + let output = child.wait_with_output()?; + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + let stderr = if stderr.trim().is_empty() { + format!("Command exceeded timeout of {timeout_ms} ms") + } else { + format!( + "{} +Command exceeded timeout of {timeout_ms} ms", + stderr.trim_end() + ) + }; + return Ok(runtime::BashCommandOutput { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr, + raw_output_path: None, + interrupted: true, + is_image: None, + background_task_id: None, + backgrounded_by_user: None, + assistant_auto_backgrounded: None, + dangerously_disable_sandbox: None, + return_code_interpretation: Some(String::from("timeout")), + no_output_expected: Some(false), + structured_content: None, + persisted_output_path: None, + persisted_output_size: None, + }); + } + std::thread::sleep(Duration::from_millis(10)); + } + } + + let output = process.output()?; + Ok(runtime::BashCommandOutput { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + raw_output_path: None, + interrupted: false, + is_image: None, + background_task_id: None, + backgrounded_by_user: None, + assistant_auto_backgrounded: None, + dangerously_disable_sandbox: None, + return_code_interpretation: output + .status + .code() + .filter(|code| *code != 0) + .map(|code| format!("exit_code:{code}")), + no_output_expected: Some(output.stdout.is_empty() && output.stderr.is_empty()), + structured_content: None, + persisted_output_path: None, + persisted_output_size: None, + }) +} + +fn resolve_cell_index( + cells: &[serde_json::Value], + cell_id: Option<&str>, + edit_mode: NotebookEditMode, +) -> Result { + if cells.is_empty() + && matches!( + edit_mode, + NotebookEditMode::Replace | NotebookEditMode::Delete + ) + { + return Err(String::from("Notebook has no cells to edit")); + } + if let Some(cell_id) = cell_id { + cells + .iter() + .position(|cell| cell.get("id").and_then(serde_json::Value::as_str) == Some(cell_id)) + .ok_or_else(|| format!("Cell id not found: {cell_id}")) + } else { + Ok(cells.len().saturating_sub(1)) + } +} + +fn source_lines(source: &str) -> Vec { + if source.is_empty() { + return vec![serde_json::Value::String(String::new())]; + } + source + .split_inclusive('\n') + .map(|line| serde_json::Value::String(line.to_string())) + .collect() +} + +fn format_notebook_edit_mode(mode: NotebookEditMode) -> String { + match mode { + NotebookEditMode::Replace => String::from("replace"), + NotebookEditMode::Insert => String::from("insert"), + NotebookEditMode::Delete => String::from("delete"), + } +} + +fn make_cell_id(index: usize) -> String { + format!("cell-{}", index + 1) +} + +fn parse_skill_description(contents: &str) -> Option { + for line in contents.lines() { + if let Some(value) = line.strip_prefix("description:") { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + #[cfg(test)] mod tests { + use std::io::{Read, Write}; + use std::net::{SocketAddr, TcpListener}; + use std::sync::{Arc, Mutex, OnceLock}; + use std::thread; + use std::time::Duration; + use super::{execute_tool, mvp_tool_specs}; use serde_json::json; + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + #[test] fn exposes_mvp_tools() { let names = mvp_tool_specs() @@ -250,6 +1794,15 @@ mod tests { .collect::>(); assert!(names.contains(&"bash")); assert!(names.contains(&"read_file")); + assert!(names.contains(&"WebFetch")); + assert!(names.contains(&"WebSearch")); + assert!(names.contains(&"TodoWrite")); + assert!(names.contains(&"Skill")); + assert!(names.contains(&"Agent")); + assert!(names.contains(&"ToolSearch")); + assert!(names.contains(&"NotebookEdit")); + assert!(names.contains(&"Sleep")); + assert!(names.contains(&"PowerShell")); } #[test] @@ -257,4 +1810,542 @@ mod tests { let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected"); assert!(error.contains("unsupported tool")); } + + #[test] + fn web_fetch_returns_prompt_aware_summary() { + let server = TestServer::spawn(Arc::new(|request_line: &str| { + assert!(request_line.starts_with("GET /page ")); + HttpResponse::html( + 200, + "OK", + "Ignored

Test Page

Hello world from local server.

", + ) + })); + + let result = execute_tool( + "WebFetch", + &json!({ + "url": format!("http://{}/page", server.addr()), + "prompt": "Summarize this page" + }), + ) + .expect("WebFetch should succeed"); + + let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); + assert_eq!(output["code"], 200); + let summary = output["result"].as_str().expect("result string"); + assert!(summary.contains("Fetched")); + assert!(summary.contains("Test Page")); + assert!(summary.contains("Hello world from local server")); + + let titled = execute_tool( + "WebFetch", + &json!({ + "url": format!("http://{}/page", server.addr()), + "prompt": "What is the page title?" + }), + ) + .expect("WebFetch title query should succeed"); + let titled_output: serde_json::Value = serde_json::from_str(&titled).expect("valid json"); + let titled_summary = titled_output["result"].as_str().expect("result string"); + assert!(titled_summary.contains("Title: Ignored")); + } + + #[test] + fn web_search_extracts_and_filters_results() { + let server = TestServer::spawn(Arc::new(|request_line: &str| { + assert!(request_line.contains("GET /search?q=rust+web+search ")); + HttpResponse::html( + 200, + "OK", + r#" + + Reqwest docs + Blocked result + + "#, + ) + })); + + std::env::set_var( + "CLAWD_WEB_SEARCH_BASE_URL", + format!("http://{}/search", server.addr()), + ); + let result = execute_tool( + "WebSearch", + &json!({ + "query": "rust web search", + "allowed_domains": ["https://DOCS.rs/"], + "blocked_domains": ["HTTPS://EXAMPLE.COM"] + }), + ) + .expect("WebSearch should succeed"); + std::env::remove_var("CLAWD_WEB_SEARCH_BASE_URL"); + + let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); + assert_eq!(output["query"], "rust web search"); + let results = output["results"].as_array().expect("results array"); + let search_result = results + .iter() + .find(|item| item.get("content").is_some()) + .expect("search result block present"); + let content = search_result["content"].as_array().expect("content array"); + assert_eq!(content.len(), 1); + assert_eq!(content[0]["title"], "Reqwest docs"); + assert_eq!(content[0]["url"], "https://docs.rs/reqwest"); + } + + #[test] + fn todo_write_persists_and_returns_previous_state() { + let path = std::env::temp_dir().join(format!( + "clawd-tools-todos-{}.json", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + std::env::set_var("CLAWD_TODO_STORE", &path); + + let first = execute_tool( + "TodoWrite", + &json!({ + "todos": [ + {"content": "Add tool", "activeForm": "Adding tool", "status": "in_progress"}, + {"content": "Run tests", "activeForm": "Running tests", "status": "pending"} + ] + }), + ) + .expect("TodoWrite should succeed"); + let first_output: serde_json::Value = serde_json::from_str(&first).expect("valid json"); + assert_eq!(first_output["oldTodos"].as_array().expect("array").len(), 0); + + let second = execute_tool( + "TodoWrite", + &json!({ + "todos": [ + {"content": "Add tool", "activeForm": "Adding tool", "status": "completed"}, + {"content": "Run tests", "activeForm": "Running tests", "status": "completed"}, + {"content": "Verify", "activeForm": "Verifying", "status": "completed"} + ] + }), + ) + .expect("TodoWrite should succeed"); + std::env::remove_var("CLAWD_TODO_STORE"); + let _ = std::fs::remove_file(path); + + let second_output: serde_json::Value = serde_json::from_str(&second).expect("valid json"); + assert_eq!( + second_output["oldTodos"].as_array().expect("array").len(), + 2 + ); + assert_eq!( + second_output["newTodos"].as_array().expect("array").len(), + 3 + ); + assert!(second_output["verificationNudgeNeeded"].is_null()); + } + + #[test] + fn skill_loads_local_skill_prompt() { + let result = execute_tool( + "Skill", + &json!({ + "skill": "help", + "args": "overview" + }), + ) + .expect("Skill should succeed"); + + let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); + assert_eq!(output["skill"], "help"); + assert!(output["path"] + .as_str() + .expect("path") + .ends_with("/help/SKILL.md")); + assert!(output["prompt"] + .as_str() + .expect("prompt") + .contains("Guide on using oh-my-codex plugin")); + + let dollar_result = execute_tool( + "Skill", + &json!({ + "skill": "$help" + }), + ) + .expect("Skill should accept $skill invocation form"); + let dollar_output: serde_json::Value = + serde_json::from_str(&dollar_result).expect("valid json"); + assert_eq!(dollar_output["skill"], "$help"); + assert!(dollar_output["path"] + .as_str() + .expect("path") + .ends_with("/help/SKILL.md")); + } + + #[test] + fn tool_search_supports_keyword_and_select_queries() { + let keyword = execute_tool( + "ToolSearch", + &json!({"query": "web current", "max_results": 3}), + ) + .expect("ToolSearch should succeed"); + let keyword_output: serde_json::Value = serde_json::from_str(&keyword).expect("valid json"); + let matches = keyword_output["matches"].as_array().expect("matches"); + assert!(matches.iter().any(|value| value == "WebSearch")); + + let selected = execute_tool("ToolSearch", &json!({"query": "select:Agent,Skill"})) + .expect("ToolSearch should succeed"); + let selected_output: serde_json::Value = + serde_json::from_str(&selected).expect("valid json"); + assert_eq!(selected_output["matches"][0], "Agent"); + assert_eq!(selected_output["matches"][1], "Skill"); + + let aliased = execute_tool("ToolSearch", &json!({"query": "AgentTool"})) + .expect("ToolSearch should support tool aliases"); + let aliased_output: serde_json::Value = serde_json::from_str(&aliased).expect("valid json"); + assert_eq!(aliased_output["matches"][0], "Agent"); + assert_eq!(aliased_output["normalized_query"], "agent"); + + let selected_with_alias = + execute_tool("ToolSearch", &json!({"query": "select:AgentTool,Skill"})) + .expect("ToolSearch alias select should succeed"); + let selected_with_alias_output: serde_json::Value = + serde_json::from_str(&selected_with_alias).expect("valid json"); + assert_eq!(selected_with_alias_output["matches"][0], "Agent"); + assert_eq!(selected_with_alias_output["matches"][1], "Skill"); + } + + #[test] + fn agent_persists_handoff_metadata() { + let dir = std::env::temp_dir().join(format!( + "clawd-agent-store-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + std::env::set_var("CLAWD_AGENT_STORE", &dir); + + let result = execute_tool( + "Agent", + &json!({ + "description": "Audit the branch", + "prompt": "Check tests and outstanding work.", + "subagent_type": "Explore", + "name": "ship-audit" + }), + ) + .expect("Agent should succeed"); + std::env::remove_var("CLAWD_AGENT_STORE"); + + let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); + assert_eq!(output["name"], "ship-audit"); + assert_eq!(output["subagentType"], "Explore"); + assert_eq!(output["status"], "queued"); + assert!(output["createdAt"].as_str().is_some()); + let manifest_file = output["manifestFile"].as_str().expect("manifest file"); + let output_file = output["outputFile"].as_str().expect("output file"); + let contents = std::fs::read_to_string(output_file).expect("agent file exists"); + let manifest_contents = + std::fs::read_to_string(manifest_file).expect("manifest file exists"); + assert!(contents.contains("Audit the branch")); + assert!(contents.contains("Check tests and outstanding work.")); + assert!(manifest_contents.contains("\"subagentType\": \"Explore\"")); + + let normalized = execute_tool( + "Agent", + &json!({ + "description": "Verify the branch", + "prompt": "Check tests.", + "subagent_type": "explorer" + }), + ) + .expect("Agent should normalize built-in aliases"); + let normalized_output: serde_json::Value = + serde_json::from_str(&normalized).expect("valid json"); + assert_eq!(normalized_output["subagentType"], "Explore"); + + let named = execute_tool( + "Agent", + &json!({ + "description": "Review the branch", + "prompt": "Inspect diff.", + "name": "Ship Audit!!!" + }), + ) + .expect("Agent should normalize explicit names"); + let named_output: serde_json::Value = serde_json::from_str(&named).expect("valid json"); + assert_eq!(named_output["name"], "ship-audit"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn notebook_edit_replaces_inserts_and_deletes_cells() { + let path = std::env::temp_dir().join(format!( + "clawd-notebook-{}.ipynb", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + std::fs::write( + &path, + r#"{ + "cells": [ + {"cell_type": "code", "id": "cell-a", "metadata": {}, "source": ["print(1)\n"], "outputs": [], "execution_count": null} + ], + "metadata": {"kernelspec": {"language": "python"}}, + "nbformat": 4, + "nbformat_minor": 5 +}"#, + ) + .expect("write notebook"); + + let replaced = execute_tool( + "NotebookEdit", + &json!({ + "notebook_path": path.display().to_string(), + "cell_id": "cell-a", + "new_source": "print(2)\n", + "edit_mode": "replace" + }), + ) + .expect("NotebookEdit replace should succeed"); + let replaced_output: serde_json::Value = serde_json::from_str(&replaced).expect("json"); + assert_eq!(replaced_output["cell_id"], "cell-a"); + assert_eq!(replaced_output["cell_type"], "code"); + + let inserted = execute_tool( + "NotebookEdit", + &json!({ + "notebook_path": path.display().to_string(), + "cell_id": "cell-a", + "new_source": "# heading\n", + "cell_type": "markdown", + "edit_mode": "insert" + }), + ) + .expect("NotebookEdit insert should succeed"); + let inserted_output: serde_json::Value = serde_json::from_str(&inserted).expect("json"); + assert_eq!(inserted_output["cell_type"], "markdown"); + let appended = execute_tool( + "NotebookEdit", + &json!({ + "notebook_path": path.display().to_string(), + "new_source": "print(3)\n", + "edit_mode": "insert" + }), + ) + .expect("NotebookEdit append should succeed"); + let appended_output: serde_json::Value = serde_json::from_str(&appended).expect("json"); + assert_eq!(appended_output["cell_type"], "code"); + + let deleted = execute_tool( + "NotebookEdit", + &json!({ + "notebook_path": path.display().to_string(), + "cell_id": "cell-a", + "edit_mode": "delete" + }), + ) + .expect("NotebookEdit delete should succeed without new_source"); + let deleted_output: serde_json::Value = serde_json::from_str(&deleted).expect("json"); + assert!(deleted_output["cell_type"].is_null()); + assert_eq!(deleted_output["new_source"], ""); + + let final_notebook: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).expect("read notebook")) + .expect("valid notebook json"); + let cells = final_notebook["cells"].as_array().expect("cells array"); + assert_eq!(cells.len(), 2); + assert_eq!(cells[0]["cell_type"], "markdown"); + assert!(cells[0].get("outputs").is_none()); + assert_eq!(cells[1]["cell_type"], "code"); + assert_eq!(cells[1]["source"][0], "print(3)\n"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn sleep_waits_and_reports_duration() { + let started = std::time::Instant::now(); + let result = + execute_tool("Sleep", &json!({"duration_ms": 20})).expect("Sleep should succeed"); + let elapsed = started.elapsed(); + let output: serde_json::Value = serde_json::from_str(&result).expect("json"); + assert_eq!(output["duration_ms"], 20); + assert!(output["message"] + .as_str() + .expect("message") + .contains("Slept for 20ms")); + assert!(elapsed >= Duration::from_millis(15)); + } + + #[test] + fn powershell_runs_via_stub_shell() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + let dir = std::env::temp_dir().join(format!( + "clawd-pwsh-bin-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("create dir"); + let script = dir.join("pwsh"); + std::fs::write( + &script, + r#"#!/bin/sh +while [ "$1" != "-Command" ] && [ $# -gt 0 ]; do shift; done +shift +printf 'pwsh:%s' "$1" +"#, + ) + .expect("write script"); + std::process::Command::new("/bin/chmod") + .arg("+x") + .arg(&script) + .status() + .expect("chmod"); + let original_path = std::env::var("PATH").unwrap_or_default(); + std::env::set_var("PATH", format!("{}:{}", dir.display(), original_path)); + + let result = execute_tool( + "PowerShell", + &json!({"command": "Write-Output hello", "timeout": 1000}), + ) + .expect("PowerShell should succeed"); + + let background = execute_tool( + "PowerShell", + &json!({"command": "Write-Output hello", "run_in_background": true}), + ) + .expect("PowerShell background should succeed"); + + std::env::set_var("PATH", original_path); + let _ = std::fs::remove_dir_all(dir); + + let output: serde_json::Value = serde_json::from_str(&result).expect("json"); + assert_eq!(output["stdout"], "pwsh:Write-Output hello"); + assert!(output["stderr"].as_str().expect("stderr").is_empty()); + + let background_output: serde_json::Value = serde_json::from_str(&background).expect("json"); + assert!(background_output["backgroundTaskId"].as_str().is_some()); + assert_eq!(background_output["backgroundedByUser"], true); + assert_eq!(background_output["assistantAutoBackgrounded"], false); + } + + #[test] + fn powershell_errors_when_shell_is_missing() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + let original_path = std::env::var("PATH").unwrap_or_default(); + let empty_dir = std::env::temp_dir().join(format!( + "clawd-empty-bin-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + std::fs::create_dir_all(&empty_dir).expect("create empty dir"); + std::env::set_var("PATH", empty_dir.display().to_string()); + + let err = execute_tool("PowerShell", &json!({"command": "Write-Output hello"})) + .expect_err("PowerShell should fail when shell is missing"); + + std::env::set_var("PATH", original_path); + let _ = std::fs::remove_dir_all(empty_dir); + + assert!(err.contains("PowerShell executable not found")); + } + + struct TestServer { + addr: SocketAddr, + shutdown: Option>, + handle: Option>, + } + + impl TestServer { + fn spawn(handler: Arc HttpResponse + Send + Sync + 'static>) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + listener + .set_nonblocking(true) + .expect("set nonblocking listener"); + let addr = listener.local_addr().expect("local addr"); + let (tx, rx) = std::sync::mpsc::channel::<()>(); + + let handle = thread::spawn(move || loop { + if rx.try_recv().is_ok() { + break; + } + + match listener.accept() { + Ok((mut stream, _)) => { + let mut buffer = [0_u8; 4096]; + let size = stream.read(&mut buffer).expect("read request"); + let request = String::from_utf8_lossy(&buffer[..size]).into_owned(); + let request_line = request.lines().next().unwrap_or_default().to_string(); + let response = handler(&request_line); + stream + .write_all(response.to_bytes().as_slice()) + .expect("write response"); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("server accept failed: {error}"), + } + }); + + Self { + addr, + shutdown: Some(tx), + handle: Some(handle), + } + } + + fn addr(&self) -> SocketAddr { + self.addr + } + } + + impl Drop for TestServer { + fn drop(&mut self) { + if let Some(tx) = self.shutdown.take() { + let _ = tx.send(()); + } + if let Some(handle) = self.handle.take() { + handle.join().expect("join test server"); + } + } + } + + struct HttpResponse { + status: u16, + reason: &'static str, + content_type: &'static str, + body: String, + } + + impl HttpResponse { + fn html(status: u16, reason: &'static str, body: &str) -> Self { + Self { + status, + reason, + content_type: "text/html; charset=utf-8", + body: body.to_string(), + } + } + + fn to_bytes(&self) -> Vec { + format!( + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + self.status, + self.reason, + self.content_type, + self.body.len(), + self.body + ) + .into_bytes() + } + } }