From 2d1cade31bcb5f67d4501ed4d923db059f93368a Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 19:43:10 +0000 Subject: [PATCH] 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>,