feat: agent teams with task claiming, context management, and team monitoring

- 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 <crush@charm.land>
This commit is contained in:
TheArchitectit 2026-04-29 14:58:30 -05:00
parent c3d3635924
commit 173588bd6a
9 changed files with 900 additions and 35 deletions

View File

@ -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::{

View File

@ -722,6 +722,7 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
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<ModelTokenLimit> {
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)
}
}

View File

@ -1199,6 +1199,9 @@ pub enum SlashCommand {
action: Option<String>,
target: Option<String>,
},
Team {
action: Option<String>,
},
Unknown(String),
Team {
action: Option<String>,
@ -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)
}
}

View File

@ -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<C, T> {
hook_abort_signal: HookAbortSignal,
hook_progress_reporter: Option<Box<dyn HookProgressReporter>>,
session_tracer: Option<SessionTracer>,
turn_progress_reporter: Option<Box<dyn TurnProgressReporter>>,
}
impl<C, T> ConversationRuntime<C, T>
@ -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<dyn TurnProgressReporter>,
) -> 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);
}
}

View File

@ -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,

View File

@ -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) => {

View File

@ -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",

View File

@ -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,
}
}

File diff suppressed because it is too large Load Diff