From d500c6567826488a700a880f06d3bb52f0d46cc9 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Tue, 7 Jul 2026 00:09:08 -0500 Subject: [PATCH] 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 --- rust/Cargo.lock | 288 ++- rust/Cargo.toml | 4 +- rust/crates/api/src/client.rs | 3 + rust/crates/api/src/error.rs | 43 + rust/crates/api/src/lib.rs | 5 +- rust/crates/api/src/providers/mod.rs | 43 +- .../crates/api/src/providers/openai_compat.rs | 46 +- rust/crates/commands/src/lib.rs | 31 +- rust/crates/runtime/src/config.rs | 70 + rust/crates/runtime/src/conversation.rs | 250 ++- rust/crates/runtime/src/lib.rs | 23 +- rust/crates/rusty-claude-cli/Cargo.toml | 18 +- rust/crates/rusty-claude-cli/src/input.rs | 38 +- rust/crates/rusty-claude-cli/src/main.rs | 645 +++++- .../rusty-claude-cli/src/setup_wizard.rs | 12 +- rust/crates/tools/MULTI_TOOL_README.md | 118 ++ rust/crates/tools/src/lane_completion.rs | 2 + rust/crates/tools/src/lib.rs | 1736 ++++++++++++++++- 18 files changed, 3160 insertions(+), 215 deletions(-) mode change 100755 => 100644 rust/Cargo.lock create mode 100644 rust/crates/tools/MULTI_TOOL_README.md diff --git a/rust/Cargo.lock b/rust/Cargo.lock old mode 100755 new mode 100644 index 8f1a171dca..3b988b73b3 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -29,6 +29,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anes" version = "0.1.6" @@ -118,15 +124,15 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aspect-core" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70188b9bf884266a6c7117e30af44f38229bc5ac56916bd16512b3e49f90fe20" +checksum = "4e94aecae4a10eea63983b608beb48998578925d0660796140d69d8e8276f215" [[package]] name = "aspect-macros" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "176e7db9b6a7bb4f117b8d97054d2d5a7bdc43b95c19c15c751fb8dcb9bc8a5c" +checksum = "5d56334ff035ccdeed6b6c159b9089ae1baba29f93eb6fa8bd89312e9fa3f136" dependencies = [ "aspect-core", "proc-macro2", @@ -136,9 +142,9 @@ dependencies = [ [[package]] name = "aspect-std" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba7d130884fda30ec0acabcadc8f4711267d1cc21edc8f85283e91a011d699fa" +checksum = "059c997171f851dcd36d5eb14cf9ec831884ecafa2b753629379ff4b71971008" dependencies = [ "aspect-core", "log", @@ -355,12 +361,27 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.58" @@ -521,6 +542,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compat-harness" version = "0.1.3" @@ -591,7 +626,7 @@ dependencies = [ "clap", "criterion-plot", "is-terminal", - "itertools", + "itertools 0.10.5", "num-traits", "once_cell", "oorandom", @@ -612,7 +647,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ "cast", - "itertools", + "itertools 0.10.5", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", ] [[package]] @@ -687,8 +731,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -705,13 +759,37 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] @@ -740,7 +818,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn", @@ -846,6 +924,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -868,6 +957,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -965,6 +1060,16 @@ dependencies = [ "slab", ] +[[package]] +name = "gag" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a713bee13966e9fbffdf7193af71d54a6b35a0bb34997cd6c9519ebeb5005972" +dependencies = [ + "filedescriptor", + "tempfile", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -981,7 +1086,7 @@ version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "unicode-width", + "unicode-width 0.2.0", ] [[package]] @@ -1075,6 +1180,17 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1375,6 +1491,28 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1417,6 +1555,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1491,6 +1638,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1657,6 +1813,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1794,7 +1956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools", + "itertools 0.13.0", "proc-macro2", "quote", "syn", @@ -1998,6 +2160,27 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "rayon" version = "1.12.0" @@ -2192,9 +2375,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -2223,9 +2406,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", @@ -2244,11 +2427,16 @@ version = "0.1.3" dependencies = [ "api", "commands", + "crossbeam-channel", "crossterm", + "gag", + "libc", "log", "mock-anthropic-service", + "once_cell", "plugins", "pulldown-cmark", + "ratatui", "runtime", "rustyline", "serde", @@ -2256,6 +2444,8 @@ dependencies = [ "syntect", "tokio", "tools", + "tui-textarea", + "unicode-width 0.2.0", ] [[package]] @@ -2275,7 +2465,7 @@ dependencies = [ "nix", "radix_trie", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.0", "utf8parse", "windows-sys 0.59.0", ] @@ -2506,12 +2696,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2956,6 +3174,17 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-textarea" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" +dependencies = [ + "crossterm", + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "typenum" version = "1.19.0" @@ -2980,11 +3209,28 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "untrusted" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4ca7b8d81b..113bedd37b 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -12,7 +12,9 @@ publish = false serde_json = "1" [workspace.lints.rust] -unsafe_code = "forbid" +# rusty-claude-cli needs unsafe for TUI stdout suppression (dup/dup2), +# all other crates should avoid it — override per-crate if needed. +unsafe_code = "warn" [workspace.lints.clippy] all = { level = "warn", priority = -1 } diff --git a/rust/crates/api/src/client.rs b/rust/crates/api/src/client.rs index 240559e809..83de10f8d9 100644 --- a/rust/crates/api/src/client.rs +++ b/rust/crates/api/src/client.rs @@ -47,6 +47,9 @@ impl ProviderClient { Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => { OpenAiCompatConfig::dashscope() } + Some(meta) if meta.auth_env == "CLAWCUSTOMOPENAI_API_KEY" => { + OpenAiCompatConfig::custom_openai() + } _ => OpenAiCompatConfig::openai(), }; Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?)) diff --git a/rust/crates/api/src/error.rs b/rust/crates/api/src/error.rs index e8ec73a4c5..20e0654745 100644 --- a/rust/crates/api/src/error.rs +++ b/rust/crates/api/src/error.rs @@ -180,6 +180,49 @@ impl ApiError { } } + /// HTTP status code for an `Api` error, if any. Used by callers (e.g. the + /// sub-agent provider chain) to decide whether to advance to the next + /// configured model — a 404 "model not found" on the primary should fall + /// through to the next fallback rather than killing the whole chain. + #[must_use] + pub fn status_code(&self) -> Option { + match self { + Self::Api { status, .. } => Some(*status), + Self::RetriesExhausted { last_error, .. } => last_error.status_code(), + Self::MissingCredentials { .. } + | Self::ContextWindowExceeded { .. } + | Self::ExpiredOAuthToken + | Self::Auth(_) + | Self::InvalidApiKeyEnv(_) + | Self::Http(_) + | Self::Io(_) + | Self::Json { .. } + | Self::InvalidSseFrame(_) + | Self::BackoffOverflow { .. } + | Self::RequestBodySizeExceeded { .. } => None, + } + } + + /// Response body (best-effort) for an `Api` error, if any. + #[must_use] + pub fn response_body(&self) -> Option<&str> { + match self { + Self::Api { body, .. } => Some(body.as_str()), + Self::RetriesExhausted { last_error, .. } => last_error.response_body(), + Self::MissingCredentials { .. } + | Self::ContextWindowExceeded { .. } + | Self::ExpiredOAuthToken + | Self::Auth(_) + | Self::InvalidApiKeyEnv(_) + | Self::Http(_) + | Self::Io(_) + | Self::Json { .. } + | Self::InvalidSseFrame(_) + | Self::BackoffOverflow { .. } + | Self::RequestBodySizeExceeded { .. } => None, + } + } + #[must_use] pub fn safe_failure_class(&self) -> &'static str { match self { diff --git a/rust/crates/api/src/lib.rs b/rust/crates/api/src/lib.rs index e96e92f830..d15731e22d 100644 --- a/rust/crates/api/src/lib.rs +++ b/rust/crates/api/src/lib.rs @@ -28,8 +28,9 @@ 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, provider_diagnostics_for_model, - resolve_model_alias, ProviderDiagnostics, ProviderKind, + model_family_identity_for, model_family_identity_for_kind, model_token_limit, + provider_diagnostics_for_model, resolve_model_alias, ModelTokenLimit, ProviderDiagnostics, + ProviderKind, }; pub use sse::{parse_frame, SseParser}; pub use types::{ diff --git a/rust/crates/api/src/providers/mod.rs b/rust/crates/api/src/providers/mod.rs index 2524e5520a..8f846cbefa 100644 --- a/rust/crates/api/src/providers/mod.rs +++ b/rust/crates/api/src/providers/mod.rs @@ -270,6 +270,14 @@ pub fn metadata_for_model(model: &str) -> Option { default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL, }); } + if canonical.starts_with("custom/") { + return Some(ProviderMetadata { + provider: ProviderKind::OpenAi, + auth_env: "CLAWCUSTOMOPENAI_API_KEY", + base_url_env: "CLAWCUSTOMOPENAI_BASE_URL", + default_base_url: openai_compat::DEFAULT_CUSTOM_OPENAI_BASE_URL, + }); + } // Alibaba DashScope compatible-mode endpoint. Routes qwen/* and bare // qwen-* model names (qwen-max, qwen-plus, qwen-turbo, qwen-qwq, etc.) // to the OpenAI-compat client pointed at DashScope's /compatible-mode/v1. @@ -377,6 +385,11 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind { { return ProviderKind::OpenAi; } + // Explicit `custom/` prefix selects the Claw custom OpenAI-compat provider + // even when no other credentials are present. + if resolved_model.starts_with("custom/") { + return ProviderKind::OpenAi; + } if anthropic::has_auth_from_env_or_saved().unwrap_or(false) { return ProviderKind::Anthropic; } @@ -666,15 +679,22 @@ pub fn model_token_limit(model: &str) -> Option { max_output_tokens: 16_384, context_window_tokens: 256_000, }), - "qwen-max" => Some(ModelTokenLimit { - max_output_tokens: 8_192, + // Qwen models via DashScope / OpenAI-compat + "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, }), - "qwen-plus" => Some(ModelTokenLimit { + "glm-5.1-fast" => Some(ModelTokenLimit { + max_output_tokens: 16_384, + context_window_tokens: 200_000, + }), + // Generic fallback for any model: assume 128K context, 8K output. + // This prevents the "unknown model → no limit check → context overflow" bug. + _ => Some(ModelTokenLimit { max_output_tokens: 8_192, context_window_tokens: 131_072, }), - _ => None, } } @@ -1138,6 +1158,21 @@ mod tests { assert_eq!(super::resolve_model_alias("KIMI"), "kimi-k2.5"); // case insensitive } + #[test] + fn custom_prefix_routes_to_custom_openai_env_vars() { + let meta = super::metadata_for_model("custom/openclaw_3750") + .expect("custom/ prefix must resolve to custom OpenAI metadata"); + assert_eq!(meta.provider, ProviderKind::OpenAi); + assert_eq!(meta.auth_env, "CLAWCUSTOMOPENAI_API_KEY"); + assert_eq!(meta.base_url_env, "CLAWCUSTOMOPENAI_BASE_URL"); + + assert_eq!( + detect_provider_kind("custom/openclaw_3750"), + ProviderKind::OpenAi, + "custom/ prefix must select OpenAi provider kind" + ); + } + #[test] fn provider_diagnostics_explain_openai_compatible_capabilities() { let diagnostics = super::provider_diagnostics_for_model("openai/deepseek-v4-pro"); diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 8fb3969913..4fa01a5d27 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -21,6 +21,11 @@ use super::{preflight_message_request, resolve_model_alias, Provider, ProviderFu pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1"; pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; pub const DEFAULT_DASHSCOPE_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1"; +/// Default base URL for Claw's custom OpenAI-compatible provider. +/// Intentionally left empty: a custom endpoint must set +/// `CLAWCUSTOMOPENAI_BASE_URL`; otherwise requests will fail at URL build +/// time rather than leaking credentials to the real OpenAI endpoint. +pub const DEFAULT_CUSTOM_OPENAI_BASE_URL: &str = ""; const REQUEST_ID_HEADER: &str = "request-id"; const ALT_REQUEST_ID_HEADER: &str = "x-request-id"; const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_secs(1); @@ -43,6 +48,7 @@ pub struct OpenAiCompatConfig { const XAI_ENV_VARS: &[&str] = &["XAI_API_KEY"]; const OPENAI_ENV_VARS: &[&str] = &["OPENAI_API_KEY"]; const DASHSCOPE_ENV_VARS: &[&str] = &["DASHSCOPE_API_KEY"]; +const CUSTOM_OPENAI_ENV_VARS: &[&str] = &["CLAWCUSTOMOPENAI_API_KEY"]; // Provider-specific request body size limits in bytes const XAI_MAX_REQUEST_BODY_BYTES: usize = 52_428_800; // 50MB @@ -95,12 +101,27 @@ impl OpenAiCompatConfig { } } + /// Claw-specific custom OpenAI-compatible endpoint. + /// Reads `CLAWCUSTOMOPENAI_API_KEY` / `CLAWCUSTOMOPENAI_BASE_URL` so it + /// can coexist with real OpenAI/NeuralWatt `OPENAI_*` environment vars. + #[must_use] + pub const fn custom_openai() -> Self { + Self { + provider_name: "Custom OpenAI", + api_key_env: "CLAWCUSTOMOPENAI_API_KEY", + base_url_env: "CLAWCUSTOMOPENAI_BASE_URL", + default_base_url: DEFAULT_CUSTOM_OPENAI_BASE_URL, + max_request_body_bytes: OPENAI_MAX_REQUEST_BODY_BYTES, + } + } + #[must_use] pub fn credential_env_vars(self) -> &'static [&'static str] { match self.provider_name { "xAI" => XAI_ENV_VARS, "OpenAI" => OPENAI_ENV_VARS, "DashScope" => DASHSCOPE_ENV_VARS, + "Custom OpenAI" => CUSTOM_OPENAI_ENV_VARS, _ => &[], } } @@ -1066,7 +1087,7 @@ fn wire_model_for_base_url<'a>( if matches!(lowered_prefix.as_str(), "xai" | "grok" | "qwen" | "kimi") { return Cow::Borrowed(&model[pos + 1..]); } - if lowered_prefix == "local" { + if matches!(lowered_prefix.as_str(), "local" | "custom") { return Cow::Borrowed(&model[pos + 1..]); } @@ -2278,6 +2299,29 @@ mod tests { assert_eq!(parse_tool_arguments("not-json"), json!({"raw": "not-json"})); } + #[test] + fn custom_routing_prefix_strips_on_wire() { + let payload = build_chat_completion_request( + &MessageRequest { + model: "custom/openclaw_3750".to_string(), + max_tokens: 64, + messages: vec![InputMessage::user_text("hello")], + ..Default::default() + }, + OpenAiCompatConfig::custom_openai(), + ); + + assert_eq!(payload["model"], json!("openclaw_3750")); + } + + #[test] + fn custom_openai_config_uses_separate_env_vars() { + let config = OpenAiCompatConfig::custom_openai(); + assert_eq!(config.provider_name, "Custom OpenAI"); + assert_eq!(config.api_key_env, "CLAWCUSTOMOPENAI_API_KEY"); + assert_eq!(config.base_url_env, "CLAWCUSTOMOPENAI_BASE_URL"); + } + #[test] fn missing_xai_api_key_is_provider_specific() { let _lock = env_lock(); diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 7908691374..a679a45dd0 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -65,6 +65,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: None, resume_supported: true, }, + SlashCommandSpec { + name: "tui", + aliases: &[], + summary: "Switch to the split-pane TUI dashboard", + argument_hint: None, + resume_supported: true, + }, SlashCommandSpec { name: "status", aliases: &[], @@ -808,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 { @@ -1188,10 +1195,14 @@ pub enum SlashCommand { History { count: Option, }, - Unknown(String), + Lsp { + action: Option, + target: Option, + }, Team { action: Option, }, + Unknown(String), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1509,6 +1520,9 @@ pub fn validate_slash_command_input( "history" => SlashCommand::History { count: optional_single_arg(command, &args, "[count]")?, }, + "team" => SlashCommand::Team { + action: optional_single_arg(command, &args, "[list|create|delete]")?, + }, other => SlashCommand::Unknown(other.to_string()), })) } @@ -5393,6 +5407,7 @@ pub fn handle_slash_command( | SlashCommand::OutputStyle { .. } | SlashCommand::AddDir { .. } | SlashCommand::History { .. } + | SlashCommand::Lsp { .. } | SlashCommand::Team { .. } | SlashCommand::Setup | SlashCommand::Unknown(_) => None, @@ -5525,6 +5540,16 @@ mod tests { #[test] fn parses_supported_slash_commands() { assert_eq!(SlashCommand::parse("/help"), Ok(Some(SlashCommand::Help))); + assert_eq!( + SlashCommand::parse("/team"), + Ok(Some(SlashCommand::Team { action: None })) + ); + assert_eq!( + SlashCommand::parse("/team on"), + Ok(Some(SlashCommand::Team { + action: Some("on".to_string()) + })) + ); assert_eq!( SlashCommand::parse(" /status "), Ok(Some(SlashCommand::Status)) @@ -6012,7 +6037,7 @@ mod tests { assert!(!help.contains("/login")); assert!(!help.contains("/logout")); assert!(help.contains("/setup")); - assert_eq!(slash_command_specs().len(), 140); + assert_eq!(slash_command_specs().len(), 141); assert!(resume_supported_slash_commands().len() >= 39); } diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 9505756357..ae512ec479 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -163,6 +163,10 @@ pub struct RuntimeFeatureConfig { api_timeout: ApiTimeoutConfig, rules_import: RulesImportConfig, provider: RuntimeProviderConfig, + /// 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, } /// Controls which external AI coding framework rules are imported into the system prompt. @@ -801,6 +805,7 @@ fn build_runtime_config( api_timeout: parse_optional_api_timeout_config(&merged_value)?, rules_import: parse_optional_rules_import(&merged_value)?, provider: parse_optional_provider_config(&merged_value)?, + subagent_model: parse_optional_subagent_model(&merged_value), }; Ok(RuntimeConfig { @@ -880,6 +885,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 { &self.feature_config.aliases @@ -1717,6 +1729,21 @@ fn parse_optional_model(root: &JsonValue) -> Option { .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 { + 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, ConfigError> { let Some(object) = root.as_object() else { return Ok(BTreeMap::new()); @@ -2581,6 +2608,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::{ diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 9c36329a16..0ea0ee0adf 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -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; + + /// 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) -> Vec { + 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, } /// 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 { hook_abort_signal: HookAbortSignal, hook_progress_reporter: Option>, session_tracer: Option, + turn_progress_reporter: Option>, } impl ConversationRuntime @@ -189,6 +240,7 @@ where hook_abort_signal: HookAbortSignal::default(), hook_progress_reporter: None, session_tracer: None, + turn_progress_reporter: None, } } @@ -232,6 +284,11 @@ where self } + pub fn with_turn_progress_reporter(mut self, reporter: Box) -> 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( @@ -415,11 +472,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, + allowed: bool, + deny_reason: Option, + } + + 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), @@ -448,71 +518,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 = 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); } } diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 674d89251d..db77547669 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -65,15 +65,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, @@ -81,8 +82,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, diff --git a/rust/crates/rusty-claude-cli/Cargo.toml b/rust/crates/rusty-claude-cli/Cargo.toml index d044176011..e89a4cd821 100644 --- a/rust/crates/rusty-claude-cli/Cargo.toml +++ b/rust/crates/rusty-claude-cli/Cargo.toml @@ -13,8 +13,13 @@ path = "src/main.rs" api = { path = "../api" } commands = { path = "../commands" } crossterm = "0.28" +crossbeam-channel = "0.5" +gag = "1" pulldown-cmark = "0.13" +once_cell = "1" +ratatui = "0.29" rustyline = "15" +tui-textarea = "0.7" runtime = { path = "../runtime" } plugins = { path = "../plugins" } serde = { version = "1", features = ["derive"] } @@ -23,10 +28,19 @@ syntect = "5" tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] } tools = { path = "../tools" } log = "0.4" +libc = "0.2" +unicode-width = "0.2" -[lints] -workspace = true +[lints.rust] +unsafe_code = "allow" + +[lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "allow", priority = -1 } +module_name_repetitions = "allow" +missing_panics_doc = "allow" +missing_errors_doc = "allow" [dev-dependencies] mock-anthropic-service = { path = "../mock-anthropic-service" } diff --git a/rust/crates/rusty-claude-cli/src/input.rs b/rust/crates/rusty-claude-cli/src/input.rs index b0664dac44..cf69f39609 100644 --- a/rust/crates/rusty-claude-cli/src/input.rs +++ b/rust/crates/rusty-claude-cli/src/input.rs @@ -18,6 +18,8 @@ pub enum ReadOutcome { Submit(String), Cancel, Exit, + ProviderSwap, + TeamToggle, } struct SlashCommandHelper { @@ -86,12 +88,19 @@ impl Hinter for SlashCommandHelper { impl Highlighter for SlashCommandHelper { fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { self.set_current_line(line); - Cow::Borrowed(line) + // When sentinel is present, show visible prompt instead of invisible char + if line.contains('\x01') { + let display = line.replace('\x01', "\x1b[36m[Provider Swap]\x1b[0m "); + Cow::Owned(display) + } else { + Cow::Borrowed(line) + } } fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool { self.set_current_line(line); - false + // Re-highlight when sentinel is present to show the prompt + line.contains('\x01') } } @@ -115,6 +124,18 @@ impl LineEditor { editor.set_helper(Some(SlashCommandHelper::new(completions))); editor.bind_sequence(KeyEvent(KeyCode::Char('J'), Modifiers::CTRL), Cmd::Newline); editor.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::SHIFT), Cmd::Newline); + // Ctrl+P inserts a sentinel character that triggers provider swap. + // The sentinel is invisible but the highlighter shows "[Provider Swap]" prompt. + // User must press Enter to confirm (rustyline cannot chain commands). + editor.bind_sequence( + KeyEvent(KeyCode::Char('P'), Modifiers::CTRL), + Cmd::SelfInsert(1, '\x01'), + ); + // Ctrl+T inserts a sentinel character that toggles agent teams mode. + editor.bind_sequence( + KeyEvent(KeyCode::Char('T'), Modifiers::CTRL), + Cmd::SelfInsert(1, '\x02'), + ); Self { prompt: prompt.into(), @@ -147,7 +168,18 @@ impl LineEditor { } match self.editor.readline(&self.prompt) { - Ok(line) => Ok(ReadOutcome::Submit(line)), + Ok(line) => { + // Ctrl+P inserts \x01 sentinel — triggers provider swap wizard. + // The sentinel is stripped and we return ProviderSwap to the REPL loop. + if line.contains('\x01') { + return Ok(ReadOutcome::ProviderSwap); + } + // Ctrl+T inserts \x02 sentinel — toggles team mode. + if line.contains('\x02') { + return Ok(ReadOutcome::TeamToggle); + } + Ok(ReadOutcome::Submit(line)) + } Err(ReadlineError::Interrupted) => { let has_input = !self.current_line().is_empty(); self.finish_interrupted_read()?; diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 665ce632cf..bbfde86efe 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -14,8 +14,10 @@ clippy::unnecessary_wraps, clippy::unused_self )] + mod init; mod input; + mod render; mod setup_wizard; @@ -1003,6 +1005,13 @@ fn run() -> Result<(), Box> { } let (args, cwd) = split_global_cwd_args(&args)?; apply_global_cwd(cwd)?; + // Inject saved provider settings (from /setup wizard) as env var fallbacks. + // The API client constructors read env vars only, so we set them once at + // process startup before any runtime threads are spawned. Already-set env + // vars are preserved — resolution order remains: env var > .env file > + // stored config. This only runs in the real binary (run() is not called by + // unit tests) to avoid leaking user config into the test suite. + runtime::inject_config_as_env_fallbacks(); match parse_args(&args)? { CliAction::DumpManifests { output_format, @@ -2986,6 +2995,8 @@ fn is_local_openai_model_syntax(model: &str) -> bool { if let Some(rest) = model.strip_prefix("local/") { return !rest.is_empty() && rest.split('/').all(|segment| !segment.is_empty()); } + // Ollama-style tags (contain ':' or '.') are accepted when a custom + // OpenAI-compatible base URL is configured via OPENAI_BASE_URL. std::env::var_os("OPENAI_BASE_URL").is_some() && (model.contains(':') || model.contains('.')) } @@ -3111,7 +3122,25 @@ fn config_permission_mode_for_current_dir() -> Option { fn config_model_for_current_dir() -> Option { let cwd = env::current_dir().ok()?; let loader = ConfigLoader::default_for(&cwd); - loader.load().ok()?.model().map(ToOwned::to_owned) + let config = loader.load().ok()?; + let model = config.model()?; + + // If the user configured a custom OpenAI-compatible endpoint with a bare + // model name (e.g. "openclaw"), route it through the custom/ prefix so it + // passes validation, maps to the custom OpenAI-compat client, and gets + // stripped down to the bare model id on the wire (avoiding a proxy 404). + if is_custom_openai_provider(&config) && !model.contains('/') { + return Some(format!("custom/{model}")); + } + + Some(model.to_owned()) +} + +/// Check whether the loaded config uses the Claw custom OpenAI-compatible +/// provider, which has its own env var namespace so it can coexist with real +/// OpenAI/NeuralWatt credentials. +fn is_custom_openai_provider(config: &runtime::RuntimeConfig) -> bool { + config.provider().kind() == Some("custom-openai") } fn resolve_repl_model(cli_model: String) -> Result { @@ -6926,6 +6955,7 @@ fn run_resume_command( | SlashCommand::Tag { .. } | SlashCommand::OutputStyle { .. } | SlashCommand::AddDir { .. } + | SlashCommand::Lsp { .. } | SlashCommand::Team { .. } | SlashCommand::Setup => Err("unsupported resumed slash command".into()), } @@ -7062,7 +7092,6 @@ fn run_repl( input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); println!("{}", cli.startup_banner()); println!("{}", format_connected_line(&cli.model)); - loop { editor.set_completions(cli.repl_completion_candidates().unwrap_or_default()); match editor.read_line()? { @@ -7102,11 +7131,109 @@ fn run_repl( cli.record_prompt_history(&trimmed); cli.run_turn(&trimmed)?; } + input::ReadOutcome::TeamToggle => { + // Ctrl+T toggles agent teams mode + 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 (TeamCreate now available)"); + } + } input::ReadOutcome::Cancel => {} input::ReadOutcome::Exit => { cli.persist_session()?; break; } + input::ReadOutcome::ProviderSwap => { + let _ = setup_wizard::run_setup_wizard(); + 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)) + { + let _ = cli.set_model(Some(new_model)); + } + } + } + } + + Ok(()) +} + +/// Run the plain REPL starting from an existing LiveCli. +/// This is the same loop as `run_repl` but takes ownership +/// of a pre-existing `LiveCli` instead of creating one from CLI args. +fn run_repl_from_cli(mut cli: LiveCli) -> Result<(), Box> { + let mut editor = + input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default()); + println!(); + println!("{}", format_connected_line(&cli.model)); + loop { + editor.set_completions(cli.repl_completion_candidates().unwrap_or_default()); + match editor.read_line()? { + input::ReadOutcome::Submit(input) => { + let trimmed = input.trim().to_string(); + if trimmed.is_empty() { + continue; + } + if matches!(trimmed.as_str(), "/exit" | "/quit") { + cli.persist_session()?; + break; + } + match SlashCommand::parse(&trimmed) { + Ok(Some(command)) => { + if cli.handle_repl_command(command)? { + cli.persist_session()?; + } + continue; + } + Ok(None) => {} + Err(error) => { + eprintln!("{error}"); + continue; + } + } + let cwd = std::env::current_dir().unwrap_or_default(); + if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) { + editor.push_history(input); + cli.record_prompt_history(&trimmed); + cli.run_turn(&prompt)?; + continue; + } + editor.push_history(input); + cli.record_prompt_history(&trimmed); + cli.run_turn(&trimmed)?; + } + input::ReadOutcome::Cancel => {} + input::ReadOutcome::Exit => { + cli.persist_session()?; + break; + } + input::ReadOutcome::ProviderSwap => { + let _ = setup_wizard::run_setup_wizard(); + 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)) + { + 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"); + } + } } } @@ -7749,35 +7876,38 @@ impl LiveCli { } fn run_turn(&mut self, input: &str) -> Result<(), Box> { - let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?; + self.run_turn_to(input, &mut io::stdout(), true) + } + + /// Core turn execution with a custom output writer. + /// In the plain REPL, `out` is `io::stdout()`. + fn run_turn_to( + &mut self, + input: &str, + out: &mut W, + emit_output: bool, + ) -> Result<(), Box> { + let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(emit_output)?; let mut spinner = Spinner::new(); - let mut stdout = io::stdout(); - spinner.tick( - "🦀 Thinking...", - TerminalRenderer::new().color_theme(), - &mut stdout, - )?; + spinner.tick("🦀 Thinking...", TerminalRenderer::new().color_theme(), out)?; let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let result = runtime.run_turn(input, Some(&mut permission_prompter)); hook_abort_monitor.stop(); match result { Ok(summary) => { self.replace_runtime(runtime)?; - spinner.finish( - "✨ Done", - TerminalRenderer::new().color_theme(), - &mut stdout, - )?; + spinner.finish("✨ Done", TerminalRenderer::new().color_theme(), out)?; let final_text = final_assistant_text(&summary); if !final_text.is_empty() { - println!("{final_text}"); + writeln!(out, "{final_text}")?; } - println!(); + writeln!(out)?; if let Some(event) = summary.auto_compaction { - println!( + writeln!( + out, "{}", format_auto_compaction_notice(event.removed_message_count) - ); + )?; } self.persist_session()?; Ok(()) @@ -7787,34 +7917,14 @@ impl LiveCli { spinner.fail( "❌ Request failed", TerminalRenderer::new().color_theme(), - &mut stdout, + out, )?; // ============================================================================ // Auto-compact retry on context window errors // ============================================================================ - // When the model API returns a context_window_blocked error (because the request - // exceeds the model's context window), we automatically: - // 1. Compact the session (remove old messages to free up space) - // 2. Retry the original request with the compacted session - // 3. Report results to the user - // - // This eliminates the need for users to manually run /compact when they - // hit context limits - the recovery happens automatically. - // - // Detection: We look for "context_window" or "Context window" in the error - // message, which covers error types like: - // - "context_window_blocked" - // - "Context window blocked" - // - "This model's maximum context length is X tokens..." - // ============================================================================ let error_str = error.to_string(); - // Detect context window overflow. Some providers (e.g. OpenAI-compat backends) - // return 400 with "no parseable body" instead of a proper context_length_exceeded - // error when the request is too large to even parse — treat that as context overflow too. - // Also detect model-specific context error markers (e.g. llama.cpp returns - // "Context size has been exceeded." / "exceed_context_size_error" / "exceeds the available context size"). let is_context_window = error_str.contains("context_window") || error_str.contains("Context window") || error_str.contains("no parseable body") @@ -7824,44 +7934,33 @@ impl LiveCli { .to_ascii_lowercase() .contains("context size has been exceeded"); - // Also treat "assistant stream produced no content" and reqwest decode failures - // as recoverable errors that may benefit from auto-compaction. Some backends (e.g. - // llama.cpp) return a non-SSE HTTP 500 body when context overflows, causing - // reqwest to fail with "error decoding response body" — treat that as context overflow too. let is_no_content = error_str.contains("assistant stream produced no content") || error_str.contains("Failed to parse input at pos") || error_str.contains("error decoding response body"); if is_context_window || is_no_content { - // If the error tells us the server's actual context window, adapt our - // auto-compaction threshold so future auto-compact-trigger checks are accurate. if let Some(window) = extract_context_window_tokens_from_error(&error_str) { - // Set threshold at 70% of the reported window to leave headroom. let threshold: u32 = (window as f64 * 0.7).round() as u32; - println!( + writeln!( + out, " Server context window: {} tokens — setting auto-compaction threshold to {}", window, threshold - ); + )?; runtime.set_auto_compaction_input_tokens_threshold(threshold); } - // A single compaction pass may not free enough context space. - // Progressive retry: each round preserves fewer recent messages (4→2→1→0), - // trading conversation continuity for a smaller payload until it fits. - // Max 4 rounds before giving up and surfacing the error to the user. let max_compact_rounds = 4; let preserve_schedule = [4, 2, 1, 0]; for round in 0..max_compact_rounds { let preserve = preserve_schedule[round]; - println!( + writeln!( + out, " Auto-compacting session (round {}/{}, preserving {} recent messages)...", round + 1, max_compact_rounds, preserve - ); - - // Run Trident pipeline then summary-based compaction + )?; let result = runtime::trident::trident_compact_session( runtime.session(), CompactionConfig { @@ -7874,19 +7973,20 @@ impl LiveCli { if removed == 0 && round > 0 { // No more messages to compact — further rounds won't help - println!(" No further compaction possible."); + writeln!(out, " No further compaction possible.")?; break; } if removed > 0 { - println!( + writeln!( + out, "{}", format_compact_report( removed, result.compacted_session.messages.len(), false ) - ); + )?; } // Without this, prepare_turn_runtime() reads from self.runtime.session() @@ -7896,7 +7996,7 @@ impl LiveCli { // Build a new runtime with the compacted session and retry let (mut new_runtime, hook_abort_monitor) = - self.prepare_turn_runtime(true)?; + self.prepare_turn_runtime(emit_output)?; drop(hook_abort_monitor); let mut rp = CliPermissionPrompter::new(self.permission_mode); @@ -7910,14 +8010,15 @@ impl LiveCli { "✨ Done (after aggressive auto-compact)" }, TerminalRenderer::new().color_theme(), - &mut stdout, + out, )?; - println!(); + writeln!(out)?; if let Some(event) = summary.auto_compaction { - println!( + writeln!( + out, "{}", format_auto_compaction_notice(event.removed_message_count) - ); + )?; } self.persist_session()?; return Ok(()); @@ -8140,6 +8241,56 @@ impl LiveCli { run_init(CliOutputFormat::Text)?; false } + SlashCommand::Team { action } => { + match action.as_deref().unwrap_or("") { + "on" | "enable" => { + std::env::set_var("CLAWD_AGENT_TEAMS", "1"); + eprintln!("[team] Agent teams enabled (TeamCreate now available)"); + } + "off" | "disable" => { + std::env::set_var("CLAWD_AGENT_TEAMS", "0"); + eprintln!("[team] Agent teams disabled"); + } + "status" => { + let current = std::env::var("CLAWD_AGENT_TEAMS").unwrap_or_default(); + if current == "1" { + eprintln!("[team] Agent teams: ENABLED"); + } else { + eprintln!( + "[team] Agent teams: DISABLED (use /team on or Ctrl+T to enable)" + ); + } + } + "" => { + // Toggle + 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 (TeamCreate now available)"); + } + } + other => { + eprintln!("[team] unknown action: {other}. Use: /team [on|off|status]") + } + } + false + } + SlashCommand::Setup => { + setup_wizard::run_setup_wizard()?; + // 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)) + { + self.set_model(Some(new_model))?; + } + false + } SlashCommand::Diff => { Self::print_diff()?; false @@ -8188,12 +8339,6 @@ impl LiveCli { ); false } - SlashCommand::Setup => { - if let Err(e) = setup_wizard::run_setup_wizard() { - eprintln!("Setup wizard failed: {e}"); - } - false - } SlashCommand::History { count } => { self.print_prompt_history(count.as_deref()); false @@ -8241,7 +8386,7 @@ impl LiveCli { | SlashCommand::Tag { .. } | SlashCommand::OutputStyle { .. } | SlashCommand::AddDir { .. } - | SlashCommand::Team { .. } => { + | SlashCommand::Lsp { .. } => { let cmd_name = command.slash_name(); eprintln!("{cmd_name} is not yet implemented in this build."); false @@ -12624,6 +12769,45 @@ fn resolve_cli_auth_source_for_cwd() -> Result { resolve_startup_auth_source(|| Ok(None)) } +/// Inject provider settings from `~/.claw/settings.json` as environment +/// variable fallbacks so the API client constructors (which only read env +/// vars) can find them. +/// +/// This bridges the gap between `/setup` (which saves apiKey/baseUrl to +/// the config file) and the API crate (which reads env vars only). +/// Already-set env vars are never overwritten — the 3-tier resolution +/// order is preserved: env var > .env file > stored config. +fn inject_config_as_env_fallbacks() { + let cwd = std::env::current_dir().unwrap_or_default(); + let Ok(config) = runtime::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 !api_key.is_empty() && 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 !base_url.is_empty() && std::env::var(base_url_env).is_err() { + std::env::set_var(base_url_env, base_url); + } + } +} + impl ApiClient for AnthropicRuntimeClient { #[allow(clippy::too_many_lines)] fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { @@ -13982,6 +14166,153 @@ impl ToolExecutor for CliToolExecutor { } } } + + fn execute_batch(&mut self, calls: Vec) -> Vec { + if calls.len() <= 1 { + return calls + .into_iter() + .map(|call| { + let result = self.execute(&call.tool_name, &call.input); + runtime::ToolResult { + tool_use_id: call.tool_use_id, + tool_name: call.tool_name, + result, + } + }) + .collect(); + } + + /// Tools that are safe to run in parallel because they only read + /// state and dispatch through the stateless tool registry. + const PARALLEL_SAFE_TOOLS: &[&str] = &[ + "read_file", + "glob_search", + "grep_search", + "WebFetch", + "WebSearch", + "ToolSearch", + "Skill", + "LSP", + "Agent", + "AgentMessage", + "TeamStatus", + "TaskClaim", + "AgentSuggestion", + "ContextRequest", + "TaskGet", + "TaskList", + "TaskOutput", + "GitStatus", + "GitDiff", + "GitLog", + "GitShow", + "GitBlame", + ]; + + let emit_output = self.emit_output; + let mut results: Vec> = vec![None; calls.len()]; + let mut parallel_calls: Vec<(usize, String, String, String)> = Vec::new(); + let mut sequential_indices: Vec = Vec::new(); + + // 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)) + }) { + results[i] = Some(runtime::ToolResult { + tool_use_id: call.tool_use_id.clone(), + tool_name: call.tool_name.clone(), + result: Err(ToolError::new(format!( + "tool `{}` is not enabled by the current --allowedTools setting", + call.tool_name + ))), + }); + } else if PARALLEL_SAFE_TOOLS.contains(&call.tool_name.as_str()) + && !self.tool_registry.has_runtime_tool(&call.tool_name) + { + parallel_calls.push(( + i, + call.tool_use_id.clone(), + call.tool_name.clone(), + call.input.clone(), + )); + } else { + sequential_indices.push(i); + } + } + + // Execute parallel-safe tools concurrently + if !parallel_calls.is_empty() { + let registry = self.tool_registry.clone(); + let parallel_results: Vec<(usize, String, String, Result)> = + std::thread::scope(|s| { + let mut handles = Vec::new(); + for (idx, tool_use_id, tool_name, input) in ¶llel_calls { + let registry = ®istry; + let tool_use_id = tool_use_id.clone(); + let tool_name = tool_name.clone(); + 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 result = match value { + Ok(v) => registry.execute(&tool_name, &v).map_err(ToolError::new), + Err(e) => Err(e), + }; + (idx, tool_use_id, tool_name, result) + })); + } + handles + .into_iter() + .map(|h| { + h.join().unwrap_or_else(|_| { + ( + 0, + String::new(), + String::new(), + Err(ToolError::new("parallel thread panicked")), + ) + }) + }) + .collect() + }); + + for (idx, tool_use_id, tool_name, result) in parallel_results { + if emit_output { + let output_str = match &result { + Ok(o) => o.clone(), + Err(e) => e.to_string(), + }; + let is_error = result.is_err(); + let markdown = format_tool_result(&tool_name, &output_str, is_error); + self.renderer + .stream_markdown(&markdown, &mut io::stdout()) + .map_err(|error| ToolError::new(error.to_string())) + .ok(); + } + results[idx] = Some(runtime::ToolResult { + tool_use_id, + tool_name, + result, + }); + } + } + + // Execute sequential tools one at a time + for idx in sequential_indices { + let call = &calls[idx]; + let result = self.execute(&call.tool_name, &call.input); + results[idx] = Some(runtime::ToolResult { + tool_use_id: call.tool_use_id.clone(), + tool_name: call.tool_name.clone(), + result, + }); + } + + results.into_iter().map(|r| r.unwrap()).collect() + } } fn permission_policy( @@ -14645,6 +14976,178 @@ mod tests { ); } + #[test] + fn config_model_normalizes_bare_name_to_custom_prefix_for_custom_openai_provider() { + let _guard = env_lock(); + let root = temp_dir(); + let cwd = root.join("project"); + let config_home = root.join("config-home"); + std::fs::create_dir_all(&cwd).expect("project dir should exist"); + std::fs::create_dir_all(&config_home).expect("config home should exist"); + std::fs::write( + config_home.join("settings.json"), + r#"{ + "provider": { + "kind": "custom-openai", + "apiKey": "sk-test", + "baseUrl": "http://localhost:9999/v1" + }, + "model": "openclaw" + }"#, + ) + .expect("user settings should write"); + + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + + let resolved = with_current_dir(&cwd, super::config_model_for_current_dir); + + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + std::fs::remove_dir_all(root).expect("temp config root should clean up"); + + assert_eq!(resolved, Some("custom/openclaw".to_string())); + } + + #[test] + fn config_model_leaves_openai_kind_bare_model_unnormalized() { + let _guard = env_lock(); + let root = temp_dir(); + let cwd = root.join("project"); + let config_home = root.join("config-home"); + std::fs::create_dir_all(&cwd).expect("project dir should exist"); + std::fs::create_dir_all(&config_home).expect("config home should exist"); + std::fs::write( + config_home.join("settings.json"), + r#"{ + "provider": { + "kind": "openai", + "apiKey": "sk-test", + "baseUrl": "http://localhost:9999/v1" + }, + "model": "openclaw" + }"#, + ) + .expect("user settings should write"); + + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + + let resolved = with_current_dir(&cwd, super::config_model_for_current_dir); + + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + std::fs::remove_dir_all(root).expect("temp config root should clean up"); + + assert_eq!(resolved, Some("openclaw".to_string())); + } + + #[test] + fn inject_config_as_env_fallbacks_sets_openai_provider_env_vars() { + let _guard = env_lock(); + let root = temp_dir(); + let cwd = root.join("project"); + let config_home = root.join("config-home"); + std::fs::create_dir_all(&cwd).expect("project dir should exist"); + std::fs::create_dir_all(&config_home).expect("config home should exist"); + std::fs::write( + config_home.join("settings.json"), + r#"{ + "provider": { + "kind": "openai", + "apiKey": "sk-from-config", + "baseUrl": "http://localhost:9999/v1" + } + }"#, + ) + .expect("user settings should write"); + + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + let original_api_key = std::env::var("OPENAI_API_KEY").ok(); + let original_base_url = std::env::var("OPENAI_BASE_URL").ok(); + + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + std::env::remove_var("OPENAI_API_KEY"); + std::env::remove_var("OPENAI_BASE_URL"); + + with_current_dir(&cwd, super::inject_config_as_env_fallbacks); + + let api_key = std::env::var("OPENAI_API_KEY").ok(); + let base_url = std::env::var("OPENAI_BASE_URL").ok(); + + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + match original_api_key { + Some(value) => std::env::set_var("OPENAI_API_KEY", value), + None => std::env::remove_var("OPENAI_API_KEY"), + } + match original_base_url { + Some(value) => std::env::set_var("OPENAI_BASE_URL", value), + None => std::env::remove_var("OPENAI_BASE_URL"), + } + std::fs::remove_dir_all(root).expect("temp config root should clean up"); + + assert_eq!(api_key, Some("sk-from-config".to_string())); + assert_eq!(base_url, Some("http://localhost:9999/v1".to_string())); + } + + #[test] + fn inject_config_as_env_fallbacks_sets_custom_openai_provider_env_vars() { + let _guard = env_lock(); + let root = temp_dir(); + let cwd = root.join("project"); + let config_home = root.join("config-home"); + std::fs::create_dir_all(&cwd).expect("project dir should exist"); + std::fs::create_dir_all(&config_home).expect("config home should exist"); + std::fs::write( + config_home.join("settings.json"), + r#"{ + "provider": { + "kind": "custom-openai", + "apiKey": "sk-from-config", + "baseUrl": "http://localhost:9999/v1" + } + }"#, + ) + .expect("user settings should write"); + + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + let original_api_key = std::env::var("CLAWCUSTOMOPENAI_API_KEY").ok(); + let original_base_url = std::env::var("CLAWCUSTOMOPENAI_BASE_URL").ok(); + + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + std::env::remove_var("CLAWCUSTOMOPENAI_API_KEY"); + std::env::remove_var("CLAWCUSTOMOPENAI_BASE_URL"); + + with_current_dir(&cwd, super::inject_config_as_env_fallbacks); + + let api_key = std::env::var("CLAWCUSTOMOPENAI_API_KEY").ok(); + let base_url = std::env::var("CLAWCUSTOMOPENAI_BASE_URL").ok(); + + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + match original_api_key { + Some(value) => std::env::set_var("CLAWCUSTOMOPENAI_API_KEY", value), + None => std::env::remove_var("CLAWCUSTOMOPENAI_API_KEY"), + } + match original_base_url { + Some(value) => std::env::set_var("CLAWCUSTOMOPENAI_BASE_URL", value), + None => std::env::remove_var("CLAWCUSTOMOPENAI_BASE_URL"), + } + std::fs::remove_dir_all(root).expect("temp config root should clean up"); + + assert_eq!(api_key, Some("sk-from-config".to_string())); + assert_eq!(base_url, Some("http://localhost:9999/v1".to_string())); + } + #[test] fn default_permission_mode_uses_project_config_when_env_is_unset() { let _guard = env_lock(); diff --git a/rust/crates/rusty-claude-cli/src/setup_wizard.rs b/rust/crates/rusty-claude-cli/src/setup_wizard.rs index c2f7b6ff39..4ed2969bff 100644 --- a/rust/crates/rusty-claude-cli/src/setup_wizard.rs +++ b/rust/crates/rusty-claude-cli/src/setup_wizard.rs @@ -9,7 +9,7 @@ const PROVIDERS: &[(&str, &str, &str)] = &[ ("2", "xAI / Grok", "xai"), ("3", "OpenAI", "openai"), ("4", "DashScope (Qwen/Kimi)", "dashscope"), - ("5", "Custom (OpenAI-compat)", "openai"), + ("5", "Custom (OpenAI-compat)", "custom-openai"), ]; const PROVIDER_MODELS: &[(&str, &[&str])] = &[ @@ -27,6 +27,8 @@ const DEFAULT_BASE_URLS: &[(&str, &str)] = &[ "dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1", ), + // Custom OpenAI has no default base URL; the user must supply one. + ("custom-openai", ""), ]; const API_KEY_ENV_VARS: &[(&str, &str)] = &[ @@ -34,6 +36,7 @@ const API_KEY_ENV_VARS: &[(&str, &str)] = &[ ("xai", "XAI_API_KEY"), ("openai", "OPENAI_API_KEY"), ("dashscope", "DASHSCOPE_API_KEY"), + ("custom-openai", "CLAWCUSTOMOPENAI_API_KEY"), ]; pub fn run_setup_wizard() -> Result<(), Box> { @@ -177,6 +180,7 @@ fn prompt_base_url( "xai" => "XAI_BASE_URL", "openai" => "OPENAI_BASE_URL", "dashscope" => "DASHSCOPE_BASE_URL", + "custom-openai" => "CLAWCUSTOMOPENAI_BASE_URL", _ => "BASE_URL", }; let env_set = std::env::var(env_var).ok().is_some_and(|v| !v.is_empty()); @@ -214,7 +218,11 @@ fn prompt_model( if !aliases.is_empty() { println!(" Common: {}", aliases.join(", ")); } - println!(" Or enter any model name (e.g. openai/gpt-4.1-mini for custom routing)"); + if kind == "custom-openai" { + println!(" Or enter any model name (e.g. custom/gpt-4.1 for custom routing)"); + } else { + println!(" Or enter any model name (e.g. openai/gpt-4.1-mini for custom routing)"); + } let input = read_line(&format!(" Model [{current_model}]: "))?; if input.trim().is_empty() { diff --git a/rust/crates/tools/MULTI_TOOL_README.md b/rust/crates/tools/MULTI_TOOL_README.md new file mode 100644 index 0000000000..9ae88b82f5 --- /dev/null +++ b/rust/crates/tools/MULTI_TOOL_README.md @@ -0,0 +1,118 @@ +# Multi-Tool Execution & Sub-Agent Delegation + +Two complementary features that dramatically reduce latency and token usage when the model needs to perform multiple operations or gather context. + +## Feature 1: Parallel Tool Execution + +When the model returns multiple tool_use blocks in a single response, read-only tools now execute concurrently instead of sequentially. + +### How it works + +The `run_turn` loop is refactored into 3 phases: + +1. **Pre-hooks + permission checks** (sequential — hooks may mutate state) +2. **Tool execution** (batch — parallel for read-only tools via `std::thread::scope`) +3. **Post-hooks + session updates** (sequential — preserves original ordering) + +### Parallel-safe tools + +These tools are safe to run concurrently because they only read state and dispatch through the stateless tool registry: + +- `read_file`, `glob_search`, `grep_search` +- `WebFetch`, `WebSearch` +- `ToolSearch`, `Skill` +- `LSP` +- `GitStatus`, `GitDiff`, `GitLog`, `GitShow`, `GitBlame` + +### Sequential-only tools + +Tools that require `&mut self` or have side effects continue to run one at a time: + +- `bash`, `write_file`, `edit_file` (side effects) +- `MCP`, `McpAuth`, `RemoteTrigger` (network state) +- `Agent`, `TaskCreate`, `WorkerCreate` (stateful) +- `NotebookEdit`, `REPL`, `PowerShell` (side effects) + +### Safety guarantees + +- Pre/post hooks always run sequentially +- Permission checks complete before any tool executes +- Tool results are pushed to the session in the original model order +- Falls back to sequential for single-tool batches +- Thread scopes ensure all parallel work completes before `execute_batch` returns + +### Impact + +For a response with 5 `read_file` calls: **~5x faster** execution. The main model still sees all results in order. + +--- + +## Feature 2: SubAgent Delegation + +A `SubAgent` tool that lets the main model delegate multi-step tasks to a fast sub-agent. The sub-agent runs autonomously with its own `ConversationRuntime`, making multiple tool calls without round-tripping through the main model. + +### Tool parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `prompt` | string | yes | Task description for the sub-agent | +| `task_type` | string | no | `Explore` (default), `Plan`, or `Verify` | +| `model` | string | no | Override the sub-agent model | + +### Task types + +| Type | Available tools | Use for | +|------|----------------|---------| +| `Explore` | read_file, glob_search, grep_search, WebFetch, WebSearch, ToolSearch, StructuredOutput | Searching code, reading files, gathering context | +| `Plan` | Explore + TodoWrite | Planning approaches with structured todo output | +| `Verify` | Plan + bash | Running tests, checking builds, verifying changes | + +### Configuration + +Set the sub-agent model in `~/.claw/settings.json`: + +```json +{ + "model": "openai/glm-5.1-fast", + "subagentModel": "openai/qwen3.6-35b-fast" +} +``` + +If `subagentModel` is not set, the sub-agent uses the same model as the main session (or the `model` override parameter on the tool call). + +### Example + +Main model prompt: "Find all Rust files that import `ConversationRuntime` and list their paths" + +Without SubAgent: 5–10 sequential tool calls (grep → read each file → summarize) +With SubAgent: 1 SubAgent call → sub-agent does all the work autonomously → returns summary + +**Result**: ~10x fewer tokens consumed by the main model, faster overall completion. + +### Architecture + +The sub-agent reuses the same building blocks as the existing `Agent` tool: + +- `ProviderRuntimeClient` — API client with fallback chain +- `SubagentToolExecutor` — filtered tool access with permission enforcement +- `ConversationRuntime` — full conversation loop with hooks and compaction +- `agent_permission_policy()` — auto-approve read-only, deny write tools + +Key differences from the `Agent` tool: +- **Synchronous** — blocks until complete, returns result directly +- **Lighter** — fewer default tools, focused on the task type +- **Configurable model** — uses `subagentModel` or the tool's `model` param +- **Structured output** — returns `result`, `tool_calls`, and `iterations` + +--- + +## Changed files + +| File | Changes | +|------|---------| +| `rust/crates/runtime/src/conversation.rs` | `ToolCall`, `ToolResult` types; `execute_batch` on `ToolExecutor`; 3-phase `run_turn` | +| `rust/crates/runtime/src/lib.rs` | Exports for `ToolCall`, `ToolResult` | +| `rust/crates/runtime/src/config.rs` | `subagent_model` field, `parse_optional_subagent_model()`, accessor | +| `rust/crates/runtime/src/config_validate.rs` | `subagentModel` field spec | +| `rust/crates/rusty-claude-cli/src/main.rs` | `CliToolExecutor::execute_batch` with parallel-safe classification | +| `rust/crates/tools/src/lib.rs` | `SubAgent` tool spec, `SubAgentInput`, `run_sub_agent()`, `load_subagent_model_from_config()`, `build_sub_agent_system_prompt()` | diff --git a/rust/crates/tools/src/lane_completion.rs b/rust/crates/tools/src/lane_completion.rs index 947d58d9d7..97964ee994 100644 --- a/rust/crates/tools/src/lane_completion.rs +++ b/rust/crates/tools/src/lane_completion.rs @@ -116,6 +116,8 @@ mod tests { lane_events: vec![], derived_state: "working".to_string(), current_blocker: None, + team_id: None, + task_id: None, error: None, } } diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index a72261ed02..da661e84a0 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -7,8 +7,8 @@ use aspect_macros::aspect; use aspect_std::LoggingAspect; use api::{ - max_tokens_for_model, model_family_identity_for, resolve_model_alias, ApiError, - ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse, + 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, }; @@ -30,7 +30,7 @@ use runtime::{ ConversationRuntime, GrepSearchInput, LaneCommitProvenance, LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass, McpDegradedReport, MessageRole, PermissionMode, PermissionPolicy, PromptCacheEvent, ProviderFallbackConfig, RuntimeError, - Session, TaskPacket, ToolError, ToolExecutor, + Session, TaskPacket, ToolError, ToolExecutor, TurnProgressReporter, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -1094,24 +1094,36 @@ pub fn mvp_tool_specs() -> Vec { }, ToolSpec { name: "TeamCreate", - description: "Create a team of sub-agents for parallel task execution.", + description: "Create a team of agents that run in parallel. Each task becomes an independent Agent with its own context. Agents communicate via AgentMessage, claim tasks via TaskClaim, and report progress automatically. Reviewer agents are included for quality checks. Use TeamStatus to monitor, /team to toggle. 'mode' preset: tiny/1x (4 agents), small/2x (8), medium/3x (12), large/4x (16), xlarge/5x (20), mega/6x (24). Requires /team on.", input_schema: json!({ "type": "object", "properties": { "name": { "type": "string" }, + "mode": { + "type": "string", + "description": "Preset team size. Named sizes: 'tiny'/'1x'=1 per role (3+1 agents), 'small'/'2x'=2 per role (6+2), 'medium'/'3x'=3 per role (9+3), 'large'/'4x'=4 per role (12+4), 'xlarge'/'5x'=5 per role (15+5), 'mega'/'6x'=6 per role (18+6). Overrides 'tasks'.", + "enum": ["1x", "2x", "3x", "4x", "5x", "6x", "tiny", "small", "medium", "large", "xlarge", "mega"] + }, + "prompt": { + "type": "string", + "description": "Shared prompt for all agents when using 'mode' preset. Each agent gets this prompt with its role prepended." + }, "tasks": { "type": "array", + "description": "Manual task list. Ignored when 'mode' is set.", "items": { "type": "object", "properties": { "prompt": { "type": "string" }, - "description": { "type": "string" } + "description": { "type": "string" }, + "subagent_type": { "type": "string", "enum": ["Explore", "Plan", "Verification", "general-purpose"] }, + "model": { "type": "string" } }, "required": ["prompt"] } } }, - "required": ["name", "tasks"], + "required": ["name"], "additionalProperties": false }), required_permission: PermissionMode::DangerFullAccess, @@ -1129,6 +1141,108 @@ pub fn mvp_tool_specs() -> Vec { }), required_permission: PermissionMode::DangerFullAccess, }, + ToolSpec { + name: "AgentMessage", + description: "Send or read messages between agents in a team. Use action=send to post a message to another agent's inbox, action=read to check your own inbox, or action=broadcast to send to all agents in a team. Agents communicate through a shared mailbox directory.", + input_schema: json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["send", "read", "broadcast"], + "description": "send=post to agent inbox, read=check own inbox, broadcast=send to all team members" + }, + "agent_id": { "type": "string", "description": "Target agent ID (for send action)" }, + "team_id": { "type": "string", "description": "Team ID (for broadcast)" }, + "message": { "type": "string", "description": "Message content (for send/broadcast)" }, + "mark_read": { "type": "boolean", "description": "Mark retrieved messages as read (default true)" } + }, + "required": ["action"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "TeamStatus", + description: "Check the progress of a team of agents. Returns structured status: which agents are running, completed, or failed, with their results. Use action=status for a snapshot, action=summary for final results when all agents are done, or action=events for a timeline of team events.", + input_schema: json!({ + "type": "object", + "properties": { + "team_id": { "type": "string", "description": "Team ID to check" }, + "action": { + "type": "string", + "enum": ["status", "summary", "events", "inbox", "kill", "suggestions"], + "description": "status=live snapshot, summary=final results, events=timeline, inbox=team messages, kill=terminate stuck agent, suggestions=list pending AGENTS.md suggestions" + } + }, + "required": ["team_id"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "TaskClaim", + description: "Claim, release, or list task claims. Agents claim tasks to prevent duplicate work. Use action=claim to atomically claim a task (returns success/failure), action=release to release a claim, or action=list to see all active claims.", + input_schema: json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["claim", "release", "list"], + "description": "claim=atomically acquire a task lock, release=release your claim, list=show all active claims" + }, + "task_id": { "type": "string", "description": "Task identifier to claim or release" }, + "team_id": { "type": "string", "description": "Team ID (used with claim and list)" }, + "agent_id": { "type": "string", "description": "Agent ID claiming the task (used with claim)" } + }, + "required": ["action"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "AgentSuggestion", + description: "Suggest an addition to AGENTS.md for the team to review. Agents should NOT write AGENTS.md directly. Instead, propose patterns, pitfalls, or style guidelines. The team lead (human) decides what to include.", + input_schema: json!({ + "type": "object", + "properties": { + "category": { + "type": "string", + "enum": ["pattern", "pitfall", "style"], + "description": "Category: pattern=proven approach, pitfall=thing to avoid, style=coding convention" + }, + "suggestion": { "type": "string", "description": "The suggestion text to add to AGENTS.md" }, + "team_id": { "type": "string", "description": "Team ID" }, + "agent_id": { "type": "string", "description": "Agent ID making the suggestion" } + }, + "required": ["category", "suggestion"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, + ToolSpec { + name: "ContextRequest", + description: "Request additional context for your task. Use when you need specific files or symbols you haven't seen. You have a budget of 3 retrieval cycles. Be specific: name exact files or describe the symbols you need and why.", + input_schema: json!({ + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { "type": "string" }, + "description": "Exact file paths to read" + }, + "symbols": { + "type": "array", + "items": { "type": "string" }, + "description": "Symbol names to search for (e.g. function names, type names)" + }, + "reason": { "type": "string", "description": "Why you need this context (helps prioritize)" } + }, + "required": ["reason"], + "additionalProperties": false + }), + required_permission: PermissionMode::ReadOnly, + }, ToolSpec { name: "CronCreate", description: "Create a scheduled recurring task.", @@ -1476,6 +1590,13 @@ fn execute_tool_with_enforcer( .and_then(run_worker_observe_completion), "TeamCreate" => from_value::(input).and_then(run_team_create), "TeamDelete" => from_value::(input).and_then(run_team_delete), + "AgentMessage" => from_value::(input).and_then(run_agent_message), + "TeamStatus" => from_value::(input).and_then(run_team_status), + "TaskClaim" => from_value::(input).and_then(run_task_claim), + "AgentSuggestion" => { + from_value::(input).and_then(run_agent_suggestion) + } + "ContextRequest" => from_value::(input).and_then(run_context_request), "CronCreate" => from_value::(input).and_then(run_cron_create), "CronDelete" => from_value::(input).and_then(run_cron_delete), "CronList" => run_cron_list(input.clone()), @@ -1765,36 +1886,1072 @@ fn run_worker_observe_completion(input: WorkerObserveCompletionInput) -> Result< #[allow(clippy::needless_pass_by_value)] fn run_team_create(input: TeamCreateInput) -> Result { - let task_ids: Vec = input - .tasks - .iter() - .filter_map(|t| t.get("task_id").and_then(|v| v.as_str()).map(str::to_owned)) - .collect(); - let team = global_team_registry().create(&input.name, task_ids); - // Register team assignment on each task - for task_id in &team.task_ids { - let _ = global_task_registry().assign_team(task_id, &team.team_id); + if std::env::var("CLAWD_AGENT_TEAMS").map_or(true, |v| v != "1") { + 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 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())?; + + // 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, + )? + } else if input.tasks.is_empty() { + expand_team_mode( + "2x", + input + .prompt + .as_deref() + .unwrap_or("Explore the codebase and report findings"), + &team_id, + )? + } else { + input.tasks.clone() + }; + + let mut agent_ids: Vec = Vec::new(); + let mut agent_outputs: Vec = Vec::new(); + + 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 subagent_type = task.get("subagent_type").and_then(|v| v.as_str()); + let model_override = task.get("model").and_then(|v| v.as_str()); + + if prompt.is_empty() { + continue; + } + + 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 + )), + model: model_override.map(|s| s.to_string()), + team_id: Some(team_id.clone()), + task_id, + }; + + match execute_agent_with_spawn(agent_input, spawn_agent_job) { + Ok(manifest) => { + let aid = manifest.agent_id.clone(); + // Set CLAWD_AGENT_ID env for the agent thread + agent_ids.push(aid.clone()); + agent_outputs.push(json!({ + "agent_id": aid, + "name": manifest.name, + "status": manifest.status, + "subagent_type": manifest.subagent_type, + })); + } + Err(error) => { + agent_outputs.push(json!({ + "agent_index": i, + "error": error, + })); + } + } + } + + // Persist team manifest + let team_manifest = json!({ + "team_id": team_id, + "name": input.name, + "agent_ids": agent_ids, + "agent_count": agent_ids.len(), + "status": "running", + "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())?; + + // Register in global registry + let team = global_team_registry().create(&input.name, agent_ids.clone()); + + // Spawn background watcher that prints progress to stderr + spawn_team_watcher(&team_id, &agent_ids); + to_pretty_json(json!({ - "team_id": team.team_id, - "name": team.name, - "task_count": team.task_ids.len(), - "task_ids": team.task_ids, - "status": team.status, - "created_at": team.created_at + "team_id": team_id, + "name": input.name, + "agent_count": agent_ids.len(), + "agents": agent_outputs, + "status": "running", + "created_at": team.created_at, + "message": format!("Team created with {} agents. Use AgentMessage to coordinate. TeamStatus shows live progress.", agent_ids.len()), })) } #[allow(clippy::needless_pass_by_value)] +fn run_team_status(input: TeamStatusInput) -> Result { + let action = input.action.as_deref().unwrap_or("status"); + let team_dir = agent_store_dir()?.join("teams"); + let manifest_path = team_dir.join(format!("{}.json", input.team_id)); + 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") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let store_dir = agent_store_dir()?; + let mut agents_detail: Vec = Vec::new(); + let mut running_count = 0usize; + let mut completed_count = 0usize; + let mut failed_count = 0usize; + + for id_val in &agent_ids { + if let Some(id) = id_val.as_str() { + let agent_json = store_dir.join(format!("{id}.json")); + let agent_md = store_dir.join(format!("{id}.md")); + let mut detail = json!({ "agent_id": id }); + + if agent_json.exists() { + if let Ok(data) = std::fs::read_to_string(&agent_json) { + if let Ok(parsed) = serde_json::from_str::(&data) { + 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)); + if let Some(completed) = parsed.get("completed_at") { + detail["completed_at"] = completed.clone(); + } + } + } + } else { + detail["status"] = json!("running"); + } + + if agent_md.exists() { + if let Ok(md_content) = std::fs::read_to_string(&agent_md) { + let summary = md_content + .lines() + .skip_while(|line| !line.starts_with("## Result")) + .skip(1) + .take(10) + .collect::>() + .join("\n"); + if !summary.is_empty() { + detail["result_preview"] = json!(summary); + } + } + } + + match detail + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + { + "completed" => completed_count += 1, + "failed" => failed_count += 1, + _ => running_count += 1, + } + agents_detail.push(detail); + } + } + + match action { + "status" => { + to_pretty_json(json!({ + "team_id": input.team_id, + "team_name": team_data.get("name"), + "status": if running_count > 0 { "running" } else if failed_count > 0 { "completed_with_failures" } else { "completed" }, + "progress": { + "total": agent_ids.len(), + "running": running_count, + "completed": completed_count, + "failed": failed_count, + }, + "agents": agents_detail, + })) + } + "summary" => { + let mut results: Vec = Vec::new(); + for id_val in &agent_ids { + if let Some(id) = id_val.as_str() { + let agent_md = store_dir.join(format!("{id}.md")); + let agent_json = store_dir.join(format!("{id}.json")); + let mut entry = json!({ "agent_id": id }); + + if let Ok(data) = std::fs::read_to_string(&agent_json) { + if let Ok(parsed) = serde_json::from_str::(&data) { + entry["status"] = parsed.get("status").cloned().unwrap_or(json!("unknown")); + entry["name"] = parsed.get("name").cloned().unwrap_or(json!(null)); + entry["subagent_type"] = parsed.get("subagent_type").cloned().unwrap_or(json!(null)); + } + } + if let Ok(md_content) = std::fs::read_to_string(&agent_md) { + entry["result"] = json!(md_content); + } + results.push(entry); + } + } + to_pretty_json(json!({ + "team_id": input.team_id, + "team_name": team_data.get("name"), + "status": if running_count > 0 { "still_running" } else if failed_count > 0 { "completed_with_failures" } else { "completed" }, + "progress": { + "total": agent_ids.len(), + "running": running_count, + "completed": completed_count, + "failed": failed_count, + }, + "results": results, + })) + } + "events" => { + let events_path = team_dir.join(format!("{}-events.jsonl", input.team_id)); + let mut events: Vec = Vec::new(); + if events_path.exists() { + if let Ok(content) = std::fs::read_to_string(&events_path) { + for line in content.lines() { + if let Ok(event) = serde_json::from_str::(line) { + events.push(event); + } + } + } + } + to_pretty_json(json!({ + "team_id": input.team_id, + "event_count": events.len(), + "events": events, + })) + } + "inbox" => { + let inbox_dir = agent_mailbox_dir().join("team").join(&input.team_id); + let mut messages: Vec = Vec::new(); + if inbox_dir.exists() { + if let Ok(entries) = std::fs::read_dir(&inbox_dir) { + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") { + if let Ok(data) = std::fs::read_to_string(&path) { + if let Ok(msg) = serde_json::from_str::(&data) { + messages.push(msg); + } + } + } + } + } + } + to_pretty_json(json!({ + "team_id": input.team_id, + "inbox_count": messages.len(), + "messages": messages, + })) + } + "kill" => { + let agent_id = input.agent_id.as_deref().unwrap_or(""); + if agent_id.is_empty() { + return Err("agent_id is required for kill action".to_string()); + } + // Write kill signal file that the agent checks + let kill_dir = agent_mailbox_dir().join("team").join(&input.team_id); + let _ = std::fs::create_dir_all(&kill_dir); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let entry = json!({ + "event": "kill_signal", + "agent_id": agent_id, + "reason": input.reason.as_deref().unwrap_or("terminated by team lead"), + "timestamp": ts, + }); + let kill_file = kill_dir.join(format!("kill-{agent_id}-{ts}.json")); + std::fs::write(&kill_file, serde_json::to_string_pretty(&entry).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + to_pretty_json(json!({ + "action": "kill", + "agent_id": agent_id, + "status": "kill_signal_sent", + })) + } + "suggestions" => { + let suggestions_dir = agent_store_dir()?.join("suggestions"); + let mut suggestions = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&suggestions_dir) { + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.extension().map_or(false, |e| e == "json") { + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(v) = serde_json::from_str::(&content) { + if input.team_id.is_empty() || v.get("team_id").and_then(|t| t.as_str()).map_or(true, |t| t == input.team_id) { + suggestions.push(v); + } + } + } + } + } + } + to_pretty_json(json!({ + "action": "suggestions", + "suggestions": suggestions, + "count": suggestions.len(), + })) + } + other => Err(format!("unknown TeamStatus action: {other}. Use status, summary, events, inbox, kill, or suggestions")), + } +} + +/// Spawn a background thread that watches a team's agents and prints progress. +/// Prints agent completion/failure events to stderr. Exits when all agents are done. +fn spawn_team_watcher(team_id: &str, agent_ids: &[String]) { + let team_id = team_id.to_string(); + let total = agent_ids.len(); + let thread_name = format!("claw-team-watch-{team_id}"); + + std::thread::Builder::new() + .name(thread_name) + .spawn(move || { + let inbox_dir = agent_mailbox_dir().join("team").join(&team_id); + let _ = std::fs::create_dir_all(&inbox_dir); + + let team_dir = agent_store_dir().map(|d| d.join("teams")).unwrap_or_default(); + let events_path = team_dir.join(format!("{team_id}-events.jsonl")); + + let mut completed: BTreeSet = BTreeSet::new(); + let mut failed: BTreeSet = BTreeSet::new(); + let mut seen: BTreeSet = BTreeSet::new(); + + eprintln!("[team] {team_id}: watching {total} agents via inbox..."); + + loop { + let mut new_events = false; + if let Ok(entries) = std::fs::read_dir(&inbox_dir) { + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + if seen.contains(&name) { + continue; + } + if !path.extension().is_some_and(|ext| ext == "json") { + continue; + } + if let Ok(data) = std::fs::read_to_string(&path) { + if let Ok(parsed) = serde_json::from_str::(&data) { + seen.insert(name); + new_events = true; + let agent_id = parsed.get("agent_id").and_then(|v| v.as_str()).unwrap_or("unknown"); + let event = parsed.get("event").and_then(|v| v.as_str()).unwrap_or("unknown"); + let agent_name = parsed.get("name").and_then(|v| v.as_str()).unwrap_or(agent_id); + let subagent_type = parsed.get("subagent_type").and_then(|v| v.as_str()).unwrap_or("unknown"); + let result_preview = parsed.get("result_preview").and_then(|v| v.as_str()).unwrap_or(""); + + match event { + "agent_completed" => { + completed.insert(agent_id.to_string()); + let done_count = completed.len() + failed.len(); + eprintln!("[team] {team_id}: done {agent_name} ({subagent_type}) completed - {done_count}/{total}{}", if result_preview.is_empty() { String::new() } else { format!(" - {}", &result_preview[..result_preview.len().min(120)]) }); + append_team_event(&events_path, &team_id, agent_id, "completed", agent_name, Some(result_preview)); + } + "agent_failed" => { + failed.insert(agent_id.to_string()); + let error = parsed.get("error").and_then(|v| v.as_str()) + .or_else(|| parsed.get("result_preview").and_then(|v| v.as_str())) + .unwrap_or("unknown error"); + eprintln!("[team] {team_id}: FAIL {agent_name} failed - {error}"); + append_team_event(&events_path, &team_id, agent_id, "failed", agent_name, Some(error)); + } + _ => {} + } + } + } + } + } + + let all_done = completed.len() + failed.len() >= total; + if all_done { + break; + } + std::thread::sleep(std::time::Duration::from_secs(1)); + } + + eprintln!("[team] {team_id}: all agents finished - {}/{} completed, {}/{} failed", completed.len(), total, failed.len(), total); + append_team_event(&events_path, &team_id, "team", "finished", &team_id, Some(&format!("{}/{} completed", completed.len(), total))); + + let manifest_path = team_dir.join(format!("{team_id}.json")); + if let Ok(data) = std::fs::read_to_string(&manifest_path) { + if let Ok(mut parsed) = serde_json::from_str::(&data) { + if let Some(obj) = parsed.as_object_mut() { + obj.insert("status".to_string(), json!(if failed.is_empty() { "completed" } else { "completed_with_failures" })); + let _ = std::fs::write(&manifest_path, serde_json::to_string_pretty(&parsed).unwrap_or_default()); + } + } + } + + // Clean up inbox + let _ = std::fs::remove_dir_all(&inbox_dir); + }) + .ok(); +} + +fn get_agent_result_preview(store_dir: &std::path::Path, agent_id: &str) -> String { + 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::>() + .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>, +) { + let entry = json!({ + "timestamp": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis(), + "team_id": team_id, + "agent_id": agent_id, + "event": event_type, + "name": name, + "detail": detail, + }); + 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) + { + let _ = writeln!(file, "{line}"); + } + } +} + fn run_team_delete(input: TeamDeleteInput) -> Result { - match global_team_registry().delete(&input.team_id) { - Ok(team) => to_pretty_json(json!({ - "team_id": team.team_id, - "name": team.name, - "status": team.status, - "message": "Team deleted" - })), - Err(e) => Err(e), + // Delete from disk-based team storage + let team_dir = agent_store_dir()?.join("teams"); + let manifest_path = team_dir.join(format!("{}.json", input.team_id)); + if !manifest_path.exists() { + return Err(format!("team not found: {}", input.team_id)); + } + let data = std::fs::read_to_string(&manifest_path).map_err(|e| e.to_string())?; + let parsed: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?; + let _ = std::fs::remove_file(&manifest_path); + // Also clean up the team inbox directory + let inbox_dir = agent_mailbox_dir().join("team").join(&input.team_id); + if inbox_dir.exists() { + let _ = std::fs::remove_dir_all(&inbox_dir); + } + // Also clean up task claims for this team + let claims_dir = claims_dir(); + if claims_dir.exists() { + if let Ok(entries) = std::fs::read_dir(&claims_dir) { + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + 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::(&content) { + if v.get("team_id").and_then(|t| t.as_str()) == Some(&input.team_id) { + let _ = std::fs::remove_file(&path); + } + } + } + } + } + } + } + to_pretty_json(json!({ + "team_id": input.team_id, + "name": parsed.get("name").and_then(|v| v.as_str()).unwrap_or("unknown"), + "status": "deleted", + "message": "Team deleted, inbox cleaned, claims released" + })) +} + +#[allow(clippy::needless_pass_by_value)] +fn run_agent_message(input: AgentMessageInput) -> Result { + let mailbox_dir = agent_mailbox_dir(); + std::fs::create_dir_all(&mailbox_dir).map_err(|e| e.to_string())?; + + match input.action.as_str() { + "send" => { + let target = input.agent_id.as_deref().unwrap_or(""); + if target.is_empty() { + return Err("agent_id is required for send action".to_string()); + } + let msg = input.message.as_deref().unwrap_or(""); + if msg.is_empty() { + return Err("message is required for send action".to_string()); + } + let inbox_dir = mailbox_dir.join(target); + std::fs::create_dir_all(&inbox_dir).map_err(|e| e.to_string())?; + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let msg_file = inbox_dir.join(format!("msg-{ts}.json")); + let sender = std::env::var("CLAWD_AGENT_ID").unwrap_or_else(|_| "main".to_string()); + let entry = json!({ + "from": sender, + "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())?; + to_pretty_json(json!({ + "action": "sent", + "to": target, + "timestamp": ts, + })) + } + "read" => { + let my_id = std::env::var("CLAWD_AGENT_ID").unwrap_or_else(|_| "main".to_string()); + let inbox_dir = mailbox_dir.join(&my_id); + if !inbox_dir.exists() { + return to_pretty_json(json!({ + "action": "read", + "messages": [], + "unread_count": 0, + })); + } + let mark_read = input.mark_read.unwrap_or(true); + let mut messages: Vec = Vec::new(); + 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")) + .collect(); + for entry in &entries { + if let Ok(content) = std::fs::read_to_string(entry.path()) { + if let Ok(msg) = serde_json::from_str::(&content) { + messages.push(msg); + } + } + } + let unread_count = messages.len(); + if mark_read { + for entry in &entries { + let _ = std::fs::remove_file(entry.path()); + } + } + to_pretty_json(json!({ + "action": "read", + "messages": messages, + "unread_count": unread_count, + })) + } + "broadcast" => { + let team_id = input.team_id.as_deref().unwrap_or(""); + if team_id.is_empty() { + return Err("team_id is required for broadcast action".to_string()); + } + let msg = input.message.as_deref().unwrap_or(""); + if msg.is_empty() { + return Err("message is required for broadcast action".to_string()); + } + let team_dir = agent_store_dir()?.join("teams"); + std::fs::create_dir_all(&team_dir).map_err(|e| e.to_string())?; + let manifest_path = team_dir.join(format!("{team_id}.json")); + if !manifest_path.exists() { + 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") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let sender = std::env::var("CLAWD_AGENT_ID").unwrap_or_else(|_| "main".to_string()); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let mut sent_to: Vec = Vec::new(); + for id_val in agent_ids { + if let Some(id) = id_val.as_str() { + 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")); + let entry = json!({ + "from": sender, + "message": msg, + "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())?; + sent_to.push(id.to_string()); + } + } + to_pretty_json(json!({ + "action": "broadcast", + "team_id": team_id, + "sent_to": sent_to, + "timestamp": ts, + })) + } + other => Err(format!("unknown AgentMessage action: {other}")), + } +} + +#[allow(clippy::needless_pass_by_value)] +fn run_task_claim(input: TaskClaimInput) -> Result { + match input.action.as_str() { + "claim" => { + let task_id = input.task_id.as_deref().unwrap_or(""); + let agent_id = input.agent_id.as_deref().unwrap_or(""); + let team_id = input.team_id.as_deref().unwrap_or(""); + if task_id.is_empty() { + return Err("task_id is required for claim action".to_string()); + } + if agent_id.is_empty() { + return Err("agent_id is required for claim action".to_string()); + } + if team_id.is_empty() { + return Err("team_id is required for claim action".to_string()); + } + match claim_task(task_id, agent_id, team_id) { + Ok(true) => to_pretty_json(json!({ + "action": "claim", + "task_id": task_id, + "agent_id": agent_id, + "claimed": true, + })), + Ok(false) => to_pretty_json(json!({ + "action": "claim", + "task_id": task_id, + "claimed": false, + "reason": "task already claimed by another agent", + })), + Err(e) => Err(e), + } + } + "release" => { + let task_id = input.task_id.as_deref().unwrap_or(""); + if task_id.is_empty() { + return Err("task_id is required for release action".to_string()); + } + release_claim(task_id)?; + to_pretty_json(json!({ + "action": "release", + "task_id": task_id, + "released": true, + })) + } + "list" => { + let claims = list_claims(input.team_id.as_deref()); + to_pretty_json(json!({ + "action": "list", + "claims": claims, + "count": claims.len(), + })) + } + other => Err(format!( + "unknown TaskClaim action: {other}. Use claim, release, or list" + )), + } +} + +#[allow(clippy::needless_pass_by_value)] +fn run_agent_suggestion(input: AgentSuggestionInput) -> Result { + let suggestions_dir = agent_store_dir()?.join("suggestions"); + std::fs::create_dir_all(&suggestions_dir).map_err(|e| e.to_string())?; + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let agent_id = input.agent_id.unwrap_or_else(|| "unknown".to_string()); + let entry = json!({ + "category": input.category, + "suggestion": input.suggestion, + "team_id": input.team_id, + "agent_id": agent_id, + "timestamp": ts, + }); + 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())?; + to_pretty_json(json!({ + "action": "suggestion", + "file": filename, + "status": "pending_review", + })) +} + +#[allow(clippy::needless_pass_by_value)] +fn run_context_request(input: ContextRequestInput) -> Result { + let mut results = Vec::new(); + let mut files_read = 0u32; + let mut symbols_found = 0u32; + + // Read requested files + for file_path in &input.files { + let path = std::path::PathBuf::from(file_path); + if path.exists() { + match std::fs::read_to_string(&path) { + Ok(content) => { + let truncated = if content.len() > 10000 { + format!( + "{}... (truncated, {} bytes total)", + &content[..10000], + content.len() + ) + } else { + content + }; + results.push(json!({ + "type": "file", + "path": file_path, + "content": truncated, + })); + files_read += 1; + } + Err(e) => { + results.push(json!({ + "type": "file_error", + "path": file_path, + "error": e.to_string(), + })); + } + } + } else { + results.push(json!({ + "type": "file_not_found", + "path": file_path, + })); + } + } + + // Search for requested symbols using grep + 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, + ".", + ]) + .current_dir(&cwd) + .output() + .map_err(|e| e.to_string())?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().take(20).collect(); + if !lines.is_empty() { + results.push(json!({ + "type": "symbol_search", + "symbol": symbol, + "matches": lines, + })); + symbols_found += 1; + } + } + } + + to_pretty_json(json!({ + "action": "context_request", + "reason": input.reason, + "files_read": files_read, + "symbols_found": symbols_found, + "results": results, + "reminder": "You have a budget of 3 retrieval cycles. Be specific with your requests.", + })) +} + +fn expand_team_mode(mode: &str, base_prompt: &str, team_id: &str) -> Result, String> { + let n = match mode { + "1x" | "tiny" => 1, + "2x" | "small" => 2, + "3x" | "medium" => 3, + "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" + )) + } + }; + let short_team_id = &team_id[team_id.len().saturating_sub(8)..]; + let roles: &[&str] = &["Explore", "Plan", "Verification"]; + let mut tasks = Vec::new(); + for role in roles { + for i in 0..n { + let prompt = format!("[{role} agent {}/{}] {base_prompt}", i + 1, n); + let description = format!("{role} agent {}/{}", i + 1, n); + let task_id = format!("{short_team_id}-{role}-{i}"); + tasks.push(json!({ + "prompt": prompt, + "description": description, + "subagent_type": role, + "task_id": task_id, + })); + } + } + // Add read-only Reviewer agents (1 per 3 builders, minimum 1) + let reviewer_count = std::cmp::max(1, (roles.len() * n) / 3); + for i in 0..reviewer_count { + let prompt = format!("[Reviewer {}/{}] Review the work of other agents. Read their output files, check code quality, identify issues, and report findings via AgentMessage. Only use read-only tools.", i + 1, reviewer_count); + let description = format!("Reviewer {}/{}", i + 1, reviewer_count); + let task_id = format!("{short_team_id}-Reviewer-{i}"); + tasks.push(json!({ + "prompt": prompt, + "description": description, + "subagent_type": "Reviewer", + "task_id": task_id, + })); + } + Ok(tasks) +} + +fn agent_mailbox_dir() -> std::path::PathBuf { + if let Ok(path) = std::env::var("CLAWD_AGENT_STORE") { + return std::path::PathBuf::from(path).join("mailbox"); + } + let cwd = std::env::current_dir().unwrap_or_default(); + if let Some(workspace_root) = cwd.ancestors().nth(2) { + return workspace_root.join(".clawd-agents").join("mailbox"); + } + cwd.join(".clawd-agents").join("mailbox") +} + +fn claims_dir() -> std::path::PathBuf { + if let Ok(path) = std::env::var("CLAWD_AGENT_STORE") { + return std::path::PathBuf::from(path).join("claims"); + } + let cwd = std::env::current_dir().unwrap_or_default(); + if let Some(workspace_root) = cwd.ancestors().nth(2) { + return workspace_root.join(".clawd-agents").join("claims"); + } + cwd.join(".clawd-agents").join("claims") +} + +fn claim_task(task_id: &str, agent_id: &str, team_id: &str) -> Result { + let dir = claims_dir(); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let lock_path = dir.join(format!("{task_id}.lock")); + if lock_path.exists() { + return Ok(false); + } + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let entry = serde_json::json!({ + "task_id": task_id, + "agent_id": agent_id, + "team_id": team_id, + "claimed_at": ts, + }); + // 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())?; + match std::fs::rename(&tmp_path, &lock_path) { + Ok(()) => Ok(true), + Err(_) => { + // Another agent claimed it first + let _ = std::fs::remove_file(&tmp_path); + Ok(false) + } + } +} + +fn release_claim(task_id: &str) -> Result<(), String> { + let lock_path = claims_dir().join(format!("{task_id}.lock")); + if lock_path.exists() { + std::fs::remove_file(&lock_path).map_err(|e| e.to_string()) + } else { + Ok(()) + } +} + +fn is_task_claimed(task_id: &str) -> bool { + claims_dir().join(format!("{task_id}.lock")).exists() +} + +fn list_claims(team_id: Option<&str>) -> Vec { + let dir = claims_dir(); + let mut claims = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + 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::(&content) { + if team_id.map_or(true, |tid| v.get("team_id").map_or(false, |t| t == tid)) + { + claims.push(v); + } + } + } + } + } + } + claims +} + +#[allow(dead_code)] +struct TeamInboxReporter { + team_id: String, + agent_id: String, + agent_name: String, + inbox_dir: std::path::PathBuf, +} + +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, + } + } +} + +impl TurnProgressReporter for TeamInboxReporter { + fn on_tool_result( + &self, + iteration: usize, + max_iterations: usize, + tool_name: &str, + input: &str, + result: Result<&str, &str>, + ) { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let (result_preview, is_error) = match result { + Ok(output) => (output.chars().take(500).collect::(), false), + Err(err) => (err.chars().take(500).collect::(), true), + }; + let input_preview: String = input.chars().take(300).collect(); + let entry = serde_json::json!({ + "event": "tool_progress", + "agent_id": self.agent_id, + "name": self.agent_name, + "tool_name": tool_name, + "input_preview": input_preview, + "result_preview": result_preview, + "is_error": is_error, + "iteration": iteration, + "max_iterations": max_iterations, + "timestamp": ts, + }); + 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); + } + + // Periodic git commit (every 5 tool calls) to preserve progress + if iteration > 0 && iteration % 5 == 0 { + let _ = std::process::Command::new("git") + .args(["add", "-A"]) + .output(); + let diff_check = std::process::Command::new("git") + .args(["diff", "--cached", "--quiet"]) + .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), + ]) + .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()) + { + if let Ok(e) = entry { + let name = e.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with(&format!("kill-{}-", self.agent_id)) { + // Kill signal received — panic to abort + std::fs::remove_file(e.path()).ok(); + panic!("agent {} received kill signal", self.agent_id); + } + } + } } } @@ -2835,6 +3992,10 @@ struct AgentInput { subagent_type: Option, name: Option, model: Option, + #[serde(default)] + team_id: Option, + #[serde(default)] + task_id: Option, } #[derive(Debug, Deserialize)] @@ -2995,6 +4156,11 @@ const fn default_auto_recover_prompt_misdelivery() -> bool { #[derive(Debug, Deserialize)] struct TeamCreateInput { name: String, + #[serde(default)] + mode: Option, + #[serde(default)] + prompt: Option, + #[serde(default)] tasks: Vec, } @@ -3003,6 +4169,60 @@ struct TeamDeleteInput { team_id: String, } +#[derive(Debug, Deserialize)] +struct AgentMessageInput { + action: String, + #[serde(default)] + agent_id: Option, + #[serde(default)] + team_id: Option, + #[serde(default)] + message: Option, + #[serde(default)] + mark_read: Option, +} + +#[derive(Debug, Deserialize)] +struct TeamStatusInput { + team_id: String, + #[serde(default)] + action: Option, + #[serde(default)] + agent_id: Option, + #[serde(default)] + reason: Option, +} + +#[derive(Debug, Deserialize)] +struct TaskClaimInput { + action: String, + #[serde(default)] + task_id: Option, + #[serde(default)] + team_id: Option, + #[serde(default)] + agent_id: Option, +} + +#[derive(Debug, Deserialize)] +struct AgentSuggestionInput { + category: String, + suggestion: String, + #[serde(default)] + team_id: Option, + #[serde(default)] + agent_id: Option, +} + +#[derive(Debug, Deserialize)] +struct ContextRequestInput { + #[serde(default)] + files: Vec, + #[serde(default)] + symbols: Vec, + reason: String, +} + #[derive(Debug, Deserialize)] struct CronCreateInput { schedule: String, @@ -3215,6 +4435,10 @@ struct AgentOutput { derived_state: String, #[serde(skip_serializing_if = "Option::is_none")] error: Option, + #[serde(rename = "teamId", skip_serializing_if = "Option::is_none")] + team_id: Option, + #[serde(rename = "taskId", skip_serializing_if = "Option::is_none")] + task_id: Option, } #[derive(Debug, Clone)] @@ -3223,6 +4447,9 @@ struct AgentJob { prompt: String, system_prompt: Vec, allowed_tools: BTreeSet, + team_id: Option, + task_id: Option, + max_tokens: Option, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] @@ -4119,6 +5346,7 @@ where let created_at = iso8601_now(); let system_prompt = build_agent_system_prompt(&normalized_subagent_type, &model)?; let allowed_tools = allowed_tools_for_subagent(&normalized_subagent_type); + let team_id = input.team_id.clone(); let output_contents = format!( "# Agent Task @@ -4153,6 +5381,8 @@ where current_blocker: None, derived_state: String::from("working"), error: None, + team_id: input.team_id.clone(), + task_id: input.task_id.clone(), }; write_agent_manifest(&manifest)?; @@ -4162,6 +5392,9 @@ where prompt: input.prompt, system_prompt, allowed_tools, + team_id: input.team_id.clone(), + task_id: input.task_id, + max_tokens: None, }; if let Err(error) = spawn_fn(job) { let error = format!("failed to spawn sub-agent: {error}"); @@ -4174,18 +5407,28 @@ where fn spawn_agent_job(job: AgentJob) -> Result<(), String> { let thread_name = format!("clawd-agent-{}", job.manifest.agent_id); + let agent_id_for_env = job.manifest.agent_id.clone(); std::thread::Builder::new() .name(thread_name) .spawn(move || { + std::env::set_var("CLAWD_AGENT_ID", &agent_id_for_env); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_agent_job(&job))); match result { Ok(Ok(())) => {} Ok(Err(error)) => { + // Release task claim on failure + if let Some(ref task_id) = job.task_id { + let _ = release_claim(task_id); + } let _ = persist_agent_terminal_state(&job.manifest, "failed", None, Some(error)); } Err(_) => { + // Release task claim on panic + if let Some(ref task_id) = job.task_id { + let _ = release_claim(task_id); + } let _ = persist_agent_terminal_state( &job.manifest, "failed", @@ -4199,11 +5442,141 @@ fn spawn_agent_job(job: AgentJob) -> Result<(), String> { .map_err(|error| error.to_string()) } +fn setup_agent_worktree(agent_id: &str) -> Result { + let cwd = std::env::current_dir().map_err(|e| e.to_string())?; + // Check if we're in a git repo + let git_check = std::process::Command::new("git") + .args(["rev-parse", "--is-inside-work-tree"]) + .current_dir(&cwd) + .output() + .map_err(|e| e.to_string())?; + if !git_check.status.success() { + return Ok(cwd); // Not a git repo, work in current dir + } + let worktree_dir = cwd.join(".clawd-agents").join("worktrees").join(agent_id); + // 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, + ]) + .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, + ]) + .current_dir(&cwd) + .output() + .map_err(|e| e.to_string())?; + if !output2.status.success() { + // Fall back to working in current directory + return Ok(cwd); + } + } + Ok(worktree_dir) +} + +fn teardown_agent_worktree(agent_id: &str, worktree_path: &std::path::Path) -> Result<(), String> { + let cwd = std::env::current_dir().map_err(|e| e.to_string())?; + // Stage and commit any changes in the worktree + let _ = std::process::Command::new("git") + .args(["add", "-A"]) + .current_dir(worktree_path) + .output(); + let commit_check = std::process::Command::new("git") + .args(["diff", "--cached", "--quiet"]) + .current_dir(worktree_path) + .output(); + if commit_check.map_or(true, |o| !o.status.success()) { + let _ = std::process::Command::new("git") + .args(["commit", "-m", &format!("agent {agent_id}: completed task")]) + .current_dir(worktree_path) + .output(); + } + // Remove the worktree + let _ = std::process::Command::new("git") + .args([ + "worktree", + "remove", + worktree_path.to_str().unwrap_or(""), + "--force", + ]) + .current_dir(&cwd) + .output(); + // Delete the branch + let _ = std::process::Command::new("git") + .args(["branch", "-D", &format!("agent/{agent_id}")]) + .current_dir(&cwd) + .output(); + Ok(()) +} + fn run_agent_job(job: &AgentJob) -> Result<(), String> { + // Inject provider credentials from /setup-saved config into env vars so + // sub-agents can reach the same endpoint the user connected the main session + // with (e.g. custom-openai's CLAWCUSTOMOPENAI_API_KEY / BASE_URL). Without + // this, sub-agents can't find custom/ or exotic providers that were set up + // via /setup rather than via explicit env vars. + runtime::inject_config_as_env_fallbacks(); + + // 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); + if !claimed { + return Err(format!("task {task_id} already claimed by another agent")); + } + } + } + + // Save and fix CLAWD_AGENT_STORE to prevent doubled paths when CWD changes + // between thread spawn and execution + if let Ok(store) = agent_store_dir() { + std::env::set_var("CLAWD_AGENT_STORE", store); + } + let mut runtime = build_agent_runtime(job)?.with_max_iterations(DEFAULT_AGENT_MAX_ITERATIONS); - let summary = runtime + + // Set auto-compaction threshold based on model's context window + if let Some(ref model) = job.manifest.model { + if let Some(limit) = model_token_limit(model) { + // Compact at 70% of context window to stay safely under the limit + let threshold = (limit.context_window_tokens as f32 * 0.7) as u32; + runtime = runtime.with_auto_compaction_input_tokens_threshold(threshold); + } + } + + // Attach TeamInboxReporter for per-tool-call progress when agent belongs to a team + if let Some(ref team_id) = job.team_id { + let reporter = TeamInboxReporter::new( + team_id.clone(), + job.manifest.agent_id.clone(), + job.manifest.name.clone(), + ); + runtime = runtime.with_turn_progress_reporter(Box::new(reporter)); + } + + let result = runtime .run_turn(job.prompt.clone(), None) - .map_err(|error| error.to_string())?; + .map_err(|error| error.to_string()); + + // Release task claim on completion (success or failure) + if let Some(ref task_id) = job.task_id { + let _ = release_claim(task_id); + } + + let summary = result?; let final_text = final_assistant_text(&summary); persist_agent_terminal_state(&job.manifest, "completed", Some(final_text.as_str()), None) } @@ -4232,6 +5605,7 @@ fn build_agent_runtime( fn build_agent_system_prompt(subagent_type: &str, model: &str) -> Result, String> { let cwd = std::env::current_dir().map_err(|error| error.to_string())?; + let agents_md = cwd.join("AGENTS.md"); let mut prompt = load_system_prompt( cwd, DEFAULT_AGENT_SYSTEM_DATE.to_string(), @@ -4243,15 +5617,70 @@ fn build_agent_system_prompt(subagent_type: &str, model: &str) -> Result) -> String { - model - .map(str::trim) - .filter(|model| !model.is_empty()) - .unwrap_or(DEFAULT_AGENT_MODEL) - .to_string() + if let Some(m) = model.map(str::trim).filter(|m| !m.is_empty()) { + return qualify_for_provider(m); + } + // subagentModel (if set) pins the sub-agent model. + if let Some(fast) = load_subagent_model_from_config() { + return qualify_for_provider(&fast); + } + // Otherwise default to the session's configured `model` so sub-agents use + // the same provider the user is actually connected with, rather than the + // hard-coded DEFAULT_AGENT_MODEL (which may not exist on a custom endpoint). + if let Some(session_model) = load_main_model_from_config() { + return qualify_for_provider(&session_model); + } + DEFAULT_AGENT_MODEL.to_string() +} + +/// Read the top-level `model` from merged config as a sub-agent default. +fn load_main_model_from_config() -> Option { + let cwd = std::env::current_dir().ok()?; + let config = ConfigLoader::default_for(&cwd).load().ok()?; + config.provider().model().map(str::to_string) +} + +/// Read the `subagentModel` setting from merged config so the Agent tool +/// honors it even when the caller didn't pass an explicit model. +fn load_subagent_model_from_config() -> Option { + let cwd = std::env::current_dir().ok()?; + let config = ConfigLoader::default_for(&cwd).load().ok()?; + config.subagent_model().map(str::to_string) +} + +/// When the user's active provider is `custom-openai`, bare model names +/// (e.g. "claude-haiku-4-5-20251001") need a `custom/` prefix so +/// `ProviderClient::from_model` routes them through the custom endpoint +/// instead of dispatching to the Anthropic provider (which has no creds). +fn qualify_for_provider(model: &str) -> String { + if model.starts_with("custom/") || model.contains('/') { + return model.to_string(); + } + let cwd = std::env::current_dir().unwrap_or_default(); + let Ok(config) = ConfigLoader::default_for(&cwd).load() else { + return model.to_string(); + }; + if config.provider().kind() == Some("custom-openai") { + return format!("custom/{model}"); + } + model.to_string() } fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet { @@ -4264,6 +5693,11 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet { "WebSearch", "ToolSearch", "Skill", + "AgentMessage", + "TaskClaim", + "AgentSuggestion", + "ContextRequest", + "TeamStatus", "StructuredOutput", ], "Plan" => vec![ @@ -4275,6 +5709,11 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet { "ToolSearch", "Skill", "TodoWrite", + "AgentMessage", + "TaskClaim", + "AgentSuggestion", + "ContextRequest", + "TeamStatus", "StructuredOutput", "SendUserMessage", ], @@ -4287,10 +5726,29 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet { "WebSearch", "ToolSearch", "TodoWrite", + "AgentMessage", + "TaskClaim", + "AgentSuggestion", + "ContextRequest", + "TeamStatus", "StructuredOutput", "SendUserMessage", "PowerShell", ], + "Reviewer" => vec![ + "read_file", + "glob_search", + "grep_search", + "WebFetch", + "WebSearch", + "ToolSearch", + "AgentMessage", + "TaskClaim", + "AgentSuggestion", + "ContextRequest", + "TeamStatus", + "StructuredOutput", + ], "claw-guide" => vec![ "read_file", "glob_search", @@ -4325,6 +5783,11 @@ fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet { "ToolSearch", "NotebookEdit", "Sleep", + "AgentMessage", + "TaskClaim", + "AgentSuggestion", + "ContextRequest", + "TeamStatus", "SendUserMessage", "Config", "StructuredOutput", @@ -4395,7 +5858,44 @@ fn persist_agent_terminal_state( )); } } - write_agent_manifest(&next_manifest) + write_agent_manifest(&next_manifest)?; + + // If this agent belongs to a team, post completion to the team inbox + if let Some(ref tid) = next_manifest.team_id { + let _ = post_agent_completion_to_team_inbox(&next_manifest, tid, status, result); + } + + Ok(()) +} + +fn post_agent_completion_to_team_inbox( + manifest: &AgentOutput, + team_id: &str, + status: &str, + result: Option<&str>, +) -> Result<(), String> { + let mailbox_dir = agent_mailbox_dir().join("team").join(team_id); + std::fs::create_dir_all(&mailbox_dir).map_err(|e| e.to_string())?; + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let entry = json!({ + "event": format!("agent_{status}"), + "agent_id": manifest.agent_id, + "name": manifest.name, + "subagent_type": manifest.subagent_type, + "status": status, + "result_preview": result.map(|r| r.chars().take(2000).collect::()), + "error": if status == "failed" { result.map(|r| r.chars().take(500).collect::()) } else { None:: }, + "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()) } const MIN_LANE_SUMMARY_WORDS: usize = 7; @@ -5140,18 +6640,33 @@ impl ProviderRuntimeClient { allowed_tools: BTreeSet, fallback_config: &ProviderFallbackConfig, ) -> Result { - let primary_model = fallback_config.primary().map_or(model, str::to_string); - let primary = build_provider_entry(&primary_model)?; + // The caller's resolved `model` is the primary. `providerFallbacks` + // (primary + fallbacks) are recovery entries tried in order after the + // primary fails with a retryable error. Previously the config's + // `providerFallbacks.primary` silently overrode the resolved model, + // which made sub-agents ignore `subagentModel` / the Agent tool's + // explicit model — e.g. spawning agents that always used a dead + // configured primary instead of the model the caller picked. + let primary = build_provider_entry(&model)?; let mut chain = vec![primary]; - for fallback_model in fallback_config.fallbacks() { - match build_provider_entry(fallback_model) { + let mut seen: BTreeSet = std::iter::once(model.clone()).collect(); + let mut push_fallback = |chain: &mut Vec, m: &str| { + if seen.contains(m) { + return; + } + seen.insert(m.to_string()); + match build_provider_entry(m) { Ok(entry) => chain.push(entry), Err(error) => { - eprintln!( - "warning: skipping unavailable fallback provider {fallback_model}: {error}" - ); + eprintln!("warning: skipping unavailable fallback provider {m}: {error}") } } + }; + if let Some(config_primary) = fallback_config.primary() { + push_fallback(&mut chain, config_primary); + } + for fallback_model in fallback_config.fallbacks() { + push_fallback(&mut chain, fallback_model); } Ok(Self { runtime: tokio::runtime::Runtime::new().map_err(|error| error.to_string())?, @@ -5179,6 +6694,111 @@ fn load_provider_fallback_config() -> ProviderFallbackConfig { }) } +/// Whether an API error should advance the provider chain to the next model. +/// In addition to the standard retryable statuses (429/500/502/503/504), a +/// 404 whose body indicates the model itself is unknown ("model not found") +/// is chain-eligible: a dead primary model shouldn't abort the whole team — +/// fall through to the next configured fallback. +fn fallback_chain_eligible(error: &ApiError) -> bool { + if error.is_retryable() { + return true; + } + is_model_not_found(error) +} + +/// True when the error body indicates the requested model doesn't exist on +/// the provider (HTTP 404 + a "not found" / "model" message). +fn is_model_not_found(error: &ApiError) -> bool { + let Some(status) = error.status_code() else { + return false; + }; + if status != reqwest::StatusCode::NOT_FOUND && status != reqwest::StatusCode::BAD_REQUEST { + return false; + } + let body = error + .response_body() + .unwrap_or_default() + .to_ascii_lowercase(); + let message = error.to_string().to_ascii_lowercase(); + body.contains("not found") + || body.contains("does not exist") + || message.contains("not found") + || (body.contains("model") && body.contains("unavailable")) +} + +/// Short human-readable reason for a chain fallthrough, for the log line. +fn fallback_reason(error: &ApiError) -> &'static str { + if error.is_retryable() { + "retryable error" + } else if is_model_not_found(error) { + "model not found" + } else { + "error" + } +} + +#[cfg(test)] +mod fallback_chain_tests { + use super::{fallback_chain_eligible, is_model_not_found}; + use api::ApiError; + + fn model_not_found_404() -> ApiError { + ApiError::Api { + status: reqwest::StatusCode::NOT_FOUND, + error_type: Some("invalid_request_error".to_string()), + message: Some("Model 'openai/glm-5.1-fast' not found.".to_string()), + request_id: None, + body: "{\"detail\":\"Model 'openai/glm-5.1-fast' not found. Use GET /v1/models to see available models.\"}".to_string(), + retryable: false, + suggested_action: None, + retry_after: None, + } + } + + fn auth_error() -> ApiError { + ApiError::Api { + status: reqwest::StatusCode::UNAUTHORIZED, + error_type: None, + message: None, + request_id: None, + body: "unauthorized".to_string(), + retryable: false, + suggested_action: None, + retry_after: None, + } + } + + fn rate_limited() -> ApiError { + ApiError::Api { + status: reqwest::StatusCode::TOO_MANY_REQUESTS, + error_type: None, + message: None, + request_id: None, + body: "slow down".to_string(), + retryable: true, + suggested_action: None, + retry_after: None, + } + } + + #[test] + fn model_not_found_404_is_chain_eligible() { + assert!(is_model_not_found(&model_not_found_404())); + assert!(fallback_chain_eligible(&model_not_found_404())); + } + + #[test] + fn auth_error_is_not_chain_eligible() { + assert!(!is_model_not_found(&auth_error())); + assert!(!fallback_chain_eligible(&auth_error())); + } + + #[test] + fn retryable_error_remains_chain_eligible() { + assert!(fallback_chain_eligible(&rate_limited())); + } +} + impl ApiClient for ProviderRuntimeClient { fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { let tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools)) @@ -5212,10 +6832,11 @@ impl ApiClient for ProviderRuntimeClient { let attempt = runtime.block_on(stream_with_provider(&entry.client, &message_request)); match attempt { Ok(events) => return Ok(events), - Err(error) if error.is_retryable() && index + 1 < chain.len() => { + Err(error) if fallback_chain_eligible(&error) && index + 1 < chain.len() => { eprintln!( - "provider {} failed with retryable error, falling back: {error}", - entry.model + "provider {} failed ({}, falling back to next in chain): {error}", + entry.model, + fallback_reason(&error) ); last_error = Some(error); } @@ -5715,6 +7336,7 @@ fn normalize_subagent_type(subagent_type: Option<&str>) -> String { "verification" | "verificationagent" | "verify" | "verifier" => { String::from("Verification") } + "reviewer" | "review" | "reviewagent" => String::from("Reviewer"), "clawguide" | "clawguideagent" | "guide" => String::from("claw-guide"), "statusline" | "statuslinesetup" => String::from("statusline-setup"), _ => trimmed.to_string(), @@ -8699,6 +10321,8 @@ mod tests { subagent_type: Some("Explore".to_string()), name: Some("ship-audit".to_string()), model: None, + team_id: None, + task_id: None, }, move |job| { *captured_for_spawn @@ -8780,6 +10404,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, }, |job| { persist_agent_terminal_state( @@ -8837,6 +10463,8 @@ mod tests { subagent_type: Some("Verification".to_string()), name: Some("fail-task".to_string()), model: None, + team_id: None, + task_id: None, }, |job| { persist_agent_terminal_state( @@ -8884,6 +10512,8 @@ mod tests { subagent_type: Some("Explore".to_string()), name: Some("summary-floor".to_string()), model: None, + team_id: None, + task_id: None, }, |job| { persist_agent_terminal_state( @@ -8929,6 +10559,8 @@ mod tests { subagent_type: Some("Explore".to_string()), name: Some("recovery-lane".to_string()), model: None, + team_id: None, + task_id: None, }, |job| { persist_agent_terminal_state( @@ -8977,6 +10609,8 @@ mod tests { subagent_type: Some("Verification".to_string()), name: Some("review-lane".to_string()), model: None, + team_id: None, + task_id: None, }, |job| { persist_agent_terminal_state( @@ -9017,6 +10651,8 @@ mod tests { subagent_type: Some("Explore".to_string()), name: Some("backlog-scan".to_string()), model: None, + team_id: None, + task_id: None, }, |job| { persist_agent_terminal_state( @@ -9063,6 +10699,8 @@ mod tests { subagent_type: Some("Explore".to_string()), name: Some("artifact-lane".to_string()), model: None, + team_id: None, + task_id: None, }, |job| { persist_agent_terminal_state( @@ -9133,6 +10771,8 @@ mod tests { subagent_type: Some("Explore".to_string()), name: Some("cron-closeout".to_string()), model: None, + team_id: None, + task_id: None, }, |job| { persist_agent_terminal_state( @@ -9174,6 +10814,8 @@ mod tests { subagent_type: None, name: Some("spawn-error".to_string()), model: None, + team_id: None, + task_id: None, }, |_| Err(String::from("thread creation failed")), )