From 173588bd6a4532b3e5adbf8c551c2a25d61f29df Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Wed, 29 Apr 2026 14:58:30 -0500 Subject: [PATCH] feat: agent teams with task claiming, context management, and team monitoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TeamInboxReporter: per-tool-call progress reporting to team inbox - TaskClaim tool: atomic claim/release/list with .clawd-agents/claims/ lock files - Team-scoped task_ids to prevent cross-team claim collisions - AgentSuggestion tool: propose AGENTS.md additions (human review required) - ContextRequest tool: iterative retrieval with 3-cycle budget for sub-agents - Context-window-aware auto-compaction (70% threshold) prevents overflow - Model token limits for qwen/glm/generic models with 131K fallback - Reviewer subagent_type: read-only tools, no bash/write - Team mode presets: 1x-6x (tiny/small/medium/large/xlarge/mega) - /team slash command + Ctrl+T toggle (off by default, CLAWD_AGENT_TEAMS=1) - TeamDelete: disk-based deletion with inbox/claims cleanup - TeamStatus: kill stuck agents, list AGENTS.md suggestions - AGENTS.md: auto-loaded shared learnings in sub-agent system prompt - Periodic git commits every 5 tool calls via TeamInboxReporter - Claims released on failure/panic in spawn_agent_job - Fixed doubled .clawd-agents/.clawd-agents/ paths (set CLAWD_AGENT_STORE abs) - Fixed "unknown error" in team watcher (added error field to inbox messages) 💘 Generated with Crush Assisted-by: GLM 5.1 FP8 via Crush --- rust/crates/api/src/lib.rs | 4 + rust/crates/api/src/providers/mod.rs | 18 + rust/crates/commands/src/lib.rs | 18 + rust/crates/runtime/src/conversation.rs | 52 +- rust/crates/runtime/src/lib.rs | 2 +- rust/crates/rusty-claude-cli/src/input.rs | 10 + rust/crates/rusty-claude-cli/src/main.rs | 68 +- rust/crates/tools/src/lane_completion.rs | 3 +- rust/crates/tools/src/lib.rs | 760 +++++++++++++++++++++- 9 files changed, 900 insertions(+), 35 deletions(-) diff --git a/rust/crates/api/src/lib.rs b/rust/crates/api/src/lib.rs index 93262914..10c38c85 100644 --- a/rust/crates/api/src/lib.rs +++ b/rust/crates/api/src/lib.rs @@ -34,8 +34,12 @@ pub use providers::openai_compat::{ }; pub use providers::{ detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override, +<<<<<<< HEAD model_family_identity_for, model_family_identity_for_kind, provider_diagnostics_for_model, resolve_model_alias, ProviderDiagnostics, ProviderKind, +======= + model_token_limit, ModelTokenLimit, resolve_model_alias, ProviderKind, +>>>>>>> 7ab899c0 (feat: agent teams with task claiming, context management, and team monitoring) }; pub use sse::{parse_frame, SseParser}; pub use types::{ diff --git a/rust/crates/api/src/providers/mod.rs b/rust/crates/api/src/providers/mod.rs index 1a264547..d144c551 100644 --- a/rust/crates/api/src/providers/mod.rs +++ b/rust/crates/api/src/providers/mod.rs @@ -722,6 +722,7 @@ pub fn model_token_limit(model: &str) -> Option { max_output_tokens: 16_384, context_window_tokens: 256_000, }), +<<<<<<< HEAD "qwen-max" => Some(ModelTokenLimit { max_output_tokens: 8_192, context_window_tokens: 131_072, @@ -731,6 +732,23 @@ pub fn model_token_limit(model: &str) -> Option { context_window_tokens: 131_072, }), _ => None, +======= + // Qwen models via DashScope / OpenAI-compat + "qwen3.6-35b-fast" | "qwen3-235b-a22b" | "qwen-max" | "qwen-plus" | "qwen-turbo" | "qwen-qwq" => Some(ModelTokenLimit { + max_output_tokens: 16_384, + context_window_tokens: 131_072, + }), + "glm-5.1-fast" => Some(ModelTokenLimit { + max_output_tokens: 16_384, + context_window_tokens: 200_000, + }), + // Generic fallback for any model: assume 128K context, 8K output + // This prevents the "unknown model → no limit check → context overflow" bug + _ => Some(ModelTokenLimit { + max_output_tokens: 8_192, + context_window_tokens: 131_072, + }), +>>>>>>> 7ab899c0 (feat: agent teams with task claiming, context management, and team monitoring) } } diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index fd5b0bdd..374402b9 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -1199,6 +1199,9 @@ pub enum SlashCommand { action: Option, target: Option, }, + Team { + action: Option, + }, Unknown(String), Team { action: Option, @@ -1289,6 +1292,11 @@ impl SlashCommand { Self::Theme { .. } => "/theme", Self::Voice { .. } => "/voice", Self::Usage { .. } => "/usage", +<<<<<<< HEAD +======= + Self::Team { .. } => "/team", + Self::Setup => "/setup", +>>>>>>> 7ab899c0 (feat: agent teams with task claiming, context management, and team monitoring) Self::Rename { .. } => "/rename", Self::Copy { .. } => "/copy", Self::Hooks { .. } => "/hooks", @@ -1513,6 +1521,11 @@ pub fn validate_slash_command_input( "theme" => SlashCommand::Theme { name: remainder }, "voice" => SlashCommand::Voice { mode: remainder }, "usage" => SlashCommand::Usage { scope: remainder }, +<<<<<<< HEAD +======= + "team" => SlashCommand::Team { action: remainder }, + "setup" => SlashCommand::Setup, +>>>>>>> 7ab899c0 (feat: agent teams with task claiming, context management, and team monitoring) "rename" => SlashCommand::Rename { name: remainder }, "copy" => SlashCommand::Copy { target: remainder }, "hooks" => SlashCommand::Hooks { args: remainder }, @@ -5421,6 +5434,7 @@ pub fn handle_slash_command( | SlashCommand::Team { .. } ======= | SlashCommand::Lsp { .. } +<<<<<<< HEAD >>>>>>> 856409d3 (feat: full LSP (Language Server Protocol) integration) | SlashCommand::Setup ======= @@ -5432,6 +5446,10 @@ pub fn handle_slash_command( ======= >>>>>>> 0b227b62 (fix: resolve cherry-pick conflicts and remove non-LSP artifacts) | SlashCommand::Unknown(_) => None, +======= + | SlashCommand::Team { .. } + | SlashCommand::Setup | SlashCommand::Unknown(_) => None, +>>>>>>> 7ab899c0 (feat: agent teams with task claiming, context management, and team monitoring) } } diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 27ed058e..60b8f8b1 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -120,6 +120,22 @@ impl Display for ToolError { impl std::error::Error for ToolError {} +/// Callback trait for reporting tool execution progress during a turn. +/// Implementations can post progress to a team inbox, log to stderr, etc. +/// Called after each tool call completes (success or failure). +pub trait TurnProgressReporter: Send + Sync { + /// Called after a tool execution completes. + /// `iteration` is 1-based index of the tool call within this turn. + fn on_tool_result( + &self, + iteration: usize, + max_iterations: usize, + tool_name: &str, + input: &str, + result: Result<&str, &str>, + ); +} + /// Error returned when a conversation turn cannot be completed. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeError { @@ -174,6 +190,7 @@ pub struct ConversationRuntime { hook_abort_signal: HookAbortSignal, hook_progress_reporter: Option>, session_tracer: Option, + turn_progress_reporter: Option>, } impl ConversationRuntime @@ -223,6 +240,7 @@ where hook_abort_signal: HookAbortSignal::default(), hook_progress_reporter: None, session_tracer: None, + turn_progress_reporter: None, } } @@ -266,6 +284,14 @@ where self } + pub fn with_turn_progress_reporter( + mut self, + reporter: Box, + ) -> Self { + self.turn_progress_reporter = Some(reporter); + self + } + fn run_pre_tool_use_hook(&mut self, tool_name: &str, input: &str) -> HookRunResult { if let Some(reporter) = self.hook_progress_reporter.as_mut() { self.hook_runner.run_pre_tool_use_with_context( @@ -551,7 +577,8 @@ where // Phase 3: Post-hooks and session updates (sequential, original order). for p in &pending { - let result_message = if p.allowed { + // Capture progress data for the reporter. + let (progress_tool_name, progress_input, progress_output, progress_is_error, result_message) = if p.allowed { let batch_result = &batch_results[batch_index]; batch_index += 1; let (mut output, mut is_error) = match &batch_result.result { @@ -587,25 +614,36 @@ where || post_hook_result.is_failed() || post_hook_result.is_cancelled(), ); - - ConversationMessage::tool_result( + let progress_output = output.clone(); + let result_message = ConversationMessage::tool_result( p.tool_use_id.clone(), p.tool_name.clone(), output, is_error, - ) + ); + (p.tool_name.clone(), p.effective_input.clone(), progress_output, is_error, result_message) } else { - ConversationMessage::tool_result( + let denied_output = merge_hook_feedback(&p.pre_hook_messages, p.deny_reason.clone().unwrap_or_default(), true); + let result_message = ConversationMessage::tool_result( p.tool_use_id.clone(), p.tool_name.clone(), - merge_hook_feedback(&p.pre_hook_messages, p.deny_reason.clone().unwrap_or_default(), true), + denied_output, true, - ) + ); + (p.tool_name.clone(), String::new(), String::new(), true, result_message) }; self.session .push_message(result_message.clone()) .map_err(|error| RuntimeError::new(error.to_string()))?; self.record_tool_finished(iterations, &result_message); + if let Some(ref reporter) = self.turn_progress_reporter { + let report_result = if progress_is_error { + Err(progress_output.as_str()) + } else { + Ok(progress_output.as_str()) + }; + reporter.on_tool_result(iterations, self.max_iterations, &progress_tool_name, &progress_input, report_result); + } tool_results.push(result_message); } } diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index a7b045fe..7e99db34 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -124,7 +124,7 @@ pub use lsp_discovery::{ pub use conversation::{ auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent, ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolCall, ToolError, - ToolExecutor, ToolResult, TurnSummary, + ToolExecutor, ToolResult, TurnProgressReporter, TurnSummary, }; pub use file_ops::{ edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search, diff --git a/rust/crates/rusty-claude-cli/src/input.rs b/rust/crates/rusty-claude-cli/src/input.rs index 011065a7..cf69f396 100644 --- a/rust/crates/rusty-claude-cli/src/input.rs +++ b/rust/crates/rusty-claude-cli/src/input.rs @@ -19,6 +19,7 @@ pub enum ReadOutcome { Cancel, Exit, ProviderSwap, + TeamToggle, } struct SlashCommandHelper { @@ -130,6 +131,11 @@ impl LineEditor { KeyEvent(KeyCode::Char('P'), Modifiers::CTRL), Cmd::SelfInsert(1, '\x01'), ); + // Ctrl+T inserts a sentinel character that toggles agent teams mode. + editor.bind_sequence( + KeyEvent(KeyCode::Char('T'), Modifiers::CTRL), + Cmd::SelfInsert(1, '\x02'), + ); Self { prompt: prompt.into(), @@ -168,6 +174,10 @@ impl LineEditor { if line.contains('\x01') { return Ok(ReadOutcome::ProviderSwap); } + // Ctrl+T inserts \x02 sentinel — toggles team mode. + if line.contains('\x02') { + return Ok(ReadOutcome::TeamToggle); + } Ok(ReadOutcome::Submit(line)) } Err(ReadlineError::Interrupted) => { diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index e5327d98..6de88cee 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -6937,6 +6937,7 @@ fn run_resume_command( | SlashCommand::AddDir { .. } => Err("unsupported resumed slash command".into()), | SlashCommand::AddDir { .. } | SlashCommand::Lsp { .. } +<<<<<<< HEAD >>>>>>> e9582034 (feat: full LSP (Language Server Protocol) integration) | SlashCommand::Setup => Err("unsupported resumed slash command".into()), ======= @@ -6944,6 +6945,10 @@ fn run_resume_command( | SlashCommand::Lsp { .. } => Err("unsupported resumed slash command".into()), >>>>>>> 0b227b62 (fix: resolve cherry-pick conflicts and remove non-LSP artifacts) } +======= + | SlashCommand::Team { .. } + | SlashCommand::Setup => Err("unsupported resumed slash command".into()), } +>>>>>>> 7ab899c0 (feat: agent teams with task claiming, context management, and team monitoring) } /// Detect if the current working directory is "broad" (home directory or @@ -7207,6 +7212,17 @@ fn run_repl( } println!("{}", format_connected_line(&cli.model)); } + input::ReadOutcome::TeamToggle => { + // Ctrl+T toggles agent teams mode + let current = std::env::var("CLAWD_AGENT_TEAMS").unwrap_or_default(); + if current == "1" { + std::env::set_var("CLAWD_AGENT_TEAMS", "0"); + eprintln!("[team] Agent teams disabled"); + } else { + std::env::set_var("CLAWD_AGENT_TEAMS", "1"); + eprintln!("[team] Agent teams enabled (TeamCreate now available)"); + } + } input::ReadOutcome::Cancel => {} input::ReadOutcome::Exit => { cli.shutdown_lsp_servers(); @@ -8322,6 +8338,52 @@ impl LiveCli { run_init(CliOutputFormat::Text)?; false } +<<<<<<< HEAD +======= + SlashCommand::Team { action } => { + match action.as_deref().unwrap_or("") { + "on" | "enable" => { + std::env::set_var("CLAWD_AGENT_TEAMS", "1"); + eprintln!("[team] Agent teams enabled (TeamCreate now available)"); + } + "off" | "disable" => { + std::env::set_var("CLAWD_AGENT_TEAMS", "0"); + eprintln!("[team] Agent teams disabled"); + } + "status" => { + let current = std::env::var("CLAWD_AGENT_TEAMS").unwrap_or_default(); + if current == "1" { + eprintln!("[team] Agent teams: ENABLED"); + } else { + eprintln!("[team] Agent teams: DISABLED (use /team on or Ctrl+T to enable)"); + } + } + "" => { + // Toggle + let current = std::env::var("CLAWD_AGENT_TEAMS").unwrap_or_default(); + if current == "1" { + std::env::set_var("CLAWD_AGENT_TEAMS", "0"); + eprintln!("[team] Agent teams disabled"); + } else { + std::env::set_var("CLAWD_AGENT_TEAMS", "1"); + eprintln!("[team] Agent teams enabled (TeamCreate now available)"); + } + } + other => eprintln!("[team] unknown action: {other}. Use: /team [on|off|status]"), + } + false + } + SlashCommand::Setup => { + setup_wizard::run_setup_wizard()?; + // Reload the model from config after wizard saves + let cwd = std::env::current_dir().unwrap_or_default(); + let config = runtime::ConfigLoader::default_for(&cwd).load().ok(); + if let Some(new_model) = config.as_ref().and_then(|c| c.provider().model().map(str::to_string)) { + self.set_model(Some(new_model))?; + } + false + } +>>>>>>> 7ab899c0 (feat: agent teams with task claiming, context management, and team monitoring) SlashCommand::Diff => { Self::print_diff()?; false @@ -14270,8 +14332,10 @@ impl ToolExecutor for CliToolExecutor { "LSP", "Agent", "AgentMessage", - "TeamStatus", - "TaskGet", + "TeamStatus", + "TaskClaim", + "AgentSuggestion", + "ContextRequest", "TaskGet", "TaskList", "TaskOutput", "GitStatus", diff --git a/rust/crates/tools/src/lane_completion.rs b/rust/crates/tools/src/lane_completion.rs index 5b190498..97964ee9 100644 --- a/rust/crates/tools/src/lane_completion.rs +++ b/rust/crates/tools/src/lane_completion.rs @@ -116,8 +116,9 @@ mod tests { lane_events: vec![], derived_state: "working".to_string(), current_blocker: None, - error: None, team_id: None, + task_id: None, + error: None, } } diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 05dc7542..da6d8786 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -7,10 +7,16 @@ use aspect_macros::aspect; use aspect_std::LoggingAspect; use api::{ +<<<<<<< HEAD max_tokens_for_model, model_family_identity_for, resolve_model_alias, ApiError, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse, OutputContentBlock, ProviderClient, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, +======= + max_tokens_for_model, model_token_limit, ModelTokenLimit, resolve_model_alias, ApiError, ContentBlockDelta, InputContentBlock, + InputMessage, MessageRequest, MessageResponse, OutputContentBlock, ProviderClient, + StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, +>>>>>>> 7ab899c0 (feat: agent teams with task claiming, context management, and team monitoring) }; use plugins::PluginTool; use reqwest::blocking::Client; @@ -31,12 +37,21 @@ use runtime::{ task_registry::TaskRegistry, team_cron_registry::{CronRegistry, TeamRegistry}, worker_boot::{WorkerReadySnapshot, WorkerRegistry, WorkerTaskReceipt}, +<<<<<<< HEAD write_file_in_workspace, ApiClient, ApiRequest, AssistantEvent, BashCommandInput, BashCommandOutput, BranchFreshness, ConfigLoader, ContentBlock, ConversationMessage, ConversationRuntime, GrepSearchInput, LaneCommitProvenance, LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass, McpDegradedReport, MessageRole, PermissionMode, PermissionPolicy, PromptCacheEvent, ProviderFallbackConfig, RuntimeError, Session, TaskPacket, ToolError, ToolExecutor, +======= + write_file, ApiClient, ApiRequest, AssistantEvent, BashCommandInput, BashCommandOutput, + BranchFreshness, ConfigLoader, ContentBlock, ConversationMessage, ConversationRuntime, + GrepSearchInput, LaneCommitProvenance, LaneEvent, LaneEventBlocker, LaneEventName, + LaneEventStatus, LaneFailureClass, McpDegradedReport, MessageRole, PermissionMode, + PermissionPolicy, PromptCacheEvent, ProviderFallbackConfig, RuntimeError, Session, TaskPacket, + ToolError, ToolExecutor, TurnProgressReporter, +>>>>>>> 7ab899c0 (feat: agent teams with task claiming, context management, and team monitoring) }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -1100,15 +1115,15 @@ pub fn mvp_tool_specs() -> Vec { }, ToolSpec { name: "TeamCreate", - description: "Create a team of agents that run in parallel. Each task becomes an independent Agent with its own context. Agents can communicate via AgentMessage. Use TeamStatus to check progress. Use 'mode' preset to auto-generate agent teams (e.g. '2x' = 2 Explore + 2 Plan + 2 Verification agents).", + description: "Create a team of agents that run in parallel. Each task becomes an independent Agent with its own context. Agents communicate via AgentMessage, claim tasks via TaskClaim, and report progress automatically. Reviewer agents are included for quality checks. Use TeamStatus to monitor, /team to toggle. 'mode' preset: tiny/1x (4 agents), small/2x (8), medium/3x (12), large/4x (16), xlarge/5x (20), mega/6x (24). Requires /team on.", input_schema: json!({ "type": "object", "properties": { "name": { "type": "string" }, "mode": { "type": "string", - "description": "Preset team size: '2x'=2 per role, '4x'=4 per role, '6x'=6 per role. Overrides 'tasks'.", - "enum": ["2x", "4x", "6x"] + "description": "Preset team size. Named sizes: 'tiny'/'1x'=1 per role (3+1 agents), 'small'/'2x'=2 per role (6+2), 'medium'/'3x'=3 per role (9+3), 'large'/'4x'=4 per role (12+4), 'xlarge'/'5x'=5 per role (15+5), 'mega'/'6x'=6 per role (18+6). Overrides 'tasks'.", + "enum": ["1x", "2x", "3x", "4x", "5x", "6x", "tiny", "small", "medium", "large", "xlarge", "mega"] }, "prompt": { "type": "string", @@ -1177,8 +1192,8 @@ pub fn mvp_tool_specs() -> Vec { "team_id": { "type": "string", "description": "Team ID to check" }, "action": { "type": "string", - "enum": ["status", "summary", "events", "inbox"], - "description": "status=live snapshot, summary=final results, events=timeline, inbox=team messages from agents" + "enum": ["status", "summary", "events", "inbox", "kill", "suggestions"], + "description": "status=live snapshot, summary=final results, events=timeline, inbox=team messages, kill=terminate stuck agent, suggestions=list pending AGENTS.md suggestions" } }, "required": ["team_id"], @@ -1186,6 +1201,69 @@ pub fn mvp_tool_specs() -> Vec { }), required_permission: PermissionMode::ReadOnly, }, + ToolSpec { + name: "TaskClaim", + description: "Claim, release, or list task claims. Agents claim tasks to prevent duplicate work. Use action=claim to atomically claim a task (returns success/failure), action=release to release a claim, or action=list to see all active claims.", + input_schema: json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["claim", "release", "list"], + "description": "claim=atomically acquire a task lock, release=release your claim, list=show all active claims" + }, + "task_id": { "type": "string", "description": "Task identifier to claim or release" }, + "team_id": { "type": "string", "description": "Team ID (used with claim and list)" }, + "agent_id": { "type": "string", "description": "Agent ID claiming the task (used with claim)" } + }, + "required": ["action"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "AgentSuggestion", + description: "Suggest an addition to AGENTS.md for the team to review. Agents should NOT write AGENTS.md directly. Instead, propose patterns, pitfalls, or style guidelines. The team lead (human) decides what to include.", + input_schema: json!({ + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": ["pattern", "pitfall", "style"], + "description": "Category: pattern=proven approach, pitfall=thing to avoid, style=coding convention" + }, + "suggestion": { "type": "string", "description": "The suggestion text to add to AGENTS.md" }, + "team_id": { "type": "string", "description": "Team ID" }, + "agent_id": { "type": "string", "description": "Agent ID making the suggestion" } + }, + "required": ["category", "suggestion"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "ContextRequest", + description: "Request additional context for your task. Use when you need specific files or symbols you haven't seen. You have a budget of 3 retrieval cycles. Be specific: name exact files or describe the symbols you need and why.", + input_schema: json!({ + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { "type": "string" }, + "description": "Exact file paths to read" + }, + "symbols": { + "type": "array", + "items": { "type": "string" }, + "description": "Symbol names to search for (e.g. function names, type names)" + }, + "reason": { "type": "string", "description": "Why you need this context (helps prioritize)" } + }, + "required": ["reason"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, ToolSpec { name: "CronCreate", description: "Create a scheduled recurring task.", @@ -1557,6 +1635,9 @@ fn execute_tool_with_enforcer( "TeamDelete" => from_value::(input).and_then(run_team_delete), "AgentMessage" => from_value::(input).and_then(run_agent_message), "TeamStatus" => from_value::(input).and_then(run_team_status), + "TaskClaim" => from_value::(input).and_then(run_task_claim), + "AgentSuggestion" => from_value::(input).and_then(run_agent_suggestion), + "ContextRequest" => from_value::(input).and_then(run_context_request), "CronCreate" => from_value::(input).and_then(run_cron_create), "CronDelete" => from_value::(input).and_then(run_cron_delete), "CronList" => run_cron_list(input.clone()), @@ -1850,6 +1931,10 @@ fn run_worker_observe_completion(input: WorkerObserveCompletionInput) -> Result< #[allow(clippy::needless_pass_by_value)] fn run_team_create(input: TeamCreateInput) -> Result { + if std::env::var("CLAWD_AGENT_TEAMS").map_or(true, |v| v != "1") { + return Err("Agent teams is disabled. Use /team on or Ctrl+T to enable.".to_string()); + } + let team_id = format!("team-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -1861,9 +1946,9 @@ fn run_team_create(input: TeamCreateInput) -> Result { // Expand mode preset into tasks, or use manual tasks. // Default to "2x" when neither mode nor tasks are provided. let tasks = if let Some(mode) = &input.mode { - expand_team_mode(mode, input.prompt.as_deref().unwrap_or("Explore the codebase and report findings"))? + expand_team_mode(mode, input.prompt.as_deref().unwrap_or("Explore the codebase and report findings"), &team_id)? } else if input.tasks.is_empty() { - expand_team_mode("2x", input.prompt.as_deref().unwrap_or("Explore the codebase and report findings"))? + expand_team_mode("2x", input.prompt.as_deref().unwrap_or("Explore the codebase and report findings"), &team_id)? } else { input.tasks.clone() }; @@ -1881,6 +1966,7 @@ fn run_team_create(input: TeamCreateInput) -> Result { continue; } + let task_id = task.get("task_id").and_then(|v| v.as_str()).map(|s| s.to_string()); let agent_input = AgentInput { description: description.to_string(), prompt: prompt.to_string(), @@ -1888,6 +1974,7 @@ fn run_team_create(input: TeamCreateInput) -> Result { name: Some(format!("{}-agent-{}", slugify_agent_name(&input.name), i + 1)), model: model_override.map(|s| s.to_string()), team_id: Some(team_id.clone()), + task_id, }; match execute_agent_with_spawn(agent_input, spawn_agent_job) { @@ -2097,7 +2184,57 @@ fn run_team_status(input: TeamStatusInput) -> Result { "messages": messages, })) } - other => Err(format!("unknown TeamStatus action: {other}. Use status, summary, events, or inbox")), + "kill" => { + let agent_id = input.agent_id.as_deref().unwrap_or(""); + if agent_id.is_empty() { + return Err("agent_id is required for kill action".to_string()); + } + // Write kill signal file that the agent checks + let kill_dir = agent_mailbox_dir().join("team").join(&input.team_id); + let _ = std::fs::create_dir_all(&kill_dir); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let entry = json!({ + "event": "kill_signal", + "agent_id": agent_id, + "reason": input.reason.as_deref().unwrap_or("terminated by team lead"), + "timestamp": ts, + }); + let kill_file = kill_dir.join(format!("kill-{agent_id}-{ts}.json")); + std::fs::write(&kill_file, serde_json::to_string_pretty(&entry).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + to_pretty_json(json!({ + "action": "kill", + "agent_id": agent_id, + "status": "kill_signal_sent", + })) + } + "suggestions" => { + let suggestions_dir = agent_store_dir()?.join("suggestions"); + let mut suggestions = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&suggestions_dir) { + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.extension().map_or(false, |e| e == "json") { + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(v) = serde_json::from_str::(&content) { + if input.team_id.is_empty() || v.get("team_id").and_then(|t| t.as_str()).map_or(true, |t| t == input.team_id) { + suggestions.push(v); + } + } + } + } + } + } + to_pretty_json(json!({ + "action": "suggestions", + "suggestions": suggestions, + "count": suggestions.len(), + })) + } + other => Err(format!("unknown TeamStatus action: {other}. Use status, summary, events, inbox, kill, or suggestions")), } } @@ -2154,7 +2291,9 @@ fn spawn_team_watcher(team_id: &str, agent_ids: &[String]) { } "agent_failed" => { failed.insert(agent_id.to_string()); - let error = parsed.get("error").and_then(|v| v.as_str()).unwrap_or("unknown error"); + let error = parsed.get("error").and_then(|v| v.as_str()) + .or_else(|| parsed.get("result_preview").and_then(|v| v.as_str())) + .unwrap_or("unknown error"); eprintln!("[team] {team_id}: FAIL {agent_name} failed - {error}"); append_team_event(&events_path, &team_id, agent_id, "failed", agent_name, Some(error)); } @@ -2221,15 +2360,44 @@ fn append_team_event(events_path: &std::path::Path, team_id: &str, agent_id: &st fn run_team_delete(input: TeamDeleteInput) -> Result { - match global_team_registry().delete(&input.team_id) { - Ok(team) => to_pretty_json(json!({ - "team_id": team.team_id, - "name": team.name, - "status": team.status, - "message": "Team deleted" - })), - Err(e) => Err(e), + // Delete from disk-based team storage + let team_dir = agent_store_dir()?.join("teams"); + let manifest_path = team_dir.join(format!("{}.json", input.team_id)); + if !manifest_path.exists() { + return Err(format!("team not found: {}", input.team_id)); } + let data = std::fs::read_to_string(&manifest_path).map_err(|e| e.to_string())?; + let parsed: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?; + let _ = std::fs::remove_file(&manifest_path); + // Also clean up the team inbox directory + let inbox_dir = agent_mailbox_dir().join("team").join(&input.team_id); + if inbox_dir.exists() { + let _ = std::fs::remove_dir_all(&inbox_dir); + } + // Also clean up task claims for this team + let claims_dir = claims_dir(); + if claims_dir.exists() { + if let Ok(entries) = std::fs::read_dir(&claims_dir) { + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.extension().map_or(false, |e| e == "lock") { + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(v) = serde_json::from_str::(&content) { + if v.get("team_id").and_then(|t| t.as_str()) == Some(&input.team_id) { + let _ = std::fs::remove_file(&path); + } + } + } + } + } + } + } + to_pretty_json(json!({ + "team_id": input.team_id, + "name": parsed.get("name").and_then(|v| v.as_str()).unwrap_or("unknown"), + "status": "deleted", + "message": "Team deleted, inbox cleaned, claims released" + })) } #[allow(clippy::needless_pass_by_value)] @@ -2362,26 +2530,200 @@ fn run_agent_message(input: AgentMessageInput) -> Result { } } -fn expand_team_mode(mode: &str, base_prompt: &str) -> Result, String> { +#[allow(clippy::needless_pass_by_value)] +fn run_task_claim(input: TaskClaimInput) -> Result { + match input.action.as_str() { + "claim" => { + let task_id = input.task_id.as_deref().unwrap_or(""); + let agent_id = input.agent_id.as_deref().unwrap_or(""); + let team_id = input.team_id.as_deref().unwrap_or(""); + if task_id.is_empty() { + return Err("task_id is required for claim action".to_string()); + } + if agent_id.is_empty() { + return Err("agent_id is required for claim action".to_string()); + } + if team_id.is_empty() { + return Err("team_id is required for claim action".to_string()); + } + match claim_task(task_id, agent_id, team_id) { + Ok(true) => to_pretty_json(json!({ + "action": "claim", + "task_id": task_id, + "agent_id": agent_id, + "claimed": true, + })), + Ok(false) => to_pretty_json(json!({ + "action": "claim", + "task_id": task_id, + "claimed": false, + "reason": "task already claimed by another agent", + })), + Err(e) => Err(e), + } + } + "release" => { + let task_id = input.task_id.as_deref().unwrap_or(""); + if task_id.is_empty() { + return Err("task_id is required for release action".to_string()); + } + release_claim(task_id)?; + to_pretty_json(json!({ + "action": "release", + "task_id": task_id, + "released": true, + })) + } + "list" => { + let claims = list_claims(input.team_id.as_deref()); + to_pretty_json(json!({ + "action": "list", + "claims": claims, + "count": claims.len(), + })) + } + other => Err(format!("unknown TaskClaim action: {other}. Use claim, release, or list")), + } +} + +#[allow(clippy::needless_pass_by_value)] +fn run_agent_suggestion(input: AgentSuggestionInput) -> Result { + let suggestions_dir = agent_store_dir()?.join("suggestions"); + std::fs::create_dir_all(&suggestions_dir).map_err(|e| e.to_string())?; + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let agent_id = input.agent_id.unwrap_or_else(|| "unknown".to_string()); + let entry = json!({ + "category": input.category, + "suggestion": input.suggestion, + "team_id": input.team_id, + "agent_id": agent_id, + "timestamp": ts, + }); + let filename = format!("suggestion-{agent_id}-{ts}.json"); + let path = suggestions_dir.join(&filename); + std::fs::write(&path, serde_json::to_string_pretty(&entry).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + to_pretty_json(json!({ + "action": "suggestion", + "file": filename, + "status": "pending_review", + })) +} + +#[allow(clippy::needless_pass_by_value)] +fn run_context_request(input: ContextRequestInput) -> Result { + let mut results = Vec::new(); + let mut files_read = 0u32; + let mut symbols_found = 0u32; + + // Read requested files + for file_path in &input.files { + let path = std::path::PathBuf::from(file_path); + if path.exists() { + match std::fs::read_to_string(&path) { + Ok(content) => { + let truncated = if content.len() > 10000 { + format!("{}... (truncated, {} bytes total)", &content[..10000], content.len()) + } else { + content + }; + results.push(json!({ + "type": "file", + "path": file_path, + "content": truncated, + })); + files_read += 1; + } + Err(e) => { + results.push(json!({ + "type": "file_error", + "path": file_path, + "error": e.to_string(), + })); + } + } + } else { + results.push(json!({ + "type": "file_not_found", + "path": file_path, + })); + } + } + + // Search for requested symbols using grep + for symbol in &input.symbols { + let cwd = std::env::current_dir().map_err(|e| e.to_string())?; + let output = std::process::Command::new("grep") + .args(["-rn", "--include=*.rs", "--include=*.ts", "--include=*.js", "--include=*.py", symbol, "."]) + .current_dir(&cwd) + .output() + .map_err(|e| e.to_string())?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().take(20).collect(); + if !lines.is_empty() { + results.push(json!({ + "type": "symbol_search", + "symbol": symbol, + "matches": lines, + })); + symbols_found += 1; + } + } + } + + to_pretty_json(json!({ + "action": "context_request", + "reason": input.reason, + "files_read": files_read, + "symbols_found": symbols_found, + "results": results, + "reminder": "You have a budget of 3 retrieval cycles. Be specific with your requests.", + })) +} + +fn expand_team_mode(mode: &str, base_prompt: &str, team_id: &str) -> Result, String> { let n = match mode { - "2x" => 2, - "4x" => 4, - "6x" => 6, - other => return Err(format!("unknown team mode '{other}'. Use '2x', '4x', or '6x'")), + "1x" | "tiny" => 1, + "2x" | "small" => 2, + "3x" | "medium" => 3, + "4x" | "large" => 4, + "5x" | "xlarge" => 5, + "6x" | "mega" => 6, + other => return Err(format!("unknown team mode '{other}'. Use 1x-6x or tiny/small/medium/large/xlarge/mega")), }; + let short_team_id = &team_id[team_id.len().saturating_sub(8)..]; let roles: &[&str] = &["Explore", "Plan", "Verification"]; let mut tasks = Vec::new(); for role in roles { for i in 0..n { let prompt = format!("[{role} agent {}/{}] {base_prompt}", i + 1, n); let description = format!("{role} agent {}/{}", i + 1, n); + let task_id = format!("{short_team_id}-{role}-{i}"); tasks.push(json!({ "prompt": prompt, "description": description, "subagent_type": role, + "task_id": task_id, })); } } + // Add read-only Reviewer agents (1 per 3 builders, minimum 1) + let reviewer_count = std::cmp::max(1, (roles.len() * n) / 3); + for i in 0..reviewer_count { + let prompt = format!("[Reviewer {}/{}] Review the work of other agents. Read their output files, check code quality, identify issues, and report findings via AgentMessage. Only use read-only tools.", i + 1, reviewer_count); + let description = format!("Reviewer {}/{}", i + 1, reviewer_count); + let task_id = format!("{short_team_id}-Reviewer-{i}"); + tasks.push(json!({ + "prompt": prompt, + "description": description, + "subagent_type": "Reviewer", + "task_id": task_id, + })); + } Ok(tasks) } @@ -2396,6 +2738,165 @@ fn agent_mailbox_dir() -> std::path::PathBuf { cwd.join(".clawd-agents").join("mailbox") } +fn claims_dir() -> std::path::PathBuf { + if let Ok(path) = std::env::var("CLAWD_AGENT_STORE") { + return std::path::PathBuf::from(path).join("claims"); + } + let cwd = std::env::current_dir().unwrap_or_default(); + if let Some(workspace_root) = cwd.ancestors().nth(2) { + return workspace_root.join(".clawd-agents").join("claims"); + } + cwd.join(".clawd-agents").join("claims") +} + +fn claim_task(task_id: &str, agent_id: &str, team_id: &str) -> Result { + let dir = claims_dir(); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let lock_path = dir.join(format!("{task_id}.lock")); + if lock_path.exists() { + return Ok(false); + } + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let entry = serde_json::json!({ + "task_id": task_id, + "agent_id": agent_id, + "team_id": team_id, + "claimed_at": ts, + }); + // Atomic claim: write to temp file then rename + let tmp_path = dir.join(format!("{task_id}.lock.tmp.{agent_id}")); + std::fs::write(&tmp_path, serde_json::to_string_pretty(&entry).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + match std::fs::rename(&tmp_path, &lock_path) { + Ok(()) => Ok(true), + Err(_) => { + // Another agent claimed it first + let _ = std::fs::remove_file(&tmp_path); + Ok(false) + } + } +} + +fn release_claim(task_id: &str) -> Result<(), String> { + let lock_path = claims_dir().join(format!("{task_id}.lock")); + if lock_path.exists() { + std::fs::remove_file(&lock_path).map_err(|e| e.to_string()) + } else { + Ok(()) + } +} + +fn is_task_claimed(task_id: &str) -> bool { + claims_dir().join(format!("{task_id}.lock")).exists() +} + +fn list_claims(team_id: Option<&str>) -> Vec { + let dir = claims_dir(); + let mut claims = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.extension().map_or(false, |e| e == "lock") { + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(v) = serde_json::from_str::(&content) { + if team_id.map_or(true, |tid| v.get("team_id").map_or(false, |t| t == tid)) { + claims.push(v); + } + } + } + } + } + } + claims +} + +#[allow(dead_code)] +struct TeamInboxReporter { + team_id: String, + agent_id: String, + agent_name: String, + inbox_dir: std::path::PathBuf, +} + +impl TeamInboxReporter { + fn new(team_id: String, agent_id: String, agent_name: String) -> Self { + let inbox_dir = agent_mailbox_dir().join("team").join(&team_id); + let _ = std::fs::create_dir_all(&inbox_dir); + Self { team_id, agent_id, agent_name, inbox_dir } + } +} + +impl TurnProgressReporter for TeamInboxReporter { + fn on_tool_result( + &self, + iteration: usize, + max_iterations: usize, + tool_name: &str, + input: &str, + result: Result<&str, &str>, + ) { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let (result_preview, is_error) = match result { + Ok(output) => (output.chars().take(500).collect::(), false), + Err(err) => (err.chars().take(500).collect::(), true), + }; + let input_preview: String = input.chars().take(300).collect(); + let entry = serde_json::json!({ + "event": "tool_progress", + "agent_id": self.agent_id, + "name": self.agent_name, + "tool_name": tool_name, + "input_preview": input_preview, + "result_preview": result_preview, + "is_error": is_error, + "iteration": iteration, + "max_iterations": max_iterations, + "timestamp": ts, + }); + let msg_file = self.inbox_dir.join(format!( + "tp-{}-{}-{ts}.json", + self.agent_id, iteration + )); + if let Ok(line) = serde_json::to_string(&entry) { + let _ = std::fs::write(&msg_file, line); + } + + // Periodic git commit (every 5 tool calls) to preserve progress + if iteration > 0 && iteration % 5 == 0 { + let _ = std::process::Command::new("git") + .args(["add", "-A"]) + .output(); + let diff_check = std::process::Command::new("git") + .args(["diff", "--cached", "--quiet"]) + .output(); + if diff_check.map_or(true, |o| !o.status.success()) { + let _ = std::process::Command::new("git") + .args(["commit", "-m", &format!("agent {} progress: iteration {iteration}", self.agent_id)]) + .output(); + } + } + + // Check for kill signal from team lead + for entry in std::fs::read_dir(&self.inbox_dir).unwrap_or_else(|_| std::fs::read_dir(".").unwrap()) { + if let Ok(e) = entry { + let name = e.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with(&format!("kill-{}-", self.agent_id)) { + // Kill signal received — panic to abort + std::fs::remove_file(e.path()).ok(); + panic!("agent {} received kill signal", self.agent_id); + } + } + } + } +} + #[allow(clippy::needless_pass_by_value)] fn run_cron_create(input: CronCreateInput) -> Result { let entry = @@ -3635,6 +4136,8 @@ struct AgentInput { model: Option, #[serde(default)] team_id: Option, + #[serde(default)] + task_id: Option, } #[derive(Debug, Deserialize)] @@ -3826,6 +4329,40 @@ struct TeamStatusInput { team_id: String, #[serde(default)] action: Option, + #[serde(default)] + agent_id: Option, + #[serde(default)] + reason: Option, +} + +#[derive(Debug, Deserialize)] +struct TaskClaimInput { + action: String, + #[serde(default)] + task_id: Option, + #[serde(default)] + team_id: Option, + #[serde(default)] + agent_id: Option, +} + +#[derive(Debug, Deserialize)] +struct AgentSuggestionInput { + category: String, + suggestion: String, + #[serde(default)] + team_id: Option, + #[serde(default)] + agent_id: Option, +} + +#[derive(Debug, Deserialize)] +struct ContextRequestInput { + #[serde(default)] + files: Vec, + #[serde(default)] + symbols: Vec, + reason: String, } #[derive(Debug, Deserialize)] @@ -4056,6 +4593,8 @@ struct AgentOutput { error: Option, #[serde(rename = "teamId", skip_serializing_if = "Option::is_none")] team_id: Option, + #[serde(rename = "taskId", skip_serializing_if = "Option::is_none")] + task_id: Option, } #[derive(Debug, Clone)] @@ -4065,6 +4604,8 @@ struct AgentJob { system_prompt: Vec, allowed_tools: BTreeSet, team_id: Option, + task_id: Option, + max_tokens: Option, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] @@ -4997,6 +5538,7 @@ where derived_state: String::from("working"), error: None, team_id: input.team_id.clone(), + task_id: input.task_id.clone(), }; write_agent_manifest(&manifest)?; @@ -5007,6 +5549,8 @@ where system_prompt, allowed_tools, team_id: input.team_id.clone(), + task_id: input.task_id, + max_tokens: None, }; if let Err(error) = spawn_fn(job) { let error = format!("failed to spawn sub-agent: {error}"); @@ -5029,10 +5573,18 @@ fn spawn_agent_job(job: AgentJob) -> Result<(), String> { match result { Ok(Ok(())) => {} Ok(Err(error)) => { + // Release task claim on failure + if let Some(ref task_id) = job.task_id { + let _ = release_claim(task_id); + } let _ = persist_agent_terminal_state(&job.manifest, "failed", None, Some(error)); } Err(_) => { + // Release task claim on panic + if let Some(ref task_id) = job.task_id { + let _ = release_claim(task_id); + } let _ = persist_agent_terminal_state( &job.manifest, "failed", @@ -5046,11 +5598,119 @@ fn spawn_agent_job(job: AgentJob) -> Result<(), String> { .map_err(|error| error.to_string()) } +fn setup_agent_worktree(agent_id: &str) -> Result { + let cwd = std::env::current_dir().map_err(|e| e.to_string())?; + // Check if we're in a git repo + let git_check = std::process::Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .current_dir(&cwd) + .output() + .map_err(|e| e.to_string())?; + if !git_check.status.success() { + return Ok(cwd); // Not a git repo, work in current dir + } + let worktree_dir = cwd.join(".clawd-agents").join("worktrees").join(agent_id); + // Create worktree on a new branch + let branch_name = format!("agent/{agent_id}"); + let output = std::process::Command::new("git") + .args(["worktree", "add", worktree_dir.to_str().unwrap_or(""), "-b", &branch_name]) + .current_dir(&cwd) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + // Branch might already exist, try with existing branch + let output2 = std::process::Command::new("git") + .args(["worktree", "add", worktree_dir.to_str().unwrap_or(""), &branch_name]) + .current_dir(&cwd) + .output() + .map_err(|e| e.to_string())?; + if !output2.status.success() { + // Fall back to working in current directory + return Ok(cwd); + } + } + Ok(worktree_dir) +} + +fn teardown_agent_worktree(agent_id: &str, worktree_path: &std::path::Path) -> Result<(), String> { + let cwd = std::env::current_dir().map_err(|e| e.to_string())?; + // Stage and commit any changes in the worktree + let _ = std::process::Command::new("git") + .args(["add", "-A"]) + .current_dir(worktree_path) + .output(); + let commit_check = std::process::Command::new("git") + .args(["diff", "--cached", "--quiet"]) + .current_dir(worktree_path) + .output(); + if commit_check.map_or(true, |o| !o.status.success()) { + let _ = std::process::Command::new("git") + .args(["commit", "-m", &format!("agent {agent_id}: completed task")]) + .current_dir(worktree_path) + .output(); + } + // Remove the worktree + let _ = std::process::Command::new("git") + .args(["worktree", "remove", worktree_path.to_str().unwrap_or(""), "--force"]) + .current_dir(&cwd) + .output(); + // Delete the branch + let _ = std::process::Command::new("git") + .args(["branch", "-D", &format!("agent/{agent_id}")]) + .current_dir(&cwd) + .output(); + Ok(()) +} + fn run_agent_job(job: &AgentJob) -> Result<(), String> { + // Claim task if task_id is set (prevents duplicate work) + if let Some(ref task_id) = job.task_id { + if let Some(ref team_id) = job.team_id { + let claimed = claim_task(task_id, &job.manifest.agent_id, team_id) + .unwrap_or(false); + if !claimed { + return Err(format!("task {task_id} already claimed by another agent")); + } + } + } + + // Save and fix CLAWD_AGENT_STORE to prevent doubled paths when CWD changes + // between thread spawn and execution + if let Ok(store) = agent_store_dir() { + std::env::set_var("CLAWD_AGENT_STORE", store); + } + let mut runtime = build_agent_runtime(job)?.with_max_iterations(DEFAULT_AGENT_MAX_ITERATIONS); - let summary = runtime + + // Set auto-compaction threshold based on model's context window + if let Some(ref model) = job.manifest.model { + if let Some(limit) = model_token_limit(model) { + // Compact at 70% of context window to stay safely under the limit + let threshold = (limit.context_window_tokens as f32 * 0.7) as u32; + runtime = runtime.with_auto_compaction_input_tokens_threshold(threshold); + } + } + + // Attach TeamInboxReporter for per-tool-call progress when agent belongs to a team + if let Some(ref team_id) = job.team_id { + let reporter = TeamInboxReporter::new( + team_id.clone(), + job.manifest.agent_id.clone(), + job.manifest.name.clone(), + ); + runtime = runtime.with_turn_progress_reporter(Box::new(reporter)); + } + + let result = runtime .run_turn(job.prompt.clone(), None) - .map_err(|error| error.to_string())?; + .map_err(|error| error.to_string()); + + // Release task claim on completion (success or failure) + if let Some(ref task_id) = job.task_id { + let _ = release_claim(task_id); + } + + let summary = result?; let final_text = final_assistant_text(&summary); persist_agent_terminal_state(&job.manifest, "completed", Some(final_text.as_str()), None) } @@ -5079,6 +5739,7 @@ fn build_agent_runtime( fn build_agent_system_prompt(subagent_type: &str, model: &str) -> Result, String> { let cwd = std::env::current_dir().map_err(|error| error.to_string())?; + let agents_md = cwd.join("AGENTS.md"); let mut prompt = load_system_prompt( cwd, DEFAULT_AGENT_SYSTEM_DATE.to_string(), @@ -5090,6 +5751,19 @@ fn build_agent_system_prompt(subagent_type: &str, model: &str) -> Result BTreeSet { "ToolSearch", "Skill", "AgentMessage", + "TaskClaim", + "AgentSuggestion", + "ContextRequest", "TeamStatus", "StructuredOutput", ], @@ -5151,6 +5828,9 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet { "Skill", "TodoWrite", "AgentMessage", + "TaskClaim", + "AgentSuggestion", + "ContextRequest", "TeamStatus", "StructuredOutput", "SendUserMessage", @@ -5165,11 +5845,28 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet { "ToolSearch", "TodoWrite", "AgentMessage", + "TaskClaim", + "AgentSuggestion", + "ContextRequest", "TeamStatus", "StructuredOutput", "SendUserMessage", "PowerShell", ], + "Reviewer" => vec![ + "read_file", + "glob_search", + "grep_search", + "WebFetch", + "WebSearch", + "ToolSearch", + "AgentMessage", + "TaskClaim", + "AgentSuggestion", + "ContextRequest", + "TeamStatus", + "StructuredOutput", + ], "claw-guide" => vec![ "read_file", "glob_search", @@ -5205,6 +5902,9 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet { "NotebookEdit", "Sleep", "AgentMessage", + "TaskClaim", + "AgentSuggestion", + "ContextRequest", "TeamStatus", "SendUserMessage", "Config", @@ -5305,6 +6005,7 @@ fn post_agent_completion_to_team_inbox( "subagent_type": manifest.subagent_type, "status": status, "result_preview": result.map(|r| r.chars().take(2000).collect::()), + "error": if status == "failed" { result.map(|r| r.chars().take(500).collect::()) } else { None:: }, "timestamp": ts, }); let msg_file = mailbox_dir.join(format!("{}-{ts}.json", manifest.agent_id)); @@ -6629,6 +7330,7 @@ fn normalize_subagent_type(subagent_type: Option<&str>) -> String { "verification" | "verificationagent" | "verify" | "verifier" => { String::from("Verification") } + "reviewer" | "review" | "reviewagent" => String::from("Reviewer"), "clawguide" | "clawguideagent" | "guide" => String::from("claw-guide"), "statusline" | "statuslinesetup" => String::from("statusline-setup"), _ => trimmed.to_string(), @@ -9614,6 +10316,7 @@ mod tests { name: Some("ship-audit".to_string()), model: None, team_id: None, + task_id: None, }, move |job| { *captured_for_spawn @@ -9696,6 +10399,7 @@ mod tests { name: Some("complete-task".to_string()), model: Some("claude-sonnet-4-6".to_string()), team_id: None, + task_id: None, }, |job| { persist_agent_terminal_state( @@ -9754,6 +10458,7 @@ mod tests { name: Some("fail-task".to_string()), model: None, team_id: None, + task_id: None, }, |job| { persist_agent_terminal_state( @@ -9802,6 +10507,7 @@ mod tests { name: Some("summary-floor".to_string()), model: None, team_id: None, + task_id: None, }, |job| { persist_agent_terminal_state( @@ -9848,6 +10554,7 @@ mod tests { name: Some("recovery-lane".to_string()), model: None, team_id: None, + task_id: None, }, |job| { persist_agent_terminal_state( @@ -9897,6 +10604,7 @@ mod tests { name: Some("review-lane".to_string()), model: None, team_id: None, + task_id: None, }, |job| { persist_agent_terminal_state( @@ -9938,6 +10646,7 @@ mod tests { name: Some("backlog-scan".to_string()), model: None, team_id: None, + task_id: None, }, |job| { persist_agent_terminal_state( @@ -9985,6 +10694,7 @@ mod tests { name: Some("artifact-lane".to_string()), model: None, team_id: None, + task_id: None, }, |job| { persist_agent_terminal_state( @@ -10056,6 +10766,7 @@ mod tests { name: Some("cron-closeout".to_string()), model: None, team_id: None, + task_id: None, }, |job| { persist_agent_terminal_state( @@ -10098,6 +10809,7 @@ mod tests { name: Some("spawn-error".to_string()), model: None, team_id: None, + task_id: None, }, |_| Err(String::from("thread creation failed")), )