This commit is contained in:
TheArchitectit 2026-08-16 13:20:03 +07:00 committed by GitHub
commit a263895f0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 2953 additions and 215 deletions

1
.gitignore vendored
View File

@ -13,3 +13,4 @@ archive/
status-help.txt
# Legacy Python port session scratch artifacts
.port_sessions/
.claude/

View File

@ -364,10 +364,11 @@ Reasoning variants (`qwen-qwq-*`, `qwq-*`, `*-thinking`) automatically strip `te
| **xAI** | OpenAI-compatible | `XAI_API_KEY` | `XAI_BASE_URL` | `https://api.x.ai/v1` |
| **OpenAI-compatible** | OpenAI Chat Completions | `OPENAI_API_KEY` | `OPENAI_BASE_URL` | `https://api.openai.com/v1` |
| **DashScope** (Alibaba) | OpenAI-compatible | `DASHSCOPE_API_KEY` | `DASHSCOPE_BASE_URL` | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
| **Custom OpenAI-compat** | OpenAI Chat Completions | `CLAWCUSTOMOPENAI_API_KEY` | `CLAWCUSTOMOPENAI_BASE_URL` | none (must be supplied) |
The OpenAI-compatible backend also serves as the gateway for **OpenRouter**, **Ollama**, and any other service that speaks the OpenAI `/v1/chat/completions` wire format — just point `OPENAI_BASE_URL` at the service.
**Model-name prefix routing:** If a model name starts with `openai/`, `local/`, `gpt-`, `qwen/`, `qwen-`, `kimi/`, or `kimi-`, the provider is selected by the prefix regardless of which env vars are set. This prevents accidental misrouting to Anthropic when multiple credentials exist in the environment. For the default OpenAI API and local/private OpenAI-compatible endpoints, `openai/` is a routing prefix and is stripped before the request hits the wire. For non-local custom `OPENAI_BASE_URL` gateways, slash-containing OpenAI-compatible slugs (for example OpenRouter-style `openai/gpt-4.1-mini`) are preserved so the gateway receives the model ID it expects. The `local/` prefix is an explicit escape hatch for local slash-containing model IDs: it is stripped while the rest of the model ID is sent verbatim.
**Model-name prefix routing:** If a model name starts with `openai/`, `local/`, `custom/`, `gpt-`, `qwen/`, `qwen-`, `kimi/`, or `kimi-`, the provider is selected by the prefix regardless of which env vars are set. This prevents accidental misrouting to Anthropic when multiple credentials exist in the environment. For the default OpenAI API and local/private OpenAI-compatible endpoints, `openai/` is a routing prefix and is stripped before the request hits the wire. For non-local custom `OPENAI_BASE_URL` gateways, slash-containing OpenAI-compatible slugs (for example OpenRouter-style `openai/gpt-4.1-mini`) are preserved so the gateway receives the model ID it expects. The `local/` prefix is an explicit escape hatch for local slash-containing model IDs: it is stripped while the rest of the model ID is sent verbatim. The `custom/` prefix selects the Claw-specific custom OpenAI-compat provider (`CLAWCUSTOMOPENAI_*` env vars) and is also stripped on the wire; use it when you want a private OpenAI-compatible endpoint that does not collide with real `OPENAI_*` credentials.
### Tested models and aliases
@ -407,15 +408,22 @@ Local project settings override user-level settings. Aliases resolve through the
Model selection precedence is CLI flag, environment, config, then default. The environment model slot accepts `CLAW_MODEL`, `ANTHROPIC_MODEL`, and `ANTHROPIC_DEFAULT_MODEL` in that order; aliases from those variables are resolved and validated before provider startup. `claw --output-format json status` exposes `model_raw`, `model_alias_resolved_to`, and `model_env_var` so automation can see the winning value.
### /setup wizard
Run `/setup` inside the REPL (or `claw setup` from the shell) to save provider credentials interactively to `~/.claw/settings.json`. Saved settings are injected as env-var fallbacks at startup, preserving the precedence: env var > `.env` file > stored config.
For a **Custom (OpenAI-compat)** endpoint, `/setup` now stores `kind: "custom-openai"` and sets the dedicated `CLAWCUSTOMOPENAI_API_KEY` / `CLAWCUSTOMOPENAI_BASE_URL` env vars. This lets a private OpenAI-compatible proxy coexist with real OpenAI, NeuralWatt, or other tools that already consume `OPENAI_*`. A bare model name saved by `/setup` is normalized to `custom/<model>` and stripped back to the bare id on the wire.
### How provider detection works
1. If the resolved model name starts with `claude` → Anthropic.
2. If it starts with `grok` → xAI.
3. If it starts with `openai/`, `local/`, or `gpt-` → OpenAI-compatible.
3. If it starts with `openai/`, `local/`, `custom/`, or `gpt-` → OpenAI-compatible.
4. If it starts with `qwen/`, `qwen-`, `kimi/`, or `kimi-` → DashScope-compatible OpenAI wire format.
5. If `OPENAI_BASE_URL` is set, local-looking unknown model names such as `llama3.2` or `qwen2.5-coder:7b` route to the OpenAI-compatible client for local/gateway servers.
6. Otherwise, `claw` checks which credential is set: Anthropic first, then OpenAI, then xAI. If only `OPENAI_BASE_URL` is set, it still routes to OpenAI-compatible for authless local servers.
7. If nothing matches, it defaults to Anthropic.
6. If it starts with `custom/` → the Claw custom OpenAI-compat provider (`CLAWCUSTOMOPENAI_API_KEY` / `CLAWCUSTOMOPENAI_BASE_URL`).
7. Otherwise, `claw` checks which credential is set: Anthropic first, then OpenAI, then xAI. If only `OPENAI_BASE_URL` is set, it still routes to OpenAI-compatible for authless local servers.
8. If nothing matches, it defaults to Anthropic.
### Provider diagnostics and custom OpenAI-compatible parameters

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

@ -118,15 +118,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 +136,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",
@ -591,7 +591,7 @@ dependencies = [
"clap",
"criterion-plot",
"is-terminal",
"itertools",
"itertools 0.10.5",
"num-traits",
"once_cell",
"oorandom",
@ -612,7 +612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools",
"itertools 0.10.5",
]
[[package]]
@ -1417,6 +1417,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"
@ -1794,7 +1803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools",
"itertools 0.13.0",
"proc-macro2",
"quote",
"syn",
@ -2192,9 +2201,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 +2232,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",
@ -2247,6 +2256,7 @@ dependencies = [
"crossterm",
"log",
"mock-anthropic-service",
"once_cell",
"plugins",
"pulldown-cmark",
"runtime",
@ -2982,9 +2992,9 @@ checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-width"
version = "0.2.2"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "untrusted"

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,14 +679,18 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
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 {
max_output_tokens: 8_192,
context_window_tokens: 131_072,
"glm-5.1-fast" => Some(ModelTokenLimit {
max_output_tokens: 16_384,
context_window_tokens: 200_000,
}),
// Unknown models return None so preflight skips the context-window
// check and lets the API enforce its own limits.
_ => None,
}
}
@ -1138,6 +1155,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

@ -808,7 +808,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 +1188,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 +1513,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 +5400,7 @@ pub fn handle_slash_command(
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. }
| SlashCommand::History { .. }
| SlashCommand::Lsp { .. }
| SlashCommand::Team { .. }
| SlashCommand::Setup
| SlashCommand::Unknown(_) => None,
@ -5525,6 +5533,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))

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::{
@ -3891,4 +3961,79 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn subagent_model_reads_camel_case_config_key() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
"{\n \"subagentModel\": \"custom/openclaw\"\n}\n",
)
.expect("write settings");
let config = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
config.subagent_model(),
Some("custom/openclaw"),
"subagentModel camelCase key should be read end-to-end"
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn subagent_model_reads_snake_case_config_key() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
"{\n \"subagent_model\": \"qwen3.6-35b-fast\"\n}\n",
)
.expect("write settings");
let config = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
config.subagent_model(),
Some("qwen3.6-35b-fast"),
"subagent_model snake_case key should be read end-to-end"
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn subagent_model_blank_value_returns_none() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
"{\n \"subagentModel\": \" \"\n}\n",
)
.expect("write settings");
let config = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
config.subagent_model(),
None,
"blank subagentModel should fall back to None"
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
}

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

@ -14,6 +14,7 @@ api = { path = "../api" }
commands = { path = "../commands" }
crossterm = "0.28"
pulldown-cmark = "0.13"
once_cell = "1"
rustyline = "15"
runtime = { path = "../runtime" }
plugins = { path = "../plugins" }
@ -25,8 +26,15 @@ tools = { path = "../tools" }
log = "0.4"
[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
@ -13982,6 +14127,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 +14937,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, runtime::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, runtime::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