mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-30 00:00:22 +08:00
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:
1 parent
76fe2e9f46
commit
7aa00a2798
15 files changed
+3021
-552
No files matched your search
@@ -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::{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user