This commit is contained in:
TheArchitectit 2026-07-07 05:09:21 +00:00 committed by GitHub
commit 266bb9a716
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 3163 additions and 218 deletions

290
rust/Cargo.lock generated Executable file → Normal file
View File

@ -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"
@ -2981,10 +3210,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-width"
version = "0.2.2"
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools 0.13.0",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]]
name = "unicode-width"
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 = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "untrusted"

View File

@ -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 }

View File

@ -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)?))

View File

@ -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<reqwest::StatusCode> {
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 {

View File

@ -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::{

View File

@ -270,6 +270,14 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
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<ModelTokenLimit> {
max_output_tokens: 16_384,
context_window_tokens: 256_000,
}),
"qwen-max" => Some(ModelTokenLimit {
// 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,
}),
"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,
}),
"qwen-plus" => 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");

View File

@ -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();

View File

@ -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<String>,
},
Unknown(String),
Lsp {
action: Option<String>,
target: Option<String>,
},
Team {
action: Option<String>,
},
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);
}

View File

@ -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<String>,
}
/// 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<String, String> {
&self.feature_config.aliases
@ -1717,6 +1729,21 @@ fn parse_optional_model(root: &JsonValue) -> Option<String> {
.map(ToOwned::to_owned)
}
/// Reads `subagentModel` (or the snake_case `subagent_model` alias) from
/// merged settings. Returns `None` when absent or blank so the Agent tool
/// falls back to the default model.
fn parse_optional_subagent_model(root: &JsonValue) -> Option<String> {
root.as_object()
.and_then(|object| {
object
.get("subagentModel")
.or_else(|| object.get("subagent_model"))
})
.and_then(JsonValue::as_str)
.filter(|s| !s.trim().is_empty())
.map(|s| s.trim().to_string())
}
fn parse_optional_aliases(root: &JsonValue) -> Result<BTreeMap<String, String>, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(BTreeMap::new());
@ -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::{

View File

@ -61,6 +61,40 @@ pub trait ApiClient {
/// Trait implemented by tool dispatchers that execute model-requested tools.
pub trait ToolExecutor {
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError>;
/// Execute a batch of tool calls, potentially in parallel.
/// Returns results in the same order as the input calls.
/// The default implementation executes sequentially via `execute`.
/// Override this to provide parallel execution for read-only tools.
fn execute_batch(&mut self, calls: Vec<ToolCall>) -> Vec<ToolResult> {
calls
.into_iter()
.map(|call| {
let result = self.execute(&call.tool_name, &call.input);
ToolResult {
tool_use_id: call.tool_use_id,
tool_name: call.tool_name,
result,
}
})
.collect()
}
}
/// A single tool call to execute.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolCall {
pub tool_use_id: String,
pub tool_name: String,
pub input: String,
}
/// The result of executing a tool call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolResult {
pub tool_use_id: String,
pub tool_name: String,
pub result: Result<String, ToolError>,
}
/// Error returned when a tool invocation fails locally.
@ -86,6 +120,22 @@ impl Display for ToolError {
impl std::error::Error for ToolError {}
/// Callback trait for reporting tool execution progress during a turn.
/// Implementations can post progress to a team inbox, log to stderr, etc.
/// Called after each tool call completes (success or failure).
pub trait TurnProgressReporter: Send + Sync {
/// Called after a tool execution completes.
/// `iteration` is 1-based index of the tool call within this turn.
fn on_tool_result(
&self,
iteration: usize,
max_iterations: usize,
tool_name: &str,
input: &str,
result: Result<&str, &str>,
);
}
/// Error returned when a conversation turn cannot be completed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeError {
@ -140,6 +190,7 @@ pub struct ConversationRuntime<C, T> {
hook_abort_signal: HookAbortSignal,
hook_progress_reporter: Option<Box<dyn HookProgressReporter>>,
session_tracer: Option<SessionTracer>,
turn_progress_reporter: Option<Box<dyn TurnProgressReporter>>,
}
impl<C, T> ConversationRuntime<C, T>
@ -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<dyn TurnProgressReporter>) -> Self {
self.turn_progress_reporter = Some(reporter);
self
}
fn run_pre_tool_use_hook(&mut self, tool_name: &str, input: &str) -> HookRunResult {
if let Some(reporter) = self.hook_progress_reporter.as_mut() {
self.hook_runner.run_pre_tool_use_with_context(
@ -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<String>,
allowed: bool,
deny_reason: Option<String>,
}
let mut pending = Vec::with_capacity(pending_tool_uses.len());
for (tool_use_id, tool_name, input) in &pending_tool_uses {
let pre_hook_result = self.run_pre_tool_use_hook(tool_name, input);
let effective_input = pre_hook_result
.updated_input()
.map_or_else(|| input.clone(), ToOwned::to_owned);
let pre_hook_messages = pre_hook_result.messages().to_vec();
let permission_context = PermissionContext::new(
pre_hook_result.permission_override(),
pre_hook_result.permission_reason().map(ToOwned::to_owned),
@ -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<ToolCall> = pending
.iter()
.filter(|p| p.allowed)
.map(|p| {
self.record_tool_started(iterations, &p.tool_name);
ToolCall {
tool_use_id: p.tool_use_id.clone(),
tool_name: p.tool_name.clone(),
input: p.effective_input.clone(),
}
})
.collect();
let batch_results = self.tool_executor.execute_batch(allowed_calls);
let mut batch_index = 0;
// Phase 3: Post-hooks and session updates (sequential, original order).
for p in &pending {
// Capture progress data for the reporter.
let (
progress_tool_name,
progress_input,
progress_output,
progress_is_error,
result_message,
) = if p.allowed {
let batch_result = &batch_results[batch_index];
batch_index += 1;
let (mut output, mut is_error) = match &batch_result.result {
Ok(output) => (output.clone(), false),
Err(error) => (error.to_string(), true),
};
output = merge_hook_feedback(&p.pre_hook_messages, output, false);
let post_hook_result = if is_error {
self.run_post_tool_use_failure_hook(
&p.tool_name,
&p.effective_input,
&output,
)
} else {
self.run_post_tool_use_hook(
&p.tool_name,
&p.effective_input,
&output,
false,
)
};
if post_hook_result.is_denied()
|| post_hook_result.is_failed()
|| post_hook_result.is_cancelled()
{
is_error = true;
}
output = merge_hook_feedback(
post_hook_result.messages(),
output,
post_hook_result.is_denied()
|| post_hook_result.is_failed()
|| post_hook_result.is_cancelled(),
);
let progress_output = output.clone();
let result_message = ConversationMessage::tool_result(
p.tool_use_id.clone(),
p.tool_name.clone(),
output,
is_error,
);
(
p.tool_name.clone(),
p.effective_input.clone(),
progress_output,
is_error,
result_message,
)
} else {
let denied_output = merge_hook_feedback(
&p.pre_hook_messages,
p.deny_reason.clone().unwrap_or_default(),
true,
),
);
let result_message = ConversationMessage::tool_result(
p.tool_use_id.clone(),
p.tool_name.clone(),
denied_output,
true,
);
(
p.tool_name.clone(),
String::new(),
String::new(),
true,
result_message,
)
};
self.session
.push_message(result_message.clone())
.map_err(|error| RuntimeError::new(error.to_string()))?;
self.record_tool_finished(iterations, &result_message);
if let Some(ref reporter) = self.turn_progress_reporter {
let report_result = if progress_is_error {
Err(progress_output.as_str())
} else {
Ok(progress_output.as_str())
};
reporter.on_tool_result(
iterations,
self.max_iterations,
&progress_tool_name,
&progress_input,
report_result,
);
}
tool_results.push(result_message);
}
}

View File

@ -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,

View File

@ -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" }

View File

@ -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()?;

View File

@ -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<dyn std::error::Error>> {
}
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<PermissionMode> {
fn config_model_for_current_dir() -> Option<String> {
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<String, String> {
@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<W: io::Write>(
&mut self,
input: &str,
out: &mut W,
emit_output: bool,
) -> Result<(), Box<dyn std::error::Error>> {
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<AuthSource, api::ApiError> {
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<Vec<AssistantEvent>, RuntimeError> {
@ -13982,6 +14166,153 @@ impl ToolExecutor for CliToolExecutor {
}
}
}
fn execute_batch(&mut self, calls: Vec<runtime::ToolCall>) -> Vec<runtime::ToolResult> {
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<Option<runtime::ToolResult>> = vec![None; calls.len()];
let mut parallel_calls: Vec<(usize, String, String, String)> = Vec::new();
let mut sequential_indices: Vec<usize> = 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<String, ToolError>)> =
std::thread::scope(|s| {
let mut handles = Vec::new();
for (idx, tool_use_id, tool_name, input) in &parallel_calls {
let registry = &registry;
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();

View File

@ -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<dyn std::error::Error>> {
@ -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() {

View File

@ -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: 510 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()` |

View File

@ -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,
}
}

File diff suppressed because it is too large Load Diff