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:
parent
1b6ef83f78
commit
3438fe8f2d
|
|
@ -29,7 +29,7 @@ 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, model_token_limit,
|
||||
ModelTokenLimit, provider_diagnostics_for_model, resolve_model_alias, ProviderDiagnostics,
|
||||
provider_diagnostics_for_model, resolve_model_alias, ModelTokenLimit, ProviderDiagnostics,
|
||||
ProviderKind,
|
||||
};
|
||||
pub use sse::{parse_frame, SseParser};
|
||||
|
|
|
|||
|
|
@ -680,7 +680,8 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
|||
context_window_tokens: 256_000,
|
||||
}),
|
||||
// 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,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -815,7 +815,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||
name: "team",
|
||||
aliases: &[],
|
||||
summary: "Manage agent teams",
|
||||
argument_hint: Some("[list|create|delete]"),
|
||||
argument_hint: Some("[on|off|status]"),
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
|
|
@ -5545,9 +5545,9 @@ mod tests {
|
|||
Ok(Some(SlashCommand::Team { action: None }))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/team list"),
|
||||
SlashCommand::parse("/team on"),
|
||||
Ok(Some(SlashCommand::Team {
|
||||
action: Some("list".to_string())
|
||||
action: Some("on".to_string())
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -1734,7 +1734,11 @@ fn parse_optional_model(root: &JsonValue) -> Option<String> {
|
|||
/// 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(|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())
|
||||
|
|
|
|||
|
|
@ -284,10 +284,7 @@ where
|
|||
self
|
||||
}
|
||||
|
||||
pub fn with_turn_progress_reporter(
|
||||
mut self,
|
||||
reporter: Box<dyn TurnProgressReporter>,
|
||||
) -> Self {
|
||||
pub fn with_turn_progress_reporter(mut self, reporter: Box<dyn TurnProgressReporter>) -> Self {
|
||||
self.turn_progress_reporter = Some(reporter);
|
||||
self
|
||||
}
|
||||
|
|
@ -578,7 +575,13 @@ where
|
|||
// Phase 3: Post-hooks and session updates (sequential, original order).
|
||||
for p in &pending {
|
||||
// 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];
|
||||
batch_index += 1;
|
||||
let (mut output, mut is_error) = match &batch_result.result {
|
||||
|
|
@ -621,16 +624,32 @@ where
|
|||
output,
|
||||
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 {
|
||||
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(
|
||||
p.tool_use_id.clone(),
|
||||
p.tool_name.clone(),
|
||||
denied_output,
|
||||
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
|
||||
.push_message(result_message.clone())
|
||||
|
|
@ -642,7 +661,13 @@ where
|
|||
} else {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7173,16 +7173,6 @@ fn run_repl(
|
|||
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" {
|
||||
eprintln!("[team] Agent teams: ENABLED");
|
||||
} 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)");
|
||||
}
|
||||
}
|
||||
other => eprintln!("[team] unknown action: {other}. Use: /team [on|off|status]"),
|
||||
other => {
|
||||
eprintln!("[team] unknown action: {other}. Use: /team [on|off|status]")
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
|
@ -8664,7 +8658,10 @@ impl LiveCli {
|
|||
// 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)) {
|
||||
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
|
||||
|
|
@ -14573,10 +14570,11 @@ impl ToolExecutor for CliToolExecutor {
|
|||
"LSP",
|
||||
"Agent",
|
||||
"AgentMessage",
|
||||
"TeamStatus",
|
||||
"TeamStatus",
|
||||
"TaskClaim",
|
||||
"AgentSuggestion",
|
||||
"ContextRequest", "TaskGet",
|
||||
"ContextRequest",
|
||||
"TaskGet",
|
||||
"TaskList",
|
||||
"TaskOutput",
|
||||
"GitStatus",
|
||||
|
|
@ -14593,11 +14591,9 @@ impl ToolExecutor for CliToolExecutor {
|
|||
|
||||
// Classify calls as parallel-safe or sequential
|
||||
for (i, call) in calls.iter().enumerate() {
|
||||
if self
|
||||
.allowed_tools
|
||||
.as_ref()
|
||||
.is_some_and(|allowed| !allowed.contains(&canonical_allowed_tool_name(&call.tool_name)))
|
||||
{
|
||||
if self.allowed_tools.as_ref().is_some_and(|allowed| {
|
||||
!allowed.contains(&canonical_allowed_tool_name(&call.tool_name))
|
||||
}) {
|
||||
results[i] = Some(runtime::ToolResult {
|
||||
tool_use_id: call.tool_use_id.clone(),
|
||||
tool_name: call.tool_name.clone(),
|
||||
|
|
@ -14633,12 +14629,11 @@ impl ToolExecutor for CliToolExecutor {
|
|||
let input = input.clone();
|
||||
let idx = *idx;
|
||||
handles.push(s.spawn(move || {
|
||||
let value = serde_json::from_str(&input)
|
||||
.map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")));
|
||||
let value = serde_json::from_str(&input).map_err(|error| {
|
||||
ToolError::new(format!("invalid tool input JSON: {error}"))
|
||||
});
|
||||
let result = match value {
|
||||
Ok(v) => registry
|
||||
.execute(&tool_name, &v)
|
||||
.map_err(ToolError::new),
|
||||
Ok(v) => registry.execute(&tool_name, &v).map_err(ToolError::new),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
(idx, tool_use_id, tool_name, result)
|
||||
|
|
@ -14646,14 +14641,16 @@ impl ToolExecutor for CliToolExecutor {
|
|||
}
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|h| h.join().unwrap_or_else(|_| {
|
||||
(
|
||||
0,
|
||||
String::new(),
|
||||
String::new(),
|
||||
Err(ToolError::new("parallel thread panicked")),
|
||||
)
|
||||
}))
|
||||
.map(|h| {
|
||||
h.join().unwrap_or_else(|_| {
|
||||
(
|
||||
0,
|
||||
String::new(),
|
||||
String::new(),
|
||||
Err(ToolError::new("parallel thread panicked")),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ use aspect_macros::aspect;
|
|||
use aspect_std::LoggingAspect;
|
||||
|
||||
use api::{
|
||||
max_tokens_for_model, model_family_identity_for, model_token_limit, ModelTokenLimit,
|
||||
resolve_model_alias, ApiError, ContentBlockDelta, InputContentBlock, InputMessage,
|
||||
MessageRequest, MessageResponse, OutputContentBlock, ProviderClient,
|
||||
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||
max_tokens_for_model, model_family_identity_for, model_token_limit, resolve_model_alias,
|
||||
ApiError, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse,
|
||||
OutputContentBlock, ProviderClient, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
|
||||
ToolResultContentBlock,
|
||||
};
|
||||
use plugins::PluginTool;
|
||||
use reqwest::blocking::Client;
|
||||
|
|
@ -1593,7 +1593,9 @@ fn execute_tool_with_enforcer(
|
|||
"AgentMessage" => from_value::<AgentMessageInput>(input).and_then(run_agent_message),
|
||||
"TeamStatus" => from_value::<TeamStatusInput>(input).and_then(run_team_status),
|
||||
"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),
|
||||
"CronCreate" => from_value::<CronCreateInput>(input).and_then(run_cron_create),
|
||||
"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());
|
||||
}
|
||||
|
||||
let team_id = format!("team-{}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos());
|
||||
let team_id = format!(
|
||||
"team-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let output_dir = agent_store_dir()?;
|
||||
let team_dir = output_dir.join("teams");
|
||||
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.
|
||||
// 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"), &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() {
|
||||
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 {
|
||||
input.tasks.clone()
|
||||
};
|
||||
|
|
@ -1911,7 +1930,10 @@ fn run_team_create(input: TeamCreateInput) -> Result<String, String> {
|
|||
|
||||
for (i, task) in tasks.iter().enumerate() {
|
||||
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 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;
|
||||
}
|
||||
|
||||
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 {
|
||||
description: description.to_string(),
|
||||
prompt: prompt.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()),
|
||||
team_id: Some(team_id.clone()),
|
||||
task_id,
|
||||
|
|
@ -1961,8 +1990,11 @@ fn run_team_create(input: TeamCreateInput) -> Result<String, String> {
|
|||
"created_at": iso8601_now(),
|
||||
});
|
||||
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())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(
|
||||
&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
|
||||
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() {
|
||||
return Err(format!("team {} not found", input.team_id));
|
||||
}
|
||||
let team_data: Value = serde_json::from_str(
|
||||
&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")
|
||||
let team_data: Value =
|
||||
serde_json::from_str(&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")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
|
@ -2012,9 +2045,11 @@ fn run_team_status(input: TeamStatusInput) -> Result<String, String> {
|
|||
if agent_json.exists() {
|
||||
if let Ok(data) = std::fs::read_to_string(&agent_json) {
|
||||
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["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") {
|
||||
detail["completed_at"] = completed.clone();
|
||||
}
|
||||
|
|
@ -2026,7 +2061,8 @@ fn run_team_status(input: TeamStatusInput) -> Result<String, String> {
|
|||
|
||||
if agent_md.exists() {
|
||||
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(1)
|
||||
.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,
|
||||
"failed" => failed_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"));
|
||||
if let Ok(content) = std::fs::read_to_string(&md_path) {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let start = lines.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()
|
||||
let start = lines
|
||||
.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 {
|
||||
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!({
|
||||
"timestamp": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis(),
|
||||
"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) {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn run_team_delete(input: TeamDeleteInput) -> Result<String, String> {
|
||||
// Delete from disk-based team storage
|
||||
let team_dir = agent_store_dir()?.join("teams");
|
||||
|
|
@ -2381,8 +2443,11 @@ fn run_agent_message(input: AgentMessageInput) -> Result<String, String> {
|
|||
"message": msg,
|
||||
"timestamp": ts,
|
||||
});
|
||||
std::fs::write(&msg_file, serde_json::to_string_pretty(&entry).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(
|
||||
&msg_file,
|
||||
serde_json::to_string_pretty(&entry).map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
to_pretty_json(json!({
|
||||
"action": "sent",
|
||||
"to": target,
|
||||
|
|
@ -2404,9 +2469,7 @@ fn run_agent_message(input: AgentMessageInput) -> Result<String, String> {
|
|||
let entries: Vec<_> = std::fs::read_dir(&inbox_dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path().extension().is_some_and(|ext| ext == "json")
|
||||
})
|
||||
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
|
||||
.collect();
|
||||
for entry in &entries {
|
||||
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"));
|
||||
}
|
||||
let team_data: Value = serde_json::from_str(
|
||||
&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")
|
||||
&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")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
|
@ -2457,7 +2522,9 @@ fn run_agent_message(input: AgentMessageInput) -> Result<String, String> {
|
|||
let mut sent_to: Vec<String> = Vec::new();
|
||||
for id_val in agent_ids {
|
||||
if let Some(id) = id_val.as_str() {
|
||||
if id == sender { continue; }
|
||||
if id == sender {
|
||||
continue;
|
||||
}
|
||||
let inbox_dir = mailbox_dir.join(id);
|
||||
std::fs::create_dir_all(&inbox_dir).map_err(|e| e.to_string())?;
|
||||
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,
|
||||
"team_id": team_id,
|
||||
});
|
||||
std::fs::write(&msg_file, serde_json::to_string_pretty(&entry).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(
|
||||
&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());
|
||||
}
|
||||
}
|
||||
|
|
@ -2535,7 +2605,9 @@ fn run_task_claim(input: TaskClaimInput) -> Result<String, String> {
|
|||
"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 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())?;
|
||||
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,
|
||||
|
|
@ -2579,7 +2654,11 @@ fn run_context_request(input: ContextRequestInput) -> Result<String, String> {
|
|||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
let truncated = if content.len() > 10000 {
|
||||
format!("{}... (truncated, {} bytes total)", &content[..10000], content.len())
|
||||
format!(
|
||||
"{}... (truncated, {} bytes total)",
|
||||
&content[..10000],
|
||||
content.len()
|
||||
)
|
||||
} else {
|
||||
content
|
||||
};
|
||||
|
|
@ -2610,7 +2689,15 @@ fn run_context_request(input: ContextRequestInput) -> Result<String, String> {
|
|||
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, "."])
|
||||
.args([
|
||||
"-rn",
|
||||
"--include=*.rs",
|
||||
"--include=*.ts",
|
||||
"--include=*.js",
|
||||
"--include=*.py",
|
||||
symbol,
|
||||
".",
|
||||
])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.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,
|
||||
"5x" | "xlarge" => 5,
|
||||
"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 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
|
||||
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())?;
|
||||
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(_) => {
|
||||
|
|
@ -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 let Ok(content) = std::fs::read_to_string(&path) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -2778,7 +2873,12 @@ 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 }
|
||||
Self {
|
||||
team_id,
|
||||
agent_id,
|
||||
agent_name,
|
||||
inbox_dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2812,10 +2912,9 @@ impl TurnProgressReporter for TeamInboxReporter {
|
|||
"max_iterations": max_iterations,
|
||||
"timestamp": ts,
|
||||
});
|
||||
let msg_file = self.inbox_dir.join(format!(
|
||||
"tp-{}-{}-{ts}.json",
|
||||
self.agent_id, iteration
|
||||
));
|
||||
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);
|
||||
}
|
||||
|
|
@ -2830,13 +2929,19 @@ impl TurnProgressReporter for TeamInboxReporter {
|
|||
.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)])
|
||||
.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()) {
|
||||
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();
|
||||
|
|
@ -5352,14 +5457,25 @@ fn setup_agent_worktree(agent_id: &str) -> Result<std::path::PathBuf, String> {
|
|||
// 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])
|
||||
.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])
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
worktree_dir.to_str().unwrap_or(""),
|
||||
&branch_name,
|
||||
])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.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
|
||||
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)
|
||||
.output();
|
||||
// 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)
|
||||
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);
|
||||
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"));
|
||||
}
|
||||
|
|
@ -5732,8 +5852,11 @@ fn post_agent_completion_to_team_inbox(
|
|||
"timestamp": ts,
|
||||
});
|
||||
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())?)
|
||||
.map_err(|e| e.to_string())
|
||||
std::fs::write(
|
||||
&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;
|
||||
|
|
@ -10038,8 +10161,8 @@ mod tests {
|
|||
subagent_type: Some("Explore".to_string()),
|
||||
name: Some("ship-audit".to_string()),
|
||||
model: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
},
|
||||
move |job| {
|
||||
*captured_for_spawn
|
||||
|
|
@ -10121,8 +10244,8 @@ mod tests {
|
|||
subagent_type: Some("Explore".to_string()),
|
||||
name: Some("complete-task".to_string()),
|
||||
model: Some("claude-sonnet-4-6".to_string()),
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
},
|
||||
|job| {
|
||||
persist_agent_terminal_state(
|
||||
|
|
@ -10180,8 +10303,8 @@ mod tests {
|
|||
subagent_type: Some("Verification".to_string()),
|
||||
name: Some("fail-task".to_string()),
|
||||
model: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
},
|
||||
|job| {
|
||||
persist_agent_terminal_state(
|
||||
|
|
@ -10229,8 +10352,8 @@ mod tests {
|
|||
subagent_type: Some("Explore".to_string()),
|
||||
name: Some("summary-floor".to_string()),
|
||||
model: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
},
|
||||
|job| {
|
||||
persist_agent_terminal_state(
|
||||
|
|
@ -10326,8 +10449,8 @@ mod tests {
|
|||
subagent_type: Some("Verification".to_string()),
|
||||
name: Some("review-lane".to_string()),
|
||||
model: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
},
|
||||
|job| {
|
||||
persist_agent_terminal_state(
|
||||
|
|
@ -10488,8 +10611,8 @@ mod tests {
|
|||
subagent_type: Some("Explore".to_string()),
|
||||
name: Some("cron-closeout".to_string()),
|
||||
model: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
},
|
||||
|job| {
|
||||
persist_agent_terminal_state(
|
||||
|
|
@ -10531,8 +10654,8 @@ mod tests {
|
|||
subagent_type: None,
|
||||
name: Some("spawn-error".to_string()),
|
||||
model: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
},
|
||||
|_| Err(String::from("thread creation failed")),
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue