Skip to content

Commit 42002e5

Browse files
feat(team): team enhancements, subagentModel, parallel tool exec
Team coordination layer, sub-agent model wiring, parallel read-only tool execution, and model-resolution robustness fixes. ~2,100 lines across 11 files — zero deletions of upstream functionality. subagentModel config wiring (fix): - RuntimeFeatureConfig.subagent_model field + accessor + parser so the subagentModel setting from /setup is actually read and stored instead of silently dropped. - resolve_agent_model: subagentModel -> session provider model -> default. Parallel tool execution (perf): - ToolCall/ToolResult/TurnProgressReporter re-exported from runtime. - CliToolExecutor::execute_batch runs read-only tools (read_file, glob, grep, WebFetch/WebSearch, LSP, Git*, ToolSearch, Skill, Agent*, TeamStatus, Task*) concurrently via std::thread::scope. - Results in original model order; hooks stay sequential. Team coordination layer (feature): - AgentMessage, TaskClaim, TeamStatus tools + shared mailbox + mode presets (tiny/1x ... mega/6x) + enriched tool descriptions + team watcher. - /team slash command: on/off/status of CLAWD_AGENT_TEAMS. Model-resolution robustness fixes: - qualify_for_provider(): prefix bare models with custom/ when provider is custom-openai so sub-agents route through the same endpoint. - providerFallbacks.primary no longer silently overrides caller's model. - 404 model-not-found falls through to next chain entry. - Sub-agents call runtime::inject_config_as_env_fallbacks() so /setup credentials work for spawned agents. Also: - lane_completion test helper: remove stale team_id/task_id (compile fix). - /team slash command: add missing validate_slash_command_input match arm. - ApiError: add status_code() and response_body() accessors. Tests: tools 112 passed, commands 42 passed, runtime team_cron 17 passed, cli 205 passed, provider chain 4 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d229a9b commit 42002e5

11 files changed

Lines changed: 2095 additions & 113 deletions

File tree

rust/crates/api/src/error.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,49 @@ impl ApiError {
180180
}
181181
}
182182

183+
/// HTTP status code for an `Api` error, if any. Used by callers (e.g. the
184+
/// sub-agent provider chain) to decide whether to advance to the next
185+
/// configured model — a 404 "model not found" on the primary should fall
186+
/// through to the next fallback rather than killing the whole chain.
187+
#[must_use]
188+
pub fn status_code(&self) -> Option<reqwest::StatusCode> {
189+
match self {
190+
Self::Api { status, .. } => Some(*status),
191+
Self::RetriesExhausted { last_error, .. } => last_error.status_code(),
192+
Self::MissingCredentials { .. }
193+
| Self::ContextWindowExceeded { .. }
194+
| Self::ExpiredOAuthToken
195+
| Self::Auth(_)
196+
| Self::InvalidApiKeyEnv(_)
197+
| Self::Http(_)
198+
| Self::Io(_)
199+
| Self::Json { .. }
200+
| Self::InvalidSseFrame(_)
201+
| Self::BackoffOverflow { .. }
202+
| Self::RequestBodySizeExceeded { .. } => None,
203+
}
204+
}
205+
206+
/// Response body (best-effort) for an `Api` error, if any.
207+
#[must_use]
208+
pub fn response_body(&self) -> Option<&str> {
209+
match self {
210+
Self::Api { body, .. } => Some(body.as_str()),
211+
Self::RetriesExhausted { last_error, .. } => last_error.response_body(),
212+
Self::MissingCredentials { .. }
213+
| Self::ContextWindowExceeded { .. }
214+
| Self::ExpiredOAuthToken
215+
| Self::Auth(_)
216+
| Self::InvalidApiKeyEnv(_)
217+
| Self::Http(_)
218+
| Self::Io(_)
219+
| Self::Json { .. }
220+
| Self::InvalidSseFrame(_)
221+
| Self::BackoffOverflow { .. }
222+
| Self::RequestBodySizeExceeded { .. } => None,
223+
}
224+
}
225+
183226
#[must_use]
184227
pub fn safe_failure_class(&self) -> &'static str {
185228
match self {

rust/crates/api/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ pub use providers::openai_compat::{
2828
};
2929
pub use providers::{
3030
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
31-
model_family_identity_for, model_family_identity_for_kind, provider_diagnostics_for_model,
32-
resolve_model_alias, ProviderDiagnostics, ProviderKind,
31+
model_family_identity_for, model_family_identity_for_kind, model_token_limit,
32+
provider_diagnostics_for_model, resolve_model_alias, ModelTokenLimit, ProviderDiagnostics,
33+
ProviderKind,
3334
};
3435
pub use sse::{parse_frame, SseParser};
3536
pub use types::{

rust/crates/api/src/providers/mod.rs

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,14 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
270270
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
271271
});
272272
}
273+
if canonical.starts_with("custom/") {
274+
return Some(ProviderMetadata {
275+
provider: ProviderKind::OpenAi,
276+
auth_env: "CLAWCUSTOMOPENAI_API_KEY",
277+
base_url_env: "CLAWCUSTOMOPENAI_BASE_URL",
278+
default_base_url: openai_compat::DEFAULT_CUSTOM_OPENAI_BASE_URL,
279+
});
280+
}
273281
// Alibaba DashScope compatible-mode endpoint. Routes qwen/* and bare
274282
// qwen-* model names (qwen-max, qwen-plus, qwen-turbo, qwen-qwq, etc.)
275283
// to the OpenAI-compat client pointed at DashScope's /compatible-mode/v1.
@@ -377,6 +385,11 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
377385
{
378386
return ProviderKind::OpenAi;
379387
}
388+
// Explicit `custom/` prefix selects the Claw custom OpenAI-compat provider
389+
// even when no other credentials are present.
390+
if resolved_model.starts_with("custom/") {
391+
return ProviderKind::OpenAi;
392+
}
380393
if anthropic::has_auth_from_env_or_saved().unwrap_or(false) {
381394
return ProviderKind::Anthropic;
382395
}
@@ -666,15 +679,22 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
666679
max_output_tokens: 16_384,
667680
context_window_tokens: 256_000,
668681
}),
669-
"qwen-max" => Some(ModelTokenLimit {
670-
max_output_tokens: 8_192,
682+
// Qwen models via DashScope / OpenAI-compat
683+
"qwen3.6-35b-fast" | "qwen3-235b-a22b" | "qwen-max" | "qwen-plus" | "qwen-turbo"
684+
| "qwen-qwq" => Some(ModelTokenLimit {
685+
max_output_tokens: 16_384,
671686
context_window_tokens: 131_072,
672687
}),
673-
"qwen-plus" => Some(ModelTokenLimit {
688+
"glm-5.1-fast" => Some(ModelTokenLimit {
689+
max_output_tokens: 16_384,
690+
context_window_tokens: 200_000,
691+
}),
692+
// Generic fallback for any model: assume 128K context, 8K output.
693+
// This prevents the "unknown model → no limit check → context overflow" bug.
694+
_ => Some(ModelTokenLimit {
674695
max_output_tokens: 8_192,
675696
context_window_tokens: 131_072,
676697
}),
677-
_ => None,
678698
}
679699
}
680700

@@ -1138,6 +1158,21 @@ mod tests {
11381158
assert_eq!(super::resolve_model_alias("KIMI"), "kimi-k2.5"); // case insensitive
11391159
}
11401160

1161+
#[test]
1162+
fn custom_prefix_routes_to_custom_openai_env_vars() {
1163+
let meta = super::metadata_for_model("custom/openclaw_3750")
1164+
.expect("custom/ prefix must resolve to custom OpenAI metadata");
1165+
assert_eq!(meta.provider, ProviderKind::OpenAi);
1166+
assert_eq!(meta.auth_env, "CLAWCUSTOMOPENAI_API_KEY");
1167+
assert_eq!(meta.base_url_env, "CLAWCUSTOMOPENAI_BASE_URL");
1168+
1169+
assert_eq!(
1170+
detect_provider_kind("custom/openclaw_3750"),
1171+
ProviderKind::OpenAi,
1172+
"custom/ prefix must select OpenAi provider kind"
1173+
);
1174+
}
1175+
11411176
#[test]
11421177
fn provider_diagnostics_explain_openai_compatible_capabilities() {
11431178
let diagnostics = super::provider_diagnostics_for_model("openai/deepseek-v4-pro");

rust/crates/api/src/providers/openai_compat.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use super::{preflight_message_request, resolve_model_alias, Provider, ProviderFu
2121
pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
2222
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
2323
pub const DEFAULT_DASHSCOPE_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
24+
pub const DEFAULT_CUSTOM_OPENAI_BASE_URL: &str = "";
2425
const REQUEST_ID_HEADER: &str = "request-id";
2526
const ALT_REQUEST_ID_HEADER: &str = "x-request-id";
2627
const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_secs(1);

rust/crates/commands/src/lib.rs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
6565
argument_hint: None,
6666
resume_supported: true,
6767
},
68+
SlashCommandSpec {
69+
name: "tui",
70+
aliases: &[],
71+
summary: "Switch to the split-pane TUI dashboard",
72+
argument_hint: None,
73+
resume_supported: true,
74+
},
6875
SlashCommandSpec {
6976
name: "status",
7077
aliases: &[],
@@ -808,7 +815,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
808815
name: "team",
809816
aliases: &[],
810817
summary: "Manage agent teams",
811-
argument_hint: Some("[list|create|delete]"),
818+
argument_hint: Some("[on|off|status]"),
812819
resume_supported: true,
813820
},
814821
SlashCommandSpec {
@@ -1188,10 +1195,14 @@ pub enum SlashCommand {
11881195
History {
11891196
count: Option<String>,
11901197
},
1191-
Unknown(String),
1198+
Lsp {
1199+
action: Option<String>,
1200+
target: Option<String>,
1201+
},
11921202
Team {
11931203
action: Option<String>,
11941204
},
1205+
Unknown(String),
11951206
}
11961207

11971208
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -1509,6 +1520,9 @@ pub fn validate_slash_command_input(
15091520
"history" => SlashCommand::History {
15101521
count: optional_single_arg(command, &args, "[count]")?,
15111522
},
1523+
"team" => SlashCommand::Team {
1524+
action: optional_single_arg(command, &args, "[list|create|delete]")?,
1525+
},
15121526
other => SlashCommand::Unknown(other.to_string()),
15131527
}))
15141528
}
@@ -5393,6 +5407,7 @@ pub fn handle_slash_command(
53935407
| SlashCommand::OutputStyle { .. }
53945408
| SlashCommand::AddDir { .. }
53955409
| SlashCommand::History { .. }
5410+
| SlashCommand::Lsp { .. }
53965411
| SlashCommand::Team { .. }
53975412
| SlashCommand::Setup
53985413
| SlashCommand::Unknown(_) => None,
@@ -5525,6 +5540,16 @@ mod tests {
55255540
#[test]
55265541
fn parses_supported_slash_commands() {
55275542
assert_eq!(SlashCommand::parse("/help"), Ok(Some(SlashCommand::Help)));
5543+
assert_eq!(
5544+
SlashCommand::parse("/team"),
5545+
Ok(Some(SlashCommand::Team { action: None }))
5546+
);
5547+
assert_eq!(
5548+
SlashCommand::parse("/team on"),
5549+
Ok(Some(SlashCommand::Team {
5550+
action: Some("on".to_string())
5551+
}))
5552+
);
55285553
assert_eq!(
55295554
SlashCommand::parse(" /status "),
55305555
Ok(Some(SlashCommand::Status))
@@ -6012,7 +6037,7 @@ mod tests {
60126037
assert!(!help.contains("/login"));
60136038
assert!(!help.contains("/logout"));
60146039
assert!(help.contains("/setup"));
6015-
assert_eq!(slash_command_specs().len(), 140);
6040+
assert_eq!(slash_command_specs().len(), 141);
60166041
assert!(resume_supported_slash_commands().len() >= 39);
60176042
}
60186043

rust/crates/runtime/src/config.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,10 @@ pub struct RuntimeFeatureConfig {
163163
api_timeout: ApiTimeoutConfig,
164164
rules_import: RulesImportConfig,
165165
provider: RuntimeProviderConfig,
166+
/// Model override used when spawning sub-agents via the Agent tool.
167+
/// Read from `subagentModel` (or `subagent_model`) in settings; falls
168+
/// back to the default model when unset.
169+
subagent_model: Option<String>,
166170
}
167171

168172
/// Controls which external AI coding framework rules are imported into the system prompt.
@@ -801,6 +805,7 @@ fn build_runtime_config(
801805
api_timeout: parse_optional_api_timeout_config(&merged_value)?,
802806
rules_import: parse_optional_rules_import(&merged_value)?,
803807
provider: parse_optional_provider_config(&merged_value)?,
808+
subagent_model: parse_optional_subagent_model(&merged_value),
804809
};
805810

806811
Ok(RuntimeConfig {
@@ -880,6 +885,13 @@ impl RuntimeConfig {
880885
self.feature_config.model.as_deref()
881886
}
882887

888+
/// Model override used when spawning sub-agents via the Agent tool.
889+
/// Read from `subagentModel` in settings; `None` means "use default model".
890+
#[must_use]
891+
pub fn subagent_model(&self) -> Option<&str> {
892+
self.feature_config.subagent_model.as_deref()
893+
}
894+
883895
#[must_use]
884896
pub fn aliases(&self) -> &BTreeMap<String, String> {
885897
&self.feature_config.aliases
@@ -1717,6 +1729,21 @@ fn parse_optional_model(root: &JsonValue) -> Option<String> {
17171729
.map(ToOwned::to_owned)
17181730
}
17191731

1732+
/// Reads `subagentModel` (or the snake_case `subagent_model` alias) from
1733+
/// merged settings. Returns `None` when absent or blank so the Agent tool
1734+
/// falls back to the default model.
1735+
fn parse_optional_subagent_model(root: &JsonValue) -> Option<String> {
1736+
root.as_object()
1737+
.and_then(|object| {
1738+
object
1739+
.get("subagentModel")
1740+
.or_else(|| object.get("subagent_model"))
1741+
})
1742+
.and_then(JsonValue::as_str)
1743+
.filter(|s| !s.trim().is_empty())
1744+
.map(|s| s.trim().to_string())
1745+
}
1746+
17201747
fn parse_optional_aliases(root: &JsonValue) -> Result<BTreeMap<String, String>, ConfigError> {
17211748
let Some(object) = root.as_object() else {
17221749
return Ok(BTreeMap::new());
@@ -2581,6 +2608,49 @@ fn deep_merge_objects(
25812608
}
25822609
}
25832610

2611+
/// Read the provider config saved by `/setup` and inject its credentials
2612+
/// into the environment so `ProviderClient::from_model()` can find them via
2613+
/// the env-var-based provider dispatch. Only sets vars that aren't already
2614+
/// present (preserves explicit env). Idempotent.
2615+
///
2616+
/// Called by the main CLI at startup and by sub-agent threads in the tools
2617+
/// crate so that custom/ exotic providers work for spawned agents too.
2618+
pub fn inject_config_as_env_fallbacks() {
2619+
let cwd = std::env::current_dir().unwrap_or_default();
2620+
let Ok(config) = ConfigLoader::default_for(&cwd).load() else {
2621+
return;
2622+
};
2623+
let provider = config.provider();
2624+
2625+
// Map provider kind to the expected env var names
2626+
let (api_key_env, base_url_env) = match provider.kind().unwrap_or("anthropic") {
2627+
"anthropic" => ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"),
2628+
"xai" => ("XAI_API_KEY", "XAI_BASE_URL"),
2629+
"openai" => ("OPENAI_API_KEY", "OPENAI_BASE_URL"),
2630+
"dashscope" => ("DASHSCOPE_API_KEY", "DASHSCOPE_BASE_URL"),
2631+
"custom-openai" => ("CLAWCUSTOMOPENAI_API_KEY", "CLAWCUSTOMOPENAI_BASE_URL"),
2632+
_ => return, // unknown provider kind — don't inject
2633+
};
2634+
2635+
// Only set env vars that aren't already set (preserve user's explicit env)
2636+
if let Some(api_key) = provider.api_key() {
2637+
if std::env::var(api_key_env).is_err() {
2638+
std::env::set_var(api_key_env, api_key);
2639+
}
2640+
}
2641+
if let Some(base_url) = provider.base_url() {
2642+
if std::env::var(base_url_env).is_err() {
2643+
std::env::set_var(base_url_env, base_url);
2644+
}
2645+
}
2646+
// Also inject the saved model so resolve_model_alias sees it
2647+
if let Some(model) = provider.model() {
2648+
if std::env::var("CLAWD_PROVIDER_MODEL").is_err() {
2649+
std::env::set_var("CLAWD_PROVIDER_MODEL", model);
2650+
}
2651+
}
2652+
}
2653+
25842654
#[cfg(test)]
25852655
mod tests {
25862656
use super::{

0 commit comments

Comments
 (0)