feat(team): port subagent_model, parallel tool exec, team coordination layer

Port the team-enhancement feature layer from feat/multi-tool-exec onto
feat/team-test so the agent-teams stack is buildable and testable on top
of the current CLI/runtime.

Subagent model (config wiring):
- Add subagent_model field to RuntimeFeatureConfig + RuntimeConfig::subagent_model()
  accessor + parse_optional_subagent_model() so the subagentModel setting
  (already validated by config_validate.rs) is now actually read and stored.
- Agent tool's resolve_agent_model falls back to subagentModel from config
  when no explicit model is passed.

Parallel tool execution:
- Re-export ToolCall/ToolResult/TurnProgressReporter from runtime so the
  CliToolExecutor::execute_batch override type-checks.
- Override execute_batch to classify read-only tools (read_file, glob/grep
  search, WebFetch/WebSearch, LSP, Git*, ToolSearch, Skill, Agent*,
  TeamStatus, Task*) as parallel-safe and run them concurrently via
  std:🧵:scope; everything else stays sequential. Results return in
  original model order.

Team coordination layer (cherry-picked from feat/multi-tool-exec):
- AgentMessage, TaskClaim, TeamStatus tools + shared mailbox directory +
  mode presets (tiny/1x ... mega/6x) + enriched TeamCreate/Agent tool
  descriptions + background team watcher.
- /team slash command: on/off/status/toggle of CLAWD_AGENT_TEAMS (the flag
  TeamCreate requires), replacing the info-only stub. Spec updated to
  [on|off|status].
- resolve_conflicts resolved: de-duplicated SlashCommand::Team/Setup arms
  and ReadOutcome::TeamToggle arms that the cherry-picks overlapped with
  the existing /team parse fix.

Verification:
- cargo build --workspace OK
- cargo test -p tools --lib: 111 passed (1 env failure: repl_executes_python_code)
- cargo test -p runtime --lib team_cron: 17 passed
- cargo test -p commands --lib: 42 passed (incl /team parse regression)
- cargo test -p rusty-claude-cli --bin claw: 345 passed
- scripts/fmt.sh --check OK

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
TheArchitectit 2026-06-17 11:48:17 -05:00
parent 1b6ef83f78
commit 3438fe8f2d
7 changed files with 275 additions and 125 deletions

View File

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

View File

@ -680,7 +680,8 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
context_window_tokens: 256_000, context_window_tokens: 256_000,
}), }),
// Qwen models via DashScope / OpenAI-compat // Qwen models via DashScope / OpenAI-compat
"qwen3.6-35b-fast" | "qwen3-235b-a22b" | "qwen-max" | "qwen-plus" | "qwen-turbo" | "qwen-qwq" => Some(ModelTokenLimit { "qwen3.6-35b-fast" | "qwen3-235b-a22b" | "qwen-max" | "qwen-plus" | "qwen-turbo"
| "qwen-qwq" => Some(ModelTokenLimit {
max_output_tokens: 16_384, max_output_tokens: 16_384,
context_window_tokens: 131_072, context_window_tokens: 131_072,
}), }),

View File

@ -815,7 +815,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
name: "team", name: "team",
aliases: &[], aliases: &[],
summary: "Manage agent teams", summary: "Manage agent teams",
argument_hint: Some("[list|create|delete]"), argument_hint: Some("[on|off|status]"),
resume_supported: true, resume_supported: true,
}, },
SlashCommandSpec { SlashCommandSpec {
@ -5545,9 +5545,9 @@ mod tests {
Ok(Some(SlashCommand::Team { action: None })) Ok(Some(SlashCommand::Team { action: None }))
); );
assert_eq!( assert_eq!(
SlashCommand::parse("/team list"), SlashCommand::parse("/team on"),
Ok(Some(SlashCommand::Team { Ok(Some(SlashCommand::Team {
action: Some("list".to_string()) action: Some("on".to_string())
})) }))
); );
assert_eq!( assert_eq!(

View File

@ -1734,7 +1734,11 @@ fn parse_optional_model(root: &JsonValue) -> Option<String> {
/// falls back to the default model. /// falls back to the default model.
fn parse_optional_subagent_model(root: &JsonValue) -> Option<String> { fn parse_optional_subagent_model(root: &JsonValue) -> Option<String> {
root.as_object() root.as_object()
.and_then(|object| object.get("subagentModel").or_else(|| object.get("subagent_model"))) .and_then(|object| {
object
.get("subagentModel")
.or_else(|| object.get("subagent_model"))
})
.and_then(JsonValue::as_str) .and_then(JsonValue::as_str)
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())

View File

@ -284,10 +284,7 @@ where
self self
} }
pub fn with_turn_progress_reporter( pub fn with_turn_progress_reporter(mut self, reporter: Box<dyn TurnProgressReporter>) -> Self {
mut self,
reporter: Box<dyn TurnProgressReporter>,
) -> Self {
self.turn_progress_reporter = Some(reporter); self.turn_progress_reporter = Some(reporter);
self self
} }
@ -578,7 +575,13 @@ where
// Phase 3: Post-hooks and session updates (sequential, original order). // Phase 3: Post-hooks and session updates (sequential, original order).
for p in &pending { for p in &pending {
// Capture progress data for the reporter. // Capture progress data for the reporter.
let (progress_tool_name, progress_input, progress_output, progress_is_error, result_message) = if p.allowed { let (
progress_tool_name,
progress_input,
progress_output,
progress_is_error,
result_message,
) = if p.allowed {
let batch_result = &batch_results[batch_index]; let batch_result = &batch_results[batch_index];
batch_index += 1; batch_index += 1;
let (mut output, mut is_error) = match &batch_result.result { let (mut output, mut is_error) = match &batch_result.result {
@ -621,16 +624,32 @@ where
output, output,
is_error, is_error,
); );
(p.tool_name.clone(), p.effective_input.clone(), progress_output, is_error, result_message) (
p.tool_name.clone(),
p.effective_input.clone(),
progress_output,
is_error,
result_message,
)
} else { } else {
let denied_output = merge_hook_feedback(&p.pre_hook_messages, p.deny_reason.clone().unwrap_or_default(), true); let denied_output = merge_hook_feedback(
&p.pre_hook_messages,
p.deny_reason.clone().unwrap_or_default(),
true,
);
let result_message = ConversationMessage::tool_result( let result_message = ConversationMessage::tool_result(
p.tool_use_id.clone(), p.tool_use_id.clone(),
p.tool_name.clone(), p.tool_name.clone(),
denied_output, denied_output,
true, true,
); );
(p.tool_name.clone(), String::new(), String::new(), true, result_message) (
p.tool_name.clone(),
String::new(),
String::new(),
true,
result_message,
)
}; };
self.session self.session
.push_message(result_message.clone()) .push_message(result_message.clone())
@ -642,7 +661,13 @@ where
} else { } else {
Ok(progress_output.as_str()) Ok(progress_output.as_str())
}; };
reporter.on_tool_result(iterations, self.max_iterations, &progress_tool_name, &progress_input, report_result); reporter.on_tool_result(
iterations,
self.max_iterations,
&progress_tool_name,
&progress_input,
report_result,
);
} }
tool_results.push(result_message); tool_results.push(result_message);
} }

View File

@ -7173,16 +7173,6 @@ fn run_repl(
let _ = cli.set_model(Some(new_model)); let _ = cli.set_model(Some(new_model));
} }
} }
input::ReadOutcome::TeamToggle => {
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");
}
}
} }
} }
@ -8641,7 +8631,9 @@ impl LiveCli {
if current == "1" { if current == "1" {
eprintln!("[team] Agent teams: ENABLED"); eprintln!("[team] Agent teams: ENABLED");
} else { } else {
eprintln!("[team] Agent teams: DISABLED (use /team on or Ctrl+T to enable)"); eprintln!(
"[team] Agent teams: DISABLED (use /team on or Ctrl+T to enable)"
);
} }
} }
"" => { "" => {
@ -8655,7 +8647,9 @@ impl LiveCli {
eprintln!("[team] Agent teams enabled (TeamCreate now available)"); eprintln!("[team] Agent teams enabled (TeamCreate now available)");
} }
} }
other => eprintln!("[team] unknown action: {other}. Use: /team [on|off|status]"), other => {
eprintln!("[team] unknown action: {other}. Use: /team [on|off|status]")
}
} }
false false
} }
@ -8664,7 +8658,10 @@ impl LiveCli {
// Reload the model from config after wizard saves // Reload the model from config after wizard saves
let cwd = std::env::current_dir().unwrap_or_default(); let cwd = std::env::current_dir().unwrap_or_default();
let config = runtime::ConfigLoader::default_for(&cwd).load().ok(); 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)) { if let Some(new_model) = config
.as_ref()
.and_then(|c| c.provider().model().map(str::to_string))
{
self.set_model(Some(new_model))?; self.set_model(Some(new_model))?;
} }
false false
@ -14573,10 +14570,11 @@ impl ToolExecutor for CliToolExecutor {
"LSP", "LSP",
"Agent", "Agent",
"AgentMessage", "AgentMessage",
"TeamStatus", "TeamStatus",
"TaskClaim", "TaskClaim",
"AgentSuggestion", "AgentSuggestion",
"ContextRequest", "TaskGet", "ContextRequest",
"TaskGet",
"TaskList", "TaskList",
"TaskOutput", "TaskOutput",
"GitStatus", "GitStatus",
@ -14593,11 +14591,9 @@ impl ToolExecutor for CliToolExecutor {
// Classify calls as parallel-safe or sequential // Classify calls as parallel-safe or sequential
for (i, call) in calls.iter().enumerate() { for (i, call) in calls.iter().enumerate() {
if self if self.allowed_tools.as_ref().is_some_and(|allowed| {
.allowed_tools !allowed.contains(&canonical_allowed_tool_name(&call.tool_name))
.as_ref() }) {
.is_some_and(|allowed| !allowed.contains(&canonical_allowed_tool_name(&call.tool_name)))
{
results[i] = Some(runtime::ToolResult { results[i] = Some(runtime::ToolResult {
tool_use_id: call.tool_use_id.clone(), tool_use_id: call.tool_use_id.clone(),
tool_name: call.tool_name.clone(), tool_name: call.tool_name.clone(),
@ -14633,12 +14629,11 @@ impl ToolExecutor for CliToolExecutor {
let input = input.clone(); let input = input.clone();
let idx = *idx; let idx = *idx;
handles.push(s.spawn(move || { handles.push(s.spawn(move || {
let value = serde_json::from_str(&input) let value = serde_json::from_str(&input).map_err(|error| {
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}"))); ToolError::new(format!("invalid tool input JSON: {error}"))
});
let result = match value { let result = match value {
Ok(v) => registry Ok(v) => registry.execute(&tool_name, &v).map_err(ToolError::new),
.execute(&tool_name, &v)
.map_err(ToolError::new),
Err(e) => Err(e), Err(e) => Err(e),
}; };
(idx, tool_use_id, tool_name, result) (idx, tool_use_id, tool_name, result)
@ -14646,14 +14641,16 @@ impl ToolExecutor for CliToolExecutor {
} }
handles handles
.into_iter() .into_iter()
.map(|h| h.join().unwrap_or_else(|_| { .map(|h| {
( h.join().unwrap_or_else(|_| {
0, (
String::new(), 0,
String::new(), String::new(),
Err(ToolError::new("parallel thread panicked")), String::new(),
) Err(ToolError::new("parallel thread panicked")),
})) )
})
})
.collect() .collect()
}); });

View File

@ -7,10 +7,10 @@ use aspect_macros::aspect;
use aspect_std::LoggingAspect; use aspect_std::LoggingAspect;
use api::{ use api::{
max_tokens_for_model, model_family_identity_for, model_token_limit, ModelTokenLimit, max_tokens_for_model, model_family_identity_for, model_token_limit, resolve_model_alias,
resolve_model_alias, ApiError, ContentBlockDelta, InputContentBlock, InputMessage, ApiError, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse,
MessageRequest, MessageResponse, OutputContentBlock, ProviderClient, OutputContentBlock, ProviderClient, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, ToolResultContentBlock,
}; };
use plugins::PluginTool; use plugins::PluginTool;
use reqwest::blocking::Client; use reqwest::blocking::Client;
@ -1593,7 +1593,9 @@ fn execute_tool_with_enforcer(
"AgentMessage" => from_value::<AgentMessageInput>(input).and_then(run_agent_message), "AgentMessage" => from_value::<AgentMessageInput>(input).and_then(run_agent_message),
"TeamStatus" => from_value::<TeamStatusInput>(input).and_then(run_team_status), "TeamStatus" => from_value::<TeamStatusInput>(input).and_then(run_team_status),
"TaskClaim" => from_value::<TaskClaimInput>(input).and_then(run_task_claim), "TaskClaim" => from_value::<TaskClaimInput>(input).and_then(run_task_claim),
"AgentSuggestion" => from_value::<AgentSuggestionInput>(input).and_then(run_agent_suggestion), "AgentSuggestion" => {
from_value::<AgentSuggestionInput>(input).and_then(run_agent_suggestion)
}
"ContextRequest" => from_value::<ContextRequestInput>(input).and_then(run_context_request), "ContextRequest" => from_value::<ContextRequestInput>(input).and_then(run_context_request),
"CronCreate" => from_value::<CronCreateInput>(input).and_then(run_cron_create), "CronCreate" => from_value::<CronCreateInput>(input).and_then(run_cron_create),
"CronDelete" => from_value::<CronDeleteInput>(input).and_then(run_cron_delete), "CronDelete" => from_value::<CronDeleteInput>(input).and_then(run_cron_delete),
@ -1888,10 +1890,13 @@ fn run_team_create(input: TeamCreateInput) -> Result<String, String> {
return Err("Agent teams is disabled. Use /team on or Ctrl+T to enable.".to_string()); 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() let team_id = format!(
.duration_since(std::time::UNIX_EPOCH) "team-{}",
.unwrap_or_default() std::time::SystemTime::now()
.as_nanos()); .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let output_dir = agent_store_dir()?; let output_dir = agent_store_dir()?;
let team_dir = output_dir.join("teams"); let team_dir = output_dir.join("teams");
std::fs::create_dir_all(&team_dir).map_err(|e| e.to_string())?; std::fs::create_dir_all(&team_dir).map_err(|e| e.to_string())?;
@ -1899,9 +1904,23 @@ fn run_team_create(input: TeamCreateInput) -> Result<String, String> {
// Expand mode preset into tasks, or use manual tasks. // Expand mode preset into tasks, or use manual tasks.
// Default to "2x" when neither mode nor tasks are provided. // Default to "2x" when neither mode nor tasks are provided.
let tasks = if let Some(mode) = &input.mode { let tasks = if let Some(mode) = &input.mode {
expand_team_mode(mode, input.prompt.as_deref().unwrap_or("Explore the codebase and report findings"), &team_id)? expand_team_mode(
mode,
input
.prompt
.as_deref()
.unwrap_or("Explore the codebase and report findings"),
&team_id,
)?
} else if input.tasks.is_empty() { } else if input.tasks.is_empty() {
expand_team_mode("2x", input.prompt.as_deref().unwrap_or("Explore the codebase and report findings"), &team_id)? expand_team_mode(
"2x",
input
.prompt
.as_deref()
.unwrap_or("Explore the codebase and report findings"),
&team_id,
)?
} else { } else {
input.tasks.clone() input.tasks.clone()
}; };
@ -1911,7 +1930,10 @@ fn run_team_create(input: TeamCreateInput) -> Result<String, String> {
for (i, task) in tasks.iter().enumerate() { for (i, task) in tasks.iter().enumerate() {
let prompt = task.get("prompt").and_then(|v| v.as_str()).unwrap_or(""); let prompt = task.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
let description = task.get("description").and_then(|v| v.as_str()).unwrap_or(&input.name); let description = task
.get("description")
.and_then(|v| v.as_str())
.unwrap_or(&input.name);
let subagent_type = task.get("subagent_type").and_then(|v| v.as_str()); let subagent_type = task.get("subagent_type").and_then(|v| v.as_str());
let model_override = task.get("model").and_then(|v| v.as_str()); let model_override = task.get("model").and_then(|v| v.as_str());
@ -1919,12 +1941,19 @@ fn run_team_create(input: TeamCreateInput) -> Result<String, String> {
continue; continue;
} }
let task_id = task.get("task_id").and_then(|v| v.as_str()).map(|s| s.to_string()); let task_id = task
.get("task_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let agent_input = AgentInput { let agent_input = AgentInput {
description: description.to_string(), description: description.to_string(),
prompt: prompt.to_string(), prompt: prompt.to_string(),
subagent_type: subagent_type.map(|s| s.to_string()), subagent_type: subagent_type.map(|s| s.to_string()),
name: Some(format!("{}-agent-{}", slugify_agent_name(&input.name), i + 1)), name: Some(format!(
"{}-agent-{}",
slugify_agent_name(&input.name),
i + 1
)),
model: model_override.map(|s| s.to_string()), model: model_override.map(|s| s.to_string()),
team_id: Some(team_id.clone()), team_id: Some(team_id.clone()),
task_id, task_id,
@ -1961,8 +1990,11 @@ fn run_team_create(input: TeamCreateInput) -> Result<String, String> {
"created_at": iso8601_now(), "created_at": iso8601_now(),
}); });
let manifest_path = team_dir.join(format!("{team_id}.json")); let manifest_path = team_dir.join(format!("{team_id}.json"));
std::fs::write(&manifest_path, serde_json::to_string_pretty(&team_manifest).map_err(|e| e.to_string())?) std::fs::write(
.map_err(|e| e.to_string())?; &manifest_path,
serde_json::to_string_pretty(&team_manifest).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
// Register in global registry // Register in global registry
let team = global_team_registry().create(&input.name, agent_ids.clone()); let team = global_team_registry().create(&input.name, agent_ids.clone());
@ -1989,10 +2021,11 @@ fn run_team_status(input: TeamStatusInput) -> Result<String, String> {
if !manifest_path.exists() { if !manifest_path.exists() {
return Err(format!("team {} not found", input.team_id)); return Err(format!("team {} not found", input.team_id));
} }
let team_data: Value = serde_json::from_str( let team_data: Value =
&std::fs::read_to_string(&manifest_path).map_err(|e| e.to_string())? serde_json::from_str(&std::fs::read_to_string(&manifest_path).map_err(|e| e.to_string())?)
).map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let agent_ids = team_data.get("agent_ids") let agent_ids = team_data
.get("agent_ids")
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
@ -2012,9 +2045,11 @@ fn run_team_status(input: TeamStatusInput) -> Result<String, String> {
if agent_json.exists() { if agent_json.exists() {
if let Ok(data) = std::fs::read_to_string(&agent_json) { if let Ok(data) = std::fs::read_to_string(&agent_json) {
if let Ok(parsed) = serde_json::from_str::<Value>(&data) { if let Ok(parsed) = serde_json::from_str::<Value>(&data) {
detail["status"] = parsed.get("status").cloned().unwrap_or(json!("unknown")); detail["status"] =
parsed.get("status").cloned().unwrap_or(json!("unknown"));
detail["name"] = parsed.get("name").cloned().unwrap_or(json!(null)); detail["name"] = parsed.get("name").cloned().unwrap_or(json!(null));
detail["subagent_type"] = parsed.get("subagent_type").cloned().unwrap_or(json!(null)); detail["subagent_type"] =
parsed.get("subagent_type").cloned().unwrap_or(json!(null));
if let Some(completed) = parsed.get("completed_at") { if let Some(completed) = parsed.get("completed_at") {
detail["completed_at"] = completed.clone(); detail["completed_at"] = completed.clone();
} }
@ -2026,7 +2061,8 @@ fn run_team_status(input: TeamStatusInput) -> Result<String, String> {
if agent_md.exists() { if agent_md.exists() {
if let Ok(md_content) = std::fs::read_to_string(&agent_md) { if let Ok(md_content) = std::fs::read_to_string(&agent_md) {
let summary = md_content.lines() let summary = md_content
.lines()
.skip_while(|line| !line.starts_with("## Result")) .skip_while(|line| !line.starts_with("## Result"))
.skip(1) .skip(1)
.take(10) .take(10)
@ -2038,7 +2074,11 @@ fn run_team_status(input: TeamStatusInput) -> Result<String, String> {
} }
} }
match detail.get("status").and_then(|v| v.as_str()).unwrap_or("unknown") { match detail
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
{
"completed" => completed_count += 1, "completed" => completed_count += 1,
"failed" => failed_count += 1, "failed" => failed_count += 1,
_ => running_count += 1, _ => running_count += 1,
@ -2287,14 +2327,33 @@ fn get_agent_result_preview(store_dir: &std::path::Path, agent_id: &str) -> Stri
let md_path = store_dir.join(format!("{agent_id}.md")); let md_path = store_dir.join(format!("{agent_id}.md"));
if let Ok(content) = std::fs::read_to_string(&md_path) { if let Ok(content) = std::fs::read_to_string(&md_path) {
let lines: Vec<&str> = content.lines().collect(); let lines: Vec<&str> = content.lines().collect();
let start = lines.iter().position(|l| l.starts_with("## Result")).map(|i| i + 1).unwrap_or(0); let start = lines
lines[start..].iter().take(5).cloned().collect::<Vec<&str>>().join(" ").chars().take(200).collect() .iter()
.position(|l| l.starts_with("## Result"))
.map(|i| i + 1)
.unwrap_or(0);
lines[start..]
.iter()
.take(5)
.cloned()
.collect::<Vec<&str>>()
.join(" ")
.chars()
.take(200)
.collect()
} else { } else {
String::new() String::new()
} }
} }
fn append_team_event(events_path: &std::path::Path, team_id: &str, agent_id: &str, event_type: &str, name: &str, detail: Option<&str>) { fn append_team_event(
events_path: &std::path::Path,
team_id: &str,
agent_id: &str,
event_type: &str,
name: &str,
detail: Option<&str>,
) {
let entry = json!({ let entry = json!({
"timestamp": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis(), "timestamp": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis(),
"team_id": team_id, "team_id": team_id,
@ -2305,13 +2364,16 @@ fn append_team_event(events_path: &std::path::Path, team_id: &str, agent_id: &st
}); });
if let Ok(line) = serde_json::to_string(&entry) { if let Ok(line) = serde_json::to_string(&entry) {
use std::io::Write; use std::io::Write;
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(events_path) { if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(events_path)
{
let _ = writeln!(file, "{line}"); let _ = writeln!(file, "{line}");
} }
} }
} }
fn run_team_delete(input: TeamDeleteInput) -> Result<String, String> { fn run_team_delete(input: TeamDeleteInput) -> Result<String, String> {
// Delete from disk-based team storage // Delete from disk-based team storage
let team_dir = agent_store_dir()?.join("teams"); let team_dir = agent_store_dir()?.join("teams");
@ -2381,8 +2443,11 @@ fn run_agent_message(input: AgentMessageInput) -> Result<String, String> {
"message": msg, "message": msg,
"timestamp": ts, "timestamp": ts,
}); });
std::fs::write(&msg_file, serde_json::to_string_pretty(&entry).map_err(|e| e.to_string())?) std::fs::write(
.map_err(|e| e.to_string())?; &msg_file,
serde_json::to_string_pretty(&entry).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
to_pretty_json(json!({ to_pretty_json(json!({
"action": "sent", "action": "sent",
"to": target, "to": target,
@ -2404,9 +2469,7 @@ fn run_agent_message(input: AgentMessageInput) -> Result<String, String> {
let entries: Vec<_> = std::fs::read_dir(&inbox_dir) let entries: Vec<_> = std::fs::read_dir(&inbox_dir)
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| { .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
e.path().extension().is_some_and(|ext| ext == "json")
})
.collect(); .collect();
for entry in &entries { for entry in &entries {
if let Ok(content) = std::fs::read_to_string(entry.path()) { if let Ok(content) = std::fs::read_to_string(entry.path()) {
@ -2443,9 +2506,11 @@ fn run_agent_message(input: AgentMessageInput) -> Result<String, String> {
return Err(format!("team {team_id} not found")); return Err(format!("team {team_id} not found"));
} }
let team_data: Value = serde_json::from_str( let team_data: Value = serde_json::from_str(
&std::fs::read_to_string(&manifest_path).map_err(|e| e.to_string())? &std::fs::read_to_string(&manifest_path).map_err(|e| e.to_string())?,
).map_err(|e| e.to_string())?; )
let agent_ids = team_data.get("agent_ids") .map_err(|e| e.to_string())?;
let agent_ids = team_data
.get("agent_ids")
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.cloned() .cloned()
.unwrap_or_default(); .unwrap_or_default();
@ -2457,7 +2522,9 @@ fn run_agent_message(input: AgentMessageInput) -> Result<String, String> {
let mut sent_to: Vec<String> = Vec::new(); let mut sent_to: Vec<String> = Vec::new();
for id_val in agent_ids { for id_val in agent_ids {
if let Some(id) = id_val.as_str() { if let Some(id) = id_val.as_str() {
if id == sender { continue; } if id == sender {
continue;
}
let inbox_dir = mailbox_dir.join(id); let inbox_dir = mailbox_dir.join(id);
std::fs::create_dir_all(&inbox_dir).map_err(|e| e.to_string())?; std::fs::create_dir_all(&inbox_dir).map_err(|e| e.to_string())?;
let msg_file = inbox_dir.join(format!("msg-{ts}-{sender}.json")); let msg_file = inbox_dir.join(format!("msg-{ts}-{sender}.json"));
@ -2467,8 +2534,11 @@ fn run_agent_message(input: AgentMessageInput) -> Result<String, String> {
"timestamp": ts, "timestamp": ts,
"team_id": team_id, "team_id": team_id,
}); });
std::fs::write(&msg_file, serde_json::to_string_pretty(&entry).map_err(|e| e.to_string())?) std::fs::write(
.map_err(|e| e.to_string())?; &msg_file,
serde_json::to_string_pretty(&entry).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
sent_to.push(id.to_string()); sent_to.push(id.to_string());
} }
} }
@ -2535,7 +2605,9 @@ fn run_task_claim(input: TaskClaimInput) -> Result<String, String> {
"count": claims.len(), "count": claims.len(),
})) }))
} }
other => Err(format!("unknown TaskClaim action: {other}. Use claim, release, or list")), other => Err(format!(
"unknown TaskClaim action: {other}. Use claim, release, or list"
)),
} }
} }
@ -2557,8 +2629,11 @@ fn run_agent_suggestion(input: AgentSuggestionInput) -> Result<String, String> {
}); });
let filename = format!("suggestion-{agent_id}-{ts}.json"); let filename = format!("suggestion-{agent_id}-{ts}.json");
let path = suggestions_dir.join(&filename); let path = suggestions_dir.join(&filename);
std::fs::write(&path, serde_json::to_string_pretty(&entry).map_err(|e| e.to_string())?) std::fs::write(
.map_err(|e| e.to_string())?; &path,
serde_json::to_string_pretty(&entry).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())?;
to_pretty_json(json!({ to_pretty_json(json!({
"action": "suggestion", "action": "suggestion",
"file": filename, "file": filename,
@ -2579,7 +2654,11 @@ fn run_context_request(input: ContextRequestInput) -> Result<String, String> {
match std::fs::read_to_string(&path) { match std::fs::read_to_string(&path) {
Ok(content) => { Ok(content) => {
let truncated = if content.len() > 10000 { let truncated = if content.len() > 10000 {
format!("{}... (truncated, {} bytes total)", &content[..10000], content.len()) format!(
"{}... (truncated, {} bytes total)",
&content[..10000],
content.len()
)
} else { } else {
content content
}; };
@ -2610,7 +2689,15 @@ fn run_context_request(input: ContextRequestInput) -> Result<String, String> {
for symbol in &input.symbols { for symbol in &input.symbols {
let cwd = std::env::current_dir().map_err(|e| e.to_string())?; let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
let output = std::process::Command::new("grep") let output = std::process::Command::new("grep")
.args(["-rn", "--include=*.rs", "--include=*.ts", "--include=*.js", "--include=*.py", symbol, "."]) .args([
"-rn",
"--include=*.rs",
"--include=*.ts",
"--include=*.js",
"--include=*.py",
symbol,
".",
])
.current_dir(&cwd) .current_dir(&cwd)
.output() .output()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@ -2646,7 +2733,11 @@ fn expand_team_mode(mode: &str, base_prompt: &str, team_id: &str) -> Result<Vec<
"4x" | "large" => 4, "4x" | "large" => 4,
"5x" | "xlarge" => 5, "5x" | "xlarge" => 5,
"6x" | "mega" => 6, "6x" | "mega" => 6,
other => return Err(format!("unknown team mode '{other}'. Use 1x-6x or tiny/small/medium/large/xlarge/mega")), 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 short_team_id = &team_id[team_id.len().saturating_sub(8)..];
let roles: &[&str] = &["Explore", "Plan", "Verification"]; let roles: &[&str] = &["Explore", "Plan", "Verification"];
@ -2721,8 +2812,11 @@ fn claim_task(task_id: &str, agent_id: &str, team_id: &str) -> Result<bool, Stri
}); });
// Atomic claim: write to temp file then rename // Atomic claim: write to temp file then rename
let tmp_path = dir.join(format!("{task_id}.lock.tmp.{agent_id}")); 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())?) std::fs::write(
.map_err(|e| e.to_string())?; &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) { match std::fs::rename(&tmp_path, &lock_path) {
Ok(()) => Ok(true), Ok(()) => Ok(true),
Err(_) => { Err(_) => {
@ -2755,7 +2849,8 @@ fn list_claims(team_id: Option<&str>) -> Vec<serde_json::Value> {
if path.extension().map_or(false, |e| e == "lock") { if path.extension().map_or(false, |e| e == "lock") {
if let Ok(content) = std::fs::read_to_string(&path) { if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) { if let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) {
if team_id.map_or(true, |tid| v.get("team_id").map_or(false, |t| t == tid)) { if team_id.map_or(true, |tid| v.get("team_id").map_or(false, |t| t == tid))
{
claims.push(v); claims.push(v);
} }
} }
@ -2778,7 +2873,12 @@ impl TeamInboxReporter {
fn new(team_id: String, agent_id: String, agent_name: String) -> Self { fn new(team_id: String, agent_id: String, agent_name: String) -> Self {
let inbox_dir = agent_mailbox_dir().join("team").join(&team_id); let inbox_dir = agent_mailbox_dir().join("team").join(&team_id);
let _ = std::fs::create_dir_all(&inbox_dir); let _ = std::fs::create_dir_all(&inbox_dir);
Self { team_id, agent_id, agent_name, inbox_dir } Self {
team_id,
agent_id,
agent_name,
inbox_dir,
}
} }
} }
@ -2812,10 +2912,9 @@ impl TurnProgressReporter for TeamInboxReporter {
"max_iterations": max_iterations, "max_iterations": max_iterations,
"timestamp": ts, "timestamp": ts,
}); });
let msg_file = self.inbox_dir.join(format!( let msg_file = self
"tp-{}-{}-{ts}.json", .inbox_dir
self.agent_id, iteration .join(format!("tp-{}-{}-{ts}.json", self.agent_id, iteration));
));
if let Ok(line) = serde_json::to_string(&entry) { if let Ok(line) = serde_json::to_string(&entry) {
let _ = std::fs::write(&msg_file, line); let _ = std::fs::write(&msg_file, line);
} }
@ -2830,13 +2929,19 @@ impl TurnProgressReporter for TeamInboxReporter {
.output(); .output();
if diff_check.map_or(true, |o| !o.status.success()) { if diff_check.map_or(true, |o| !o.status.success()) {
let _ = std::process::Command::new("git") let _ = std::process::Command::new("git")
.args(["commit", "-m", &format!("agent {} progress: iteration {iteration}", self.agent_id)]) .args([
"commit",
"-m",
&format!("agent {} progress: iteration {iteration}", self.agent_id),
])
.output(); .output();
} }
} }
// Check for kill signal from team lead // 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()) { for entry in
std::fs::read_dir(&self.inbox_dir).unwrap_or_else(|_| std::fs::read_dir(".").unwrap())
{
if let Ok(e) = entry { if let Ok(e) = entry {
let name = e.file_name(); let name = e.file_name();
let name_str = name.to_string_lossy(); let name_str = name.to_string_lossy();
@ -5352,14 +5457,25 @@ fn setup_agent_worktree(agent_id: &str) -> Result<std::path::PathBuf, String> {
// Create worktree on a new branch // Create worktree on a new branch
let branch_name = format!("agent/{agent_id}"); let branch_name = format!("agent/{agent_id}");
let output = std::process::Command::new("git") let output = std::process::Command::new("git")
.args(["worktree", "add", worktree_dir.to_str().unwrap_or(""), "-b", &branch_name]) .args([
"worktree",
"add",
worktree_dir.to_str().unwrap_or(""),
"-b",
&branch_name,
])
.current_dir(&cwd) .current_dir(&cwd)
.output() .output()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if !output.status.success() { if !output.status.success() {
// Branch might already exist, try with existing branch // Branch might already exist, try with existing branch
let output2 = std::process::Command::new("git") let output2 = std::process::Command::new("git")
.args(["worktree", "add", worktree_dir.to_str().unwrap_or(""), &branch_name]) .args([
"worktree",
"add",
worktree_dir.to_str().unwrap_or(""),
&branch_name,
])
.current_dir(&cwd) .current_dir(&cwd)
.output() .output()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@ -5390,7 +5506,12 @@ fn teardown_agent_worktree(agent_id: &str, worktree_path: &std::path::Path) -> R
} }
// Remove the worktree // Remove the worktree
let _ = std::process::Command::new("git") let _ = std::process::Command::new("git")
.args(["worktree", "remove", worktree_path.to_str().unwrap_or(""), "--force"]) .args([
"worktree",
"remove",
worktree_path.to_str().unwrap_or(""),
"--force",
])
.current_dir(&cwd) .current_dir(&cwd)
.output(); .output();
// Delete the branch // Delete the branch
@ -5405,8 +5526,7 @@ fn run_agent_job(job: &AgentJob) -> Result<(), String> {
// Claim task if task_id is set (prevents duplicate work) // Claim task if task_id is set (prevents duplicate work)
if let Some(ref task_id) = job.task_id { if let Some(ref task_id) = job.task_id {
if let Some(ref team_id) = job.team_id { if let Some(ref team_id) = job.team_id {
let claimed = claim_task(task_id, &job.manifest.agent_id, team_id) let claimed = claim_task(task_id, &job.manifest.agent_id, team_id).unwrap_or(false);
.unwrap_or(false);
if !claimed { if !claimed {
return Err(format!("task {task_id} already claimed by another agent")); return Err(format!("task {task_id} already claimed by another agent"));
} }
@ -5732,8 +5852,11 @@ fn post_agent_completion_to_team_inbox(
"timestamp": ts, "timestamp": ts,
}); });
let msg_file = mailbox_dir.join(format!("{}-{ts}.json", manifest.agent_id)); let msg_file = mailbox_dir.join(format!("{}-{ts}.json", manifest.agent_id));
std::fs::write(&msg_file, serde_json::to_string_pretty(&entry).map_err(|e| e.to_string())?) std::fs::write(
.map_err(|e| e.to_string()) &msg_file,
serde_json::to_string_pretty(&entry).map_err(|e| e.to_string())?,
)
.map_err(|e| e.to_string())
} }
const MIN_LANE_SUMMARY_WORDS: usize = 7; const MIN_LANE_SUMMARY_WORDS: usize = 7;
@ -10038,8 +10161,8 @@ mod tests {
subagent_type: Some("Explore".to_string()), subagent_type: Some("Explore".to_string()),
name: Some("ship-audit".to_string()), name: Some("ship-audit".to_string()),
model: None, model: None,
team_id: None, team_id: None,
task_id: None, task_id: None,
}, },
move |job| { move |job| {
*captured_for_spawn *captured_for_spawn
@ -10121,8 +10244,8 @@ mod tests {
subagent_type: Some("Explore".to_string()), subagent_type: Some("Explore".to_string()),
name: Some("complete-task".to_string()), name: Some("complete-task".to_string()),
model: Some("claude-sonnet-4-6".to_string()), model: Some("claude-sonnet-4-6".to_string()),
team_id: None, team_id: None,
task_id: None, task_id: None,
}, },
|job| { |job| {
persist_agent_terminal_state( persist_agent_terminal_state(
@ -10180,8 +10303,8 @@ mod tests {
subagent_type: Some("Verification".to_string()), subagent_type: Some("Verification".to_string()),
name: Some("fail-task".to_string()), name: Some("fail-task".to_string()),
model: None, model: None,
team_id: None, team_id: None,
task_id: None, task_id: None,
}, },
|job| { |job| {
persist_agent_terminal_state( persist_agent_terminal_state(
@ -10229,8 +10352,8 @@ mod tests {
subagent_type: Some("Explore".to_string()), subagent_type: Some("Explore".to_string()),
name: Some("summary-floor".to_string()), name: Some("summary-floor".to_string()),
model: None, model: None,
team_id: None, team_id: None,
task_id: None, task_id: None,
}, },
|job| { |job| {
persist_agent_terminal_state( persist_agent_terminal_state(
@ -10326,8 +10449,8 @@ mod tests {
subagent_type: Some("Verification".to_string()), subagent_type: Some("Verification".to_string()),
name: Some("review-lane".to_string()), name: Some("review-lane".to_string()),
model: None, model: None,
team_id: None, team_id: None,
task_id: None, task_id: None,
}, },
|job| { |job| {
persist_agent_terminal_state( persist_agent_terminal_state(
@ -10488,8 +10611,8 @@ mod tests {
subagent_type: Some("Explore".to_string()), subagent_type: Some("Explore".to_string()),
name: Some("cron-closeout".to_string()), name: Some("cron-closeout".to_string()),
model: None, model: None,
team_id: None, team_id: None,
task_id: None, task_id: None,
}, },
|job| { |job| {
persist_agent_terminal_state( persist_agent_terminal_state(
@ -10531,8 +10654,8 @@ mod tests {
subagent_type: None, subagent_type: None,
name: Some("spawn-error".to_string()), name: Some("spawn-error".to_string()),
model: None, model: None,
team_id: None, team_id: None,
task_id: None, task_id: None,
}, },
|_| Err(String::from("thread creation failed")), |_| Err(String::from("thread creation failed")),
) )