From ebef38e844f3d1fe29c26180ccb96489bd8c9c19 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Wed, 1 Apr 2026 20:36:06 +0900 Subject: [PATCH] feat: tool specifications and execution framework --- rust/crates/tools/.gitignore | 1 + rust/crates/tools/Cargo.toml | 18 + rust/crates/tools/src/lib.rs | 4466 ++++++++++++++++++++++++++++++++++ 3 files changed, 4485 insertions(+) create mode 100644 rust/crates/tools/.gitignore create mode 100644 rust/crates/tools/Cargo.toml create mode 100644 rust/crates/tools/src/lib.rs diff --git a/rust/crates/tools/.gitignore b/rust/crates/tools/.gitignore new file mode 100644 index 0000000..96da1ea --- /dev/null +++ b/rust/crates/tools/.gitignore @@ -0,0 +1 @@ +.clawd-agents/ diff --git a/rust/crates/tools/Cargo.toml b/rust/crates/tools/Cargo.toml new file mode 100644 index 0000000..04d738b --- /dev/null +++ b/rust/crates/tools/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "tools" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +api = { path = "../api" } +plugins = { path = "../plugins" } +runtime = { path = "../runtime" } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json.workspace = true +tokio = { version = "1", features = ["rt-multi-thread"] } + +[lints] +workspace = true diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs new file mode 100644 index 0000000..b5fab79 --- /dev/null +++ b/rust/crates/tools/src/lib.rs @@ -0,0 +1,4466 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, Instant}; + +use api::{ + max_tokens_for_model, resolve_model_alias, ContentBlockDelta, InputContentBlock, InputMessage, + MessageRequest, MessageResponse, OutputContentBlock, ProviderClient, + StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, +}; +use plugins::PluginTool; +use reqwest::blocking::Client; +use runtime::{ + edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file, + ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage, + ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy, + RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolManifestEntry { + pub name: String, + pub source: ToolSource, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolSource { + Base, + Conditional, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ToolRegistry { + entries: Vec, +} + +impl ToolRegistry { + #[must_use] + pub fn new(entries: Vec) -> Self { + Self { entries } + } + + #[must_use] + pub fn entries(&self) -> &[ToolManifestEntry] { + &self.entries + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolSpec { + pub name: &'static str, + pub description: &'static str, + pub input_schema: Value, + pub required_permission: PermissionMode, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct GlobalToolRegistry { + plugin_tools: Vec, +} + +impl GlobalToolRegistry { + #[must_use] + pub fn builtin() -> Self { + Self { + plugin_tools: Vec::new(), + } + } + + pub fn with_plugin_tools(plugin_tools: Vec) -> Result { + let builtin_names = mvp_tool_specs() + .into_iter() + .map(|spec| spec.name.to_string()) + .collect::>(); + let mut seen_plugin_names = BTreeSet::new(); + + for tool in &plugin_tools { + let name = tool.definition().name.clone(); + if builtin_names.contains(&name) { + return Err(format!( + "plugin tool `{name}` conflicts with a built-in tool name" + )); + } + if !seen_plugin_names.insert(name.clone()) { + return Err(format!("duplicate plugin tool name `{name}`")); + } + } + + Ok(Self { plugin_tools }) + } + + pub fn normalize_allowed_tools(&self, values: &[String]) -> Result>, String> { + if values.is_empty() { + return Ok(None); + } + + let builtin_specs = mvp_tool_specs(); + let canonical_names = builtin_specs + .iter() + .map(|spec| spec.name.to_string()) + .chain(self.plugin_tools.iter().map(|tool| tool.definition().name.clone())) + .collect::>(); + let mut name_map = canonical_names + .iter() + .map(|name| (normalize_tool_name(name), name.clone())) + .collect::>(); + + for (alias, canonical) in [ + ("read", "read_file"), + ("write", "write_file"), + ("edit", "edit_file"), + ("glob", "glob_search"), + ("grep", "grep_search"), + ] { + name_map.insert(alias.to_string(), canonical.to_string()); + } + + let mut allowed = BTreeSet::new(); + for value in values { + for token in value + .split(|ch: char| ch == ',' || ch.is_whitespace()) + .filter(|token| !token.is_empty()) + { + let normalized = normalize_tool_name(token); + let canonical = name_map.get(&normalized).ok_or_else(|| { + format!( + "unsupported tool in --allowedTools: {token} (expected one of: {})", + canonical_names.join(", ") + ) + })?; + allowed.insert(canonical.clone()); + } + } + + Ok(Some(allowed)) + } + + #[must_use] + pub fn definitions(&self, allowed_tools: Option<&BTreeSet>) -> Vec { + let builtin = mvp_tool_specs() + .into_iter() + .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name))) + .map(|spec| ToolDefinition { + name: spec.name.to_string(), + description: Some(spec.description.to_string()), + input_schema: spec.input_schema, + }); + let plugin = self + .plugin_tools + .iter() + .filter(|tool| { + allowed_tools.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str())) + }) + .map(|tool| ToolDefinition { + name: tool.definition().name.clone(), + description: tool.definition().description.clone(), + input_schema: tool.definition().input_schema.clone(), + }); + builtin.chain(plugin).collect() + } + + #[must_use] + pub fn permission_specs( + &self, + allowed_tools: Option<&BTreeSet>, + ) -> Vec<(String, PermissionMode)> { + let builtin = mvp_tool_specs() + .into_iter() + .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name))) + .map(|spec| (spec.name.to_string(), spec.required_permission)); + let plugin = self + .plugin_tools + .iter() + .filter(|tool| { + allowed_tools.is_none_or(|allowed| allowed.contains(tool.definition().name.as_str())) + }) + .map(|tool| { + ( + tool.definition().name.clone(), + permission_mode_from_plugin(tool.required_permission()), + ) + }); + builtin.chain(plugin).collect() + } + + pub fn execute(&self, name: &str, input: &Value) -> Result { + if mvp_tool_specs().iter().any(|spec| spec.name == name) { + return execute_tool(name, input); + } + self.plugin_tools + .iter() + .find(|tool| tool.definition().name == name) + .ok_or_else(|| format!("unsupported tool: {name}"))? + .execute(input) + .map_err(|error| error.to_string()) + } +} + +fn normalize_tool_name(value: &str) -> String { + value.trim().replace('-', "_").to_ascii_lowercase() +} + +fn permission_mode_from_plugin(value: &str) -> PermissionMode { + match value { + "read-only" => PermissionMode::ReadOnly, + "workspace-write" => PermissionMode::WorkspaceWrite, + "danger-full-access" => PermissionMode::DangerFullAccess, + other => panic!("unsupported plugin permission: {other}"), + } +} + +#[must_use] +#[allow(clippy::too_many_lines)] +pub fn mvp_tool_specs() -> Vec { + vec![ + ToolSpec { + name: "bash", + description: "Execute a shell command in the current workspace.", + input_schema: json!({ + "type": "object", + "properties": { + "command": { "type": "string" }, + "timeout": { "type": "integer", "minimum": 1 }, + "description": { "type": "string" }, + "run_in_background": { "type": "boolean" }, + "dangerouslyDisableSandbox": { "type": "boolean" } + }, + "required": ["command"], + "additionalProperties": false + }), + required_permission: PermissionMode::DangerFullAccess, + }, + ToolSpec { + name: "read_file", + description: "Read a text file from the workspace.", + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "offset": { "type": "integer", "minimum": 0 }, + "limit": { "type": "integer", "minimum": 1 } + }, + "required": ["path"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "write_file", + description: "Write a text file in the workspace.", + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "content": { "type": "string" } + }, + "required": ["path", "content"], + "additionalProperties": false + }), + required_permission: PermissionMode::WorkspaceWrite, + }, + ToolSpec { + name: "edit_file", + description: "Replace text in a workspace file.", + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "old_string": { "type": "string" }, + "new_string": { "type": "string" }, + "replace_all": { "type": "boolean" } + }, + "required": ["path", "old_string", "new_string"], + "additionalProperties": false + }), + required_permission: PermissionMode::WorkspaceWrite, + }, + ToolSpec { + name: "glob_search", + description: "Find files by glob pattern.", + input_schema: json!({ + "type": "object", + "properties": { + "pattern": { "type": "string" }, + "path": { "type": "string" } + }, + "required": ["pattern"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "grep_search", + description: "Search file contents with a regex pattern.", + input_schema: json!({ + "type": "object", + "properties": { + "pattern": { "type": "string" }, + "path": { "type": "string" }, + "glob": { "type": "string" }, + "output_mode": { "type": "string" }, + "-B": { "type": "integer", "minimum": 0 }, + "-A": { "type": "integer", "minimum": 0 }, + "-C": { "type": "integer", "minimum": 0 }, + "context": { "type": "integer", "minimum": 0 }, + "-n": { "type": "boolean" }, + "-i": { "type": "boolean" }, + "type": { "type": "string" }, + "head_limit": { "type": "integer", "minimum": 1 }, + "offset": { "type": "integer", "minimum": 0 }, + "multiline": { "type": "boolean" } + }, + "required": ["pattern"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + 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 + }), + required_permission: PermissionMode::ReadOnly, + }, + 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 + }), + required_permission: PermissionMode::ReadOnly, + }, + 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 + }), + required_permission: PermissionMode::WorkspaceWrite, + }, + 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 + }), + required_permission: PermissionMode::ReadOnly, + }, + 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 + }), + required_permission: PermissionMode::DangerFullAccess, + }, + 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 + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "NotebookEdit", + description: "Replace, insert, or delete a cell in a Jupyter notebook.", + input_schema: json!({ + "type": "object", + "properties": { + "notebook_path": { "type": "string" }, + "cell_id": { "type": "string" }, + "new_source": { "type": "string" }, + "cell_type": { "type": "string", "enum": ["code", "markdown"] }, + "edit_mode": { "type": "string", "enum": ["replace", "insert", "delete"] } + }, + "required": ["notebook_path"], + "additionalProperties": false + }), + required_permission: PermissionMode::WorkspaceWrite, + }, + 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 + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "SendUserMessage", + description: "Send a message to the user.", + input_schema: json!({ + "type": "object", + "properties": { + "message": { "type": "string" }, + "attachments": { + "type": "array", + "items": { "type": "string" } + }, + "status": { + "type": "string", + "enum": ["normal", "proactive"] + } + }, + "required": ["message", "status"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "Config", + description: "Get or set Claw Code settings.", + input_schema: json!({ + "type": "object", + "properties": { + "setting": { "type": "string" }, + "value": { + "type": ["string", "boolean", "number"] + } + }, + "required": ["setting"], + "additionalProperties": false + }), + required_permission: PermissionMode::WorkspaceWrite, + }, + ToolSpec { + name: "StructuredOutput", + description: "Return structured output in the requested format.", + input_schema: json!({ + "type": "object", + "additionalProperties": true + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "REPL", + description: "Execute code in a REPL-like subprocess.", + input_schema: json!({ + "type": "object", + "properties": { + "code": { "type": "string" }, + "language": { "type": "string" }, + "timeout_ms": { "type": "integer", "minimum": 1 } + }, + "required": ["code", "language"], + "additionalProperties": false + }), + required_permission: PermissionMode::DangerFullAccess, + }, + 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 + }), + required_permission: PermissionMode::DangerFullAccess, + }, + ] +} + +pub fn execute_tool(name: &str, input: &Value) -> Result { + match name { + "bash" => from_value::(input).and_then(run_bash), + "read_file" => from_value::(input).and_then(run_read_file), + "write_file" => from_value::(input).and_then(run_write_file), + "edit_file" => from_value::(input).and_then(run_edit_file), + "glob_search" => from_value::(input).and_then(run_glob_search), + "grep_search" => from_value::(input).and_then(run_grep_search), + "WebFetch" => from_value::(input).and_then(run_web_fetch), + "WebSearch" => from_value::(input).and_then(run_web_search), + "TodoWrite" => from_value::(input).and_then(run_todo_write), + "Skill" => from_value::(input).and_then(run_skill), + "Agent" => from_value::(input).and_then(run_agent), + "ToolSearch" => from_value::(input).and_then(run_tool_search), + "NotebookEdit" => from_value::(input).and_then(run_notebook_edit), + "Sleep" => from_value::(input).and_then(run_sleep), + "SendUserMessage" | "Brief" => from_value::(input).and_then(run_brief), + "Config" => from_value::(input).and_then(run_config), + "StructuredOutput" => { + from_value::(input).and_then(run_structured_output) + } + "REPL" => from_value::(input).and_then(run_repl), + "PowerShell" => from_value::(input).and_then(run_powershell), + _ => Err(format!("unsupported tool: {name}")), + } +} + +fn from_value Deserialize<'de>>(input: &Value) -> Result { + serde_json::from_value(input.clone()).map_err(|error| error.to_string()) +} + +fn run_bash(input: BashCommandInput) -> Result { + serde_json::to_string_pretty(&execute_bash(input).map_err(|error| error.to_string())?) + .map_err(|error| error.to_string()) +} + +#[allow(clippy::needless_pass_by_value)] +fn run_read_file(input: ReadFileInput) -> Result { + to_pretty_json(read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?) +} + +#[allow(clippy::needless_pass_by_value)] +fn run_write_file(input: WriteFileInput) -> Result { + to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?) +} + +#[allow(clippy::needless_pass_by_value)] +fn run_edit_file(input: EditFileInput) -> Result { + to_pretty_json( + edit_file( + &input.path, + &input.old_string, + &input.new_string, + input.replace_all.unwrap_or(false), + ) + .map_err(io_to_string)?, + ) +} + +#[allow(clippy::needless_pass_by_value)] +fn run_glob_search(input: GlobSearchInputValue) -> Result { + to_pretty_json(glob_search(&input.pattern, input.path.as_deref()).map_err(io_to_string)?) +} + +#[allow(clippy::needless_pass_by_value)] +fn run_grep_search(input: GrepSearchInput) -> Result { + to_pretty_json(grep_search(&input).map_err(io_to_string)?) +} + +#[allow(clippy::needless_pass_by_value)] +fn run_web_fetch(input: WebFetchInput) -> Result { + to_pretty_json(execute_web_fetch(&input)?) +} + +#[allow(clippy::needless_pass_by_value)] +fn run_web_search(input: WebSearchInput) -> Result { + to_pretty_json(execute_web_search(&input)?) +} + +fn run_todo_write(input: TodoWriteInput) -> Result { + to_pretty_json(execute_todo_write(input)?) +} + +fn run_skill(input: SkillInput) -> Result { + to_pretty_json(execute_skill(input)?) +} + +fn run_agent(input: AgentInput) -> Result { + to_pretty_json(execute_agent(input)?) +} + +fn run_tool_search(input: ToolSearchInput) -> Result { + to_pretty_json(execute_tool_search(input)) +} + +fn run_notebook_edit(input: NotebookEditInput) -> Result { + to_pretty_json(execute_notebook_edit(input)?) +} + +fn run_sleep(input: SleepInput) -> Result { + to_pretty_json(execute_sleep(input)) +} + +fn run_brief(input: BriefInput) -> Result { + to_pretty_json(execute_brief(input)?) +} + +fn run_config(input: ConfigInput) -> Result { + to_pretty_json(execute_config(input)?) +} + +fn run_structured_output(input: StructuredOutputInput) -> Result { + to_pretty_json(execute_structured_output(input)) +} + +fn run_repl(input: ReplInput) -> Result { + to_pretty_json(execute_repl(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()) +} + +#[allow(clippy::needless_pass_by_value)] +fn io_to_string(error: std::io::Error) -> String { + error.to_string() +} + +#[derive(Debug, Deserialize)] +struct ReadFileInput { + path: String, + offset: Option, + limit: Option, +} + +#[derive(Debug, Deserialize)] +struct WriteFileInput { + path: String, + content: String, +} + +#[derive(Debug, Deserialize)] +struct EditFileInput { + path: String, + old_string: String, + new_string: String, + replace_all: Option, +} + +#[derive(Debug, Deserialize)] +struct GlobSearchInputValue { + pattern: String, + path: Option, +} + +#[derive(Debug, Deserialize)] +struct WebFetchInput { + url: String, + prompt: String, +} + +#[derive(Debug, Deserialize)] +struct WebSearchInput { + query: String, + allowed_domains: Option>, + blocked_domains: Option>, +} + +#[derive(Debug, Deserialize)] +struct TodoWriteInput { + todos: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +struct TodoItem { + content: String, + #[serde(rename = "activeForm")] + active_form: String, + status: TodoStatus, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum TodoStatus { + Pending, + InProgress, + Completed, +} + +#[derive(Debug, Deserialize)] +struct SkillInput { + skill: String, + args: Option, +} + +#[derive(Debug, Deserialize)] +struct AgentInput { + description: String, + prompt: String, + subagent_type: Option, + name: Option, + model: Option, +} + +#[derive(Debug, Deserialize)] +struct ToolSearchInput { + query: String, + max_results: Option, +} + +#[derive(Debug, Deserialize)] +struct NotebookEditInput { + notebook_path: String, + cell_id: Option, + new_source: Option, + cell_type: Option, + edit_mode: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum NotebookCellType { + Code, + Markdown, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum NotebookEditMode { + Replace, + Insert, + Delete, +} + +#[derive(Debug, Deserialize)] +struct SleepInput { + duration_ms: u64, +} + +#[derive(Debug, Deserialize)] +struct BriefInput { + message: String, + attachments: Option>, + status: BriefStatus, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum BriefStatus { + Normal, + Proactive, +} + +#[derive(Debug, Deserialize)] +struct ConfigInput { + setting: String, + value: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum ConfigValue { + String(String), + Bool(bool), + Number(f64), +} + +#[derive(Debug, Deserialize)] +#[serde(transparent)] +struct StructuredOutputInput(BTreeMap); + +#[derive(Debug, Deserialize)] +struct ReplInput { + code: String, + language: String, + timeout_ms: Option, +} + +#[derive(Debug, Deserialize)] +struct PowerShellInput { + command: String, + timeout: Option, + description: Option, + run_in_background: Option, +} + +#[derive(Debug, Serialize)] +struct WebFetchOutput { + bytes: usize, + code: u16, + #[serde(rename = "codeText")] + code_text: String, + result: String, + #[serde(rename = "durationMs")] + duration_ms: u128, + url: String, +} + +#[derive(Debug, Serialize)] +struct WebSearchOutput { + query: String, + results: Vec, + #[serde(rename = "durationSeconds")] + duration_seconds: f64, +} + +#[derive(Debug, Serialize)] +struct TodoWriteOutput { + #[serde(rename = "oldTodos")] + old_todos: Vec, + #[serde(rename = "newTodos")] + new_todos: Vec, + #[serde(rename = "verificationNudgeNeeded")] + verification_nudge_needed: Option, +} + +#[derive(Debug, Serialize)] +struct SkillOutput { + skill: String, + path: String, + args: Option, + description: Option, + prompt: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AgentOutput { + #[serde(rename = "agentId")] + agent_id: String, + name: String, + description: String, + #[serde(rename = "subagentType")] + subagent_type: Option, + model: Option, + status: String, + #[serde(rename = "outputFile")] + output_file: String, + #[serde(rename = "manifestFile")] + manifest_file: String, + #[serde(rename = "createdAt")] + created_at: String, + #[serde(rename = "startedAt", skip_serializing_if = "Option::is_none")] + started_at: Option, + #[serde(rename = "completedAt", skip_serializing_if = "Option::is_none")] + completed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Clone)] +struct AgentJob { + manifest: AgentOutput, + prompt: String, + system_prompt: Vec, + allowed_tools: BTreeSet, +} + +#[derive(Debug, Serialize)] +struct ToolSearchOutput { + matches: Vec, + query: String, + normalized_query: String, + #[serde(rename = "total_deferred_tools")] + total_deferred_tools: usize, + #[serde(rename = "pending_mcp_servers")] + pending_mcp_servers: Option>, +} + +#[derive(Debug, Serialize)] +struct NotebookEditOutput { + new_source: String, + cell_id: Option, + cell_type: Option, + language: String, + edit_mode: String, + error: Option, + notebook_path: String, + original_file: String, + updated_file: String, +} + +#[derive(Debug, Serialize)] +struct SleepOutput { + duration_ms: u64, + message: String, +} + +#[derive(Debug, Serialize)] +struct BriefOutput { + message: String, + attachments: Option>, + #[serde(rename = "sentAt")] + sent_at: String, +} + +#[derive(Debug, Serialize)] +struct ResolvedAttachment { + path: String, + size: u64, + #[serde(rename = "isImage")] + is_image: bool, +} + +#[derive(Debug, Serialize)] +struct ConfigOutput { + success: bool, + operation: Option, + setting: Option, + value: Option, + #[serde(rename = "previousValue")] + previous_value: Option, + #[serde(rename = "newValue")] + new_value: Option, + error: Option, +} + +#[derive(Debug, Serialize)] +struct StructuredOutputResult { + data: String, + structured_output: BTreeMap, +} + +#[derive(Debug, Serialize)] +struct ReplOutput { + language: String, + stdout: String, + stderr: String, + #[serde(rename = "exitCode")] + exit_code: i32, + #[serde(rename = "durationMs")] + duration_ms: u128, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +enum WebSearchResultItem { + SearchResult { + tool_use_id: String, + content: Vec, + }, + Commentary(String), +} + +#[derive(Debug, Serialize)] +struct SearchHit { + title: String, + url: String, +} + +fn execute_web_fetch(input: &WebFetchInput) -> Result { + let started = Instant::now(); + let client = build_http_client()?; + let request_url = normalize_fetch_url(&input.url)?; + let response = client + .get(request_url.clone()) + .send() + .map_err(|error| error.to_string())?; + + let status = response.status(); + let final_url = response.url().to_string(); + let code = status.as_u16(); + let code_text = status.canonical_reason().unwrap_or("Unknown").to_string(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + let body = response.text().map_err(|error| error.to_string())?; + let bytes = body.len(); + let normalized = normalize_fetched_content(&body, &content_type); + let result = summarize_web_fetch(&final_url, &input.prompt, &normalized, &body, &content_type); + + Ok(WebFetchOutput { + bytes, + code, + code_text, + result, + duration_ms: started.elapsed().as_millis(), + url: final_url, + }) +} + +fn execute_web_search(input: &WebSearchInput) -> Result { + let started = Instant::now(); + let client = build_http_client()?; + let search_url = build_search_url(&input.query)?; + let response = client + .get(search_url) + .send() + .map_err(|error| error.to_string())?; + + let final_url = response.url().clone(); + let html = response.text().map_err(|error| error.to_string())?; + let mut hits = extract_search_hits(&html); + + if hits.is_empty() && final_url.host_str().is_some() { + hits = extract_search_hits_from_generic_links(&html); + } + + if let Some(allowed) = input.allowed_domains.as_ref() { + hits.retain(|hit| host_matches_list(&hit.url, allowed)); + } + if let Some(blocked) = input.blocked_domains.as_ref() { + hits.retain(|hit| !host_matches_list(&hit.url, blocked)); + } + + dedupe_hits(&mut hits); + hits.truncate(8); + + let summary = if hits.is_empty() { + format!("No web search results matched the query {:?}.", input.query) + } else { + let rendered_hits = hits + .iter() + .map(|hit| format!("- [{}]({})", hit.title, hit.url)) + .collect::>() + .join("\n"); + format!( + "Search results for {:?}. Include a Sources section in the final answer.\n{}", + input.query, rendered_hits + ) + }; + + Ok(WebSearchOutput { + query: input.query.clone(), + results: vec![ + WebSearchResultItem::Commentary(summary), + WebSearchResultItem::SearchResult { + tool_use_id: String::from("web_search_1"), + content: hits, + }, + ], + duration_seconds: started.elapsed().as_secs_f64(), + }) +} + +fn build_http_client() -> Result { + Client::builder() + .timeout(Duration::from_secs(20)) + .redirect(reqwest::redirect::Policy::limited(10)) + .user_agent("claw-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("CLAW_WEB_SEARCH_BASE_URL") { + let mut url = reqwest::Url::parse(&base).map_err(|error| error.to_string())?; + url.query_pairs_mut().append_pair("q", query); + return Ok(url); + } + + let mut url = reqwest::Url::parse("https://html.duckduckgo.com/html/") + .map_err(|error| error.to_string())?; + url.query_pairs_mut().append_pair("q", query); + Ok(url) +} + +fn normalize_fetched_content(body: &str, content_type: &str) -> String { + if content_type.contains("html") { + html_to_text(body) + } else { + body.trim().to_string() + } +} + +fn summarize_web_fetch( + url: &str, + prompt: &str, + content: &str, + raw_body: &str, + content_type: &str, +) -> String { + let lower_prompt = prompt.to_lowercase(); + let compact = collapse_whitespace(content); + + let detail = if lower_prompt.contains("title") { + extract_title(content, raw_body, content_type).map_or_else( + || preview_text(&compact, 600), + |title| format!("Title: {title}"), + ) + } else if lower_prompt.contains("summary") || lower_prompt.contains("summarize") { + preview_text(&compact, 900) + } else { + let preview = preview_text(&compact, 900); + format!("Prompt: {prompt}\nContent preview:\n{preview}") + }; + + format!("Fetched {url}\n{detail}") +} + +fn extract_title(content: &str, raw_body: &str, content_type: &str) -> Option { + if content_type.contains("html") { + let lowered = raw_body.to_lowercase(); + if let Some(start) = lowered.find("") { + let after = start + "<title>".len(); + if let Some(end_rel) = lowered[after..].find("") { + let title = + collapse_whitespace(&decode_html_entities(&raw_body[after..after + end_rel])); + if !title.is_empty() { + return Some(title); + } + } + } + } + + for line in content.lines() { + let trimmed = line.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + None +} + +fn html_to_text(html: &str) -> String { + let mut text = String::with_capacity(html.len()); + let mut in_tag = false; + let mut previous_was_space = false; + + for ch in html.chars() { + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + _ if in_tag => {} + '&' => { + text.push('&'); + previous_was_space = false; + } + ch if ch.is_whitespace() => { + if !previous_was_space { + text.push(' '); + previous_was_space = true; + } + } + _ => { + text.push(ch); + previous_was_space = false; + } + } + } + + collapse_whitespace(&decode_html_entities(&text)) +} + +fn decode_html_entities(input: &str) -> String { + input + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace(" ", " ") +} + +fn collapse_whitespace(input: &str) -> String { + input.split_whitespace().collect::>().join(" ") +} + +fn preview_text(input: &str, max_chars: usize) -> String { + if input.chars().count() <= max_chars { + return input.to_string(); + } + let shortened = input.chars().take(max_chars).collect::(); + format!("{}…", shortened.trim_end()) +} + +fn extract_search_hits(html: &str) -> Vec { + let mut hits = Vec::new(); + let mut remaining = html; + + while let Some(anchor_start) = remaining.find("result__a") { + let after_class = &remaining[anchor_start..]; + let Some(href_idx) = after_class.find("href=") else { + remaining = &after_class[1..]; + continue; + }; + let href_slice = &after_class[href_idx + 5..]; + let Some((url, rest)) = extract_quoted_value(href_slice) else { + remaining = &after_class[1..]; + continue; + }; + let Some(close_tag_idx) = rest.find('>') else { + remaining = &after_class[1..]; + continue; + }; + let after_tag = &rest[close_tag_idx + 1..]; + let Some(end_anchor_idx) = after_tag.find("") else { + remaining = &after_tag[1..]; + continue; + }; + let title = html_to_text(&after_tag[..end_anchor_idx]); + if let Some(decoded_url) = decode_duckduckgo_redirect(&url) { + hits.push(SearchHit { + title: title.trim().to_string(), + url: decoded_url, + }); + } + remaining = &after_tag[end_anchor_idx + 4..]; + } + + hits +} + +fn extract_search_hits_from_generic_links(html: &str) -> Vec { + let mut hits = Vec::new(); + let mut remaining = html; + + while let Some(anchor_start) = remaining.find("') else { + remaining = &after_anchor[2..]; + continue; + }; + let after_tag = &rest[close_tag_idx + 1..]; + let Some(end_anchor_idx) = after_tag.find("") else { + remaining = &after_anchor[2..]; + continue; + }; + let title = html_to_text(&after_tag[..end_anchor_idx]); + if title.trim().is_empty() { + remaining = &after_tag[end_anchor_idx + 4..]; + continue; + } + let decoded_url = decode_duckduckgo_redirect(&url).unwrap_or(url); + if decoded_url.starts_with("http://") || decoded_url.starts_with("https://") { + hits.push(SearchHit { + title: title.trim().to_string(), + url: decoded_url, + }); + } + remaining = &after_tag[end_anchor_idx + 4..]; + } + + hits +} + +fn extract_quoted_value(input: &str) -> Option<(String, &str)> { + let quote = input.chars().next()?; + if quote != '"' && quote != '\'' { + return None; + } + let rest = &input[quote.len_utf8()..]; + let end = rest.find(quote)?; + Some((rest[..end].to_string(), &rest[end + quote.len_utf8()..])) +} + +fn decode_duckduckgo_redirect(url: &str) -> Option { + if url.starts_with("http://") || url.starts_with("https://") { + return Some(html_entity_decode_url(url)); + } + + let joined = if url.starts_with("//") { + format!("https:{url}") + } else if url.starts_with('/') { + format!("https://duckduckgo.com{url}") + } else { + return None; + }; + + let parsed = reqwest::Url::parse(&joined).ok()?; + if parsed.path() == "/l/" || parsed.path() == "/l" { + for (key, value) in parsed.query_pairs() { + if key == "uddg" { + return Some(html_entity_decode_url(value.as_ref())); + } + } + } + Some(joined) +} + +fn html_entity_decode_url(url: &str) -> String { + decode_html_entities(url) +} + +fn host_matches_list(url: &str, domains: &[String]) -> bool { + let Ok(parsed) = reqwest::Url::parse(url) else { + return false; + }; + let Some(host) = parsed.host_str() else { + return false; + }; + let host = host.to_ascii_lowercase(); + domains.iter().any(|domain| { + let normalized = normalize_domain_filter(domain); + !normalized.is_empty() && (host == normalized || host.ends_with(&format!(".{normalized}"))) + }) +} + +fn normalize_domain_filter(domain: &str) -> String { + let trimmed = domain.trim(); + let candidate = reqwest::Url::parse(trimmed) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + .unwrap_or_else(|| trimmed.to_string()); + candidate + .trim() + .trim_start_matches('.') + .trim_end_matches('/') + .to_ascii_lowercase() +} + +fn dedupe_hits(hits: &mut Vec) { + let mut seen = BTreeSet::new(); + hits.retain(|hit| seen.insert(hit.url.clone())); +} + +fn execute_todo_write(input: TodoWriteInput) -> Result { + validate_todos(&input.todos)?; + let store_path = todo_store_path()?; + let old_todos = if store_path.exists() { + serde_json::from_str::>( + &std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())? + } else { + Vec::new() + }; + + let all_done = input + .todos + .iter() + .all(|todo| matches!(todo.status, TodoStatus::Completed)); + let persisted = if all_done { + Vec::new() + } else { + input.todos.clone() + }; + + if let Some(parent) = store_path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + std::fs::write( + &store_path, + serde_json::to_string_pretty(&persisted).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + + let verification_nudge_needed = (all_done + && input.todos.len() >= 3 + && !input + .todos + .iter() + .any(|todo| todo.content.to_lowercase().contains("verif"))) + .then_some(true); + + Ok(TodoWriteOutput { + old_todos, + new_todos: input.todos, + verification_nudge_needed, + }) +} + +fn execute_skill(input: SkillInput) -> Result { + let skill_path = resolve_skill_path(&input.skill)?; + let prompt = std::fs::read_to_string(&skill_path).map_err(|error| error.to_string())?; + let description = parse_skill_description(&prompt); + + Ok(SkillOutput { + skill: input.skill, + path: skill_path.display().to_string(), + args: input.args, + description, + prompt, + }) +} + +fn validate_todos(todos: &[TodoItem]) -> Result<(), String> { + if todos.is_empty() { + return Err(String::from("todos must not be empty")); + } + // Allow multiple in_progress items for parallel workflows + 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("CLAW_TODO_STORE") { + return Ok(std::path::PathBuf::from(path)); + } + let cwd = std::env::current_dir().map_err(|error| error.to_string())?; + Ok(cwd.join(".claw-todos.json")) +} + +fn resolve_skill_path(skill: &str) -> Result { + let requested = skill.trim().trim_start_matches('/').trim_start_matches('$'); + if requested.is_empty() { + return Err(String::from("skill must not be empty")); + } + + let mut candidates = Vec::new(); + if let Ok(codex_home) = std::env::var("CODEX_HOME") { + candidates.push(std::path::PathBuf::from(codex_home).join("skills")); + } + if let Ok(home) = std::env::var("HOME") { + let home = std::path::PathBuf::from(home); + candidates.push(home.join(".agents").join("skills")); + candidates.push(home.join(".config").join("opencode").join("skills")); + candidates.push(home.join(".codex").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}")) +} + +const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6"; +const DEFAULT_AGENT_SYSTEM_DATE: &str = "2026-03-31"; +const DEFAULT_AGENT_MAX_ITERATIONS: usize = 32; + +fn execute_agent(input: AgentInput) -> Result { + execute_agent_with_spawn(input, spawn_agent_job) +} + +fn execute_agent_with_spawn(input: AgentInput, spawn_fn: F) -> Result +where + F: FnOnce(AgentJob) -> Result<(), String>, +{ + if input.description.trim().is_empty() { + return Err(String::from("description must not be empty")); + } + if input.prompt.trim().is_empty() { + return Err(String::from("prompt must not be empty")); + } + + let agent_id = make_agent_id(); + let output_dir = agent_store_dir()?; + std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?; + let output_file = output_dir.join(format!("{agent_id}.md")); + let manifest_file = output_dir.join(format!("{agent_id}.json")); + let normalized_subagent_type = normalize_subagent_type(input.subagent_type.as_deref()); + let model = resolve_agent_model(input.model.as_deref()); + let agent_name = input + .name + .as_deref() + .map(slugify_agent_name) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| slugify_agent_name(&input.description)); + let created_at = iso8601_now(); + let system_prompt = build_agent_system_prompt(&normalized_subagent_type)?; + let allowed_tools = allowed_tools_for_subagent(&normalized_subagent_type); + + let output_contents = format!( + "# Agent Task + +- id: {} +- name: {} +- description: {} +- subagent_type: {} +- created_at: {} + +## Prompt + +{} +", + agent_id, agent_name, input.description, normalized_subagent_type, created_at, input.prompt + ); + std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?; + + let manifest = AgentOutput { + agent_id, + name: agent_name, + description: input.description, + subagent_type: Some(normalized_subagent_type), + model: Some(model), + status: String::from("running"), + output_file: output_file.display().to_string(), + manifest_file: manifest_file.display().to_string(), + created_at: created_at.clone(), + started_at: Some(created_at), + completed_at: None, + error: None, + }; + write_agent_manifest(&manifest)?; + + let manifest_for_spawn = manifest.clone(); + let job = AgentJob { + manifest: manifest_for_spawn, + prompt: input.prompt, + system_prompt, + allowed_tools, + }; + if let Err(error) = spawn_fn(job) { + let error = format!("failed to spawn sub-agent: {error}"); + persist_agent_terminal_state(&manifest, "failed", None, Some(error.clone()))?; + return Err(error); + } + + Ok(manifest) +} + +fn spawn_agent_job(job: AgentJob) -> Result<(), String> { + let thread_name = format!("claw-agent-{}", job.manifest.agent_id); + std::thread::Builder::new() + .name(thread_name) + .spawn(move || { + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_agent_job(&job))); + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => { + let _ = + persist_agent_terminal_state(&job.manifest, "failed", None, Some(error)); + } + Err(_) => { + let _ = persist_agent_terminal_state( + &job.manifest, + "failed", + None, + Some(String::from("sub-agent thread panicked")), + ); + } + } + }) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn run_agent_job(job: &AgentJob) -> Result<(), String> { + let mut runtime = build_agent_runtime(job)?.with_max_iterations(DEFAULT_AGENT_MAX_ITERATIONS); + let summary = runtime + .run_turn(job.prompt.clone(), None) + .map_err(|error| error.to_string())?; + let final_text = final_assistant_text(&summary); + persist_agent_terminal_state(&job.manifest, "completed", Some(final_text.as_str()), None) +} + +fn build_agent_runtime( + job: &AgentJob, +) -> Result, String> { + let model = job + .manifest + .model + .clone() + .unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string()); + let allowed_tools = job.allowed_tools.clone(); + let api_client = ProviderRuntimeClient::new(model, allowed_tools.clone())?; + let tool_executor = SubagentToolExecutor::new(allowed_tools); + Ok(ConversationRuntime::new( + Session::new(), + api_client, + tool_executor, + agent_permission_policy(), + job.system_prompt.clone(), + )) +} + +fn build_agent_system_prompt(subagent_type: &str) -> Result, String> { + let cwd = std::env::current_dir().map_err(|error| error.to_string())?; + let mut prompt = load_system_prompt( + cwd, + DEFAULT_AGENT_SYSTEM_DATE.to_string(), + std::env::consts::OS, + "unknown", + ) + .map_err(|error| error.to_string())?; + prompt.push(format!( + "You are a background sub-agent of type `{subagent_type}`. Work only on the delegated task, use only the tools available to you, do not ask the user questions, and finish with a concise result." + )); + Ok(prompt) +} + +fn resolve_agent_model(model: Option<&str>) -> String { + model + .map(str::trim) + .filter(|model| !model.is_empty()) + .unwrap_or(DEFAULT_AGENT_MODEL) + .to_string() +} + +fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet { + let tools = match subagent_type { + "Explore" => vec![ + "read_file", + "glob_search", + "grep_search", + "WebFetch", + "WebSearch", + "ToolSearch", + "Skill", + "StructuredOutput", + ], + "Plan" => vec![ + "read_file", + "glob_search", + "grep_search", + "WebFetch", + "WebSearch", + "ToolSearch", + "Skill", + "TodoWrite", + "StructuredOutput", + "SendUserMessage", + ], + "Verification" => vec![ + "bash", + "read_file", + "glob_search", + "grep_search", + "WebFetch", + "WebSearch", + "ToolSearch", + "TodoWrite", + "StructuredOutput", + "SendUserMessage", + "PowerShell", + ], + "claw-guide" => vec![ + "read_file", + "glob_search", + "grep_search", + "WebFetch", + "WebSearch", + "ToolSearch", + "Skill", + "StructuredOutput", + "SendUserMessage", + ], + "statusline-setup" => vec![ + "bash", + "read_file", + "write_file", + "edit_file", + "glob_search", + "grep_search", + "ToolSearch", + ], + _ => vec![ + "bash", + "read_file", + "write_file", + "edit_file", + "glob_search", + "grep_search", + "WebFetch", + "WebSearch", + "TodoWrite", + "Skill", + "ToolSearch", + "NotebookEdit", + "Sleep", + "SendUserMessage", + "Config", + "StructuredOutput", + "REPL", + "PowerShell", + ], + }; + tools.into_iter().map(str::to_string).collect() +} + +fn agent_permission_policy() -> PermissionPolicy { + mvp_tool_specs().into_iter().fold( + PermissionPolicy::new(PermissionMode::DangerFullAccess), + |policy, spec| policy.with_tool_requirement(spec.name, spec.required_permission), + ) +} + +fn write_agent_manifest(manifest: &AgentOutput) -> Result<(), String> { + std::fs::write( + &manifest.manifest_file, + serde_json::to_string_pretty(manifest).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string()) +} + +fn persist_agent_terminal_state( + manifest: &AgentOutput, + status: &str, + result: Option<&str>, + error: Option, +) -> Result<(), String> { + append_agent_output( + &manifest.output_file, + &format_agent_terminal_output(status, result, error.as_deref()), + )?; + let mut next_manifest = manifest.clone(); + next_manifest.status = status.to_string(); + next_manifest.completed_at = Some(iso8601_now()); + next_manifest.error = error; + write_agent_manifest(&next_manifest) +} + +fn append_agent_output(path: &str, suffix: &str) -> Result<(), String> { + use std::io::Write as _; + + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(path) + .map_err(|error| error.to_string())?; + file.write_all(suffix.as_bytes()) + .map_err(|error| error.to_string()) +} + +fn format_agent_terminal_output(status: &str, result: Option<&str>, error: Option<&str>) -> String { + let mut sections = vec![format!("\n## Result\n\n- status: {status}\n")]; + if let Some(result) = result.filter(|value| !value.trim().is_empty()) { + sections.push(format!("\n### Final response\n\n{}\n", result.trim())); + } + if let Some(error) = error.filter(|value| !value.trim().is_empty()) { + sections.push(format!("\n### Error\n\n{}\n", error.trim())); + } + sections.join("") +} + +struct ProviderRuntimeClient { + runtime: tokio::runtime::Runtime, + client: ProviderClient, + model: String, + allowed_tools: BTreeSet, +} + +impl ProviderRuntimeClient { + fn new(model: String, allowed_tools: BTreeSet) -> Result { + let model = resolve_model_alias(&model).to_string(); + let client = ProviderClient::from_model(&model).map_err(|error| error.to_string())?; + Ok(Self { + runtime: tokio::runtime::Runtime::new().map_err(|error| error.to_string())?, + client, + model, + allowed_tools, + }) + } +} + +impl ApiClient for ProviderRuntimeClient { + fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { + let tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools)) + .into_iter() + .map(|spec| ToolDefinition { + name: spec.name.to_string(), + description: Some(spec.description.to_string()), + input_schema: spec.input_schema, + }) + .collect::>(); + let message_request = MessageRequest { + model: self.model.clone(), + max_tokens: max_tokens_for_model(&self.model), + messages: convert_messages(&request.messages), + system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")), + tools: (!tools.is_empty()).then_some(tools), + tool_choice: (!self.allowed_tools.is_empty()).then_some(ToolChoice::Auto), + stream: true, + }; + + self.runtime.block_on(async { + let mut stream = self + .client + .stream_message(&message_request) + .await + .map_err(|error| RuntimeError::new(error.to_string()))?; + let mut events = Vec::new(); + let mut pending_tools: BTreeMap = BTreeMap::new(); + let mut saw_stop = false; + + while let Some(event) = stream + .next_event() + .await + .map_err(|error| RuntimeError::new(error.to_string()))? + { + match event { + ApiStreamEvent::MessageStart(start) => { + for block in start.message.content { + push_output_block(block, 0, &mut events, &mut pending_tools, true); + } + } + ApiStreamEvent::ContentBlockStart(start) => { + push_output_block( + start.content_block, + start.index, + &mut events, + &mut pending_tools, + true, + ); + } + ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { + ContentBlockDelta::TextDelta { text } => { + if !text.is_empty() { + events.push(AssistantEvent::TextDelta(text)); + } + } + ContentBlockDelta::InputJsonDelta { partial_json } => { + if let Some((_, _, input)) = pending_tools.get_mut(&delta.index) { + input.push_str(&partial_json); + } + } + ContentBlockDelta::ThinkingDelta { .. } + | ContentBlockDelta::SignatureDelta { .. } => {} + }, + ApiStreamEvent::ContentBlockStop(stop) => { + if let Some((id, name, input)) = pending_tools.remove(&stop.index) { + events.push(AssistantEvent::ToolUse { id, name, input }); + } + } + ApiStreamEvent::MessageDelta(delta) => { + events.push(AssistantEvent::Usage(TokenUsage { + input_tokens: delta.usage.input_tokens, + output_tokens: delta.usage.output_tokens, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + })); + } + ApiStreamEvent::MessageStop(_) => { + saw_stop = true; + events.push(AssistantEvent::MessageStop); + } + } + } + + if !saw_stop + && events.iter().any(|event| { + matches!(event, AssistantEvent::TextDelta(text) if !text.is_empty()) + || matches!(event, AssistantEvent::ToolUse { .. }) + }) + { + events.push(AssistantEvent::MessageStop); + } + + if events + .iter() + .any(|event| matches!(event, AssistantEvent::MessageStop)) + { + return Ok(events); + } + + let response = self + .client + .send_message(&MessageRequest { + stream: false, + ..message_request.clone() + }) + .await + .map_err(|error| RuntimeError::new(error.to_string()))?; + Ok(response_to_events(response)) + }) + } +} + +struct SubagentToolExecutor { + allowed_tools: BTreeSet, +} + +impl SubagentToolExecutor { + fn new(allowed_tools: BTreeSet) -> Self { + Self { allowed_tools } + } +} + +impl ToolExecutor for SubagentToolExecutor { + fn execute(&mut self, tool_name: &str, input: &str) -> Result { + if !self.allowed_tools.contains(tool_name) { + return Err(ToolError::new(format!( + "tool `{tool_name}` is not enabled for this sub-agent" + ))); + } + let value = serde_json::from_str(input) + .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; + execute_tool(tool_name, &value).map_err(ToolError::new) + } +} + +fn tool_specs_for_allowed_tools(allowed_tools: Option<&BTreeSet>) -> Vec { + mvp_tool_specs() + .into_iter() + .filter(|spec| allowed_tools.is_none_or(|allowed| allowed.contains(spec.name))) + .collect() +} + +fn convert_messages(messages: &[ConversationMessage]) -> Vec { + messages + .iter() + .filter_map(|message| { + let role = match message.role { + MessageRole::System | MessageRole::User | MessageRole::Tool => "user", + MessageRole::Assistant => "assistant", + }; + let content = message + .blocks + .iter() + .map(|block| match block { + ContentBlock::Text { text } => InputContentBlock::Text { text: text.clone() }, + ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { + id: id.clone(), + name: name.clone(), + input: serde_json::from_str(input) + .unwrap_or_else(|_| serde_json::json!({ "raw": input })), + }, + ContentBlock::ToolResult { + tool_use_id, + output, + is_error, + .. + } => InputContentBlock::ToolResult { + tool_use_id: tool_use_id.clone(), + content: vec![ToolResultContentBlock::Text { + text: output.clone(), + }], + is_error: *is_error, + }, + }) + .collect::>(); + (!content.is_empty()).then(|| InputMessage { + role: role.to_string(), + content, + }) + }) + .collect() +} + +fn push_output_block( + block: OutputContentBlock, + block_index: u32, + events: &mut Vec, + pending_tools: &mut BTreeMap, + streaming_tool_input: bool, +) { + match block { + OutputContentBlock::Text { text } => { + if !text.is_empty() { + events.push(AssistantEvent::TextDelta(text)); + } + } + OutputContentBlock::ToolUse { id, name, input } => { + let initial_input = if streaming_tool_input + && input.is_object() + && input.as_object().is_some_and(serde_json::Map::is_empty) + { + String::new() + } else { + input.to_string() + }; + pending_tools.insert(block_index, (id, name, initial_input)); + } + OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {} + } +} + +fn response_to_events(response: MessageResponse) -> Vec { + let mut events = Vec::new(); + let mut pending_tools = BTreeMap::new(); + + for (index, block) in response.content.into_iter().enumerate() { + let index = u32::try_from(index).expect("response block index overflow"); + push_output_block(block, index, &mut events, &mut pending_tools, false); + if let Some((id, name, input)) = pending_tools.remove(&index) { + events.push(AssistantEvent::ToolUse { id, name, input }); + } + } + + events.push(AssistantEvent::Usage(TokenUsage { + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens, + cache_creation_input_tokens: response.usage.cache_creation_input_tokens, + cache_read_input_tokens: response.usage.cache_read_input_tokens, + })); + events.push(AssistantEvent::MessageStop); + events +} + +fn final_assistant_text(summary: &runtime::TurnSummary) -> String { + summary + .assistant_messages + .last() + .map(|message| { + message + .blocks + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("") + }) + .unwrap_or_default() +} + +#[allow(clippy::needless_pass_by_value)] +fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput { + let deferred = deferred_tool_specs(); + let max_results = input.max_results.unwrap_or(5).max(1); + let query = input.query.trim().to_string(); + let normalized_query = normalize_tool_search_query(&query); + let matches = search_tool_specs(&query, max_results, &deferred); + + ToolSearchOutput { + matches, + query, + normalized_query, + total_deferred_tools: deferred.len(), + pending_mcp_servers: None, + } +} + +fn deferred_tool_specs() -> Vec { + mvp_tool_specs() + .into_iter() + .filter(|spec| { + !matches!( + spec.name, + "bash" | "read_file" | "write_file" | "edit_file" | "glob_search" | "grep_search" + ) + }) + .collect() +} + +fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec { + let lowered = query.to_lowercase(); + if let Some(selection) = lowered.strip_prefix("select:") { + return selection + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .filter_map(|wanted| { + let wanted = canonical_tool_token(wanted); + specs + .iter() + .find(|spec| canonical_tool_token(spec.name) == wanted) + .map(|spec| spec.name.to_string()) + }) + .take(max_results) + .collect(); + } + + let mut required = Vec::new(); + let mut optional = Vec::new(); + for term in lowered.split_whitespace() { + if let Some(rest) = term.strip_prefix('+') { + if !rest.is_empty() { + required.push(rest); + } + } else { + optional.push(term); + } + } + let terms = if required.is_empty() { + optional.clone() + } else { + required.iter().chain(optional.iter()).copied().collect() + }; + + let mut scored = specs + .iter() + .filter_map(|spec| { + let name = spec.name.to_lowercase(); + let canonical_name = canonical_tool_token(spec.name); + let normalized_description = normalize_tool_search_query(spec.description); + let haystack = format!( + "{name} {} {canonical_name}", + spec.description.to_lowercase() + ); + let normalized_haystack = format!("{canonical_name} {normalized_description}"); + if required.iter().any(|term| !haystack.contains(term)) { + return None; + } + + let mut score = 0_i32; + for term in &terms { + let canonical_term = canonical_tool_token(term); + if haystack.contains(term) { + score += 2; + } + if name == *term { + score += 8; + } + if name.contains(term) { + score += 4; + } + if canonical_name == canonical_term { + score += 12; + } + if normalized_haystack.contains(&canonical_term) { + score += 3; + } + } + + if score == 0 && !lowered.is_empty() { + return None; + } + Some((score, spec.name.to_string())) + }) + .collect::>(); + + scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); + scored + .into_iter() + .map(|(_, name)| name) + .take(max_results) + .collect() +} + +fn normalize_tool_search_query(query: &str) -> String { + query + .trim() + .split(|ch: char| ch.is_whitespace() || ch == ',') + .filter(|term| !term.is_empty()) + .map(canonical_tool_token) + .collect::>() + .join(" ") +} + +fn canonical_tool_token(value: &str) -> String { + let mut canonical = value + .chars() + .filter(char::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("CLAW_AGENT_STORE") { + return Ok(std::path::PathBuf::from(path)); + } + let cwd = std::env::current_dir().map_err(|error| error.to_string())?; + if let Some(workspace_root) = cwd.ancestors().nth(2) { + return Ok(workspace_root.join(".claw-agents")); + } + Ok(cwd.join(".claw-agents")) +} + +fn make_agent_id() -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("agent-{nanos}") +} + +fn slugify_agent_name(description: &str) -> String { + let mut out = description + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::(); + while out.contains("--") { + out = out.replace("--", "-"); + } + out.trim_matches('-').chars().take(32).collect() +} + +fn normalize_subagent_type(subagent_type: Option<&str>) -> String { + let trimmed = subagent_type.map(str::trim).unwrap_or_default(); + if trimmed.is_empty() { + return String::from("general-purpose"); + } + + match canonical_tool_token(trimmed).as_str() { + "general" | "generalpurpose" | "generalpurposeagent" => String::from("general-purpose"), + "explore" | "explorer" | "exploreagent" => String::from("Explore"), + "plan" | "planagent" => String::from("Plan"), + "verification" | "verificationagent" | "verify" | "verifier" => { + String::from("Verification") + } + "clawguide" | "clawguideagent" | "guide" => String::from("claw-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() +} + +#[allow(clippy::too_many_lines)] +fn execute_notebook_edit(input: NotebookEditInput) -> Result { + let path = std::path::PathBuf::from(&input.notebook_path); + if path.extension().and_then(|ext| ext.to_str()) != Some("ipynb") { + return Err(String::from( + "File must be a Jupyter notebook (.ipynb file).", + )); + } + + let original_file = std::fs::read_to_string(&path).map_err(|error| error.to_string())?; + let mut notebook: serde_json::Value = + serde_json::from_str(&original_file).map_err(|error| error.to_string())?; + let language = notebook + .get("metadata") + .and_then(|metadata| metadata.get("kernelspec")) + .and_then(|kernelspec| kernelspec.get("language")) + .and_then(serde_json::Value::as_str) + .unwrap_or("python") + .to_string(); + let cells = notebook + .get_mut("cells") + .and_then(serde_json::Value::as_array_mut) + .ok_or_else(|| String::from("Notebook cells array not found"))?; + + let edit_mode = input.edit_mode.unwrap_or(NotebookEditMode::Replace); + let target_index = match input.cell_id.as_deref() { + Some(cell_id) => Some(resolve_cell_index(cells, Some(cell_id), edit_mode)?), + None if matches!( + edit_mode, + NotebookEditMode::Replace | NotebookEditMode::Delete + ) => + { + Some(resolve_cell_index(cells, None, edit_mode)?) + } + None => None, + }; + let resolved_cell_type = match edit_mode { + NotebookEditMode::Delete => None, + NotebookEditMode::Insert => Some(input.cell_type.unwrap_or(NotebookCellType::Code)), + NotebookEditMode::Replace => Some(input.cell_type.unwrap_or_else(|| { + target_index + .and_then(|index| cells.get(index)) + .and_then(cell_kind) + .unwrap_or(NotebookCellType::Code) + })), + }; + let new_source = require_notebook_source(input.new_source, edit_mode)?; + + let cell_id = match edit_mode { + NotebookEditMode::Insert => { + let resolved_cell_type = resolved_cell_type.expect("insert cell type"); + let new_id = make_cell_id(cells.len()); + let new_cell = build_notebook_cell(&new_id, resolved_cell_type, &new_source); + let insert_at = target_index.map_or(cells.len(), |index| index + 1); + cells.insert(insert_at, new_cell); + cells + .get(insert_at) + .and_then(|cell| cell.get("id")) + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + } + NotebookEditMode::Delete => { + let removed = cells.remove(target_index.expect("delete target index")); + removed + .get("id") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + } + NotebookEditMode::Replace => { + let resolved_cell_type = resolved_cell_type.expect("replace cell type"); + let cell = cells + .get_mut(target_index.expect("replace target index")) + .ok_or_else(|| String::from("Cell index out of range"))?; + cell["source"] = serde_json::Value::Array(source_lines(&new_source)); + cell["cell_type"] = serde_json::Value::String(match resolved_cell_type { + NotebookCellType::Code => String::from("code"), + NotebookCellType::Markdown => String::from("markdown"), + }); + match resolved_cell_type { + NotebookCellType::Code => { + if !cell.get("outputs").is_some_and(serde_json::Value::is_array) { + cell["outputs"] = json!([]); + } + if cell.get("execution_count").is_none() { + cell["execution_count"] = serde_json::Value::Null; + } + } + NotebookCellType::Markdown => { + if let Some(object) = cell.as_object_mut() { + object.remove("outputs"); + object.remove("execution_count"); + } + } + } + cell.get("id") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + } + }; + + let updated_file = + serde_json::to_string_pretty(¬ebook).map_err(|error| error.to_string())?; + std::fs::write(&path, &updated_file).map_err(|error| error.to_string())?; + + Ok(NotebookEditOutput { + new_source, + cell_id, + cell_type: resolved_cell_type, + language, + edit_mode: format_notebook_edit_mode(edit_mode), + error: None, + notebook_path: path.display().to_string(), + original_file, + updated_file, + }) +} + +fn require_notebook_source( + source: Option, + edit_mode: NotebookEditMode, +) -> Result { + match edit_mode { + NotebookEditMode::Delete => Ok(source.unwrap_or_default()), + NotebookEditMode::Insert | NotebookEditMode::Replace => source + .ok_or_else(|| String::from("new_source is required for insert and replace edits")), + } +} + +fn build_notebook_cell(cell_id: &str, cell_type: NotebookCellType, source: &str) -> Value { + let mut cell = json!({ + "cell_type": match cell_type { + NotebookCellType::Code => "code", + NotebookCellType::Markdown => "markdown", + }, + "id": cell_id, + "metadata": {}, + "source": source_lines(source), + }); + if let Some(object) = cell.as_object_mut() { + match cell_type { + NotebookCellType::Code => { + object.insert(String::from("outputs"), json!([])); + object.insert(String::from("execution_count"), Value::Null); + } + NotebookCellType::Markdown => {} + } + } + cell +} + +fn cell_kind(cell: &serde_json::Value) -> Option { + cell.get("cell_type") + .and_then(serde_json::Value::as_str) + .map(|kind| { + if kind == "markdown" { + NotebookCellType::Markdown + } else { + NotebookCellType::Code + } + }) +} + +#[allow(clippy::needless_pass_by_value)] +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_brief(input: BriefInput) -> Result { + if input.message.trim().is_empty() { + return Err(String::from("message must not be empty")); + } + + let attachments = input + .attachments + .as_ref() + .map(|paths| { + paths + .iter() + .map(|path| resolve_attachment(path)) + .collect::, String>>() + }) + .transpose()?; + + let message = match input.status { + BriefStatus::Normal | BriefStatus::Proactive => input.message, + }; + + Ok(BriefOutput { + message, + attachments, + sent_at: iso8601_timestamp(), + }) +} + +fn resolve_attachment(path: &str) -> Result { + let resolved = std::fs::canonicalize(path).map_err(|error| error.to_string())?; + let metadata = std::fs::metadata(&resolved).map_err(|error| error.to_string())?; + Ok(ResolvedAttachment { + path: resolved.display().to_string(), + size: metadata.len(), + is_image: is_image_path(&resolved), + }) +} + +fn is_image_path(path: &Path) -> bool { + matches!( + path.extension() + .and_then(|ext| ext.to_str()) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg") + ) +} + +fn execute_config(input: ConfigInput) -> Result { + let setting = input.setting.trim(); + if setting.is_empty() { + return Err(String::from("setting must not be empty")); + } + let Some(spec) = supported_config_setting(setting) else { + return Ok(ConfigOutput { + success: false, + operation: None, + setting: None, + value: None, + previous_value: None, + new_value: None, + error: Some(format!("Unknown setting: \"{setting}\"")), + }); + }; + + let path = config_file_for_scope(spec.scope)?; + let mut document = read_json_object(&path)?; + + if let Some(value) = input.value { + let normalized = normalize_config_value(spec, value)?; + let previous_value = get_nested_value(&document, spec.path).cloned(); + set_nested_value(&mut document, spec.path, normalized.clone()); + write_json_object(&path, &document)?; + Ok(ConfigOutput { + success: true, + operation: Some(String::from("set")), + setting: Some(setting.to_string()), + value: Some(normalized.clone()), + previous_value, + new_value: Some(normalized), + error: None, + }) + } else { + Ok(ConfigOutput { + success: true, + operation: Some(String::from("get")), + setting: Some(setting.to_string()), + value: get_nested_value(&document, spec.path).cloned(), + previous_value: None, + new_value: None, + error: None, + }) + } +} + +fn execute_structured_output(input: StructuredOutputInput) -> StructuredOutputResult { + StructuredOutputResult { + data: String::from("Structured output provided successfully"), + structured_output: input.0, + } +} + +fn execute_repl(input: ReplInput) -> Result { + if input.code.trim().is_empty() { + return Err(String::from("code must not be empty")); + } + let _ = input.timeout_ms; + let runtime = resolve_repl_runtime(&input.language)?; + let started = Instant::now(); + let output = Command::new(runtime.program) + .args(runtime.args) + .arg(&input.code) + .output() + .map_err(|error| error.to_string())?; + + Ok(ReplOutput { + language: input.language, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + exit_code: output.status.code().unwrap_or(1), + duration_ms: started.elapsed().as_millis(), + }) +} + +struct ReplRuntime { + program: &'static str, + args: &'static [&'static str], +} + +fn resolve_repl_runtime(language: &str) -> Result { + match language.trim().to_ascii_lowercase().as_str() { + "python" | "py" => Ok(ReplRuntime { + program: detect_first_command(&["python3", "python"]) + .ok_or_else(|| String::from("python runtime not found"))?, + args: &["-c"], + }), + "javascript" | "js" | "node" => Ok(ReplRuntime { + program: detect_first_command(&["node"]) + .ok_or_else(|| String::from("node runtime not found"))?, + args: &["-e"], + }), + "sh" | "shell" | "bash" => Ok(ReplRuntime { + program: detect_first_command(&["bash", "sh"]) + .ok_or_else(|| String::from("shell runtime not found"))?, + args: &["-lc"], + }), + other => Err(format!("unsupported REPL language: {other}")), + } +} + +fn detect_first_command(commands: &[&'static str]) -> Option<&'static str> { + commands + .iter() + .copied() + .find(|command| command_exists(command)) +} + +#[derive(Clone, Copy)] +enum ConfigScope { + Global, + Settings, +} + +#[derive(Clone, Copy)] +struct ConfigSettingSpec { + scope: ConfigScope, + kind: ConfigKind, + path: &'static [&'static str], + options: Option<&'static [&'static str]>, +} + +#[derive(Clone, Copy)] +enum ConfigKind { + Boolean, + String, +} + +fn supported_config_setting(setting: &str) -> Option { + Some(match setting { + "theme" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::String, + path: &["theme"], + options: None, + }, + "editorMode" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::String, + path: &["editorMode"], + options: Some(&["default", "vim", "emacs"]), + }, + "verbose" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::Boolean, + path: &["verbose"], + options: None, + }, + "preferredNotifChannel" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::String, + path: &["preferredNotifChannel"], + options: None, + }, + "autoCompactEnabled" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::Boolean, + path: &["autoCompactEnabled"], + options: None, + }, + "autoMemoryEnabled" => ConfigSettingSpec { + scope: ConfigScope::Settings, + kind: ConfigKind::Boolean, + path: &["autoMemoryEnabled"], + options: None, + }, + "autoDreamEnabled" => ConfigSettingSpec { + scope: ConfigScope::Settings, + kind: ConfigKind::Boolean, + path: &["autoDreamEnabled"], + options: None, + }, + "fileCheckpointingEnabled" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::Boolean, + path: &["fileCheckpointingEnabled"], + options: None, + }, + "showTurnDuration" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::Boolean, + path: &["showTurnDuration"], + options: None, + }, + "terminalProgressBarEnabled" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::Boolean, + path: &["terminalProgressBarEnabled"], + options: None, + }, + "todoFeatureEnabled" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::Boolean, + path: &["todoFeatureEnabled"], + options: None, + }, + "model" => ConfigSettingSpec { + scope: ConfigScope::Settings, + kind: ConfigKind::String, + path: &["model"], + options: None, + }, + "alwaysThinkingEnabled" => ConfigSettingSpec { + scope: ConfigScope::Settings, + kind: ConfigKind::Boolean, + path: &["alwaysThinkingEnabled"], + options: None, + }, + "permissions.defaultMode" => ConfigSettingSpec { + scope: ConfigScope::Settings, + kind: ConfigKind::String, + path: &["permissions", "defaultMode"], + options: Some(&["default", "plan", "acceptEdits", "dontAsk", "auto"]), + }, + "language" => ConfigSettingSpec { + scope: ConfigScope::Settings, + kind: ConfigKind::String, + path: &["language"], + options: None, + }, + "teammateMode" => ConfigSettingSpec { + scope: ConfigScope::Global, + kind: ConfigKind::String, + path: &["teammateMode"], + options: Some(&["tmux", "in-process", "auto"]), + }, + _ => return None, + }) +} + +fn normalize_config_value(spec: ConfigSettingSpec, value: ConfigValue) -> Result { + let normalized = match (spec.kind, value) { + (ConfigKind::Boolean, ConfigValue::Bool(value)) => Value::Bool(value), + (ConfigKind::Boolean, ConfigValue::String(value)) => { + match value.trim().to_ascii_lowercase().as_str() { + "true" => Value::Bool(true), + "false" => Value::Bool(false), + _ => return Err(String::from("setting requires true or false")), + } + } + (ConfigKind::Boolean, ConfigValue::Number(_)) => { + return Err(String::from("setting requires true or false")) + } + (ConfigKind::String, ConfigValue::String(value)) => Value::String(value), + (ConfigKind::String, ConfigValue::Bool(value)) => Value::String(value.to_string()), + (ConfigKind::String, ConfigValue::Number(value)) => json!(value), + }; + + if let Some(options) = spec.options { + let Some(as_str) = normalized.as_str() else { + return Err(String::from("setting requires a string value")); + }; + if !options.iter().any(|option| option == &as_str) { + return Err(format!( + "Invalid value \"{as_str}\". Options: {}", + options.join(", ") + )); + } + } + + Ok(normalized) +} + +fn config_file_for_scope(scope: ConfigScope) -> Result { + let cwd = std::env::current_dir().map_err(|error| error.to_string())?; + Ok(match scope { + ConfigScope::Global => config_home_dir()?.join("settings.json"), + ConfigScope::Settings => cwd.join(".claw").join("settings.local.json"), + }) +} + +fn config_home_dir() -> Result { + if let Ok(path) = std::env::var("CLAW_CONFIG_HOME") { + return Ok(PathBuf::from(path)); + } + let home = std::env::var("HOME").map_err(|_| String::from("HOME is not set"))?; + Ok(PathBuf::from(home).join(".claw")) +} + +fn read_json_object(path: &Path) -> Result, String> { + match std::fs::read_to_string(path) { + Ok(contents) => { + if contents.trim().is_empty() { + return Ok(serde_json::Map::new()); + } + serde_json::from_str::(&contents) + .map_err(|error| error.to_string())? + .as_object() + .cloned() + .ok_or_else(|| String::from("config file must contain a JSON object")) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::Map::new()), + Err(error) => Err(error.to_string()), + } +} + +fn write_json_object(path: &Path, value: &serde_json::Map) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + std::fs::write( + path, + serde_json::to_string_pretty(value).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string()) +} + +fn get_nested_value<'a>( + value: &'a serde_json::Map, + path: &[&str], +) -> Option<&'a Value> { + let (first, rest) = path.split_first()?; + let mut current = value.get(*first)?; + for key in rest { + current = current.as_object()?.get(*key)?; + } + Some(current) +} + +fn set_nested_value(root: &mut serde_json::Map, path: &[&str], new_value: Value) { + let (first, rest) = path.split_first().expect("config path must not be empty"); + if rest.is_empty() { + root.insert((*first).to_string(), new_value); + return; + } + + let entry = root + .entry((*first).to_string()) + .or_insert_with(|| Value::Object(serde_json::Map::new())); + if !entry.is_object() { + *entry = Value::Object(serde_json::Map::new()); + } + let map = entry.as_object_mut().expect("object inserted"); + set_nested_value(map, rest, new_value); +} + +fn iso8601_timestamp() -> String { + if let Ok(output) = Command::new("date") + .args(["-u", "+%Y-%m-%dT%H:%M:%SZ"]) + .output() + { + if output.status.success() { + return String::from_utf8_lossy(&output.stdout).trim().to_string(); + } + } + iso8601_now() +} + +#[allow(clippy::needless_pass_by_value)] +fn execute_powershell(input: PowerShellInput) -> std::io::Result { + let _ = &input.description; + let shell = detect_powershell_shell()?; + execute_shell_command( + shell, + &input.command, + input.timeout, + input.run_in_background, + ) +} + +fn detect_powershell_shell() -> std::io::Result<&'static str> { + if command_exists("pwsh") { + Ok("pwsh") + } else if command_exists("powershell") { + Ok("powershell") + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "PowerShell executable not found (expected `pwsh` or `powershell` in PATH)", + )) + } +} + +fn command_exists(command: &str) -> bool { + std::process::Command::new("sh") + .arg("-lc") + .arg(format!("command -v {command} >/dev/null 2>&1")) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +#[allow(clippy::too_many_lines)] +fn execute_shell_command( + shell: &str, + command: &str, + timeout: Option, + run_in_background: Option, +) -> std::io::Result { + if run_in_background.unwrap_or(false) { + let child = std::process::Command::new(shell) + .arg("-NoProfile") + .arg("-NonInteractive") + .arg("-Command") + .arg(command) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn()?; + return Ok(runtime::BashCommandOutput { + stdout: String::new(), + stderr: String::new(), + raw_output_path: None, + interrupted: false, + is_image: None, + background_task_id: Some(child.id().to_string()), + backgrounded_by_user: Some(true), + assistant_auto_backgrounded: Some(false), + dangerously_disable_sandbox: None, + return_code_interpretation: None, + no_output_expected: Some(true), + structured_content: None, + persisted_output_path: None, + persisted_output_size: None, + sandbox_status: 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, + sandbox_status: 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, + sandbox_status: 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, + sandbox_status: None, + }) +} + +fn resolve_cell_index( + cells: &[serde_json::Value], + cell_id: Option<&str>, + edit_mode: NotebookEditMode, +) -> Result { + if cells.is_empty() + && matches!( + edit_mode, + NotebookEditMode::Replace | NotebookEditMode::Delete + ) + { + return Err(String::from("Notebook has no cells to edit")); + } + if let Some(cell_id) = cell_id { + cells + .iter() + .position(|cell| cell.get("id").and_then(serde_json::Value::as_str) == Some(cell_id)) + .ok_or_else(|| format!("Cell id not found: {cell_id}")) + } else { + Ok(cells.len().saturating_sub(1)) + } +} + +fn source_lines(source: &str) -> Vec { + if source.is_empty() { + return vec![serde_json::Value::String(String::new())]; + } + source + .split_inclusive('\n') + .map(|line| serde_json::Value::String(line.to_string())) + .collect() +} + +fn format_notebook_edit_mode(mode: NotebookEditMode) -> String { + match mode { + NotebookEditMode::Replace => String::from("replace"), + NotebookEditMode::Insert => String::from("insert"), + NotebookEditMode::Delete => String::from("delete"), + } +} + +fn make_cell_id(index: usize) -> String { + format!("cell-{}", index + 1) +} + +fn parse_skill_description(contents: &str) -> Option { + for line in contents.lines() { + if let Some(value) = line.strip_prefix("description:") { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::collections::BTreeSet; + use std::fs; + use std::io::{Read, Write}; + use std::net::{SocketAddr, TcpListener}; + use std::path::PathBuf; + use std::sync::{Arc, Mutex, OnceLock}; + use std::thread; + use std::time::Duration; + + use super::{ + agent_permission_policy, allowed_tools_for_subagent, execute_agent_with_spawn, + execute_tool, final_assistant_text, mvp_tool_specs, persist_agent_terminal_state, + push_output_block, AgentInput, AgentJob, SubagentToolExecutor, + }; + use api::OutputContentBlock; + use runtime::{ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, Session}; + use serde_json::json; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn temp_path(name: &str) -> PathBuf { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos(); + std::env::temp_dir().join(format!("claw-tools-{unique}-{name}")) + } + + #[test] + fn exposes_mvp_tools() { + let names = mvp_tool_specs() + .into_iter() + .map(|spec| spec.name) + .collect::>(); + assert!(names.contains(&"bash")); + assert!(names.contains(&"read_file")); + assert!(names.contains(&"WebFetch")); + assert!(names.contains(&"WebSearch")); + assert!(names.contains(&"TodoWrite")); + assert!(names.contains(&"Skill")); + assert!(names.contains(&"Agent")); + assert!(names.contains(&"ToolSearch")); + assert!(names.contains(&"NotebookEdit")); + assert!(names.contains(&"Sleep")); + assert!(names.contains(&"SendUserMessage")); + assert!(names.contains(&"Config")); + assert!(names.contains(&"StructuredOutput")); + assert!(names.contains(&"REPL")); + assert!(names.contains(&"PowerShell")); + } + + #[test] + fn rejects_unknown_tool_names() { + let error = execute_tool("nope", &json!({})).expect_err("tool should be rejected"); + assert!(error.contains("unsupported tool")); + } + + #[test] + fn web_fetch_returns_prompt_aware_summary() { + let server = TestServer::spawn(Arc::new(|request_line: &str| { + assert!(request_line.starts_with("GET /page ")); + HttpResponse::html( + 200, + "OK", + "Ignored

Test Page

Hello world from local server.

", + ) + })); + + let result = execute_tool( + "WebFetch", + &json!({ + "url": format!("http://{}/page", server.addr()), + "prompt": "Summarize this page" + }), + ) + .expect("WebFetch should succeed"); + + let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); + assert_eq!(output["code"], 200); + let summary = output["result"].as_str().expect("result string"); + assert!(summary.contains("Fetched")); + assert!(summary.contains("Test Page")); + assert!(summary.contains("Hello world from local server")); + + let titled = execute_tool( + "WebFetch", + &json!({ + "url": format!("http://{}/page", server.addr()), + "prompt": "What is the page title?" + }), + ) + .expect("WebFetch title query should succeed"); + let titled_output: serde_json::Value = serde_json::from_str(&titled).expect("valid json"); + let titled_summary = titled_output["result"].as_str().expect("result string"); + assert!(titled_summary.contains("Title: Ignored")); + } + + #[test] + fn web_fetch_supports_plain_text_and_rejects_invalid_url() { + let server = TestServer::spawn(Arc::new(|request_line: &str| { + assert!(request_line.starts_with("GET /plain ")); + HttpResponse::text(200, "OK", "plain text response") + })); + + let result = execute_tool( + "WebFetch", + &json!({ + "url": format!("http://{}/plain", server.addr()), + "prompt": "Show me the content" + }), + ) + .expect("WebFetch should succeed for text content"); + + let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); + assert_eq!(output["url"], format!("http://{}/plain", server.addr())); + assert!(output["result"] + .as_str() + .expect("result") + .contains("plain text response")); + + let error = execute_tool( + "WebFetch", + &json!({ + "url": "not a url", + "prompt": "Summarize" + }), + ) + .expect_err("invalid URL should fail"); + assert!(error.contains("relative URL without a base") || error.contains("invalid")); + } + + #[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( + "CLAW_WEB_SEARCH_BASE_URL", + format!("http://{}/search", server.addr()), + ); + let result = execute_tool( + "WebSearch", + &json!({ + "query": "rust web search", + "allowed_domains": ["https://DOCS.rs/"], + "blocked_domains": ["HTTPS://EXAMPLE.COM"] + }), + ) + .expect("WebSearch should succeed"); + std::env::remove_var("CLAW_WEB_SEARCH_BASE_URL"); + + let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); + assert_eq!(output["query"], "rust web search"); + let results = output["results"].as_array().expect("results array"); + let search_result = results + .iter() + .find(|item| item.get("content").is_some()) + .expect("search result block present"); + let content = search_result["content"].as_array().expect("content array"); + assert_eq!(content.len(), 1); + assert_eq!(content[0]["title"], "Reqwest docs"); + assert_eq!(content[0]["url"], "https://docs.rs/reqwest"); + } + + #[test] + fn web_search_handles_generic_links_and_invalid_base_url() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let server = TestServer::spawn(Arc::new(|request_line: &str| { + assert!(request_line.contains("GET /fallback?q=generic+links ")); + HttpResponse::html( + 200, + "OK", + r#" + + Example One + Duplicate Example One + Tokio Docs + + "#, + ) + })); + + std::env::set_var( + "CLAW_WEB_SEARCH_BASE_URL", + format!("http://{}/fallback", server.addr()), + ); + let result = execute_tool( + "WebSearch", + &json!({ + "query": "generic links" + }), + ) + .expect("WebSearch fallback parsing should succeed"); + std::env::remove_var("CLAW_WEB_SEARCH_BASE_URL"); + + let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); + 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(), 2); + assert_eq!(content[0]["url"], "https://example.com/one"); + assert_eq!(content[1]["url"], "https://docs.rs/tokio"); + + std::env::set_var("CLAW_WEB_SEARCH_BASE_URL", "://bad-base-url"); + let error = execute_tool("WebSearch", &json!({ "query": "generic links" })) + .expect_err("invalid base URL should fail"); + std::env::remove_var("CLAW_WEB_SEARCH_BASE_URL"); + assert!(error.contains("relative URL without a base") || error.contains("empty host")); + } + + #[test] + fn pending_tools_preserve_multiple_streaming_tool_calls_by_index() { + let mut events = Vec::new(); + let mut pending_tools = BTreeMap::new(); + + push_output_block( + OutputContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "read_file".to_string(), + input: json!({}), + }, + 1, + &mut events, + &mut pending_tools, + true, + ); + push_output_block( + OutputContentBlock::ToolUse { + id: "tool-2".to_string(), + name: "grep_search".to_string(), + input: json!({}), + }, + 2, + &mut events, + &mut pending_tools, + true, + ); + + pending_tools + .get_mut(&1) + .expect("first tool pending") + .2 + .push_str("{\"path\":\"src/main.rs\"}"); + pending_tools + .get_mut(&2) + .expect("second tool pending") + .2 + .push_str("{\"pattern\":\"TODO\"}"); + + assert_eq!( + pending_tools.remove(&1), + Some(( + "tool-1".to_string(), + "read_file".to_string(), + "{\"path\":\"src/main.rs\"}".to_string(), + )) + ); + assert_eq!( + pending_tools.remove(&2), + Some(( + "tool-2".to_string(), + "grep_search".to_string(), + "{\"pattern\":\"TODO\"}".to_string(), + )) + ); + } + + #[test] + fn todo_write_persists_and_returns_previous_state() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let path = temp_path("todos.json"); + std::env::set_var("CLAW_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("CLAW_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 todo_write_rejects_invalid_payloads_and_sets_verification_nudge() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let path = temp_path("todos-errors.json"); + std::env::set_var("CLAW_TODO_STORE", &path); + + let empty = execute_tool("TodoWrite", &json!({ "todos": [] })) + .expect_err("empty todos should fail"); + assert!(empty.contains("todos must not be empty")); + + // Multiple in_progress items are now allowed for parallel workflows + let _multi_active = execute_tool( + "TodoWrite", + &json!({ + "todos": [ + {"content": "One", "activeForm": "Doing one", "status": "in_progress"}, + {"content": "Two", "activeForm": "Doing two", "status": "in_progress"} + ] + }), + ) + .expect("multiple in-progress todos should succeed"); + + let blank_content = execute_tool( + "TodoWrite", + &json!({ + "todos": [ + {"content": " ", "activeForm": "Doing it", "status": "pending"} + ] + }), + ) + .expect_err("blank content should fail"); + assert!(blank_content.contains("todo content must not be empty")); + + let nudge = execute_tool( + "TodoWrite", + &json!({ + "todos": [ + {"content": "Write tests", "activeForm": "Writing tests", "status": "completed"}, + {"content": "Fix errors", "activeForm": "Fixing errors", "status": "completed"}, + {"content": "Ship branch", "activeForm": "Shipping branch", "status": "completed"} + ] + }), + ) + .expect("completed todos should succeed"); + std::env::remove_var("CLAW_TODO_STORE"); + let _ = fs::remove_file(path); + + let output: serde_json::Value = serde_json::from_str(&nudge).expect("valid json"); + assert_eq!(output["verificationNudgeNeeded"], true); + } + + #[test] + fn skill_loads_local_skill_prompt() { + let result = execute_tool( + "Skill", + &json!({ + "skill": "help", + "args": "overview" + }), + ) + .expect("Skill should succeed"); + + let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); + assert_eq!(output["skill"], "help"); + assert!(output["path"] + .as_str() + .expect("path") + .ends_with("/help/SKILL.md")); + assert!(output["prompt"] + .as_str() + .expect("prompt") + .contains("Guide on using oh-my-codex plugin")); + + let dollar_result = execute_tool( + "Skill", + &json!({ + "skill": "$help" + }), + ) + .expect("Skill should accept $skill invocation form"); + let dollar_output: serde_json::Value = + serde_json::from_str(&dollar_result).expect("valid json"); + assert_eq!(dollar_output["skill"], "$help"); + assert!(dollar_output["path"] + .as_str() + .expect("path") + .ends_with("/help/SKILL.md")); + } + + #[test] + fn tool_search_supports_keyword_and_select_queries() { + let keyword = execute_tool( + "ToolSearch", + &json!({"query": "web current", "max_results": 3}), + ) + .expect("ToolSearch should succeed"); + let keyword_output: serde_json::Value = serde_json::from_str(&keyword).expect("valid json"); + let matches = keyword_output["matches"].as_array().expect("matches"); + assert!(matches.iter().any(|value| value == "WebSearch")); + + let selected = execute_tool("ToolSearch", &json!({"query": "select:Agent,Skill"})) + .expect("ToolSearch should succeed"); + let selected_output: serde_json::Value = + serde_json::from_str(&selected).expect("valid json"); + assert_eq!(selected_output["matches"][0], "Agent"); + assert_eq!(selected_output["matches"][1], "Skill"); + + let aliased = execute_tool("ToolSearch", &json!({"query": "AgentTool"})) + .expect("ToolSearch should support tool aliases"); + let aliased_output: serde_json::Value = serde_json::from_str(&aliased).expect("valid json"); + assert_eq!(aliased_output["matches"][0], "Agent"); + assert_eq!(aliased_output["normalized_query"], "agent"); + + let selected_with_alias = + execute_tool("ToolSearch", &json!({"query": "select:AgentTool,Skill"})) + .expect("ToolSearch alias select should succeed"); + let selected_with_alias_output: serde_json::Value = + serde_json::from_str(&selected_with_alias).expect("valid json"); + assert_eq!(selected_with_alias_output["matches"][0], "Agent"); + assert_eq!(selected_with_alias_output["matches"][1], "Skill"); + } + + #[test] + fn agent_persists_handoff_metadata() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = temp_path("agent-store"); + std::env::set_var("CLAW_AGENT_STORE", &dir); + let captured = Arc::new(Mutex::new(None::)); + let captured_for_spawn = Arc::clone(&captured); + + let manifest = execute_agent_with_spawn( + AgentInput { + description: "Audit the branch".to_string(), + prompt: "Check tests and outstanding work.".to_string(), + subagent_type: Some("Explore".to_string()), + name: Some("ship-audit".to_string()), + model: None, + }, + move |job| { + *captured_for_spawn + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(job); + Ok(()) + }, + ) + .expect("Agent should succeed"); + std::env::remove_var("CLAW_AGENT_STORE"); + + assert_eq!(manifest.name, "ship-audit"); + assert_eq!(manifest.subagent_type.as_deref(), Some("Explore")); + assert_eq!(manifest.status, "running"); + assert!(!manifest.created_at.is_empty()); + assert!(manifest.started_at.is_some()); + assert!(manifest.completed_at.is_none()); + let contents = std::fs::read_to_string(&manifest.output_file).expect("agent file exists"); + let manifest_contents = + std::fs::read_to_string(&manifest.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\"")); + assert!(manifest_contents.contains("\"status\": \"running\"")); + let captured_job = captured + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .expect("spawn job should be captured"); + assert_eq!(captured_job.prompt, "Check tests and outstanding work."); + assert!(captured_job.allowed_tools.contains("read_file")); + assert!(!captured_job.allowed_tools.contains("Agent")); + + let normalized = execute_tool( + "Agent", + &json!({ + "description": "Verify the branch", + "prompt": "Check tests.", + "subagent_type": "explorer" + }), + ) + .expect("Agent should normalize built-in aliases"); + let normalized_output: serde_json::Value = + serde_json::from_str(&normalized).expect("valid json"); + assert_eq!(normalized_output["subagentType"], "Explore"); + + let named = execute_tool( + "Agent", + &json!({ + "description": "Review the branch", + "prompt": "Inspect diff.", + "name": "Ship Audit!!!" + }), + ) + .expect("Agent should normalize explicit names"); + let named_output: serde_json::Value = serde_json::from_str(&named).expect("valid json"); + assert_eq!(named_output["name"], "ship-audit"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn agent_fake_runner_can_persist_completion_and_failure() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = temp_path("agent-runner"); + std::env::set_var("CLAW_AGENT_STORE", &dir); + + let completed = execute_agent_with_spawn( + AgentInput { + description: "Complete the task".to_string(), + prompt: "Do the work".to_string(), + subagent_type: Some("Explore".to_string()), + name: Some("complete-task".to_string()), + model: Some("claude-sonnet-4-6".to_string()), + }, + |job| { + persist_agent_terminal_state( + &job.manifest, + "completed", + Some("Finished successfully"), + None, + ) + }, + ) + .expect("completed agent should succeed"); + + let completed_manifest = std::fs::read_to_string(&completed.manifest_file) + .expect("completed manifest should exist"); + let completed_output = + std::fs::read_to_string(&completed.output_file).expect("completed output should exist"); + assert!(completed_manifest.contains("\"status\": \"completed\"")); + assert!(completed_output.contains("Finished successfully")); + + let failed = execute_agent_with_spawn( + AgentInput { + description: "Fail the task".to_string(), + prompt: "Do the failing work".to_string(), + subagent_type: Some("Verification".to_string()), + name: Some("fail-task".to_string()), + model: None, + }, + |job| { + persist_agent_terminal_state( + &job.manifest, + "failed", + None, + Some(String::from("simulated failure")), + ) + }, + ) + .expect("failed agent should still spawn"); + + let failed_manifest = + std::fs::read_to_string(&failed.manifest_file).expect("failed manifest should exist"); + let failed_output = + std::fs::read_to_string(&failed.output_file).expect("failed output should exist"); + assert!(failed_manifest.contains("\"status\": \"failed\"")); + assert!(failed_manifest.contains("simulated failure")); + assert!(failed_output.contains("simulated failure")); + + let spawn_error = execute_agent_with_spawn( + AgentInput { + description: "Spawn error task".to_string(), + prompt: "Never starts".to_string(), + subagent_type: None, + name: Some("spawn-error".to_string()), + model: None, + }, + |_| Err(String::from("thread creation failed")), + ) + .expect_err("spawn errors should surface"); + assert!(spawn_error.contains("failed to spawn sub-agent")); + let spawn_error_manifest = std::fs::read_dir(&dir) + .expect("agent dir should exist") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json")) + .find_map(|path| { + let contents = std::fs::read_to_string(&path).ok()?; + contents + .contains("\"name\": \"spawn-error\"") + .then_some(contents) + }) + .expect("failed manifest should still be written"); + assert!(spawn_error_manifest.contains("\"status\": \"failed\"")); + assert!(spawn_error_manifest.contains("thread creation failed")); + + std::env::remove_var("CLAW_AGENT_STORE"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn agent_tool_subset_mapping_is_expected() { + let general = allowed_tools_for_subagent("general-purpose"); + assert!(general.contains("bash")); + assert!(general.contains("write_file")); + assert!(!general.contains("Agent")); + + let explore = allowed_tools_for_subagent("Explore"); + assert!(explore.contains("read_file")); + assert!(explore.contains("grep_search")); + assert!(!explore.contains("bash")); + + let plan = allowed_tools_for_subagent("Plan"); + assert!(plan.contains("TodoWrite")); + assert!(plan.contains("StructuredOutput")); + assert!(!plan.contains("Agent")); + + let verification = allowed_tools_for_subagent("Verification"); + assert!(verification.contains("bash")); + assert!(verification.contains("PowerShell")); + assert!(!verification.contains("write_file")); + } + + #[derive(Debug)] + struct MockSubagentApiClient { + calls: usize, + input_path: String, + } + + impl runtime::ApiClient for MockSubagentApiClient { + fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { + self.calls += 1; + match self.calls { + 1 => { + assert_eq!(request.messages.len(), 1); + Ok(vec![ + AssistantEvent::ToolUse { + id: "tool-1".to_string(), + name: "read_file".to_string(), + input: json!({ "path": self.input_path }).to_string(), + }, + AssistantEvent::MessageStop, + ]) + } + 2 => { + assert!(request.messages.len() >= 3); + Ok(vec![ + AssistantEvent::TextDelta("Scope: completed mock review".to_string()), + AssistantEvent::MessageStop, + ]) + } + _ => panic!("unexpected mock stream call"), + } + } + } + + #[test] + fn subagent_runtime_executes_tool_loop_with_isolated_session() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let path = temp_path("subagent-input.txt"); + std::fs::write(&path, "hello from child").expect("write input file"); + + let mut runtime = ConversationRuntime::new( + Session::new(), + MockSubagentApiClient { + calls: 0, + input_path: path.display().to_string(), + }, + SubagentToolExecutor::new(BTreeSet::from([String::from("read_file")])), + agent_permission_policy(), + vec![String::from("system prompt")], + ); + + let summary = runtime + .run_turn("Inspect the delegated file", None) + .expect("subagent loop should succeed"); + + assert_eq!( + final_assistant_text(&summary), + "Scope: completed mock review" + ); + assert!(runtime + .session() + .messages + .iter() + .flat_map(|message| message.blocks.iter()) + .any(|block| matches!( + block, + runtime::ContentBlock::ToolResult { output, .. } + if output.contains("hello from child") + ))); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn agent_rejects_blank_required_fields() { + let missing_description = execute_tool( + "Agent", + &json!({ + "description": " ", + "prompt": "Inspect" + }), + ) + .expect_err("blank description should fail"); + assert!(missing_description.contains("description must not be empty")); + + let missing_prompt = execute_tool( + "Agent", + &json!({ + "description": "Inspect branch", + "prompt": " " + }), + ) + .expect_err("blank prompt should fail"); + assert!(missing_prompt.contains("prompt must not be empty")); + } + + #[test] + fn notebook_edit_replaces_inserts_and_deletes_cells() { + let path = temp_path("notebook.ipynb"); + std::fs::write( + &path, + r#"{ + "cells": [ + {"cell_type": "code", "id": "cell-a", "metadata": {}, "source": ["print(1)\n"], "outputs": [], "execution_count": null} + ], + "metadata": {"kernelspec": {"language": "python"}}, + "nbformat": 4, + "nbformat_minor": 5 +}"#, + ) + .expect("write notebook"); + + let replaced = execute_tool( + "NotebookEdit", + &json!({ + "notebook_path": path.display().to_string(), + "cell_id": "cell-a", + "new_source": "print(2)\n", + "edit_mode": "replace" + }), + ) + .expect("NotebookEdit replace should succeed"); + let replaced_output: serde_json::Value = serde_json::from_str(&replaced).expect("json"); + assert_eq!(replaced_output["cell_id"], "cell-a"); + assert_eq!(replaced_output["cell_type"], "code"); + + let inserted = execute_tool( + "NotebookEdit", + &json!({ + "notebook_path": path.display().to_string(), + "cell_id": "cell-a", + "new_source": "# heading\n", + "cell_type": "markdown", + "edit_mode": "insert" + }), + ) + .expect("NotebookEdit insert should succeed"); + let inserted_output: serde_json::Value = serde_json::from_str(&inserted).expect("json"); + assert_eq!(inserted_output["cell_type"], "markdown"); + let appended = execute_tool( + "NotebookEdit", + &json!({ + "notebook_path": path.display().to_string(), + "new_source": "print(3)\n", + "edit_mode": "insert" + }), + ) + .expect("NotebookEdit append should succeed"); + let appended_output: serde_json::Value = serde_json::from_str(&appended).expect("json"); + assert_eq!(appended_output["cell_type"], "code"); + + let deleted = execute_tool( + "NotebookEdit", + &json!({ + "notebook_path": path.display().to_string(), + "cell_id": "cell-a", + "edit_mode": "delete" + }), + ) + .expect("NotebookEdit delete should succeed without new_source"); + let deleted_output: serde_json::Value = serde_json::from_str(&deleted).expect("json"); + assert!(deleted_output["cell_type"].is_null()); + assert_eq!(deleted_output["new_source"], ""); + + let final_notebook: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).expect("read notebook")) + .expect("valid notebook json"); + let cells = final_notebook["cells"].as_array().expect("cells array"); + assert_eq!(cells.len(), 2); + assert_eq!(cells[0]["cell_type"], "markdown"); + assert!(cells[0].get("outputs").is_none()); + assert_eq!(cells[1]["cell_type"], "code"); + assert_eq!(cells[1]["source"][0], "print(3)\n"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn notebook_edit_rejects_invalid_inputs() { + let text_path = temp_path("notebook.txt"); + fs::write(&text_path, "not a notebook").expect("write text file"); + let wrong_extension = execute_tool( + "NotebookEdit", + &json!({ + "notebook_path": text_path.display().to_string(), + "new_source": "print(1)\n" + }), + ) + .expect_err("non-ipynb file should fail"); + assert!(wrong_extension.contains("Jupyter notebook")); + let _ = fs::remove_file(&text_path); + + let empty_notebook = temp_path("empty.ipynb"); + fs::write( + &empty_notebook, + r#"{"cells":[],"metadata":{"kernelspec":{"language":"python"}},"nbformat":4,"nbformat_minor":5}"#, + ) + .expect("write empty notebook"); + + let missing_source = execute_tool( + "NotebookEdit", + &json!({ + "notebook_path": empty_notebook.display().to_string(), + "edit_mode": "insert" + }), + ) + .expect_err("insert without source should fail"); + assert!(missing_source.contains("new_source is required")); + + let missing_cell = execute_tool( + "NotebookEdit", + &json!({ + "notebook_path": empty_notebook.display().to_string(), + "edit_mode": "delete" + }), + ) + .expect_err("delete on empty notebook should fail"); + assert!(missing_cell.contains("Notebook has no cells to edit")); + let _ = fs::remove_file(empty_notebook); + } + + #[test] + fn bash_tool_reports_success_exit_failure_timeout_and_background() { + let success = execute_tool("bash", &json!({ "command": "printf 'hello'" })) + .expect("bash should succeed"); + let success_output: serde_json::Value = serde_json::from_str(&success).expect("json"); + assert_eq!(success_output["stdout"], "hello"); + assert_eq!(success_output["interrupted"], false); + + let failure = execute_tool("bash", &json!({ "command": "printf 'oops' >&2; exit 7" })) + .expect("bash failure should still return structured output"); + let failure_output: serde_json::Value = serde_json::from_str(&failure).expect("json"); + assert_eq!(failure_output["returnCodeInterpretation"], "exit_code:7"); + assert!(failure_output["stderr"] + .as_str() + .expect("stderr") + .contains("oops")); + + let timeout = execute_tool("bash", &json!({ "command": "sleep 1", "timeout": 10 })) + .expect("bash timeout should return output"); + let timeout_output: serde_json::Value = serde_json::from_str(&timeout).expect("json"); + assert_eq!(timeout_output["interrupted"], true); + assert_eq!(timeout_output["returnCodeInterpretation"], "timeout"); + assert!(timeout_output["stderr"] + .as_str() + .expect("stderr") + .contains("Command exceeded timeout")); + + let background = execute_tool( + "bash", + &json!({ "command": "sleep 1", "run_in_background": true }), + ) + .expect("bash background should succeed"); + 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["noOutputExpected"], true); + } + + #[test] + fn file_tools_cover_read_write_and_edit_behaviors() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let root = temp_path("fs-suite"); + fs::create_dir_all(&root).expect("create root"); + let original_dir = std::env::current_dir().expect("cwd"); + std::env::set_current_dir(&root).expect("set cwd"); + + let write_create = execute_tool( + "write_file", + &json!({ "path": "nested/demo.txt", "content": "alpha\nbeta\nalpha\n" }), + ) + .expect("write create should succeed"); + let write_create_output: serde_json::Value = + serde_json::from_str(&write_create).expect("json"); + assert_eq!(write_create_output["type"], "create"); + assert!(root.join("nested/demo.txt").exists()); + + let write_update = execute_tool( + "write_file", + &json!({ "path": "nested/demo.txt", "content": "alpha\nbeta\ngamma\n" }), + ) + .expect("write update should succeed"); + let write_update_output: serde_json::Value = + serde_json::from_str(&write_update).expect("json"); + assert_eq!(write_update_output["type"], "update"); + assert_eq!(write_update_output["originalFile"], "alpha\nbeta\nalpha\n"); + + let read_full = execute_tool("read_file", &json!({ "path": "nested/demo.txt" })) + .expect("read full should succeed"); + let read_full_output: serde_json::Value = serde_json::from_str(&read_full).expect("json"); + assert_eq!(read_full_output["file"]["content"], "alpha\nbeta\ngamma"); + assert_eq!(read_full_output["file"]["startLine"], 1); + + let read_slice = execute_tool( + "read_file", + &json!({ "path": "nested/demo.txt", "offset": 1, "limit": 1 }), + ) + .expect("read slice should succeed"); + let read_slice_output: serde_json::Value = serde_json::from_str(&read_slice).expect("json"); + assert_eq!(read_slice_output["file"]["content"], "beta"); + assert_eq!(read_slice_output["file"]["startLine"], 2); + + let read_past_end = execute_tool( + "read_file", + &json!({ "path": "nested/demo.txt", "offset": 50 }), + ) + .expect("read past EOF should succeed"); + let read_past_end_output: serde_json::Value = + serde_json::from_str(&read_past_end).expect("json"); + assert_eq!(read_past_end_output["file"]["content"], ""); + assert_eq!(read_past_end_output["file"]["startLine"], 4); + + let read_error = execute_tool("read_file", &json!({ "path": "missing.txt" })) + .expect_err("missing file should fail"); + assert!(!read_error.is_empty()); + + let edit_once = execute_tool( + "edit_file", + &json!({ "path": "nested/demo.txt", "old_string": "alpha", "new_string": "omega" }), + ) + .expect("single edit should succeed"); + let edit_once_output: serde_json::Value = serde_json::from_str(&edit_once).expect("json"); + assert_eq!(edit_once_output["replaceAll"], false); + assert_eq!( + fs::read_to_string(root.join("nested/demo.txt")).expect("read file"), + "omega\nbeta\ngamma\n" + ); + + execute_tool( + "write_file", + &json!({ "path": "nested/demo.txt", "content": "alpha\nbeta\nalpha\n" }), + ) + .expect("reset file"); + let edit_all = execute_tool( + "edit_file", + &json!({ + "path": "nested/demo.txt", + "old_string": "alpha", + "new_string": "omega", + "replace_all": true + }), + ) + .expect("replace all should succeed"); + let edit_all_output: serde_json::Value = serde_json::from_str(&edit_all).expect("json"); + assert_eq!(edit_all_output["replaceAll"], true); + assert_eq!( + fs::read_to_string(root.join("nested/demo.txt")).expect("read file"), + "omega\nbeta\nomega\n" + ); + + let edit_same = execute_tool( + "edit_file", + &json!({ "path": "nested/demo.txt", "old_string": "omega", "new_string": "omega" }), + ) + .expect_err("identical old/new should fail"); + assert!(edit_same.contains("must differ")); + + let edit_missing = execute_tool( + "edit_file", + &json!({ "path": "nested/demo.txt", "old_string": "missing", "new_string": "omega" }), + ) + .expect_err("missing substring should fail"); + assert!(edit_missing.contains("old_string not found")); + + std::env::set_current_dir(&original_dir).expect("restore cwd"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn glob_and_grep_tools_cover_success_and_errors() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let root = temp_path("search-suite"); + fs::create_dir_all(root.join("nested")).expect("create root"); + let original_dir = std::env::current_dir().expect("cwd"); + std::env::set_current_dir(&root).expect("set cwd"); + + fs::write( + root.join("nested/lib.rs"), + "fn main() {}\nlet alpha = 1;\nlet alpha = 2;\n", + ) + .expect("write rust file"); + fs::write(root.join("nested/notes.txt"), "alpha\nbeta\n").expect("write txt file"); + + let globbed = execute_tool("glob_search", &json!({ "pattern": "nested/*.rs" })) + .expect("glob should succeed"); + let globbed_output: serde_json::Value = serde_json::from_str(&globbed).expect("json"); + assert_eq!(globbed_output["numFiles"], 1); + assert!(globbed_output["filenames"][0] + .as_str() + .expect("filename") + .ends_with("nested/lib.rs")); + + let glob_error = execute_tool("glob_search", &json!({ "pattern": "[" })) + .expect_err("invalid glob should fail"); + assert!(!glob_error.is_empty()); + + let grep_content = execute_tool( + "grep_search", + &json!({ + "pattern": "alpha", + "path": "nested", + "glob": "*.rs", + "output_mode": "content", + "-n": true, + "head_limit": 1, + "offset": 1 + }), + ) + .expect("grep content should succeed"); + let grep_content_output: serde_json::Value = + serde_json::from_str(&grep_content).expect("json"); + assert_eq!(grep_content_output["numFiles"], 0); + assert!(grep_content_output["appliedLimit"].is_null()); + assert_eq!(grep_content_output["appliedOffset"], 1); + assert!(grep_content_output["content"] + .as_str() + .expect("content") + .contains("let alpha = 2;")); + + let grep_count = execute_tool( + "grep_search", + &json!({ "pattern": "alpha", "path": "nested", "output_mode": "count" }), + ) + .expect("grep count should succeed"); + let grep_count_output: serde_json::Value = serde_json::from_str(&grep_count).expect("json"); + assert_eq!(grep_count_output["numMatches"], 3); + + let grep_error = execute_tool( + "grep_search", + &json!({ "pattern": "(alpha", "path": "nested" }), + ) + .expect_err("invalid regex should fail"); + assert!(!grep_error.is_empty()); + + std::env::set_current_dir(&original_dir).expect("restore cwd"); + let _ = fs::remove_dir_all(root); + } + + #[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 brief_returns_sent_message_and_attachment_metadata() { + let attachment = std::env::temp_dir().join(format!( + "claw-brief-{}.png", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + std::fs::write(&attachment, b"png-data").expect("write attachment"); + + let result = execute_tool( + "SendUserMessage", + &json!({ + "message": "hello user", + "attachments": [attachment.display().to_string()], + "status": "normal" + }), + ) + .expect("SendUserMessage should succeed"); + + let output: serde_json::Value = serde_json::from_str(&result).expect("json"); + assert_eq!(output["message"], "hello user"); + assert!(output["sentAt"].as_str().is_some()); + assert_eq!(output["attachments"][0]["isImage"], true); + let _ = std::fs::remove_file(attachment); + } + + #[test] + fn config_reads_and_writes_supported_values() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let root = std::env::temp_dir().join(format!( + "claw-config-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + let home = root.join("home"); + let cwd = root.join("cwd"); + std::fs::create_dir_all(home.join(".claw")).expect("home dir"); + std::fs::create_dir_all(cwd.join(".claw")).expect("cwd dir"); + std::fs::write( + home.join(".claw").join("settings.json"), + r#"{"verbose":false}"#, + ) + .expect("write global settings"); + + let original_home = std::env::var("HOME").ok(); + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + let original_dir = std::env::current_dir().expect("cwd"); + std::env::set_var("HOME", &home); + std::env::remove_var("CLAW_CONFIG_HOME"); + std::env::set_current_dir(&cwd).expect("set cwd"); + + let get = execute_tool("Config", &json!({"setting": "verbose"})).expect("get config"); + let get_output: serde_json::Value = serde_json::from_str(&get).expect("json"); + assert_eq!(get_output["value"], false); + + let set = execute_tool( + "Config", + &json!({"setting": "permissions.defaultMode", "value": "plan"}), + ) + .expect("set config"); + let set_output: serde_json::Value = serde_json::from_str(&set).expect("json"); + assert_eq!(set_output["operation"], "set"); + assert_eq!(set_output["newValue"], "plan"); + + let invalid = execute_tool( + "Config", + &json!({"setting": "permissions.defaultMode", "value": "bogus"}), + ) + .expect_err("invalid config value should error"); + assert!(invalid.contains("Invalid value")); + + let unknown = + execute_tool("Config", &json!({"setting": "nope"})).expect("unknown setting result"); + let unknown_output: serde_json::Value = serde_json::from_str(&unknown).expect("json"); + assert_eq!(unknown_output["success"], false); + + std::env::set_current_dir(&original_dir).expect("restore cwd"); + match original_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn structured_output_echoes_input_payload() { + let result = execute_tool("StructuredOutput", &json!({"ok": true, "items": [1, 2, 3]})) + .expect("StructuredOutput should succeed"); + let output: serde_json::Value = serde_json::from_str(&result).expect("json"); + assert_eq!(output["data"], "Structured output provided successfully"); + assert_eq!(output["structured_output"]["ok"], true); + assert_eq!(output["structured_output"]["items"][1], 2); + } + + #[test] + fn repl_executes_python_code() { + let result = execute_tool( + "REPL", + &json!({"language": "python", "code": "print(1 + 1)", "timeout_ms": 500}), + ) + .expect("REPL should succeed"); + let output: serde_json::Value = serde_json::from_str(&result).expect("json"); + assert_eq!(output["language"], "python"); + assert_eq!(output["exitCode"], 0); + assert!(output["stdout"].as_str().expect("stdout").contains('2')); + } + + #[test] + fn powershell_runs_via_stub_shell() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = std::env::temp_dir().join(format!( + "claw-pwsh-bin-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("create dir"); + let script = dir.join("pwsh"); + std::fs::write( + &script, + r#"#!/bin/sh +while [ "$1" != "-Command" ] && [ $# -gt 0 ]; do shift; done +shift +printf 'pwsh:%s' "$1" +"#, + ) + .expect("write script"); + std::process::Command::new("/bin/chmod") + .arg("+x") + .arg(&script) + .status() + .expect("chmod"); + let original_path = std::env::var("PATH").unwrap_or_default(); + std::env::set_var("PATH", format!("{}:{}", dir.display(), original_path)); + + let result = execute_tool( + "PowerShell", + &json!({"command": "Write-Output hello", "timeout": 1000}), + ) + .expect("PowerShell should succeed"); + + let background = execute_tool( + "PowerShell", + &json!({"command": "Write-Output hello", "run_in_background": true}), + ) + .expect("PowerShell background should succeed"); + + std::env::set_var("PATH", original_path); + let _ = std::fs::remove_dir_all(dir); + + let output: serde_json::Value = serde_json::from_str(&result).expect("json"); + assert_eq!(output["stdout"], "pwsh:Write-Output hello"); + assert!(output["stderr"].as_str().expect("stderr").is_empty()); + + let background_output: serde_json::Value = serde_json::from_str(&background).expect("json"); + assert!(background_output["backgroundTaskId"].as_str().is_some()); + assert_eq!(background_output["backgroundedByUser"], true); + assert_eq!(background_output["assistantAutoBackgrounded"], false); + } + + #[test] + fn powershell_errors_when_shell_is_missing() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let original_path = std::env::var("PATH").unwrap_or_default(); + let empty_dir = std::env::temp_dir().join(format!( + "claw-empty-bin-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + std::fs::create_dir_all(&empty_dir).expect("create empty dir"); + std::env::set_var("PATH", empty_dir.display().to_string()); + + let err = execute_tool("PowerShell", &json!({"command": "Write-Output hello"})) + .expect_err("PowerShell should fail when shell is missing"); + + std::env::set_var("PATH", original_path); + let _ = std::fs::remove_dir_all(empty_dir); + + assert!(err.contains("PowerShell executable not found")); + } + + struct TestServer { + addr: SocketAddr, + shutdown: Option>, + handle: Option>, + } + + impl TestServer { + fn spawn(handler: Arc HttpResponse + Send + Sync + 'static>) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); + listener + .set_nonblocking(true) + .expect("set nonblocking listener"); + let addr = listener.local_addr().expect("local addr"); + let (tx, rx) = std::sync::mpsc::channel::<()>(); + + let handle = thread::spawn(move || loop { + if rx.try_recv().is_ok() { + break; + } + + match listener.accept() { + Ok((mut stream, _)) => { + let mut buffer = [0_u8; 4096]; + let size = stream.read(&mut buffer).expect("read request"); + let request = String::from_utf8_lossy(&buffer[..size]).into_owned(); + let request_line = request.lines().next().unwrap_or_default().to_string(); + let response = handler(&request_line); + stream + .write_all(response.to_bytes().as_slice()) + .expect("write response"); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("server accept failed: {error}"), + } + }); + + Self { + addr, + shutdown: Some(tx), + handle: Some(handle), + } + } + + fn addr(&self) -> SocketAddr { + self.addr + } + } + + impl Drop for TestServer { + fn drop(&mut self) { + if let Some(tx) = self.shutdown.take() { + let _ = tx.send(()); + } + if let Some(handle) = self.handle.take() { + handle.join().expect("join test server"); + } + } + } + + struct HttpResponse { + status: u16, + reason: &'static str, + content_type: &'static str, + body: String, + } + + impl HttpResponse { + fn html(status: u16, reason: &'static str, body: &str) -> Self { + Self { + status, + reason, + content_type: "text/html; charset=utf-8", + body: body.to_string(), + } + } + + fn text(status: u16, reason: &'static str, body: &str) -> Self { + Self { + status, + reason, + content_type: "text/plain; 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() + } + } +}