feat(team): team enhancements, subagentModel, parallel tool exec

Port the agent-team enhancement layer onto upstream/main so the model
can spin up coordinated sub-agent teams for parallel work.

subagentModel config wiring (fix):
- Add subagent_model field to RuntimeFeatureConfig + RuntimeConfig::subagent_model()
  accessor 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.

Provider namespace separation:
- New 'custom-openai' provider kind with dedicated env vars
  (CLAWCUSTOMOPENAI_API_KEY / CLAWCUSTOMOPENAI_BASE_URL)
- /setup wizard saves kind: 'custom-openai' for option 5
- Bare model name normalized to 'custom/' prefix to avoid proxy 404s
- Sub-agents inherit /setup-saved provider config via inject_config_as_env_fallbacks

Parallel tool execution:
- Override execute_batch to classify read-only tools as parallel-safe
  and run them concurrently via std::thread::scope
- Results return in original model order

Team coordination layer:
- AgentMessage, TaskClaim, TeamStatus tools + shared mailbox directory
- Mode presets (tiny/1x ... mega/6x) + enriched TeamCreate/Agent descriptions
- Background team watcher
- /team slash command: on/off/status/toggle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
TheArchitectitandClaude Fable 5 authored and clawdbot committed 2026-10-13 00:12:35 +00:00
1 parent 76fe2e9f46
commit 7aa00a2798
15 files changed
+3021 -552

No files matched your search

+70
View File
@@ -164,6 +164,10 @@ pub struct RuntimeFeatureConfig {
rules_import: RulesImportConfig,
provider: RuntimeProviderConfig,
auto_memory_enabled: bool,
/// 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.
@@ -837,6 +841,7 @@ fn build_runtime_config(
rules_import: parse_optional_rules_import(&merged_value)?,
provider: parse_optional_provider_config(&merged_value)?,
auto_memory_enabled: parse_auto_memory_enabled(&merged_value),
subagent_model: parse_optional_subagent_model(&merged_value),
};
Ok(RuntimeConfig {
@@ -919,6 +924,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
@@ -1761,6 +1773,21 @@ 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());
@@ -2632,6 +2659,49 @@ fn deep_merge_objects(
}
}
/// Read the provider config saved by `/setup` and inject its credentials
/// into the environment so `ProviderClient::from_model()` can find them via
/// the env-var-based provider dispatch. Only sets vars that aren't already
/// present (preserves explicit env). Idempotent.
///
/// Called by the main CLI at startup and by sub-agent threads in the tools
/// crate so that custom/ exotic providers work for spawned agents too.
pub fn inject_config_as_env_fallbacks() {
let cwd = std::env::current_dir().unwrap_or_default();
let Ok(config) = ConfigLoader::default_for(&cwd).load() else {
return;
};
let provider = config.provider();
// Map provider kind to the expected env var names
let (api_key_env, base_url_env) = match provider.kind().unwrap_or("anthropic") {
"anthropic" => ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"),
"xai" => ("XAI_API_KEY", "XAI_BASE_URL"),
"openai" => ("OPENAI_API_KEY", "OPENAI_BASE_URL"),
"dashscope" => ("DASHSCOPE_API_KEY", "DASHSCOPE_BASE_URL"),
"custom-openai" => ("CLAWCUSTOMOPENAI_API_KEY", "CLAWCUSTOMOPENAI_BASE_URL"),
_ => return, // unknown provider kind — don't inject
};
// Only set env vars that aren't already set (preserve user's explicit env)
if let Some(api_key) = provider.api_key() {
if std::env::var(api_key_env).is_err() {
std::env::set_var(api_key_env, api_key);
}
}
if let Some(base_url) = provider.base_url() {
if std::env::var(base_url_env).is_err() {
std::env::set_var(base_url_env, base_url);
}
}
// Also inject the saved model so resolve_model_alias sees it
if let Some(model) = provider.model() {
if std::env::var("CLAWD_PROVIDER_MODEL").is_err() {
std::env::set_var("CLAWD_PROVIDER_MODEL", model);
}
}
}
#[cfg(test)]
mod tests {
use super::{
+203 -47
View File
@@ -61,6 +61,40 @@ pub trait ApiClient {
/// Trait implemented by tool dispatchers that execute model-requested tools.
pub trait ToolExecutor {
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError>;
/// Execute a batch of tool calls, potentially in parallel.
/// Returns results in the same order as the input calls.
/// The default implementation executes sequentially via `execute`.
/// Override this to provide parallel execution for read-only tools.
fn execute_batch(&mut self, calls: Vec<ToolCall>) -> Vec<ToolResult> {
calls
.into_iter()
.map(|call| {
let result = self.execute(&call.tool_name, &call.input);
ToolResult {
tool_use_id: call.tool_use_id,
tool_name: call.tool_name,
result,
}
})
.collect()
}
}
/// A single tool call to execute.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolCall {
pub tool_use_id: String,
pub tool_name: String,
pub input: String,
}
/// The result of executing a tool call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolResult {
pub tool_use_id: String,
pub tool_name: String,
pub result: Result<String, ToolError>,
}
/// Error returned when a tool invocation fails locally.
@@ -86,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 {
@@ -140,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>
@@ -194,6 +245,7 @@ where
hook_abort_signal: HookAbortSignal::default(),
hook_progress_reporter: None,
session_tracer: None,
turn_progress_reporter: None,
}
}
@@ -237,6 +289,11 @@ 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(
@@ -420,11 +477,24 @@ where
break;
}
for (tool_use_id, tool_name, input) in pending_tool_uses {
let pre_hook_result = self.run_pre_tool_use_hook(&tool_name, &input);
// Phase 1: Pre-hooks and permission checks (sequential).
// Hooks may mutate state and must run in order.
struct PendingTool {
tool_use_id: String,
tool_name: String,
effective_input: String,
pre_hook_messages: Vec<String>,
allowed: bool,
deny_reason: Option<String>,
}
let mut pending = Vec::with_capacity(pending_tool_uses.len());
for (tool_use_id, tool_name, input) in &pending_tool_uses {
let pre_hook_result = self.run_pre_tool_use_hook(tool_name, input);
let effective_input = pre_hook_result
.updated_input()
.map_or_else(|| input.clone(), ToOwned::to_owned);
let pre_hook_messages = pre_hook_result.messages().to_vec();
let permission_context = PermissionContext::new(
pre_hook_result.permission_override(),
pre_hook_result.permission_reason().map(ToOwned::to_owned),
@@ -453,71 +523,157 @@ where
}
} else if let Some(prompt) = prompter.as_mut() {
self.permission_policy.authorize_with_context(
&tool_name,
tool_name,
&effective_input,
&permission_context,
Some(*prompt),
)
} else {
self.permission_policy.authorize_with_context(
&tool_name,
tool_name,
&effective_input,
&permission_context,
None,
)
};
let result_message = match permission_outcome {
match permission_outcome {
PermissionOutcome::Allow => {
self.record_tool_started(iterations, &tool_name);
let (mut output, mut is_error) =
match self.tool_executor.execute(&tool_name, &effective_input) {
Ok(output) => (output, false),
Err(error) => (error.to_string(), true),
};
output = merge_hook_feedback(pre_hook_result.messages(), output, false);
let post_hook_result = if is_error {
self.run_post_tool_use_failure_hook(
&tool_name,
&effective_input,
&output,
)
} else {
self.run_post_tool_use_hook(
&tool_name,
&effective_input,
&output,
false,
)
};
if post_hook_result.is_denied()
|| post_hook_result.is_failed()
|| post_hook_result.is_cancelled()
{
is_error = true;
}
output = merge_hook_feedback(
post_hook_result.messages(),
output,
post_hook_result.is_denied()
|| post_hook_result.is_failed()
|| post_hook_result.is_cancelled(),
);
ConversationMessage::tool_result(tool_use_id, tool_name, output, is_error)
pending.push(PendingTool {
tool_use_id: tool_use_id.clone(),
tool_name: tool_name.clone(),
effective_input,
pre_hook_messages,
allowed: true,
deny_reason: None,
});
}
PermissionOutcome::Deny { reason } => ConversationMessage::tool_result(
tool_use_id,
tool_name,
merge_hook_feedback(pre_hook_result.messages(), reason, true),
PermissionOutcome::Deny { reason } => {
pending.push(PendingTool {
tool_use_id: tool_use_id.clone(),
tool_name: tool_name.clone(),
effective_input: String::new(),
pre_hook_messages,
allowed: false,
deny_reason: Some(reason),
});
}
}
}
// Phase 2: Execute allowed tools (batch, may run in parallel).
let allowed_calls: Vec<ToolCall> = pending
.iter()
.filter(|p| p.allowed)
.map(|p| {
self.record_tool_started(iterations, &p.tool_name);
ToolCall {
tool_use_id: p.tool_use_id.clone(),
tool_name: p.tool_name.clone(),
input: p.effective_input.clone(),
}
})
.collect();
let batch_results = self.tool_executor.execute_batch(allowed_calls);
let mut batch_index = 0;
// 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 batch_result = &batch_results[batch_index];
batch_index += 1;
let (mut output, mut is_error) = match &batch_result.result {
Ok(output) => (output.clone(), false),
Err(error) => (error.to_string(), true),
};
output = merge_hook_feedback(&p.pre_hook_messages, output, false);
let post_hook_result = if is_error {
self.run_post_tool_use_failure_hook(
&p.tool_name,
&p.effective_input,
&output,
)
} else {
self.run_post_tool_use_hook(
&p.tool_name,
&p.effective_input,
&output,
false,
)
};
if post_hook_result.is_denied()
|| post_hook_result.is_failed()
|| post_hook_result.is_cancelled()
{
is_error = true;
}
output = merge_hook_feedback(
post_hook_result.messages(),
output,
post_hook_result.is_denied()
|| post_hook_result.is_failed()
|| post_hook_result.is_cancelled(),
);
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 {
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,
)
};
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);
}
}
+12 -11
View File
@@ -67,15 +67,16 @@ pub use compact::{
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
};
pub use config::{
clear_user_provider_settings, default_config_home, save_user_provider_settings,
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig,
McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
clear_user_provider_settings, default_config_home, inject_config_as_env_fallbacks,
save_user_provider_settings, suppress_config_warnings_for_json_mode, ApiTimeoutConfig,
ConfigEntry, ConfigError, ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader,
ConfigSource, McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig,
McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig,
McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
ProviderFallbackConfig, ResolvedPermissionMode, RulesImportConfig, RuntimeConfig,
RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, RuntimeInvalidHookConfig,
RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig, ScopedMcpServerConfig,
CLAW_SETTINGS_SCHEMA_NAME,
};
pub use config_validate::{
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
@@ -83,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,