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 1786c5eb2e
commit 1b6ef83f78
8 changed files with 835 additions and 96 deletions

View File

@ -28,8 +28,9 @@ pub use providers::openai_compat::{
};
pub use providers::{
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
model_family_identity_for, model_family_identity_for_kind, provider_diagnostics_for_model,
resolve_model_alias, ProviderDiagnostics, ProviderKind,
model_family_identity_for, model_family_identity_for_kind, model_token_limit,
ModelTokenLimit, provider_diagnostics_for_model, resolve_model_alias, ProviderDiagnostics,
ProviderKind,
};
pub use sse::{parse_frame, SseParser};
pub use types::{

View File

@ -679,15 +679,21 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
max_output_tokens: 16_384,
context_window_tokens: 256_000,
}),
"qwen-max" => Some(ModelTokenLimit {
// 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,
}),
"qwen-plus" => Some(ModelTokenLimit {
max_output_tokens: 8_192,
context_window_tokens: 131_072,
}),
_ => None,
}
}

View File

@ -1195,10 +1195,14 @@ pub enum SlashCommand {
History {
count: Option<String>,
},
Unknown(String),
Lsp {
action: Option<String>,
target: Option<String>,
},
Team {
action: Option<String>,
},
Unknown(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -5403,6 +5407,7 @@ pub fn handle_slash_command(
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. }
| SlashCommand::History { .. }
| SlashCommand::Lsp { .. }
| SlashCommand::Team { .. }
| SlashCommand::Setup
| SlashCommand::Unknown(_) => None,

View File

@ -163,6 +163,10 @@ pub struct RuntimeFeatureConfig {
api_timeout: ApiTimeoutConfig,
rules_import: RulesImportConfig,
provider: RuntimeProviderConfig,
/// Model override used when spawning sub-agents via the Agent tool.
/// Read from `subagentModel` (or `subagent_model`) in settings; falls
/// back to the default model when unset.
subagent_model: Option<String>,
}
/// Controls which external AI coding framework rules are imported into the system prompt.
@ -801,6 +805,7 @@ fn build_runtime_config(
api_timeout: parse_optional_api_timeout_config(&merged_value)?,
rules_import: parse_optional_rules_import(&merged_value)?,
provider: parse_optional_provider_config(&merged_value)?,
subagent_model: parse_optional_subagent_model(&merged_value),
};
Ok(RuntimeConfig {
@ -880,6 +885,13 @@ impl RuntimeConfig {
self.feature_config.model.as_deref()
}
/// Model override used when spawning sub-agents via the Agent tool.
/// Read from `subagentModel` in settings; `None` means "use default model".
#[must_use]
pub fn subagent_model(&self) -> Option<&str> {
self.feature_config.subagent_model.as_deref()
}
#[must_use]
pub fn aliases(&self) -> &BTreeMap<String, String> {
&self.feature_config.aliases
@ -1717,6 +1729,17 @@ fn parse_optional_model(root: &JsonValue) -> Option<String> {
.map(ToOwned::to_owned)
}
/// Reads `subagentModel` (or the snake_case `subagent_model` alias) from
/// merged settings. Returns `None` when absent or blank so the Agent tool
/// falls back to the default model.
fn parse_optional_subagent_model(root: &JsonValue) -> Option<String> {
root.as_object()
.and_then(|object| object.get("subagentModel").or_else(|| object.get("subagent_model")))
.and_then(JsonValue::as_str)
.filter(|s| !s.trim().is_empty())
.map(|s| s.trim().to_string())
}
fn parse_optional_aliases(root: &JsonValue) -> Result<BTreeMap<String, String>, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(BTreeMap::new());

View File

@ -84,8 +84,8 @@ pub use config_validate::{
};
pub use conversation::{
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError,
ToolExecutor, TurnSummary,
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolCall, ToolError,
ToolExecutor, ToolResult, TurnProgressReporter, TurnSummary,
};
pub use file_ops::{
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,

View File

@ -6962,6 +6962,7 @@ fn run_resume_command(
| SlashCommand::Tag { .. }
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. }
| SlashCommand::Lsp { .. }
| SlashCommand::Team { .. }
| SlashCommand::Setup => Err("unsupported resumed slash command".into()),
}
@ -7145,6 +7146,17 @@ fn run_repl(
cli.record_prompt_history(&trimmed);
cli.run_turn(&trimmed)?;
}
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.persist_session()?;
@ -8614,6 +8626,49 @@ impl LiveCli {
run_init(CliOutputFormat::Text)?;
false
}
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
}
SlashCommand::Diff => {
Self::print_diff()?;
false
@ -8662,12 +8717,6 @@ impl LiveCli {
);
false
}
SlashCommand::Setup => {
if let Err(e) = setup_wizard::run_setup_wizard() {
eprintln!("Setup wizard failed: {e}");
}
false
}
SlashCommand::History { count } => {
self.print_prompt_history(count.as_deref());
false
@ -8714,15 +8763,12 @@ impl LiveCli {
| SlashCommand::Ide { .. }
| SlashCommand::Tag { .. }
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. } => {
| SlashCommand::AddDir { .. }
| SlashCommand::Lsp { .. } => {
let cmd_name = command.slash_name();
eprintln!("{cmd_name} is not yet implemented in this build.");
false
}
SlashCommand::Team { action } => {
self.print_team_command(action.as_deref());
false
}
SlashCommand::Unknown(name) => {
eprintln!("{}", format_unknown_slash_command(&name));
false
@ -8735,48 +8781,6 @@ impl LiveCli {
Ok(())
}
/// `/team` — user-facing manager for agent teams.
///
/// The team registry and the `TeamCreate` / `TeamDelete` / `Cron*` tools live
/// inside the tool executor, so the model invokes them directly during a
/// turn. This slash command surfaces a readable view of the same teams and
/// is the user's way to take action without driving the model.
fn print_team_command(&self, action: Option<&str>) {
match action.map(str::to_ascii_lowercase).as_deref() {
None | Some("help") => {
println!("Agent teams — the model creates and manages teams via its tools.");
println!();
println!("Available team tools the model can call:");
println!(" TeamCreate create a team from a set of tasks");
println!(" TeamDelete mark a team deleted by team_id");
println!(" CronCreate schedule a recurring prompt (cron)");
println!(" CronDelete remove a scheduled cron by cron_id");
println!(" CronList list scheduled crons");
println!();
println!("Slash commands:");
println!(" /team list show teams in this session registry");
println!(" /team help this message");
println!();
println!("Teams are held in-process for the lifetime of this claw session.");
}
Some("list") => {
// The team registry is owned by the in-process tool executor
// (tools::global_team_registry) and is not currently exposed to
// the CLI layer, so we can't enumerate live teams from here.
// Listing will arrive with tool-registry introspection.
println!("Live team listing isn't wired into the CLI yet.");
println!(
"Teams are created/managed by the model via the TeamCreate and TeamDelete"
);
println!("tools during a turn. Ask the model to report on its teams.");
}
Some(other) => {
println!("Unknown /team action: {other}");
println!("Available: list, help");
}
}
}
fn print_status(&self) {
let cumulative = self.runtime.usage().cumulative_usage();
let latest = self.runtime.usage().current_turn_usage();
@ -14569,8 +14573,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