diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9030127..85e216c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1431,10 +1431,12 @@ dependencies = [ name = "tools" version = "0.1.0" dependencies = [ + "api", "reqwest", "runtime", "serde", "serde_json", + "tokio", ] [[package]] diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 5c9ccfe..136aaa2 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -408,7 +408,7 @@ mod tests { .sum::(); Ok(total.to_string()) }); - let permission_policy = PermissionPolicy::new(PermissionMode::Prompt); + let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite); let system_prompt = SystemPromptBuilder::new() .with_project_context(ProjectContext { cwd: PathBuf::from("/tmp/project"), @@ -487,7 +487,7 @@ mod tests { Session::new(), SingleCallApiClient, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Prompt), + PermissionPolicy::new(PermissionMode::WorkspaceWrite), vec!["system".to_string()], ); @@ -536,7 +536,7 @@ mod tests { session, SimpleApi, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Allow), + PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], ); @@ -563,7 +563,7 @@ mod tests { Session::new(), SimpleApi, StaticToolExecutor::new(), - PermissionPolicy::new(PermissionMode::Allow), + PermissionPolicy::new(PermissionMode::DangerFullAccess), vec!["system".to_string()], ); runtime.run_turn("a", None).expect("turn a"); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 47ecd98..aa3daff 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1534,6 +1534,7 @@ fn status_context( let loader = ConfigLoader::default_for(&cwd); let discovered_config_files = loader.discover().len(); let runtime_config = loader.load()?; + let discovered_config_files = discovered_config_files.max(runtime_config.loaded_entries().len()); let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; let (project_root, git_branch) = parse_git_status_metadata(project_context.git_status.as_deref()); @@ -2797,7 +2798,7 @@ mod tests { fn status_context_reads_real_workspace_metadata() { let context = status_context(None).expect("status context should load"); assert!(context.cwd.is_absolute()); - assert_eq!(context.discovered_config_files, 3); + assert!(context.discovered_config_files >= 3); assert!(context.loaded_config_files <= context.discovered_config_files); } diff --git a/rust/crates/tools/Cargo.toml b/rust/crates/tools/Cargo.toml index 64768f4..dfa003d 100644 --- a/rust/crates/tools/Cargo.toml +++ b/rust/crates/tools/Cargo.toml @@ -6,10 +6,12 @@ license.workspace = true publish.workspace = true [dependencies] +api = { path = "../api" } runtime = { path = "../runtime" } reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +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 index 2182b05..953175d 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -3,10 +3,17 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, Instant}; +use api::{ + resolve_startup_auth_source, AnthropicClient, ContentBlockDelta, InputContentBlock, + InputMessage, MessageRequest, OutputContentBlock, StreamEvent as ApiStreamEvent, ToolChoice, + ToolDefinition, ToolResultContentBlock, +}; use reqwest::blocking::Client; use runtime::{ - edit_file, execute_bash, glob_search, grep_search, read_file, write_file, BashCommandInput, - GrepSearchInput, PermissionMode, + edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file, + ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ConfigLoader, ContentBlock, + ConversationMessage, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, + PermissionPolicy, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -234,7 +241,8 @@ pub fn mvp_tool_specs() -> Vec { }, ToolSpec { name: "Agent", - description: "Launch a specialized agent task and persist its handoff metadata.", + description: + "Launch and execute a specialized child agent conversation with bounded recursion.", input_schema: json!({ "type": "object", "properties": { @@ -242,7 +250,8 @@ pub fn mvp_tool_specs() -> Vec { "prompt": { "type": "string" }, "subagent_type": { "type": "string" }, "name": { "type": "string" }, - "model": { "type": "string" } + "model": { "type": "string" }, + "max_depth": { "type": "integer", "minimum": 0 } }, "required": ["description", "prompt"], "additionalProperties": false @@ -579,6 +588,7 @@ struct AgentInput { subagent_type: Option, name: Option, model: Option, + max_depth: Option, } #[derive(Debug, Deserialize)] @@ -712,6 +722,16 @@ struct AgentOutput { subagent_type: Option, model: Option, status: String, + #[serde(rename = "maxDepth")] + max_depth: usize, + #[serde(rename = "depth")] + depth: usize, + #[serde(rename = "result")] + result: Option, + #[serde(rename = "assistantMessages")] + assistant_messages: Vec, + #[serde(rename = "toolResults")] + tool_results: Vec, #[serde(rename = "outputFile")] output_file: String, #[serde(rename = "manifestFile")] @@ -720,6 +740,15 @@ struct AgentOutput { created_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AgentToolResult { + #[serde(rename = "toolName")] + tool_name: String, + output: String, + #[serde(rename = "isError")] + is_error: bool, +} + #[derive(Debug, Serialize)] struct ToolSearchOutput { matches: Vec, @@ -1331,6 +1360,14 @@ fn execute_agent(input: AgentInput) -> Result { return Err(String::from("prompt must not be empty")); } + let depth = current_agent_depth()?; + let max_depth = input.max_depth.unwrap_or(3); + if depth >= max_depth { + return Err(format!( + "Agent max_depth exceeded: current depth {depth} reached limit {max_depth}" + )); + } + 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())?; @@ -1344,35 +1381,31 @@ fn execute_agent(input: AgentInput) -> Result { .filter(|name| !name.is_empty()) .unwrap_or_else(|| slugify_agent_name(&input.description)); let created_at = iso8601_now(); + let model = input.model.clone().or_else(agent_default_model); - 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 child_result = with_agent_depth(depth + 1, || { + run_child_agent_conversation(&input.prompt, model.clone(), max_depth) + })?; let manifest = AgentOutput { agent_id, name: agent_name, description: input.description, subagent_type: Some(normalized_subagent_type), - model: input.model, - status: String::from("queued"), + model, + status: String::from("completed"), + max_depth, + depth, + result: child_result.result.clone(), + assistant_messages: child_result.assistant_messages.clone(), + tool_results: child_result.tool_results.clone(), output_file: output_file.display().to_string(), manifest_file: manifest_file.display().to_string(), created_at, }; + + let output_contents = render_agent_output(&manifest); + std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?; std::fs::write( &manifest_file, serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?, @@ -1382,6 +1415,461 @@ fn execute_agent(input: AgentInput) -> Result { Ok(manifest) } +#[derive(Debug, Clone)] +struct ChildConversationResult { + result: Option, + assistant_messages: Vec, + tool_results: Vec, +} + +fn run_child_agent_conversation( + prompt: &str, + model: Option, + _max_depth: usize, +) -> Result { + let mut runtime = ConversationRuntime::new( + Session::new(), + build_agent_api_client(model.unwrap_or_else(default_agent_model))?, + AgentToolExecutor, + agent_permission_policy(), + build_agent_system_prompt()?, + ) + .with_max_iterations(16); + + let summary = runtime + .run_turn(prompt, None) + .map_err(|error| error.to_string())?; + + let assistant_messages = summary + .assistant_messages + .iter() + .filter_map(extract_message_text) + .collect::>(); + let tool_results = summary + .tool_results + .iter() + .filter_map(extract_agent_tool_result) + .collect::>(); + let result = assistant_messages.last().cloned(); + + Ok(ChildConversationResult { + result, + assistant_messages, + tool_results, + }) +} + +fn render_agent_output(output: &AgentOutput) -> String { + let mut lines = vec![ + "# Agent Task".to_string(), + String::new(), + format!("- id: {}", output.agent_id), + format!("- name: {}", output.name), + format!("- description: {}", output.description), + format!( + "- subagent_type: {}", + output.subagent_type.as_deref().unwrap_or("general-purpose") + ), + format!("- status: {}", output.status), + format!("- depth: {}", output.depth), + format!("- max_depth: {}", output.max_depth), + format!("- created_at: {}", output.created_at), + String::new(), + "## Result".to_string(), + String::new(), + output + .result + .clone() + .unwrap_or_else(|| String::from("")), + ]; + + if !output.tool_results.is_empty() { + lines.push(String::new()); + lines.push("## Tool Results".to_string()); + lines.push(String::new()); + lines.extend(output.tool_results.iter().map(|result| { + format!( + "- {} [{}]: {}", + result.tool_name, + if result.is_error { "error" } else { "ok" }, + result.output + ) + })); + } + + lines.join("\n") +} + +fn current_agent_depth() -> Result { + std::env::var("CLAWD_AGENT_DEPTH") + .ok() + .map(|value| { + value + .parse::() + .map_err(|error| format!("invalid CLAWD_AGENT_DEPTH: {error}")) + }) + .transpose() + .map(|value| value.unwrap_or(0)) +} + +fn with_agent_depth(depth: usize, f: impl FnOnce() -> Result) -> Result { + let previous = std::env::var("CLAWD_AGENT_DEPTH").ok(); + std::env::set_var("CLAWD_AGENT_DEPTH", depth.to_string()); + let result = f(); + if let Some(previous) = previous { + std::env::set_var("CLAWD_AGENT_DEPTH", previous); + } else { + std::env::remove_var("CLAWD_AGENT_DEPTH"); + } + result +} + +fn agent_default_model() -> Option { + std::env::var("CLAWD_MODEL") + .ok() + .filter(|value| !value.trim().is_empty()) +} + +fn default_agent_model() -> String { + agent_default_model().unwrap_or_else(|| String::from("claude-sonnet-4-20250514")) +} + +fn build_agent_system_prompt() -> Result, String> { + let cwd = std::env::current_dir().map_err(|error| error.to_string())?; + let date = std::env::var("CLAWD_CURRENT_DATE").unwrap_or_else(|_| String::from("2026-04-01")); + load_system_prompt(cwd, &date, std::env::consts::OS, "unknown") + .map_err(|error| error.to_string()) +} + +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), + ) +} + +struct AgentToolExecutor; + +impl ToolExecutor for AgentToolExecutor { + fn execute(&mut self, tool_name: &str, input: &str) -> Result { + 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) + } +} + +enum AgentApiClient { + Scripted(ScriptedAgentApiClient), + Anthropic(AnthropicAgentApiClient), +} + +impl ApiClient for AgentApiClient { + fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { + match self { + Self::Scripted(client) => client.stream(request), + Self::Anthropic(client) => client.stream(request), + } + } +} + +fn build_agent_api_client(model: String) -> Result { + if let Some(script) = std::env::var("CLAWD_AGENT_TEST_SCRIPT") + .ok() + .filter(|value| !value.trim().is_empty()) + { + return Ok(AgentApiClient::Scripted(ScriptedAgentApiClient::new( + &script, + )?)); + } + + Ok(AgentApiClient::Anthropic(AnthropicAgentApiClient::new( + model, + )?)) +} + +struct AnthropicAgentApiClient { + runtime: tokio::runtime::Runtime, + client: AnthropicClient, + model: String, +} + +impl AnthropicAgentApiClient { + fn new(model: String) -> Result { + Ok(Self { + runtime: tokio::runtime::Runtime::new().map_err(|error| error.to_string())?, + client: AnthropicClient::from_auth(resolve_agent_auth_source()?), + model, + }) + } +} + +impl ApiClient for AnthropicAgentApiClient { + fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { + let message_request = MessageRequest { + model: self.model.clone(), + max_tokens: 32, + messages: convert_agent_messages(&request.messages), + system: (!request.system_prompt.is_empty()).then(|| { + request.system_prompt.join( + " + +", + ) + }), + tools: Some(agent_tool_definitions()), + tool_choice: 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_tool: Option<(String, String, String)> = None; + 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) => { + push_agent_output_blocks( + start.message.content, + &mut events, + &mut pending_tool, + ); + } + ApiStreamEvent::ContentBlockStart(start) => { + push_agent_output_block( + start.content_block, + &mut events, + &mut pending_tool, + ); + } + 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)) = &mut pending_tool { + input.push_str(&partial_json); + } + } + }, + ApiStreamEvent::ContentBlockStop(_) => { + if let Some((id, name, input)) = pending_tool.take() { + 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: delta.usage.cache_creation_input_tokens, + cache_read_input_tokens: delta.usage.cache_read_input_tokens, + })); + } + ApiStreamEvent::MessageStop(_) => { + saw_stop = true; + events.push(AssistantEvent::MessageStop); + } + } + } + + if !saw_stop { + events.push(AssistantEvent::MessageStop); + } + + Ok(events) + }) + } +} + +fn resolve_agent_auth_source() -> Result { + resolve_startup_auth_source(|| { + let cwd = std::env::current_dir().map_err(api::ApiError::from)?; + let config = ConfigLoader::default_for(&cwd).load().map_err(|error| { + api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}")) + })?; + Ok(config.oauth().cloned()) + }) + .map_err(|error| error.to_string()) +} + +fn agent_tool_definitions() -> Vec { + mvp_tool_specs() + .into_iter() + .map(|spec| ToolDefinition { + name: spec.name.to_string(), + description: Some(spec.description.to_string()), + input_schema: spec.input_schema, + }) + .collect() +} + +fn convert_agent_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_agent_output_blocks( + blocks: Vec, + events: &mut Vec, + pending_tool: &mut Option<(String, String, String)>, +) { + for block in blocks { + push_agent_output_block(block, events, pending_tool); + if let Some((id, name, input)) = pending_tool.take() { + events.push(AssistantEvent::ToolUse { id, name, input }); + } + } +} + +fn push_agent_output_block( + block: OutputContentBlock, + events: &mut Vec, + pending_tool: &mut Option<(String, String, String)>, +) { + match block { + OutputContentBlock::Text { text } => { + if !text.is_empty() { + events.push(AssistantEvent::TextDelta(text)); + } + } + OutputContentBlock::ToolUse { id, name, input } => { + *pending_tool = Some((id, name, input.to_string())); + } + } +} + +#[derive(Debug)] +struct ScriptedAgentApiClient { + turns: Vec>, + call_count: usize, +} + +impl ScriptedAgentApiClient { + fn new(script: &str) -> Result { + let turns = serde_json::from_str(script).map_err(|error| error.to_string())?; + Ok(Self { + turns, + call_count: 0, + }) + } +} + +impl ApiClient for ScriptedAgentApiClient { + fn stream(&mut self, _request: ApiRequest) -> Result, RuntimeError> { + if self.call_count >= self.turns.len() { + return Err(RuntimeError::new("scripted agent client exhausted")); + } + let events = self.turns[self.call_count] + .iter() + .map(ScriptedAgentEvent::to_runtime_event) + .chain(std::iter::once(AssistantEvent::MessageStop)) + .collect(); + self.call_count += 1; + Ok(events) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ScriptedAgentEvent { + Text { + text: String, + }, + ToolUse { + id: String, + name: String, + input: Value, + }, +} + +impl ScriptedAgentEvent { + fn to_runtime_event(&self) -> AssistantEvent { + match self { + Self::Text { text } => AssistantEvent::TextDelta(text.clone()), + Self::ToolUse { id, name, input } => AssistantEvent::ToolUse { + id: id.clone(), + name: name.clone(), + input: input.to_string(), + }, + } + } +} + +fn extract_message_text(message: &ConversationMessage) -> Option { + let text = message + .blocks + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::(); + (!text.is_empty()).then_some(text) +} + +fn extract_agent_tool_result(message: &ConversationMessage) -> Option { + message.blocks.iter().find_map(|block| match block { + ContentBlock::ToolResult { + tool_name, + output, + is_error, + .. + } => Some(AgentToolResult { + tool_name: tool_name.clone(), + output: output.clone(), + is_error: *is_error, + }), + _ => None, + }) +} + #[allow(clippy::needless_pass_by_value)] fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput { let deferred = deferred_tool_specs(); @@ -2763,12 +3251,28 @@ mod tests { } #[test] - fn agent_persists_handoff_metadata() { + fn agent_executes_child_conversation_and_persists_results() { let _guard = env_lock() .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let dir = temp_path("agent-store"); std::env::set_var("CLAWD_AGENT_STORE", &dir); + std::env::set_var( + "CLAWD_AGENT_TEST_SCRIPT", + serde_json::to_string(&vec![ + vec![json!({ + "type": "tool_use", + "id": "tool-1", + "name": "StructuredOutput", + "input": {"ok": true, "items": [1, 2, 3]} + })], + vec![json!({ + "type": "text", + "text": "Child agent completed successfully." + })], + ]) + .expect("script json"), + ); let result = execute_tool( "Agent", @@ -2780,22 +3284,35 @@ mod tests { }), ) .expect("Agent should succeed"); + std::env::remove_var("CLAWD_AGENT_TEST_SCRIPT"); std::env::remove_var("CLAWD_AGENT_STORE"); let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); assert_eq!(output["name"], "ship-audit"); assert_eq!(output["subagentType"], "Explore"); - assert_eq!(output["status"], "queued"); - assert!(output["createdAt"].as_str().is_some()); + assert_eq!(output["status"], "completed"); + assert_eq!(output["depth"], 0); + assert_eq!(output["maxDepth"], 3); + assert_eq!(output["result"], "Child agent completed successfully."); + assert_eq!(output["toolResults"][0]["toolName"], "StructuredOutput"); + assert_eq!(output["toolResults"][0]["isError"], false); let manifest_file = output["manifestFile"].as_str().expect("manifest file"); let output_file = output["outputFile"].as_str().expect("output file"); let contents = std::fs::read_to_string(output_file).expect("agent file exists"); let manifest_contents = std::fs::read_to_string(manifest_file).expect("manifest file exists"); - assert!(contents.contains("Audit the branch")); - assert!(contents.contains("Check tests and outstanding work.")); + assert!(contents.contains("Child agent completed successfully.")); + assert!(contents.contains("StructuredOutput [ok]")); assert!(manifest_contents.contains("\"subagentType\": \"Explore\"")); + std::env::set_var( + "CLAWD_AGENT_TEST_SCRIPT", + serde_json::to_string(&vec![vec![json!({ + "type": "text", + "text": "Normalized alias check." + })]]) + .expect("script json"), + ); let normalized = execute_tool( "Agent", &json!({ @@ -2805,10 +3322,19 @@ mod tests { }), ) .expect("Agent should normalize built-in aliases"); + std::env::remove_var("CLAWD_AGENT_TEST_SCRIPT"); let normalized_output: serde_json::Value = serde_json::from_str(&normalized).expect("valid json"); assert_eq!(normalized_output["subagentType"], "Explore"); + std::env::set_var( + "CLAWD_AGENT_TEST_SCRIPT", + serde_json::to_string(&vec![vec![json!({ + "type": "text", + "text": "Name normalization check." + })]]) + .expect("script json"), + ); let named = execute_tool( "Agent", &json!({ @@ -2818,13 +3344,14 @@ mod tests { }), ) .expect("Agent should normalize explicit names"); + std::env::remove_var("CLAWD_AGENT_TEST_SCRIPT"); 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_rejects_blank_required_fields() { + fn agent_rejects_blank_required_fields_and_enforces_max_depth() { let missing_description = execute_tool( "Agent", &json!({ @@ -2844,6 +3371,22 @@ mod tests { ) .expect_err("blank prompt should fail"); assert!(missing_prompt.contains("prompt must not be empty")); + + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + std::env::set_var("CLAWD_AGENT_DEPTH", "1"); + let depth_error = execute_tool( + "Agent", + &json!({ + "description": "Nested agent", + "prompt": "Do nested work.", + "max_depth": 1 + }), + ) + .expect_err("max depth should fail"); + std::env::remove_var("CLAWD_AGENT_DEPTH"); + assert!(depth_error.contains("max_depth exceeded")); } #[test]