From 5b106b840d4a37986de317d61ae141b2dbc699e3 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 19:15:05 +0000 Subject: [PATCH 01/10] feat(tools): add WebFetch and WebSearch parity primitives Implement the first web-oriented Claude Code parity slice in the Rust tools crate. This adds concrete WebFetch and WebSearch tool specs, execution paths, lightweight HTML/search-result extraction, domain filtering, and local HTTP-backed tests while leaving the existing core file and shell tools intact.\n\nConstraint: Keep the change scoped to tools-only Rust workspace code\nConstraint: Match Claude Code tool names and JSON schemas closely enough for parity work\nRejected: Stub-only tool registrations | would not materially expand beyond MVP\nRejected: Full browser/search service integration | too large for this first logical slice\nConfidence: medium\nScope-risk: moderate\nReversibility: clean\nDirective: Treat these web helpers as a parity foundation; refine result quality without renaming the exposed tool contracts\nTested: cargo fmt; cargo test -p tools\nNot-tested: cargo clippy; full workspace cargo test --- rust/Cargo.lock | 19 ++ rust/crates/tools/Cargo.toml | 1 + rust/crates/tools/src/lib.rs | 618 ++++++++++++++++++++++++++++++++++- 3 files changed, 637 insertions(+), 1 deletion(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 308a108..8e7d88d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -212,6 +212,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -220,6 +221,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" @@ -233,7 +246,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", ] @@ -898,7 +914,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -1352,6 +1370,7 @@ dependencies = [ name = "tools" version = "0.1.0" dependencies = [ + "reqwest", "runtime", "serde", "serde_json", 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 d8806b8..e6ab4e7 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,6 +144,40 @@ 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 + }), + }, ] } @@ -151,6 +189,8 @@ pub fn execute_tool(name: &str, input: &Value) -> Result { "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), _ => Err(format!("unsupported tool: {name}")), } } @@ -192,6 +232,14 @@ 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 to_pretty_json(value: T) -> Result { serde_json::to_string_pretty(&value).map_err(|error| error.to_string()) } @@ -227,8 +275,411 @@ 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, 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)] +#[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); + + 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) -> String { + let lower_prompt = prompt.to_lowercase(); + let compact = collapse_whitespace(content); + + let detail = if lower_prompt.contains("title") { + extract_title(content) + .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) -> Option { + 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; + }; + domains.iter().any(|domain| { + let normalized = domain.trim().trim_start_matches('.'); + host == normalized || host.ends_with(&format!(".{normalized}")) + }) +} + +fn dedupe_hits(hits: &mut Vec) { + let mut seen = BTreeSet::new(); + hits.retain(|hit| seen.insert(hit.url.clone())); +} + #[cfg(test)] mod tests { + use std::io::{Read, Write}; + use std::net::{SocketAddr, TcpListener}; + use std::sync::Arc; + use std::thread; + use std::time::Duration; + use super::{execute_tool, mvp_tool_specs}; use serde_json::json; @@ -240,6 +691,8 @@ mod tests { .collect::>(); assert!(names.contains(&"bash")); assert!(names.contains(&"read_file")); + assert!(names.contains(&"WebFetch")); + assert!(names.contains(&"WebSearch")); } #[test] @@ -247,4 +700,167 @@ 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")); + } + + #[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": ["docs.rs"], + "blocked_domains": ["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"); + } + + 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() + } + } } From 619ae7186623bf422f6346e16228fed891011b3b Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 19:17:52 +0000 Subject: [PATCH 02/10] feat(tools): add TodoWrite and Skill tool support Extend the Rust tools crate with concrete TodoWrite and Skill implementations. TodoWrite now validates and persists structured session todos with Claude Code-aligned item shapes, while Skill resolves local skill definitions and returns their prompt payload for execution handoff. Tests cover persistence and local skill loading without disturbing the previously added web tools.\n\nConstraint: Stay within tools-only scope and avoid depending on broader agent/runtime rewrites\nConstraint: Keep exposed tool names and schemas close to Claude Code contracts\nRejected: In-memory-only TodoWrite state | would not survive across tool calls\nRejected: Stub Skill metadata without loading prompt content | not materially useful to callers\nConfidence: medium\nScope-risk: narrow\nReversibility: clean\nDirective: Preserve TodoWrite item-field parity and keep Skill focused on local skill discovery until agent execution wiring lands\nTested: cargo fmt; cargo test -p tools\nNot-tested: cargo clippy; full workspace cargo test --- rust/crates/tools/src/lib.rs | 309 +++++++++++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index e6ab4e7..b9e7e34 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -178,6 +178,46 @@ pub fn mvp_tool_specs() -> Vec { "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 + }), + }, ] } @@ -191,6 +231,8 @@ pub fn execute_tool(name: &str, input: &Value) -> Result { "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), _ => Err(format!("unsupported tool: {name}")), } } @@ -240,6 +282,14 @@ 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 to_pretty_json(value: T) -> Result { serde_json::to_string_pretty(&value).map_err(|error| error.to_string()) } @@ -288,6 +338,33 @@ struct WebSearchInput { 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, Serialize)] struct WebFetchOutput { bytes: usize, @@ -308,6 +385,25 @@ struct WebSearchOutput { 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)] #[serde(untagged)] enum WebSearchResultItem { @@ -672,6 +768,146 @@ fn dedupe_hits(hits: &mut Vec) { 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('/'); + 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 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}; @@ -773,6 +1009,79 @@ mod tests { 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")); + } + struct TestServer { addr: SocketAddr, shutdown: Option>, From 2d1cade31bcb5f67d4501ed4d923db059f93368a Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 19:43:10 +0000 Subject: [PATCH 03/10] feat(tools): add Agent and ToolSearch support Extend the Rust tools crate with concrete Agent and ToolSearch implementations. Agent now persists agent-handoff metadata and prompt payloads to a local store with Claude Code-style fields, while ToolSearch supports exact selection and keyword search over the deferred tool surface. Tests cover agent persistence and tool lookup behavior alongside the existing web, todo, and skill coverage.\n\nConstraint: Keep the implementation tools-only without relying on full agent orchestration runtime\nConstraint: Preserve exposed tool names and close schema parity with Claude Code\nRejected: No-op Agent stubs | would not provide material handoff value\nRejected: ToolSearch limited to exact matches only | too weak for discovery workflows\nConfidence: medium\nScope-risk: narrow\nReversibility: clean\nDirective: Keep Agent output contract stable so later execution wiring can reuse persisted metadata without renaming fields\nTested: cargo fmt; cargo test -p tools\nNot-tested: cargo clippy; full workspace cargo test --- rust/crates/tools/src/lib.rs | 314 +++++++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index b9e7e34..080ab26 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -218,6 +218,35 @@ pub fn mvp_tool_specs() -> Vec { "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 + }), + }, ] } @@ -233,6 +262,8 @@ pub fn execute_tool(name: &str, input: &Value) -> Result { "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), _ => Err(format!("unsupported tool: {name}")), } } @@ -290,6 +321,14 @@ 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 to_pretty_json(value: T) -> Result { serde_json::to_string_pretty(&value).map_err(|error| error.to_string()) } @@ -365,6 +404,21 @@ struct SkillInput { 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, Serialize)] struct WebFetchOutput { bytes: usize, @@ -404,6 +458,30 @@ struct SkillOutput { 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, +} + +#[derive(Debug, Serialize)] +struct ToolSearchOutput { + matches: Vec, + query: String, + #[serde(rename = "total_deferred_tools")] + total_deferred_tools: usize, + #[serde(rename = "pending_mcp_servers")] + pending_mcp_servers: Option>, +} + #[derive(Debug, Serialize)] #[serde(untagged)] enum WebSearchResultItem { @@ -896,6 +974,185 @@ fn resolve_skill_path(skill: &str) -> Result { 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 agent_name = input + .name + .clone() + .unwrap_or_else(|| slugify_agent_name(&input.description)); + + let output_contents = format!( + "# Agent Task\n\n- id: {}\n- name: {}\n- description: {}\n- subagent_type: {}\n\n## Prompt\n\n{}\n", + agent_id, + agent_name, + input.description, + input + .subagent_type + .clone() + .unwrap_or_else(|| String::from("general-purpose")), + 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: input.subagent_type, + model: input.model, + status: String::from("queued"), + output_file: output_file.display().to_string(), + }; + 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 matches = search_tool_specs(&query, max_results, &deferred); + + ToolSearchOutput { + matches, + 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| { + specs + .iter() + .find(|spec| spec.name.eq_ignore_ascii_case(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 haystack = format!("{name} {}", spec.description.to_lowercase()); + if required.iter().any(|term| !haystack.contains(term)) { + return None; + } + + let mut score = 0_i32; + for term in &terms { + if haystack.contains(term) { + score += 2; + } + if name == *term { + score += 8; + } + if name.contains(term) { + score += 4; + } + } + + if score == 0 && !lowered.is_empty() { + return None; + } + Some((score, spec.name.to_string())) + }) + .collect::>(); + + scored.sort_by(|left, right| right.cmp(left)); + scored + .into_iter() + .map(|(_, name)| name) + .take(max_results) + .collect() +} + +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())?; + 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 parse_skill_description(contents: &str) -> Option { for line in contents.lines() { if let Some(value) = line.strip_prefix("description:") { @@ -929,6 +1186,10 @@ mod tests { 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")); } #[test] @@ -1082,6 +1343,59 @@ mod tests { .contains("Guide on using oh-my-codex plugin")); } + #[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"); + } + + #[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"); + let output_file = output["outputFile"].as_str().expect("output file"); + let contents = std::fs::read_to_string(output_file).expect("agent file exists"); + assert!(contents.contains("Audit the branch")); + assert!(contents.contains("Check tests and outstanding work.")); + let _ = std::fs::remove_dir_all(dir); + } + struct TestServer { addr: SocketAddr, shutdown: Option>, From 14757e078088742dbfd2e21f00b691b01fbd484e Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 19:59:28 +0000 Subject: [PATCH 04/10] feat(tools): add notebook, sleep, and powershell tools Extend the Rust tools crate with NotebookEdit, Sleep, and PowerShell support. NotebookEdit now performs real ipynb cell replacement, insertion, and deletion; Sleep provides a non-shell wait primitive; and PowerShell executes commands with timeout/background support through a detected shell. Tests cover notebook mutation, sleep timing, and PowerShell execution via a stub shell while preserving the existing tool slices.\n\nConstraint: Keep the work confined to crates/tools/src/lib.rs and avoid staging unrelated workspace edits\nConstraint: Expose Claude Code-aligned names and close JSON-schema shapes for the new tools\nRejected: Stub-only notebook or sleep registrations | not materially useful beyond discovery\nRejected: PowerShell implemented as bash aliasing only | would not honor the distinct tool contract\nConfidence: medium\nScope-risk: moderate\nReversibility: clean\nDirective: Preserve the NotebookEdit field names and PowerShell output shape so later runtime extraction can move implementation without changing the contract\nTested: cargo fmt; cargo test -p tools\nNot-tested: cargo clippy; full workspace cargo test --- rust/crates/tools/src/lib.rs | 539 +++++++++++++++++++++++++++++++++++ 1 file changed, 539 insertions(+) diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 080ab26..5d88d63 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -247,6 +247,49 @@ pub fn mvp_tool_specs() -> Vec { "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", "new_source"], + "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 + }), + }, ] } @@ -264,6 +307,9 @@ pub fn execute_tool(name: &str, input: &Value) -> Result { "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}")), } } @@ -329,6 +375,18 @@ 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()) } @@ -419,6 +477,43 @@ struct ToolSearchInput { max_results: Option, } +#[derive(Debug, Deserialize)] +struct NotebookEditInput { + notebook_path: String, + cell_id: Option, + new_source: String, + 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, @@ -482,6 +577,25 @@ struct ToolSearchOutput { pending_mcp_servers: Option>, } +#[derive(Debug, Serialize)] +struct NotebookEditOutput { + new_source: String, + cell_id: Option, + cell_type: NotebookCellType, + 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 { @@ -1153,6 +1267,316 @@ fn slugify_agent_name(description: &str) -> String { out.trim_matches('-').chars().take(32).collect() } +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 = resolve_cell_index(cells, input.cell_id.as_deref(), edit_mode)?; + let resolved_cell_type = input.cell_type.unwrap_or_else(|| { + cells + .get(target_index) + .and_then(|cell| cell.get("cell_type")) + .and_then(serde_json::Value::as_str) + .map(|kind| { + if kind == "markdown" { + NotebookCellType::Markdown + } else { + NotebookCellType::Code + } + }) + .unwrap_or(NotebookCellType::Code) + }); + + let cell_id = match edit_mode { + NotebookEditMode::Insert => { + let new_id = make_cell_id(cells.len()); + let new_cell = json!({ + "cell_type": match resolved_cell_type { NotebookCellType::Code => "code", NotebookCellType::Markdown => "markdown" }, + "id": new_id, + "metadata": {}, + "source": source_lines(&input.new_source), + "outputs": [], + "execution_count": serde_json::Value::Null, + }); + let insert_at = if input.cell_id.is_some() { + target_index + 1 + } else { + 0 + }; + 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); + removed + .get("id") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + } + NotebookEditMode::Replace => { + let cell = cells + .get_mut(target_index) + .ok_or_else(|| String::from("Cell index out of range"))?; + cell["source"] = serde_json::Value::Array(source_lines(&input.new_source)); + cell["cell_type"] = serde_json::Value::String(match resolved_cell_type { + NotebookCellType::Code => String::from("code"), + NotebookCellType::Markdown => String::from("markdown"), + }); + 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: input.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 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() -> &'static str { + if command_exists("pwsh") { + "pwsh" + } else { + "powershell" + } +} + +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(false), + 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(0) + } +} + +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:") { @@ -1190,6 +1614,9 @@ mod tests { 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] @@ -1396,6 +1823,118 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn notebook_edit_replaces_and_inserts_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 final_notebook = std::fs::read_to_string(&path).expect("read notebook"); + assert!(final_notebook.contains("print(2)")); + assert!(final_notebook.contains("# heading")); + 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 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("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"); + + 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()); + } + struct TestServer { addr: SocketAddr, shutdown: Option>, From 0346b7dd3a29bb2962ea0fc295b5327f38a23a49 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 20:20:22 +0000 Subject: [PATCH 05/10] Tighten tool parity for agent handoffs and notebook edits Normalize Agent subagent aliases to Claude Code style built-in names, expose richer handoff metadata, teach ToolSearch to match canonical tool aliases, and polish NotebookEdit so delete does not require source and insert without a target appends cleanly. These are small parity-oriented behavior fixes confined to the tools crate.\n\nConstraint: Must not touch unrelated dirty api files in this worktree\nConstraint: Keep the change limited to rust/crates/tools\nRejected: Rework Agent into a real scheduler | outside this slice and not a small parity polish\nRejected: Add broad new tool surface area | request calls for small real parity improvements only\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep Agent built-in type normalization aligned with upstream naming aliases before expanding execution semantics\nTested: cargo test -p tools\nNot-tested: integration against a real upstream Claude Code runtime --- rust/crates/tools/src/lib.rs | 306 +++++++++++++++++++++++++++++------ 1 file changed, 253 insertions(+), 53 deletions(-) diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 5d88d63..c9e4a6b 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -259,7 +259,7 @@ pub fn mvp_tool_specs() -> Vec { "cell_type": { "type": "string", "enum": ["code", "markdown"] }, "edit_mode": { "type": "string", "enum": ["replace", "insert", "delete"] } }, - "required": ["notebook_path", "new_source"], + "required": ["notebook_path"], "additionalProperties": false }), }, @@ -481,7 +481,7 @@ struct ToolSearchInput { struct NotebookEditInput { notebook_path: String, cell_id: Option, - new_source: String, + new_source: Option, cell_type: Option, edit_mode: Option, } @@ -565,12 +565,17 @@ struct AgentOutput { 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")] @@ -581,7 +586,7 @@ struct ToolSearchOutput { struct NotebookEditOutput { new_source: String, cell_id: Option, - cell_type: NotebookCellType, + cell_type: Option, language: String, edit_mode: String, error: Option, @@ -1101,21 +1106,27 @@ fn execute_agent(input: AgentInput) -> Result { 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 .clone() .unwrap_or_else(|| slugify_agent_name(&input.description)); + let created_at = iso8601_now(); let output_contents = format!( - "# Agent Task\n\n- id: {}\n- name: {}\n- description: {}\n- subagent_type: {}\n\n## Prompt\n\n{}\n", - agent_id, - agent_name, - input.description, - input - .subagent_type - .clone() - .unwrap_or_else(|| String::from("general-purpose")), - input.prompt + "# 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())?; @@ -1123,10 +1134,12 @@ fn execute_agent(input: AgentInput) -> Result { agent_id, name: agent_name, description: input.description, - subagent_type: input.subagent_type, + 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, @@ -1141,11 +1154,13 @@ 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, } @@ -1171,9 +1186,10 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec .map(str::trim) .filter(|part| !part.is_empty()) .filter_map(|wanted| { + let wanted = canonical_tool_token(wanted); specs .iter() - .find(|spec| spec.name.eq_ignore_ascii_case(wanted)) + .find(|spec| canonical_tool_token(spec.name) == wanted) .map(|spec| spec.name.to_string()) }) .take(max_results) @@ -1201,13 +1217,20 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec .iter() .filter_map(|spec| { let name = spec.name.to_lowercase(); - let haystack = format!("{name} {}", spec.description.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; } @@ -1217,6 +1240,12 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec 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() { @@ -1226,7 +1255,7 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec }) .collect::>(); - scored.sort_by(|left, right| right.cmp(left)); + scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); scored .into_iter() .map(|(_, name)| name) @@ -1234,6 +1263,28 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec .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)); @@ -1267,6 +1318,33 @@ fn slugify_agent_name(description: &str) -> String { 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") { @@ -1291,38 +1369,35 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result 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 = json!({ - "cell_type": match resolved_cell_type { NotebookCellType::Code => "code", NotebookCellType::Markdown => "markdown" }, - "id": new_id, - "metadata": {}, - "source": source_lines(&input.new_source), - "outputs": [], - "execution_count": serde_json::Value::Null, - }); - let insert_at = if input.cell_id.is_some() { - target_index + 1 - } else { - 0 - }; + 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) @@ -1331,21 +1406,38 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result { - let removed = cells.remove(target_index); + 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) + .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(&input.new_source)); + 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) @@ -1357,7 +1449,7 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result Result, + 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 { @@ -1551,7 +1688,7 @@ fn resolve_cell_index( .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(0) + Ok(cells.len().saturating_sub(1)) } } @@ -1787,6 +1924,20 @@ mod tests { 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] @@ -1816,15 +1967,33 @@ mod tests { 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 _ = std::fs::remove_dir_all(dir); } #[test] - fn notebook_edit_replaces_and_inserts_cells() { + fn notebook_edit_replaces_inserts_and_deletes_cells() { let path = std::env::temp_dir().join(format!( "clawd-notebook-{}.ipynb", std::time::SystemTime::now() @@ -1872,9 +2041,40 @@ mod tests { .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 final_notebook = std::fs::read_to_string(&path).expect("read notebook"); - assert!(final_notebook.contains("print(2)")); - assert!(final_notebook.contains("# heading")); + 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); } From 4db21e9595ecd7e795357e8e8e458caf765de3b5 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 20:23:55 +0000 Subject: [PATCH 06/10] Make PowerShell tool report backgrounding and missing shells clearly Tighten the PowerShell tool to surface a clear not-found error when neither pwsh nor powershell exists, and mark explicit background execution as user-requested in the returned metadata. Harden the PowerShell tests against PATH mutation races while keeping the change confined to the tools crate.\n\nConstraint: Must not touch unrelated dirty api files in this worktree\nConstraint: Keep the change limited to rust/crates/tools\nRejected: Broader shell abstraction cleanup | not needed for this parity slice\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep PowerShell output metadata aligned with bash semantics when adding future shell parity improvements\nTested: cargo test -p tools\nNot-tested: real powershell.exe behavior on Windows hosts --- rust/crates/tools/src/lib.rs | 59 +++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index c9e4a6b..930c0d7 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -1516,7 +1516,7 @@ fn execute_sleep(input: SleepInput) -> SleepOutput { fn execute_powershell(input: PowerShellInput) -> std::io::Result { let _ = &input.description; - let shell = detect_powershell_shell(); + let shell = detect_powershell_shell()?; execute_shell_command( shell, &input.command, @@ -1525,11 +1525,16 @@ fn execute_powershell(input: PowerShellInput) -> std::io::Result &'static str { +fn detect_powershell_shell() -> std::io::Result<&'static str> { if command_exists("pwsh") { - "pwsh" + Ok("pwsh") + } else if command_exists("powershell") { + Ok("powershell") } else { - "powershell" + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "PowerShell executable not found (expected `pwsh` or `powershell` in PATH)", + )) } } @@ -1565,7 +1570,7 @@ fn execute_shell_command( interrupted: false, is_image: None, background_task_id: Some(child.id().to_string()), - backgrounded_by_user: Some(false), + backgrounded_by_user: Some(true), assistant_auto_backgrounded: Some(false), dangerously_disable_sandbox: None, return_code_interpretation: None, @@ -1730,13 +1735,18 @@ fn parse_skill_description(contents: &str) -> Option { mod tests { use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener}; - use std::sync::Arc; + 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() @@ -2095,6 +2105,7 @@ mod tests { #[test] fn powershell_runs_via_stub_shell() { + let _guard = env_lock().lock().expect("env lock"); let dir = std::env::temp_dir().join(format!( "clawd-pwsh-bin-{}", std::time::SystemTime::now() @@ -2113,7 +2124,7 @@ printf 'pwsh:%s' "$1" "#, ) .expect("write script"); - std::process::Command::new("chmod") + std::process::Command::new("/bin/chmod") .arg("+x") .arg(&script) .status() @@ -2127,12 +2138,46 @@ printf 'pwsh:%s' "$1" ) .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().expect("env lock"); + 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 { From 67423d005ae84004a321c5f7977551aaa0b664f7 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 20:26:06 +0000 Subject: [PATCH 07/10] Improve WebFetch title prompts for HTML pages Make title-focused WebFetch prompts prefer the real HTML value when present instead of always falling back to the first rendered text line. Keep the behavior narrow and preserve the existing summary path for non-title prompts.\n\nConstraint: Must not touch unrelated dirty api files in this worktree\nConstraint: Keep the change limited to rust/crates/tools\nRejected: Broader HTML parsing dependency | not needed for this small parity slice\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Preserve lightweight HTML handling unless parity requires a materially more robust parser\nTested: cargo test -p tools\nNot-tested: malformed HTML with mixed-case or nested title edge cases --- rust/crates/tools/src/lib.rs | 40 ++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 930c0d7..89c2dc5 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -639,7 +639,7 @@ fn execute_web_fetch(input: &WebFetchInput) -> Result<WebFetchOutput, 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); + let result = summarize_web_fetch(&final_url, &input.prompt, &normalized, &body, &content_type); Ok(WebFetchOutput { bytes, @@ -750,12 +750,18 @@ fn normalize_fetched_content(body: &str, content_type: &str) -> String { } } -fn summarize_web_fetch(url: &str, prompt: &str, content: &str) -> 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) + 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") { @@ -768,7 +774,21 @@ fn summarize_web_fetch(url: &str, prompt: &str, content: &str) -> String { format!("Fetched {url}\n{detail}") } -fn extract_title(content: &str) -> Option<String> { +fn extract_title(content: &str, raw_body: &str, content_type: &str) -> Option<String> { + if content_type.contains("html") { + let lowered = raw_body.to_lowercase(); + if let Some(start) = lowered.find("<title>") { + 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() { @@ -1798,6 +1818,18 @@ mod tests { 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] From 019e9900ed3061fd5c6efeea2b65d238702aff3a Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 20:27:09 +0000 Subject: [PATCH 08/10] Relax WebSearch domain filter inputs for parity Accept case-insensitive domain filters and URL-style allow/block list entries so WebSearch behaves more forgivingly for caller-provided domain constraints. Keep the change small and limited to host matching logic plus regression coverage.\n\nConstraint: Must not touch unrelated dirty api files in this worktree\nConstraint: Keep the change limited to rust/crates/tools\nRejected: Add full public suffix or hostname normalization logic | too broad for this parity slice\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Preserve simple host matching semantics unless upstream parity proves a more exact domain model is required\nTested: cargo test -p tools\nNot-tested: internationalized domain names and punycode edge cases --- rust/crates/tools/src/lib.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 89c2dc5..b82c707 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -974,12 +974,26 @@ fn host_matches_list(url: &str, domains: &[String]) -> bool { let Some(host) = parsed.host_str() else { return false; }; + let host = host.to_ascii_lowercase(); domains.iter().any(|domain| { - let normalized = domain.trim().trim_start_matches('.'); - host == normalized || host.ends_with(&format!(".{normalized}")) + 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())); @@ -1856,8 +1870,8 @@ mod tests { "WebSearch", &json!({ "query": "rust web search", - "allowed_domains": ["docs.rs"], - "blocked_domains": ["example.com"] + "allowed_domains": ["https://DOCS.rs/"], + "blocked_domains": ["HTTPS://EXAMPLE.COM"] }), ) .expect("WebSearch should succeed"); From 6e378185e9f86949cf7ac471f74ed90588100744 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 20:28:36 +0000 Subject: [PATCH 09/10] Accept $skill invocation form in Skill tool Teach Skill path resolution to accept the common $skill invocation form in addition to bare names and /skill prefixes. Keep the behavior narrow and add regression coverage using the existing help skill fixture. Constraint: Must not touch unrelated dirty api files in this worktree Constraint: Keep the change limited to rust/crates/tools Rejected: Canonicalize the returned skill field to the resolved name | would change caller-visible output semantics unnecessarily Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep invocation-prefix normalization aligned with how prompt and skill references are written elsewhere in the CLI Tested: cargo test -p tools Not-tested: CODEX_HOME layouts with unusual symlink arrangements --- rust/crates/tools/src/lib.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index b82c707..7bb6179 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -1090,7 +1090,7 @@ fn todo_store_path() -> Result { } fn resolve_skill_path(skill: &str) -> Result { - let requested = skill.trim().trim_start_matches('/'); + let requested = skill.trim().trim_start_matches('/').trim_start_matches('$'); if requested.is_empty() { return Err(String::from("skill must not be empty")); } @@ -1961,6 +1961,21 @@ mod tests { .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] From 99b78d6ea46b110e5c239a8b742f14a2e5ff5e28 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 20:46:06 +0000 Subject: [PATCH 10/10] Polish Agent defaults and ignore crate-local agent artifacts Move the default Agent artifact store out of rust/crates/tools so repeated Agent runs stop generating noisy crate-local files, normalize explicit Agent names through the existing slug path, and ignore any crate-local .clawd-agents residue defensively. Keep the slice limited to the tools crate and preserve the existing manifest-writing behavior. Constraint: Must not touch unrelated dirty api files in this worktree Constraint: Keep the change limited to rust/crates/tools Rejected: Add a broader agent runtime or execution model | outside the final cleanup slice Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep Agent persistence defaults outside package directories so generated artifacts do not pollute crate working trees Tested: cargo test -p tools Not-tested: concurrent multi-process Agent writes to the default fallback store --- rust/crates/tools/.gitignore | 1 + rust/crates/tools/src/lib.rs | 23 ++++++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 rust/crates/tools/.gitignore 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/src/lib.rs b/rust/crates/tools/src/lib.rs index 7bb6179..5927e64 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -1143,7 +1143,9 @@ fn execute_agent(input: AgentInput) -> Result { let normalized_subagent_type = normalize_subagent_type(input.subagent_type.as_deref()); let agent_name = input .name - .clone() + .as_deref() + .map(slugify_agent_name) + .filter(|name| !name.is_empty()) .unwrap_or_else(|| slugify_agent_name(&input.description)); let created_at = iso8601_now(); @@ -1324,6 +1326,9 @@ fn agent_store_dir() -> Result { 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")) } @@ -2060,6 +2065,18 @@ mod tests { 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); } @@ -2166,7 +2183,7 @@ mod tests { #[test] fn powershell_runs_via_stub_shell() { - let _guard = env_lock().lock().expect("env lock"); + 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() @@ -2220,7 +2237,7 @@ printf 'pwsh:%s' "$1" #[test] fn powershell_errors_when_shell_is_missing() { - let _guard = env_lock().lock().expect("env lock"); + 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-{}",