mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-14 00:00:19 +08:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d8fd5f917 | ||
|
|
2d920ce915 | ||
|
|
80bfe94b77 | ||
|
|
5303de65ad | ||
|
|
50ff600466 | ||
|
|
4c3b10f087 | ||
|
|
6a797de094 | ||
|
|
e97ddc767f | ||
|
|
687c6b03a4 | ||
|
|
7c38d78759 | ||
|
|
cb572811e1 | ||
|
|
fa0d53a969 | ||
|
|
910ac4e326 | ||
|
|
a25717aded | ||
|
|
7a1391fbc1 | ||
|
|
2944e02a09 | ||
|
|
cd01ffc5f3 | ||
|
|
4361e98998 | ||
|
|
ad65ea5a47 | ||
|
|
a6c32edcf6 |
@@ -15,4 +15,3 @@ status-help.txt
|
||||
.port_sessions/
|
||||
# Local agent session state
|
||||
.gjc/
|
||||
.claude/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"schema_version":1,"session_id":"24aa7e35-e6af-4d2d-ab41-a38b9115a031","state":"running","ready_for_input":false,"updated_at":"2026-10-12T22:10:00.508Z","current_turn_id":null,"last_turn_id":null,"live":true,"reason":null,"source":"agent_session_event","event":"turn_start","cwd":"/home/bellman/Data/Workspace/gc-task-cc-pr-3250-lane","workdir":"/home/bellman/Data/Workspace/gc-task-cc-pr-3250-lane","branch":null,"session_file":"/home/bellman/.gajaeway/broker/agent/sessions/v2-famocqbiduwkxmc2g2nvg6jfq2ay6qtc5aihbhbdoqopsmduj4sa/2026-10-12T22-02-16-124Z_24aa7e35-e6af-4d2d-ab41-a38b9115a031.jsonl","activity":{"seq":149,"last_activity_at":"2026-10-12T22:10:01.196Z","tool":"bash","phase":"started","outcome":null,"elapsed_ms":null,"active_tool_count":1,"active_tools":[{"tool":"bash","started_at":"2026-10-12T22:10:01.196Z"}],"in_flight":[{"digest":"fa5f186357264e6b1be2a7a4a7162d0fff04f721630413a25d05b40bcd933836","tool":"bash","started_at":"2026-10-12T22:10:01.196Z"}]}}
|
||||
@@ -1 +0,0 @@
|
||||
{"pid":1,"start_time":"unknown","token":"8389ee99-c41c-47dd-9615-e76a5268967c","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"pid":1,"start_time":"unknown","token":"6e35ded8-fda1-421e-a727-42906ef57646","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sessionId": "24aa7e35-e6af-4d2d-ab41-a38b9115a031",
|
||||
"pid": 177576,
|
||||
"host": "127.0.0.1",
|
||||
"port": 34539,
|
||||
"url": "ws://127.0.0.1:34539",
|
||||
"token": "8FBuSDC3564yKKk5Fpgl6TEknzktQX8W",
|
||||
"startedAt": 1791842537691,
|
||||
"updatedAt": 1791842537691,
|
||||
"stale": false
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"effectMarker":"7b92c148-d616-4eb5-95e8-4e95084bebe9","incarnation":"linux:159085489","pid":177576}
|
||||
@@ -1 +0,0 @@
|
||||
{"effectMarker":"7b92c148-d616-4eb5-95e8-4e95084bebe9","incarnation":"linux:159085489","pid":177576}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Issue #3283 Triage Summary
|
||||
|
||||
**Issue:** docs: document OmniRoute OpenAI-compatible setup
|
||||
**Opened by:** @diegosouzapw
|
||||
**Status:** BLOCKED (waiting for clarification)
|
||||
**Date:** 2026-10-13
|
||||
|
||||
## Findings
|
||||
|
||||
### Investigation
|
||||
1. Searched codebase for "OmniRoute" — **no matches found**
|
||||
2. Searched documentation (docs/local-openai-compatible-providers.md, MODEL_COMPATIBILITY.md) — **no mentions**
|
||||
3. Searched git history (commits, branches, PRs) — **no references**
|
||||
4. Checked GitHub API (rate limited, but issue title confirmed)
|
||||
|
||||
### Current State
|
||||
- Issue #3283 is OPEN
|
||||
- No labels assigned
|
||||
- Title: "docs: document OmniRoute OpenAI-compatible setup"
|
||||
- This is a documentation request for setting up an OpenAI-compatible provider
|
||||
|
||||
### Existing Documentation
|
||||
The repository already has comprehensive documentation for OpenAI-compatible providers:
|
||||
- `docs/local-openai-compatible-providers.md` — documents Ollama, llama.cpp, vLLM, and OpenRouter
|
||||
- `docs/MODEL_COMPATIBILITY.md` — model-specific provider routing details
|
||||
- `rust/crates/api/src/providers/openai_compat.rs` — provider implementation
|
||||
|
||||
## Next Steps
|
||||
|
||||
**Action taken:** Comment added to issue asking for:
|
||||
1. OmniRoute documentation/website link
|
||||
2. OpenAI-compatible endpoint example (base URL, API key format)
|
||||
3. Model ID examples
|
||||
4. Any special configuration or known quirks
|
||||
|
||||
**Comment posted to:** https://github.com/ultraworkers/claw-code/issues/3283#issuecomment-6133484114
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Cannot proceed without user input.** Once the user provides the necessary information about OmniRoute, the fix will involve:
|
||||
1. Adding a new section to `docs/local-openai-compatible-providers.md`
|
||||
2. Including setup example (environment variables, model names, basic smoke test)
|
||||
3. Following the existing format used for Ollama, llama.cpp, vLLM sections
|
||||
4. Creating a PR for review
|
||||
@@ -130,6 +130,25 @@ claw --model "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit" prompt "Reply exactl
|
||||
|
||||
mlx-lm serves models under their full Hugging Face repo ID. Use the exact `id` field from `curl $OPENAI_BASE_URL/models` for `--model`. A bare name like `qwen2.5-coder-7b-instruct` will fail model resolution before the request ever reaches the server.
|
||||
|
||||
## OmniRoute and other remote OpenAI-compatible gateways
|
||||
|
||||
[OmniRoute](https://github.com/diegosouzapw/OmniRoute) is a third-party OpenAI-compatible AI gateway that can route requests to multiple providers. To use any remote OpenAI-compatible gateway with Claw, set `OPENAI_BASE_URL` to the gateway's `/v1` endpoint and provide your API key:
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL="https://api.omniroute.ai/v1"
|
||||
export OPENAI_API_KEY="your-omniroute-api-key"
|
||||
claw --model "gpt-4" prompt "Reply exactly HELLO_WORLD_123"
|
||||
```
|
||||
|
||||
To find the available model IDs on your gateway, use the `/models` endpoint:
|
||||
|
||||
```bash
|
||||
curl -sS "$OPENAI_BASE_URL/models" \
|
||||
-H "Authorization: Bearer $OPENAI_API_KEY" | jq '.data[].id'
|
||||
```
|
||||
|
||||
For local configuration, see your gateway's documentation for the correct base URL, API key format, and available model IDs. The same OPENAI_BASE_URL + OPENAI_API_KEY pattern applies to any OpenAI-compatible remote gateway.
|
||||
|
||||
## Local skills install from disk
|
||||
|
||||
Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`.
|
||||
|
||||
Generated
+66
-49
@@ -118,15 +118,15 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "aspect-core"
|
||||
version = "0.1.2"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e94aecae4a10eea63983b608beb48998578925d0660796140d69d8e8276f215"
|
||||
checksum = "70188b9bf884266a6c7117e30af44f38229bc5ac56916bd16512b3e49f90fe20"
|
||||
|
||||
[[package]]
|
||||
name = "aspect-macros"
|
||||
version = "0.1.2"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d56334ff035ccdeed6b6c159b9089ae1baba29f93eb6fa8bd89312e9fa3f136"
|
||||
checksum = "176e7db9b6a7bb4f117b8d97054d2d5a7bdc43b95c19c15c751fb8dcb9bc8a5c"
|
||||
dependencies = [
|
||||
"aspect-core",
|
||||
"proc-macro2",
|
||||
@@ -136,9 +136,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aspect-std"
|
||||
version = "0.1.2"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "059c997171f851dcd36d5eb14cf9ec831884ecafa2b753629379ff4b71971008"
|
||||
checksum = "ba7d130884fda30ec0acabcadc8f4711267d1cc21edc8f85283e91a011d699fa"
|
||||
dependencies = [
|
||||
"aspect-core",
|
||||
"log",
|
||||
@@ -383,6 +383,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
@@ -591,7 +602,7 @@ dependencies = [
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools 0.10.5",
|
||||
"itertools",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
@@ -612,7 +623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools 0.10.5",
|
||||
"itertools",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1002,12 +1013,24 @@ name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -1417,15 +1440,6 @@ 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"
|
||||
@@ -1803,7 +1817,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.13.0",
|
||||
"itertools",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
@@ -1890,14 +1904,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -1938,6 +1953,12 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "radix_trie"
|
||||
version = "0.2.1"
|
||||
@@ -1955,18 +1976,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_chacha",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1979,16 +2001,6 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
@@ -2000,11 +2012,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2201,9 +2219,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
@@ -2232,9 +2250,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.10"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -2256,7 +2274,6 @@ dependencies = [
|
||||
"crossterm",
|
||||
"log",
|
||||
"mock-anthropic-service",
|
||||
"once_cell",
|
||||
"plugins",
|
||||
"pulldown-cmark",
|
||||
"runtime",
|
||||
@@ -2992,9 +3009,9 @@ checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.0"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
|
||||
@@ -13,7 +13,130 @@ pub enum ProviderClient {
|
||||
OpenAi(OpenAiCompatClient),
|
||||
}
|
||||
|
||||
/// Provider selected explicitly by persisted/runtime configuration.
|
||||
///
|
||||
/// This is intentionally separate from `ProviderKind`: DashScope speaks the
|
||||
/// OpenAI-compatible wire protocol but remains a distinct configured provider.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderConfigKind {
|
||||
Anthropic,
|
||||
Xai,
|
||||
OpenAi,
|
||||
DashScope,
|
||||
}
|
||||
|
||||
/// Explicit provider configuration supplied by the runtime layer.
|
||||
///
|
||||
/// This keeps the API crate independent of runtime configuration types.
|
||||
/// Credentials are redacted from Debug output.
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderConfig {
|
||||
pub kind: ProviderConfigKind,
|
||||
pub model: String,
|
||||
pub api_key: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProviderConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ProviderConfig")
|
||||
.field("kind", &self.kind)
|
||||
.field("model", &self.model)
|
||||
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
||||
.field("base_url", &self.base_url)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn read_env_non_empty(key: &str) -> Option<String> {
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
impl ProviderClient {
|
||||
/// Construct a client from an explicit provider configuration.
|
||||
///
|
||||
/// The persisted provider kind is authoritative for provider selection,
|
||||
/// while environment credentials and base URLs retain precedence over
|
||||
/// persisted values.
|
||||
pub fn from_config(config: &ProviderConfig) -> Result<Self, ApiError> {
|
||||
match config.kind {
|
||||
ProviderConfigKind::Anthropic => {
|
||||
let api_key = read_env_non_empty("ANTHROPIC_API_KEY")
|
||||
.or_else(|| config.api_key.clone())
|
||||
.ok_or_else(|| {
|
||||
ApiError::missing_credentials(
|
||||
"Anthropic",
|
||||
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut client = AnthropicClient::new(api_key);
|
||||
let base_url = read_env_non_empty("ANTHROPIC_BASE_URL")
|
||||
.or_else(|| config.base_url.clone())
|
||||
.unwrap_or_else(anthropic::read_base_url);
|
||||
client = client.with_base_url(base_url);
|
||||
Ok(Self::Anthropic(client))
|
||||
}
|
||||
ProviderConfigKind::Xai => {
|
||||
let compat = OpenAiCompatConfig::xai();
|
||||
let api_key =
|
||||
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
|
||||
|
||||
let mut client = match api_key {
|
||||
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
|
||||
None => OpenAiCompatClient::from_env(compat)?,
|
||||
};
|
||||
|
||||
let base_url = read_env_non_empty(compat.base_url_env)
|
||||
.or_else(|| config.base_url.clone())
|
||||
.unwrap_or_else(|| compat.default_base_url.to_string());
|
||||
client = client.with_base_url(base_url);
|
||||
|
||||
Ok(Self::Xai(client))
|
||||
}
|
||||
ProviderConfigKind::OpenAi | ProviderConfigKind::DashScope => {
|
||||
let compat = match config.kind {
|
||||
ProviderConfigKind::OpenAi => OpenAiCompatConfig::openai(),
|
||||
ProviderConfigKind::DashScope => OpenAiCompatConfig::dashscope(),
|
||||
_ => unreachable!("non-OpenAI provider reached compatibility path"),
|
||||
};
|
||||
|
||||
if config.kind == ProviderConfigKind::OpenAi
|
||||
&& read_env_non_empty("OLLAMA_HOST").is_some()
|
||||
&& config.api_key.is_none()
|
||||
&& config.base_url.is_none()
|
||||
{
|
||||
return Ok(Self::OpenAi(
|
||||
openai_compat::OpenAiCompatClient::from_ollama_env()
|
||||
.expect("from_ollama_env always returns Some"),
|
||||
));
|
||||
}
|
||||
|
||||
let api_key =
|
||||
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
|
||||
let persisted_base_url = config.base_url.clone();
|
||||
let mut client = match api_key {
|
||||
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
|
||||
None if config.kind == ProviderConfigKind::OpenAi
|
||||
&& persisted_base_url
|
||||
.as_deref()
|
||||
.is_some_and(openai_compat::is_local_openai_compatible_base_url) =>
|
||||
{
|
||||
OpenAiCompatClient::new("local-dev-token", compat)
|
||||
}
|
||||
None => OpenAiCompatClient::from_env(compat)?,
|
||||
};
|
||||
let base_url = read_env_non_empty(compat.base_url_env)
|
||||
.or(persisted_base_url)
|
||||
.unwrap_or_else(|| compat.default_base_url.to_string());
|
||||
client = client.with_base_url(base_url);
|
||||
|
||||
Ok(Self::OpenAi(client))
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn from_model(model: &str) -> Result<Self, ApiError> {
|
||||
Self::from_model_with_anthropic_auth(model, None)
|
||||
}
|
||||
@@ -34,7 +157,7 @@ impl ProviderClient {
|
||||
ProviderKind::OpenAi => {
|
||||
// OLLAMA_HOST takes priority: local Ollama needs no API key
|
||||
// and ignores DashScope/OpenAI env-based dispatch.
|
||||
if std::env::var_os("OLLAMA_HOST").is_some() {
|
||||
if read_env_non_empty("OLLAMA_HOST").is_some() {
|
||||
Ok(Self::OpenAi(
|
||||
openai_compat::OpenAiCompatClient::from_ollama_env()
|
||||
.expect("from_ollama_env always returns Some"),
|
||||
@@ -155,7 +278,7 @@ pub fn read_xai_base_url() -> String {
|
||||
mod tests {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::ProviderClient;
|
||||
use super::{ProviderClient, ProviderConfig, ProviderConfigKind};
|
||||
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
|
||||
|
||||
/// Serializes every test in this module that mutates process-wide
|
||||
@@ -213,6 +336,73 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_provider_kind_overrides_model_provider_detection() {
|
||||
let _lock = env_lock();
|
||||
let _anthropic = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key"));
|
||||
let _xai = EnvVarGuard::set("XAI_API_KEY", Some("test-xai-key"));
|
||||
|
||||
let config = ProviderConfig {
|
||||
kind: ProviderConfigKind::Xai,
|
||||
model: "claude-sonnet-4-6".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
match ProviderClient::from_config(&config).expect("explicit xAI config should succeed") {
|
||||
ProviderClient::Xai(client) => {
|
||||
assert!(client.base_url().contains("api.x.ai"));
|
||||
}
|
||||
other => panic!("Expected explicit xAI provider, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_dashscope_kind_overrides_qwen_model_routing() {
|
||||
let _lock = env_lock();
|
||||
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
|
||||
|
||||
let config = ProviderConfig {
|
||||
kind: ProviderConfigKind::DashScope,
|
||||
model: "gpt-5".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
match ProviderClient::from_config(&config)
|
||||
.expect("explicit DashScope config should succeed")
|
||||
{
|
||||
ProviderClient::OpenAi(client) => {
|
||||
assert!(client.base_url().contains("dashscope.aliyuncs.com"));
|
||||
}
|
||||
other => panic!("Expected explicit DashScope provider, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_openai_kind_does_not_become_dashscope_from_model() {
|
||||
let _lock = env_lock();
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
|
||||
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
|
||||
let _openai_base_url = EnvVarGuard::set("OPENAI_BASE_URL", None);
|
||||
|
||||
let config = ProviderConfig {
|
||||
kind: ProviderConfigKind::OpenAi,
|
||||
model: "qwen-plus".to_string(),
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
};
|
||||
|
||||
match ProviderClient::from_config(&config).expect("explicit OpenAI config should succeed") {
|
||||
ProviderClient::OpenAi(client) => {
|
||||
assert!(client.base_url().contains("api.openai.com"));
|
||||
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
|
||||
}
|
||||
other => panic!("Expected explicit OpenAI provider, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashscope_model_uses_dashscope_config_not_openai() {
|
||||
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
|
||||
|
||||
+99
-100
@@ -52,19 +52,13 @@ pub enum ApiError {
|
||||
body_snippet: String,
|
||||
source: serde_json::Error,
|
||||
},
|
||||
Api {
|
||||
status: reqwest::StatusCode,
|
||||
error_type: Option<String>,
|
||||
message: Option<String>,
|
||||
request_id: Option<String>,
|
||||
body: String,
|
||||
retryable: bool,
|
||||
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
|
||||
suggested_action: Option<String>,
|
||||
/// Parsed Retry-After header value (seconds) for 429 responses.
|
||||
/// When present, overrides the exponential backoff delay.
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
/// A provider returned an error response.
|
||||
///
|
||||
/// Boxed: the payload is ~139 bytes, and `ApiError` is the `Err` half of nearly every
|
||||
/// `Result` in this crate, so inlining it inflates the size of every one of those returns
|
||||
/// (clippy::result_large_err). Boxing keeps `ApiError` small and means adding a field here
|
||||
/// cannot silently re-inflate every signature in the crate.
|
||||
Api(Box<ApiErrorDetails>),
|
||||
RetriesExhausted {
|
||||
attempts: u32,
|
||||
last_error: Box<ApiError>,
|
||||
@@ -81,7 +75,38 @@ pub enum ApiError {
|
||||
},
|
||||
}
|
||||
|
||||
/// Payload of [`ApiError::Api`], held behind a `Box` so the enum stays small.
|
||||
#[derive(Debug)]
|
||||
pub struct ApiErrorDetails {
|
||||
pub status: reqwest::StatusCode,
|
||||
pub error_type: Option<String>,
|
||||
pub message: Option<String>,
|
||||
pub request_id: Option<String>,
|
||||
pub body: String,
|
||||
pub retryable: bool,
|
||||
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
|
||||
pub suggested_action: Option<String>,
|
||||
/// Parsed Retry-After header value (seconds) for 429 responses.
|
||||
/// When present, overrides the exponential backoff delay.
|
||||
pub retry_after: Option<Duration>,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
/// Convenience constructor so callers do not have to spell out the `Box` at every site.
|
||||
#[must_use]
|
||||
pub fn api(details: ApiErrorDetails) -> Self {
|
||||
Self::Api(Box::new(details))
|
||||
}
|
||||
|
||||
/// Borrow the provider-error payload, if this is one.
|
||||
#[must_use]
|
||||
pub fn api_details(&self) -> Option<&ApiErrorDetails> {
|
||||
match self {
|
||||
Self::Api(details) => Some(details),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn missing_credentials(
|
||||
provider: &'static str,
|
||||
@@ -137,7 +162,7 @@ impl ApiError {
|
||||
/// over the computed backoff delay when it exists.
|
||||
pub fn retry_after(&self) -> Option<Duration> {
|
||||
match self {
|
||||
Self::Api { retry_after, .. } => *retry_after,
|
||||
Self::Api(details) => details.retry_after,
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.retry_after(),
|
||||
_ => None,
|
||||
}
|
||||
@@ -146,7 +171,7 @@ impl ApiError {
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
match self {
|
||||
Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(),
|
||||
Self::Api { retryable, .. } => *retryable,
|
||||
Self::Api(details) => details.retryable,
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
|
||||
Self::MissingCredentials { .. }
|
||||
| Self::ContextWindowExceeded { .. }
|
||||
@@ -164,7 +189,7 @@ impl ApiError {
|
||||
#[must_use]
|
||||
pub fn request_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Api { request_id, .. } => request_id.as_deref(),
|
||||
Self::Api(details) => details.request_id.as_deref(),
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.request_id(),
|
||||
Self::MissingCredentials { .. }
|
||||
| Self::ContextWindowExceeded { .. }
|
||||
@@ -180,49 +205,6 @@ 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 {
|
||||
@@ -234,12 +216,12 @@ impl ApiError {
|
||||
Self::MissingCredentials { .. } | Self::ExpiredOAuthToken | Self::Auth(_) => {
|
||||
"provider_auth"
|
||||
}
|
||||
Self::Api { status, .. } if matches!(status.as_u16(), 401 | 403) => "provider_auth",
|
||||
Self::Api(details) if matches!(details.status.as_u16(), 401 | 403) => "provider_auth",
|
||||
Self::ContextWindowExceeded { .. } => "context_window",
|
||||
Self::Api { .. } if self.is_context_window_failure() => "context_window",
|
||||
Self::Api { status, .. } if status.as_u16() == 429 => "provider_rate_limit",
|
||||
Self::Api { .. } if self.is_generic_fatal_wrapper() => "provider_internal",
|
||||
Self::Api { .. } => "provider_error",
|
||||
Self::Api(_) if self.is_context_window_failure() => "context_window",
|
||||
Self::Api(details) if details.status.as_u16() == 429 => "provider_rate_limit",
|
||||
Self::Api(_) if self.is_generic_fatal_wrapper() => "provider_internal",
|
||||
Self::Api(_) => "provider_error",
|
||||
Self::Http(_) | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => {
|
||||
"provider_transport"
|
||||
}
|
||||
@@ -251,11 +233,12 @@ impl ApiError {
|
||||
#[must_use]
|
||||
pub fn is_generic_fatal_wrapper(&self) -> bool {
|
||||
match self {
|
||||
Self::Api { message, body, .. } => {
|
||||
message
|
||||
Self::Api(details) => {
|
||||
details
|
||||
.message
|
||||
.as_deref()
|
||||
.is_some_and(looks_like_generic_fatal_wrapper)
|
||||
|| looks_like_generic_fatal_wrapper(body)
|
||||
|| looks_like_generic_fatal_wrapper(&details.body)
|
||||
}
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.is_generic_fatal_wrapper(),
|
||||
Self::MissingCredentials { .. }
|
||||
@@ -272,21 +255,36 @@ impl ApiError {
|
||||
}
|
||||
}
|
||||
|
||||
/// True for failures whose own text cannot say what went wrong: transport, decode, and frame
|
||||
/// errors.
|
||||
///
|
||||
/// These matter because a server that answers an oversized request with a non-SSE HTTP 500
|
||||
/// body lands here rather than in [`Self::is_context_window_failure`] — reqwest reports
|
||||
/// "error decoding response body" and the real cause is lost. Callers that know how large the
|
||||
/// request was can combine the two signals; callers that do not must treat this as unknown,
|
||||
/// never as an overflow.
|
||||
#[must_use]
|
||||
pub fn is_ambiguous_transport_failure(&self) -> bool {
|
||||
match self {
|
||||
Self::Http(_) | Self::Json { .. } | Self::InvalidSseFrame(_) => true,
|
||||
Self::RetriesExhausted { last_error, .. } => {
|
||||
last_error.is_ambiguous_transport_failure()
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_context_window_failure(&self) -> bool {
|
||||
match self {
|
||||
Self::ContextWindowExceeded { .. } => true,
|
||||
Self::Api {
|
||||
status,
|
||||
message,
|
||||
body,
|
||||
..
|
||||
} => {
|
||||
matches!(status.as_u16(), 400 | 413 | 422)
|
||||
&& (message
|
||||
Self::Api(details) => {
|
||||
matches!(details.status.as_u16(), 400 | 413 | 422)
|
||||
&& (details
|
||||
.message
|
||||
.as_deref()
|
||||
.is_some_and(looks_like_context_window_error)
|
||||
|| looks_like_context_window_error(body))
|
||||
|| looks_like_context_window_error(&details.body))
|
||||
}
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.is_context_window_failure(),
|
||||
Self::MissingCredentials { .. }
|
||||
@@ -370,14 +368,14 @@ impl Display for ApiError {
|
||||
"failed to parse {provider} response for model {model}: {source}; first 200 chars of body: {body_snippet}"
|
||||
),
|
||||
// #28: enhance 401/403 errors with actionable auth guidance
|
||||
Self::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
..
|
||||
} if matches!(status.as_u16(), 401 | 403) => {
|
||||
Self::Api(details) if matches!(details.status.as_u16(), 401 | 403) => {
|
||||
let (status, error_type, message, request_id, body) = (
|
||||
&details.status,
|
||||
&details.error_type,
|
||||
&details.message,
|
||||
&details.request_id,
|
||||
&details.body,
|
||||
);
|
||||
if let (Some(error_type), Some(message)) = (error_type, message) {
|
||||
write!(f, "api returned {status} ({error_type})")?;
|
||||
if let Some(request_id) = request_id {
|
||||
@@ -399,14 +397,14 @@ impl Display for ApiError {
|
||||
Run `claw doctor` to verify your credential configuration."
|
||||
)
|
||||
}
|
||||
Self::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
..
|
||||
} => {
|
||||
Self::Api(details) => {
|
||||
let (status, error_type, message, request_id, body) = (
|
||||
&details.status,
|
||||
&details.error_type,
|
||||
&details.message,
|
||||
&details.request_id,
|
||||
&details.body,
|
||||
);
|
||||
if let (Some(error_type), Some(message)) = (error_type, message) {
|
||||
write!(f, "api returned {status} ({error_type})")?;
|
||||
if let Some(request_id) = request_id {
|
||||
@@ -512,6 +510,7 @@ fn truncate_body_snippet(body: &str, max_chars: usize) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ApiErrorDetails;
|
||||
use super::{truncate_body_snippet, ApiError};
|
||||
|
||||
#[test]
|
||||
@@ -576,7 +575,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() {
|
||||
let error = ApiError::Api {
|
||||
let error = ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
error_type: Some("api_error".to_string()),
|
||||
message: Some(
|
||||
@@ -588,7 +587,7 @@ mod tests {
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
}));
|
||||
|
||||
assert!(error.is_generic_fatal_wrapper());
|
||||
assert_eq!(error.safe_failure_class(), "provider_internal");
|
||||
@@ -600,7 +599,7 @@ mod tests {
|
||||
fn retries_exhausted_preserves_nested_request_id_and_failure_class() {
|
||||
let error = ApiError::RetriesExhausted {
|
||||
attempts: 3,
|
||||
last_error: Box::new(ApiError::Api {
|
||||
last_error: Box::new(ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::BAD_GATEWAY,
|
||||
error_type: Some("api_error".to_string()),
|
||||
message: Some(
|
||||
@@ -612,7 +611,7 @@ mod tests {
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
}),
|
||||
}))),
|
||||
};
|
||||
|
||||
assert!(error.is_generic_fatal_wrapper());
|
||||
@@ -622,7 +621,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn classifies_provider_context_window_errors() {
|
||||
let error = ApiError::Api {
|
||||
let error = ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::BAD_REQUEST,
|
||||
error_type: Some("invalid_request_error".to_string()),
|
||||
message: Some(
|
||||
@@ -634,7 +633,7 @@ mod tests {
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
}));
|
||||
|
||||
assert!(error.is_context_window_failure());
|
||||
assert_eq!(error.safe_failure_class(), "context_window");
|
||||
@@ -643,7 +642,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn classifies_openai_configured_limit_errors_as_context_window_failures() {
|
||||
let error = ApiError::Api {
|
||||
let error = ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::BAD_REQUEST,
|
||||
error_type: Some("invalid_request_error".to_string()),
|
||||
message: Some(
|
||||
@@ -655,7 +654,7 @@ mod tests {
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
}));
|
||||
|
||||
assert!(error.is_context_window_failure());
|
||||
assert_eq!(error.safe_failure_class(), "context_window");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod client;
|
||||
|
||||
mod error;
|
||||
mod http_client;
|
||||
mod prompt_cache;
|
||||
@@ -8,9 +9,10 @@ mod types;
|
||||
|
||||
pub use client::{
|
||||
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
|
||||
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
|
||||
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient, ProviderConfig,
|
||||
ProviderConfigKind,
|
||||
};
|
||||
pub use error::ApiError;
|
||||
pub use error::{ApiError, ApiErrorDetails};
|
||||
pub use http_client::{
|
||||
build_http_client, build_http_client_or_default, build_http_client_with,
|
||||
build_http_client_with_opts, ProxyConfig, TimeoutConfig,
|
||||
@@ -27,10 +29,10 @@ pub use providers::openai_compat::{
|
||||
OpenAiCompatClient, OpenAiCompatConfig,
|
||||
};
|
||||
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, model_token_limit,
|
||||
provider_diagnostics_for_model, resolve_model_alias, ModelTokenLimit, ProviderDiagnostics,
|
||||
ProviderKind,
|
||||
context_window_override, detect_provider_kind, estimate_message_request_input_tokens,
|
||||
max_tokens_for_model, max_tokens_for_model_with_override, 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::{
|
||||
|
||||
@@ -12,7 +12,7 @@ use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, SessionTracer};
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::error::{ApiError, ApiErrorDetails};
|
||||
use crate::http_client::build_http_client_or_default;
|
||||
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
|
||||
|
||||
@@ -892,7 +892,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
let retryable = is_retryable_status(status);
|
||||
let retry_after = parse_retry_after(&headers, status);
|
||||
|
||||
Err(ApiError::Api {
|
||||
Err(ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status,
|
||||
error_type: parsed_error
|
||||
.as_ref()
|
||||
@@ -905,7 +905,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
retryable,
|
||||
suggested_action: None,
|
||||
retry_after,
|
||||
})
|
||||
})))
|
||||
}
|
||||
|
||||
fn parse_retry_after(
|
||||
@@ -952,86 +952,30 @@ fn is_retryable_400(status: reqwest::StatusCode, body: &str) -> bool {
|
||||
const SK_ANT_BEARER_HINT: &str = "sk-ant-* keys go in ANTHROPIC_API_KEY (x-api-key header), not ANTHROPIC_AUTH_TOKEN (Bearer header). Move your key to ANTHROPIC_API_KEY.";
|
||||
|
||||
fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
let ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
..
|
||||
} = error
|
||||
else {
|
||||
let ApiError::Api(mut details) = error else {
|
||||
return error;
|
||||
};
|
||||
if status.as_u16() != 401 {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
if details.status.as_u16() != 401 {
|
||||
return ApiError::Api(details);
|
||||
}
|
||||
let Some(bearer_token) = auth.bearer_token() else {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
return ApiError::Api(details);
|
||||
};
|
||||
if !bearer_token.starts_with("sk-ant-") {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
return ApiError::Api(details);
|
||||
}
|
||||
// Only append the hint when the AuthSource is pure BearerToken. If both
|
||||
// api_key and bearer_token are present (`ApiKeyAndBearer`), the x-api-key
|
||||
// header is already being sent alongside the Bearer header and the 401
|
||||
// is coming from a different cause — adding the hint would be misleading.
|
||||
if auth.api_key().is_some() {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
}
|
||||
let enriched_message = match message {
|
||||
Some(existing) => Some(format!("{existing} — hint: {SK_ANT_BEARER_HINT}")),
|
||||
None => Some(format!("hint: {SK_ANT_BEARER_HINT}")),
|
||||
};
|
||||
ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message: enriched_message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
return ApiError::Api(details);
|
||||
}
|
||||
details.message = Some(match details.message.take() {
|
||||
Some(existing) => format!("{existing} — hint: {SK_ANT_BEARER_HINT}"),
|
||||
None => format!("hint: {SK_ANT_BEARER_HINT}"),
|
||||
});
|
||||
ApiError::Api(details)
|
||||
}
|
||||
|
||||
fn anthropic_wire_model(model: &str) -> &str {
|
||||
@@ -1083,6 +1027,7 @@ struct AnthropicErrorBody {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
|
||||
use crate::error::ApiErrorDetails;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
@@ -1647,7 +1592,7 @@ mod tests {
|
||||
fn enrich_bearer_auth_error_appends_sk_ant_hint_on_401_with_pure_bearer_token() {
|
||||
// given
|
||||
let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string());
|
||||
let error = crate::error::ApiError::Api {
|
||||
let error = crate::error::ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid bearer token".to_string()),
|
||||
@@ -1656,7 +1601,7 @@ mod tests {
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
}));
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
@@ -1678,8 +1623,8 @@ mod tests {
|
||||
"request id should still flow through the enriched error: {rendered}"
|
||||
);
|
||||
match enriched {
|
||||
crate::error::ApiError::Api { status, .. } => {
|
||||
assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED);
|
||||
crate::error::ApiError::Api(details) => {
|
||||
assert_eq!(details.status, reqwest::StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
other => panic!("expected Api variant, got {other:?}"),
|
||||
}
|
||||
@@ -1689,7 +1634,7 @@ mod tests {
|
||||
fn enrich_bearer_auth_error_leaves_non_401_errors_unchanged() {
|
||||
// given
|
||||
let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string());
|
||||
let error = crate::error::ApiError::Api {
|
||||
let error = crate::error::ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
error_type: Some("api_error".to_string()),
|
||||
message: Some("internal server error".to_string()),
|
||||
@@ -1698,7 +1643,7 @@ mod tests {
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
}));
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
@@ -1719,7 +1664,7 @@ mod tests {
|
||||
fn enrich_bearer_auth_error_ignores_401_when_bearer_token_is_not_sk_ant() {
|
||||
// given
|
||||
let auth = AuthSource::BearerToken("oauth-access-token-opaque".to_string());
|
||||
let error = crate::error::ApiError::Api {
|
||||
let error = crate::error::ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid bearer token".to_string()),
|
||||
@@ -1728,7 +1673,7 @@ mod tests {
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
}));
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
@@ -1748,7 +1693,7 @@ mod tests {
|
||||
api_key: "sk-ant-api03-legitimate".to_string(),
|
||||
bearer_token: "sk-ant-api03-deadbeef".to_string(),
|
||||
};
|
||||
let error = crate::error::ApiError::Api {
|
||||
let error = crate::error::ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid bearer token".to_string()),
|
||||
@@ -1757,7 +1702,7 @@ mod tests {
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
}));
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
@@ -1774,7 +1719,7 @@ mod tests {
|
||||
fn enrich_bearer_auth_error_ignores_401_when_auth_source_has_no_bearer() {
|
||||
// given
|
||||
let auth = AuthSource::ApiKey("sk-ant-api03-legitimate".to_string());
|
||||
let error = crate::error::ApiError::Api {
|
||||
let error = crate::error::ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid x-api-key".to_string()),
|
||||
@@ -1783,7 +1728,7 @@ mod tests {
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
}));
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
|
||||
@@ -619,16 +619,43 @@ fn web_passthrough_diagnostic(
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides the context window every sizing guard is computed against.
|
||||
///
|
||||
/// The model *name* is not a reliable statement of the window when the endpoint is a local
|
||||
/// server: the harness sends its compiled-in default name (a Claude alias) while the backend
|
||||
/// serves whatever `-c` it was started with. Every guard downstream of [`model_token_limit`]
|
||||
/// then sizes itself against the name's window instead of the real one, which makes the
|
||||
/// preflights unable to fire rather than merely inaccurate. This variable is the operator's
|
||||
/// statement of the truth, and it wins over the table.
|
||||
const CONTEXT_WINDOW_ENV_VAR: &str = "CLAW_CONTEXT_WINDOW";
|
||||
|
||||
/// Fraction of the window a single response may be allowed to claim when the window is
|
||||
/// declared. Output and input share one budget on a local server, so reserving a quarter for
|
||||
/// the reply leaves three quarters for prompt plus transcript.
|
||||
const MAX_OUTPUT_SHARE_OF_WINDOW: u32 = 4;
|
||||
|
||||
/// The operator-declared context window, when set and parseable as a positive token count.
|
||||
#[must_use]
|
||||
pub fn context_window_override() -> Option<u32> {
|
||||
parse_context_window_override(std::env::var(CONTEXT_WINDOW_ENV_VAR).ok().as_deref())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn parse_context_window_override(value: Option<&str>) -> Option<u32> {
|
||||
value
|
||||
.and_then(|raw| raw.trim().parse::<u32>().ok())
|
||||
.filter(|window| *window > 0)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn max_tokens_for_model(model: &str) -> u32 {
|
||||
let canonical = resolve_model_alias(model);
|
||||
let heuristic = if canonical.contains("opus") {
|
||||
32_000
|
||||
} else {
|
||||
64_000
|
||||
};
|
||||
|
||||
model_token_limit(model).map_or(heuristic, |limit| heuristic.min(limit.max_output_tokens))
|
||||
// A declared window caps the reply too. Asking a 65k server for 32k of output reserves half
|
||||
// the window for a single message, which the server then silently clamps — leaving the
|
||||
// client's own accounting wrong by the difference.
|
||||
model_token_limit(model).map_or_else(
|
||||
|| max_tokens_for_model_by_name(model),
|
||||
|limit| max_tokens_for_model_by_name(model).min(limit.max_output_tokens),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the effective max output tokens for a model, preferring a plugin
|
||||
@@ -641,6 +668,33 @@ pub fn max_tokens_for_model_with_override(model: &str, plugin_override: Option<u
|
||||
|
||||
#[must_use]
|
||||
pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
||||
// A declared window replaces the table's, and supplies one for models the table has never
|
||||
// heard of. Returning `Some` for an unknown model is the point: `None` makes both preflights
|
||||
// return `Ok` without looking at anything, so an unrecognised local model gets no guard at all.
|
||||
if let Some(context_window_tokens) = context_window_override() {
|
||||
return Some(ModelTokenLimit {
|
||||
max_output_tokens: max_tokens_for_model_by_name(model)
|
||||
.min(context_window_tokens / MAX_OUTPUT_SHARE_OF_WINDOW),
|
||||
context_window_tokens,
|
||||
});
|
||||
}
|
||||
model_token_limit_by_name(model)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn max_tokens_for_model_by_name(model: &str) -> u32 {
|
||||
let canonical = resolve_model_alias(model);
|
||||
let heuristic = if canonical.contains("opus") {
|
||||
32_000
|
||||
} else {
|
||||
64_000
|
||||
};
|
||||
model_token_limit_by_name(model)
|
||||
.map_or(heuristic, |limit| heuristic.min(limit.max_output_tokens))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn model_token_limit_by_name(model: &str) -> Option<ModelTokenLimit> {
|
||||
let canonical = resolve_model_alias(model);
|
||||
let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str());
|
||||
match base_model {
|
||||
@@ -683,15 +737,13 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
||||
max_output_tokens: 8_192,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
// 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,
|
||||
"qwen-max" => 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,
|
||||
"qwen-plus" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 8_192,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
// Groq compound systems are exposed through an OpenAI-compatible API
|
||||
// but cap completions at 8,192 output tokens. Without this metadata
|
||||
@@ -701,8 +753,6 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
||||
max_output_tokens: 8_192,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
// Unknown models return None so preflight skips the context-window
|
||||
// check and lets the API enforce its own limits.
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -727,7 +777,7 @@ pub fn preflight_message_request(request: &MessageRequest) -> Result<(), ApiErro
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn estimate_message_request_input_tokens(request: &MessageRequest) -> u32 {
|
||||
pub fn estimate_message_request_input_tokens(request: &MessageRequest) -> u32 {
|
||||
let mut estimate = estimate_serialized_tokens(&request.messages);
|
||||
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.system));
|
||||
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.tools));
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::error::{ApiError, ApiErrorDetails};
|
||||
use crate::http_client::build_http_client_or_default;
|
||||
use crate::types::{
|
||||
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
|
||||
@@ -239,7 +239,7 @@ impl OpenAiCompatClient {
|
||||
.get("code")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.map(|c| c as u16);
|
||||
return Err(ApiError::Api {
|
||||
return Err(ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::from_u16(code.unwrap_or(400))
|
||||
.unwrap_or(reqwest::StatusCode::BAD_REQUEST),
|
||||
error_type: err_obj
|
||||
@@ -255,7 +255,7 @@ impl OpenAiCompatClient {
|
||||
.unwrap_or(reqwest::StatusCode::BAD_REQUEST),
|
||||
),
|
||||
retry_after: None,
|
||||
});
|
||||
})));
|
||||
}
|
||||
}
|
||||
let payload = serde_json::from_str::<ChatCompletionResponse>(&body).map_err(|error| {
|
||||
@@ -1613,7 +1613,7 @@ fn parse_sse_frame(
|
||||
.map(|c| c as u16);
|
||||
let status = reqwest::StatusCode::from_u16(code.unwrap_or(500))
|
||||
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
return Err(ApiError::Api {
|
||||
return Err(ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status,
|
||||
error_type: err_obj
|
||||
.get("type")
|
||||
@@ -1625,12 +1625,12 @@ fn parse_sse_frame(
|
||||
retryable: false,
|
||||
suggested_action: suggested_action_for_status(status),
|
||||
retry_after: None,
|
||||
});
|
||||
})));
|
||||
}
|
||||
}
|
||||
// Detect HTML responses
|
||||
if trimmed.starts_with('<') || trimmed.starts_with("<!") {
|
||||
return Err(ApiError::Api {
|
||||
return Err(ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::BAD_REQUEST,
|
||||
error_type: Some("invalid_response".to_string()),
|
||||
message: Some(
|
||||
@@ -1641,7 +1641,7 @@ fn parse_sse_frame(
|
||||
retryable: false,
|
||||
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
|
||||
retry_after: None,
|
||||
});
|
||||
})));
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -1665,7 +1665,7 @@ fn parse_sse_frame(
|
||||
.map(|c| c as u16);
|
||||
let status = reqwest::StatusCode::from_u16(code.unwrap_or(400))
|
||||
.unwrap_or(reqwest::StatusCode::BAD_REQUEST);
|
||||
return Err(ApiError::Api {
|
||||
return Err(ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status,
|
||||
error_type: err_obj
|
||||
.get("type")
|
||||
@@ -1677,13 +1677,13 @@ fn parse_sse_frame(
|
||||
retryable: false,
|
||||
suggested_action: suggested_action_for_status(status),
|
||||
retry_after: None,
|
||||
});
|
||||
})));
|
||||
}
|
||||
}
|
||||
// Detect HTML or other non-JSON responses early for better error messages
|
||||
let trimmed_payload = payload.trim();
|
||||
if trimmed_payload.starts_with('<') || trimmed_payload.starts_with("<!") {
|
||||
return Err(ApiError::Api {
|
||||
return Err(ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status: reqwest::StatusCode::BAD_REQUEST,
|
||||
error_type: Some("invalid_response".to_string()),
|
||||
message: Some(
|
||||
@@ -1694,7 +1694,7 @@ fn parse_sse_frame(
|
||||
retryable: false,
|
||||
suggested_action: Some("verify the API endpoint URL is correct".to_string()),
|
||||
retry_after: None,
|
||||
});
|
||||
})));
|
||||
}
|
||||
serde_json::from_str::<ChatCompletionChunk>(&payload)
|
||||
.map(Some)
|
||||
@@ -1754,7 +1754,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
|
||||
let suggested_action = suggested_action_for_status(status);
|
||||
|
||||
Err(ApiError::Api {
|
||||
Err(ApiError::Api(Box::new(ApiErrorDetails {
|
||||
status,
|
||||
error_type: parsed_error
|
||||
.as_ref()
|
||||
@@ -1767,7 +1767,7 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
})
|
||||
})))
|
||||
}
|
||||
|
||||
fn parse_retry_after(
|
||||
|
||||
@@ -585,14 +585,11 @@ async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
|
||||
last_error,
|
||||
} => {
|
||||
assert_eq!(attempts, 2);
|
||||
assert!(matches!(
|
||||
*last_error,
|
||||
ApiError::Api {
|
||||
status: reqwest::StatusCode::SERVICE_UNAVAILABLE,
|
||||
retryable: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
let details = last_error
|
||||
.api_details()
|
||||
.expect("last error should be a provider Api error");
|
||||
assert_eq!(details.status, reqwest::StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert!(details.retryable);
|
||||
}
|
||||
other => panic!("expected retries exhausted, got {other:?}"),
|
||||
}
|
||||
|
||||
@@ -166,8 +166,8 @@ pub fn blob_to_f32_vec(blob: &[u8], dim: usize) -> Option<Vec<f32>> {
|
||||
return None;
|
||||
}
|
||||
let mut v = Vec::with_capacity(dim);
|
||||
for chunk in blob.as_chunks::<4>().0 {
|
||||
v.push(f32::from_le_bytes(*chunk));
|
||||
for chunk in blob.chunks_exact(4) {
|
||||
v.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
@@ -808,7 +808,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||
name: "team",
|
||||
aliases: &[],
|
||||
summary: "Manage agent teams",
|
||||
argument_hint: Some("[on|off|status]"),
|
||||
argument_hint: Some("[list|create|delete]"),
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
@@ -1131,6 +1131,9 @@ pub enum SlashCommand {
|
||||
SecurityReview,
|
||||
Keybindings,
|
||||
PrivacySettings,
|
||||
Workspace {
|
||||
path: Option<String>,
|
||||
},
|
||||
Plan {
|
||||
mode: Option<String>,
|
||||
},
|
||||
@@ -1188,14 +1191,10 @@ pub enum SlashCommand {
|
||||
History {
|
||||
count: Option<String>,
|
||||
},
|
||||
Lsp {
|
||||
action: Option<String>,
|
||||
target: Option<String>,
|
||||
},
|
||||
Unknown(String),
|
||||
Team {
|
||||
action: Option<String>,
|
||||
},
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -1276,6 +1275,7 @@ impl SlashCommand {
|
||||
Self::SecurityReview => "/security-review",
|
||||
Self::Keybindings => "/keybindings",
|
||||
Self::PrivacySettings => "/privacy-settings",
|
||||
Self::Workspace { .. } => "/workspace",
|
||||
Self::Plan { .. } => "/plan",
|
||||
Self::Review { .. } => "/review",
|
||||
Self::Tasks { .. } => "/tasks",
|
||||
@@ -1409,6 +1409,9 @@ pub fn validate_slash_command_input(
|
||||
validate_no_args(command, &args)?;
|
||||
SlashCommand::Setup
|
||||
}
|
||||
"workspace" | "cwd" => SlashCommand::Workspace {
|
||||
path: optional_single_arg(command, &args, "[path]")?,
|
||||
},
|
||||
"login" | "logout" => {
|
||||
return Err(command_error(
|
||||
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
|
||||
@@ -1513,9 +1516,6 @@ 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()),
|
||||
}))
|
||||
}
|
||||
@@ -5349,6 +5349,7 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::Cost
|
||||
| SlashCommand::Resume { .. }
|
||||
| SlashCommand::Config { .. }
|
||||
| SlashCommand::Workspace { .. }
|
||||
| SlashCommand::Mcp { .. }
|
||||
| SlashCommand::Memory
|
||||
| SlashCommand::Init
|
||||
@@ -5400,7 +5401,6 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::OutputStyle { .. }
|
||||
| SlashCommand::AddDir { .. }
|
||||
| SlashCommand::History { .. }
|
||||
| SlashCommand::Lsp { .. }
|
||||
| SlashCommand::Team { .. }
|
||||
| SlashCommand::Setup
|
||||
| SlashCommand::Unknown(_) => None,
|
||||
@@ -5533,16 +5533,6 @@ 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))
|
||||
@@ -6137,6 +6127,24 @@ mod tests {
|
||||
assert_eq!(suggest_slash_commands("zzz", 3), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_workspace_slash_command_and_alias() {
|
||||
let workspace = validate_slash_command_input("/workspace")
|
||||
.expect("workspace should parse")
|
||||
.expect("workspace should be a slash command");
|
||||
assert_eq!(workspace, SlashCommand::Workspace { path: None });
|
||||
|
||||
let cwd = validate_slash_command_input("/cwd src")
|
||||
.expect("cwd alias should parse")
|
||||
.expect("cwd alias should be a slash command");
|
||||
assert_eq!(
|
||||
cwd,
|
||||
SlashCommand::Workspace {
|
||||
path: Some("src".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacts_sessions_via_slash_command() {
|
||||
let mut session = Session::new();
|
||||
|
||||
@@ -95,10 +95,21 @@ pub fn get_compact_continuation_message(
|
||||
#[must_use]
|
||||
pub fn compact_session(session: &Session, config: CompactionConfig) -> CompactionResult {
|
||||
if !should_compact(session, config) {
|
||||
// "Nothing to drop" is not the same as "nothing to do". A session can exceed the window
|
||||
// while holding fewer messages than `preserve_recent_messages` — a single read of a large
|
||||
// file does it — and message-granular compaction has no move to make there. Trim the
|
||||
// payloads anyway, so the one lever that still works is not gated behind the one that
|
||||
// does not.
|
||||
let mut trimmed_session = session.clone();
|
||||
trimmed_session.messages = session
|
||||
.messages
|
||||
.iter()
|
||||
.map(truncate_oversized_tool_results)
|
||||
.collect();
|
||||
return CompactionResult {
|
||||
summary: String::new(),
|
||||
formatted_summary: String::new(),
|
||||
compacted_session: session.clone(),
|
||||
compacted_session: trimmed_session,
|
||||
removed_message_count: 0,
|
||||
};
|
||||
}
|
||||
@@ -165,7 +176,14 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
||||
k
|
||||
};
|
||||
let removed = &session.messages[compacted_prefix_len..keep_from];
|
||||
let preserved = session.messages[keep_from..].to_vec();
|
||||
// Dropping whole messages is not enough on its own. An agentic turn is five messages
|
||||
// (user / assistant+tool_use / tool_result / assistant+tool_use / tool_result), so the
|
||||
// preserved tail IS the pair of tool results that overflowed the window, and the only
|
||||
// message compaction may drop is the short user prompt. Trim the payloads that must stay.
|
||||
let preserved = session.messages[keep_from..]
|
||||
.iter()
|
||||
.map(truncate_oversized_tool_results)
|
||||
.collect::<Vec<_>>();
|
||||
let summary =
|
||||
merge_compact_summaries(existing_summary.as_deref(), &summarize_messages(removed));
|
||||
let formatted_summary = format_compact_summary(&summary);
|
||||
@@ -190,6 +208,78 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest tool-result payload compaction will preserve verbatim, in characters.
|
||||
///
|
||||
/// ~16 KB is ~4 k tokens: big enough that ordinary tool output is never touched, small enough
|
||||
/// that several preserved results still fit a small window.
|
||||
const MAX_PRESERVED_TOOL_RESULT_CHARS: usize = 16 * 1024;
|
||||
|
||||
/// Characters kept from the tail of a trimmed payload. The head carries the structure a model
|
||||
/// needs to recognise what it read; the tail is where a file's most recent edits usually are.
|
||||
const PRESERVED_TOOL_RESULT_TAIL_CHARS: usize = 2 * 1024;
|
||||
|
||||
/// Returns `message` with any oversized `ToolResult` payload replaced by a head/tail excerpt.
|
||||
///
|
||||
/// Clones only when something is actually trimmed, so the common path stays cheap.
|
||||
fn truncate_oversized_tool_results(message: &ConversationMessage) -> ConversationMessage {
|
||||
let needs_trim = message.blocks.iter().any(|block| match block {
|
||||
ContentBlock::ToolResult { output, .. } => {
|
||||
output.chars().count() > MAX_PRESERVED_TOOL_RESULT_CHARS
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
if !needs_trim {
|
||||
return message.clone();
|
||||
}
|
||||
|
||||
let blocks = message
|
||||
.blocks
|
||||
.iter()
|
||||
.map(|block| match block {
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
output,
|
||||
is_error,
|
||||
} => ContentBlock::ToolResult {
|
||||
tool_use_id: tool_use_id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
output: excerpt_tool_result(output),
|
||||
is_error: *is_error,
|
||||
},
|
||||
other => other.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
ConversationMessage {
|
||||
role: message.role,
|
||||
blocks,
|
||||
usage: message.usage,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a head + marker + tail excerpt of an oversized tool-result payload.
|
||||
///
|
||||
/// Operates on chars, not bytes, so a multi-byte boundary can never be split.
|
||||
fn excerpt_tool_result(output: &str) -> String {
|
||||
let chars: Vec<char> = output.chars().collect();
|
||||
let total = chars.len();
|
||||
if total <= MAX_PRESERVED_TOOL_RESULT_CHARS {
|
||||
return output.to_string();
|
||||
}
|
||||
|
||||
let tail_len = PRESERVED_TOOL_RESULT_TAIL_CHARS.min(total);
|
||||
let head_len = MAX_PRESERVED_TOOL_RESULT_CHARS.saturating_sub(tail_len);
|
||||
let elided = total.saturating_sub(head_len + tail_len);
|
||||
if elided == 0 {
|
||||
return output.to_string();
|
||||
}
|
||||
|
||||
let head: String = chars[..head_len].iter().collect();
|
||||
let tail: String = chars[total - tail_len..].iter().collect();
|
||||
format!("{head}\n\n[... {elided} characters elided by compaction; re-read this file if you need the omitted portion ...]\n\n{tail}")
|
||||
}
|
||||
|
||||
fn compacted_summary_prefix_len(session: &Session) -> usize {
|
||||
usize::from(
|
||||
session
|
||||
@@ -573,7 +663,7 @@ fn extract_summary_timeline(summary: &str) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
collect_key_files, compact_session, format_compact_summary,
|
||||
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
|
||||
get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig,
|
||||
};
|
||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
@@ -763,6 +853,185 @@ mod tests {
|
||||
/// user(ToolResult) pair at the boundary. An orphaned tool-result message
|
||||
/// without the preceding assistant `tool_calls` causes a 400 on the
|
||||
/// OpenAI-compat path (gaebal-gajae repro 2026-04-09).
|
||||
/// The failure this exists for: an agentic turn is 5 messages (user / assistant+tool_use /
|
||||
/// tool_result / assistant+tool_use / tool_result), so `preserve_recent_messages: 4` protects
|
||||
/// exactly the two giant tool results that overflow the window. Message-granular compaction
|
||||
/// can only drop the tiny user prompt, so the payloads themselves must be trimmed.
|
||||
#[test]
|
||||
fn compaction_truncates_oversized_tool_results_it_must_preserve() {
|
||||
let huge = "z".repeat(80_000);
|
||||
let mut session = Session::new();
|
||||
session
|
||||
.push_message(ConversationMessage::user_text("read two files"))
|
||||
.expect("user message");
|
||||
session
|
||||
.push_message(ConversationMessage::assistant(vec![
|
||||
ContentBlock::ToolUse {
|
||||
id: "call-1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: "{\"path\":\"a.rs\"}".to_string(),
|
||||
},
|
||||
]))
|
||||
.expect("assistant tool use");
|
||||
session
|
||||
.push_message(ConversationMessage::tool_result(
|
||||
"call-1",
|
||||
"read_file",
|
||||
huge.clone(),
|
||||
false,
|
||||
))
|
||||
.expect("tool result");
|
||||
session
|
||||
.push_message(ConversationMessage::assistant(vec![
|
||||
ContentBlock::ToolUse {
|
||||
id: "call-2".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: "{\"path\":\"b.rs\"}".to_string(),
|
||||
},
|
||||
]))
|
||||
.expect("second assistant tool use");
|
||||
session
|
||||
.push_message(ConversationMessage::tool_result(
|
||||
"call-2",
|
||||
"read_file",
|
||||
huge.clone(),
|
||||
false,
|
||||
))
|
||||
.expect("second tool result");
|
||||
|
||||
let before = estimate_session_tokens(&session);
|
||||
let result = compact_session(
|
||||
&session,
|
||||
CompactionConfig {
|
||||
preserve_recent_messages: 4,
|
||||
max_estimated_tokens: 0,
|
||||
},
|
||||
);
|
||||
let after = estimate_session_tokens(&result.compacted_session);
|
||||
|
||||
assert!(
|
||||
after < before / 2,
|
||||
"compaction must shrink an oversized preserved tool result: {before} -> {after}"
|
||||
);
|
||||
|
||||
let preserved_output = result
|
||||
.compacted_session
|
||||
.messages
|
||||
.iter()
|
||||
.flat_map(|message| &message.blocks)
|
||||
.find_map(|block| match block {
|
||||
ContentBlock::ToolResult { output, .. } => Some(output.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the tool result must still be present, not deleted");
|
||||
|
||||
assert!(
|
||||
preserved_output.len() < huge.len(),
|
||||
"the payload must actually be trimmed"
|
||||
);
|
||||
assert!(
|
||||
preserved_output.contains("elided"),
|
||||
"the model must be told the view is partial, got: {}",
|
||||
&preserved_output[..preserved_output.len().min(200)]
|
||||
);
|
||||
assert!(
|
||||
preserved_output.starts_with('z'),
|
||||
"the head of the output must be kept"
|
||||
);
|
||||
assert!(
|
||||
preserved_output.ends_with('z'),
|
||||
"the tail of the output must be kept"
|
||||
);
|
||||
}
|
||||
|
||||
/// A session can exceed the window while holding FEWER messages than
|
||||
/// `preserve_recent_messages` — one read of a large file does it. `should_compact` returns
|
||||
/// false there (nothing is droppable), so trimming must not be gated behind it.
|
||||
#[test]
|
||||
fn oversized_tool_results_are_trimmed_even_when_no_message_can_be_dropped() {
|
||||
let huge = "q".repeat(80_000);
|
||||
let mut session = Session::new();
|
||||
session
|
||||
.push_message(ConversationMessage::user_text("read one enormous file"))
|
||||
.expect("user message");
|
||||
session
|
||||
.push_message(ConversationMessage::assistant(vec![
|
||||
ContentBlock::ToolUse {
|
||||
id: "call-1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: "{\"path\":\"huge.rs\"}".to_string(),
|
||||
},
|
||||
]))
|
||||
.expect("assistant tool use");
|
||||
session
|
||||
.push_message(ConversationMessage::tool_result(
|
||||
"call-1",
|
||||
"read_file",
|
||||
huge,
|
||||
false,
|
||||
))
|
||||
.expect("tool result");
|
||||
|
||||
// 3 messages, preserve 4: there is nothing to remove, yet the session is far too big.
|
||||
let before = estimate_session_tokens(&session);
|
||||
let result = compact_session(
|
||||
&session,
|
||||
CompactionConfig {
|
||||
preserve_recent_messages: 4,
|
||||
max_estimated_tokens: 0,
|
||||
},
|
||||
);
|
||||
let after = estimate_session_tokens(&result.compacted_session);
|
||||
|
||||
assert!(
|
||||
after < before / 2,
|
||||
"an untrimmable session must still have its payloads trimmed: {before} -> {after}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Trimming must not touch payloads that already fit — otherwise every ordinary tool result
|
||||
/// would grow an elision marker it does not need.
|
||||
#[test]
|
||||
fn compaction_leaves_small_tool_results_untouched() {
|
||||
let small = "ok".repeat(50);
|
||||
let mut session = Session::new();
|
||||
for index in 0..6 {
|
||||
session
|
||||
.push_message(ConversationMessage::user_text(format!("turn {index}")))
|
||||
.expect("user message");
|
||||
session
|
||||
.push_message(ConversationMessage::tool_result(
|
||||
format!("call-{index}"),
|
||||
"read_file",
|
||||
small.clone(),
|
||||
false,
|
||||
))
|
||||
.expect("tool result");
|
||||
}
|
||||
|
||||
let result = compact_session(
|
||||
&session,
|
||||
CompactionConfig {
|
||||
preserve_recent_messages: 4,
|
||||
max_estimated_tokens: 0,
|
||||
},
|
||||
);
|
||||
|
||||
for block in result
|
||||
.compacted_session
|
||||
.messages
|
||||
.iter()
|
||||
.flat_map(|message| &message.blocks)
|
||||
{
|
||||
if let ContentBlock::ToolResult { output, .. } = block {
|
||||
assert_eq!(
|
||||
output, &small,
|
||||
"a small tool result must pass through as-is"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_does_not_split_tool_use_tool_result_pair() {
|
||||
use crate::session::{ContentBlock, Session};
|
||||
@@ -826,7 +1095,7 @@ mod tests {
|
||||
"message[{}] is a ToolResult but message[{}] has no ToolUse: {:?}",
|
||||
i,
|
||||
i - 1,
|
||||
messages[i - 1].blocks
|
||||
&messages[i - 1].blocks
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,20 +164,6 @@ pub struct RuntimeFeatureConfig {
|
||||
rules_import: RulesImportConfig,
|
||||
provider: RuntimeProviderConfig,
|
||||
auto_memory_enabled: bool,
|
||||
/// Model override used when spawning sub-agents via the Agent tool.
|
||||
/// Read from `subagentModel` (or `subagent_model`) in settings; falls
|
||||
/// back to the default model when unset.
|
||||
subagent_model: Option<String>,
|
||||
/// Cap on concurrently executing parallel-safe tool calls in one batch.
|
||||
/// Read from `maxParallelToolCalls` (or `max_parallel_tool_calls`) in
|
||||
/// settings; the CLI falls back to `CLAWD_PARALLEL_TOOL_CALLS` and then
|
||||
/// to its built-in default when unset.
|
||||
max_parallel_tool_calls: Option<usize>,
|
||||
/// Default cap on concurrently *running* sub-agents per team. Read from
|
||||
/// `maxConcurrentAgents` (or `max_concurrent_agents`) in settings; a
|
||||
/// per-team `maxConcurrentAgents` on TeamCreate overrides it. `None`
|
||||
/// means no gate (current pre-fix behavior).
|
||||
max_concurrent_agents: Option<usize>,
|
||||
}
|
||||
|
||||
/// Controls which external AI coding framework rules are imported into the system prompt.
|
||||
@@ -219,6 +205,11 @@ pub struct RuntimeProviderConfig {
|
||||
}
|
||||
|
||||
impl RuntimeProviderConfig {
|
||||
#[must_use]
|
||||
pub fn is_configured(&self) -> bool {
|
||||
self.kind.is_some()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> Option<&str> {
|
||||
self.kind.as_deref()
|
||||
@@ -846,15 +837,6 @@ fn build_runtime_config(
|
||||
rules_import: parse_optional_rules_import(&merged_value)?,
|
||||
provider: parse_optional_provider_config(&merged_value)?,
|
||||
auto_memory_enabled: parse_auto_memory_enabled(&merged_value),
|
||||
subagent_model: parse_optional_subagent_model(&merged_value),
|
||||
max_parallel_tool_calls: parse_optional_positive_usize(
|
||||
&merged_value,
|
||||
&["maxParallelToolCalls", "max_parallel_tool_calls"],
|
||||
)?,
|
||||
max_concurrent_agents: parse_optional_positive_usize(
|
||||
&merged_value,
|
||||
&["maxConcurrentAgents", "max_concurrent_agents"],
|
||||
)?,
|
||||
};
|
||||
|
||||
Ok(RuntimeConfig {
|
||||
@@ -937,29 +919,6 @@ 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()
|
||||
}
|
||||
|
||||
/// Cap on concurrently executing parallel-safe tool calls in one batch.
|
||||
/// Read from `maxParallelToolCalls` in settings; `None` means the CLI
|
||||
/// falls back to `CLAWD_PARALLEL_TOOL_CALLS` and then its built-in default.
|
||||
#[must_use]
|
||||
pub fn max_parallel_tool_calls(&self) -> Option<usize> {
|
||||
self.feature_config.max_parallel_tool_calls
|
||||
}
|
||||
|
||||
/// Default cap on concurrently running sub-agents per team. Read from
|
||||
/// `maxConcurrentAgents` in settings; `None` means the built-in default
|
||||
/// applies. A per-team `maxConcurrentAgents` on TeamCreate overrides it.
|
||||
#[must_use]
|
||||
pub fn max_concurrent_agents(&self) -> Option<usize> {
|
||||
self.feature_config.max_concurrent_agents
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn aliases(&self) -> &BTreeMap<String, String> {
|
||||
&self.feature_config.aliases
|
||||
@@ -1020,20 +979,6 @@ impl RuntimeFeatureConfig {
|
||||
&self.provider
|
||||
}
|
||||
|
||||
/// Cap on concurrently executing parallel-safe tool calls in one batch.
|
||||
/// `None` means fall back to env/default.
|
||||
#[must_use]
|
||||
pub fn max_parallel_tool_calls(&self) -> Option<usize> {
|
||||
self.max_parallel_tool_calls
|
||||
}
|
||||
|
||||
/// Default per-team cap on concurrently running sub-agents; `None`
|
||||
/// means unlimited (pre-fix behavior).
|
||||
#[must_use]
|
||||
pub fn max_concurrent_agents(&self) -> Option<usize> {
|
||||
self.max_concurrent_agents
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
|
||||
self.hooks = hooks;
|
||||
@@ -1816,51 +1761,6 @@ 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())
|
||||
}
|
||||
|
||||
/// Reads the first present key from `keys` as a positive integer.
|
||||
/// Absent, null, or non-positive values parse to `None` so callers fall
|
||||
/// back to their own defaults rather than silently disabling a cap via a
|
||||
/// `0` typo. Accepts JSON numbers or numeric strings.
|
||||
fn parse_optional_positive_usize(
|
||||
root: &JsonValue,
|
||||
keys: &[&str],
|
||||
) -> Result<Option<usize>, ConfigError> {
|
||||
let Some(object) = root.as_object() else {
|
||||
return Ok(None);
|
||||
};
|
||||
for key in keys {
|
||||
let Some(value) = object.get(*key) else {
|
||||
continue;
|
||||
};
|
||||
if matches!(value, JsonValue::Null) {
|
||||
continue;
|
||||
}
|
||||
let parsed = value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_str().and_then(|s| s.trim().parse::<i64>().ok()))
|
||||
.ok_or_else(|| ConfigError::Parse(format!("{key} must be a positive integer")))?;
|
||||
if parsed <= 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
return Ok(usize::try_from(parsed).ok());
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn parse_optional_aliases(root: &JsonValue) -> Result<BTreeMap<String, String>, ConfigError> {
|
||||
let Some(object) = root.as_object() else {
|
||||
return Ok(BTreeMap::new());
|
||||
@@ -2732,49 +2632,6 @@ 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::{
|
||||
@@ -2806,6 +2663,42 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_persisted_provider_configuration_from_settings() {
|
||||
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"),
|
||||
r#"{
|
||||
"provider": {
|
||||
"kind": "dashscope",
|
||||
"apiKey": "persisted-test-key",
|
||||
"baseUrl": "https://dashscope.example/v1",
|
||||
"model": "qwen-plus"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("provider configuration should load");
|
||||
|
||||
let provider = loaded.provider();
|
||||
|
||||
assert_eq!(provider.kind(), Some("dashscope"));
|
||||
assert_eq!(provider.api_key(), Some("persisted-test-key"));
|
||||
assert_eq!(provider.base_url(), Some("https://dashscope.example/v1"));
|
||||
assert_eq!(provider.model(), Some("qwen-plus"));
|
||||
assert!(provider.is_configured());
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_object_settings_files() {
|
||||
let root = temp_dir();
|
||||
@@ -4136,151 +4029,4 @@ 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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_and_agent_caps_read_camel_and_snake_case_keys() {
|
||||
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 \"maxParallelToolCalls\": 4,\n \"max_concurrent_agents\": \"6\"\n}\n",
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let config = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
assert_eq!(
|
||||
config.max_parallel_tool_calls(),
|
||||
Some(4),
|
||||
"maxParallelToolCalls camelCase key should parse"
|
||||
);
|
||||
assert_eq!(
|
||||
config.max_concurrent_agents(),
|
||||
Some(6),
|
||||
"max_concurrent_agents snake_case key should parse numeric strings"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_and_agent_caps_reject_nonpositive_and_malformed() {
|
||||
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 \"maxParallelToolCalls\": 0\n}\n",
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let config = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("zero cap should parse as unset");
|
||||
assert_eq!(config.max_parallel_tool_calls(), None);
|
||||
assert_eq!(config.max_concurrent_agents(), None);
|
||||
fs::remove_dir_all(&root).expect("cleanup temp dir");
|
||||
|
||||
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 \"maxParallelToolCalls\": \"lots\"\n}\n",
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
let error = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect_err("non-numeric cap should be a config error");
|
||||
assert!(
|
||||
error.to_string().contains("maxParallelToolCalls"),
|
||||
"error should name the offending key: {error}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,8 +11,48 @@ use serde::{Deserialize, Serialize};
|
||||
use walkdir::{DirEntry, WalkDir};
|
||||
|
||||
/// Maximum file size that can be read (10 MB).
|
||||
///
|
||||
/// This is a *process* guard — it stops us slurping a huge or binary blob into memory. It is
|
||||
/// not a context guard: 10 MB is on the order of 2.5 M tokens, so it never fires on the files
|
||||
/// that actually blow a model's context window. `DEFAULT_READ_LINES` / `MAX_READ_CHARS` below
|
||||
/// are the context guard.
|
||||
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
|
||||
|
||||
/// Lines returned by `read_file` when the caller does not pass an explicit `limit`.
|
||||
///
|
||||
/// This is a *paging* size, not a refusal. An unbounded read is the easiest way for a model to
|
||||
/// destroy its own context: one 100 KB source file is ~25 k tokens, which does not fit alongside
|
||||
/// a system prompt in a 32 k window. When that happens the backend rejects the whole request and
|
||||
/// the session dies — so reading everything at once is what makes it stop. Returning a page plus
|
||||
/// `nextOffset` means the read always succeeds and the model can keep going.
|
||||
const DEFAULT_READ_LINES: usize = 2000;
|
||||
|
||||
/// Hard ceiling on the characters `read_file` returns, applied even when `limit` is explicit.
|
||||
///
|
||||
/// `DEFAULT_READ_LINES` alone does not bound the payload: minified bundles, JSON blobs and
|
||||
/// generated sources routinely put megabytes on a handful of lines. ~64 KB is ~16 k tokens.
|
||||
///
|
||||
/// That default assumes a large (200 k) context window. Against a small self-hosted backend it
|
||||
/// is far too generous: two reads at this size put a request over 47 k tokens, which a 32 k
|
||||
/// window rejects outright — and compaction cannot recover it, because the preserved recent
|
||||
/// messages ARE the oversized tool results. Override with [`MAX_READ_CHARS_ENV_VAR`].
|
||||
const DEFAULT_MAX_READ_CHARS: usize = 64 * 1024;
|
||||
|
||||
/// Environment override for [`DEFAULT_MAX_READ_CHARS`], in characters.
|
||||
const MAX_READ_CHARS_ENV_VAR: &str = "CLAW_MAX_READ_CHARS";
|
||||
|
||||
/// Effective character ceiling for one `read_file` page.
|
||||
///
|
||||
/// Read per call rather than cached: a long-lived process should pick up a changed budget, and
|
||||
/// this runs once per read, not per line.
|
||||
fn max_read_chars() -> usize {
|
||||
std::env::var(MAX_READ_CHARS_ENV_VAR)
|
||||
.ok()
|
||||
.and_then(|raw| raw.trim().parse::<usize>().ok())
|
||||
.filter(|chars| *chars > 0)
|
||||
.unwrap_or(DEFAULT_MAX_READ_CHARS)
|
||||
}
|
||||
|
||||
/// Maximum file size that can be written (10 MB).
|
||||
const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;
|
||||
|
||||
@@ -80,6 +120,17 @@ pub struct TextFilePayload {
|
||||
pub start_line: usize,
|
||||
#[serde(rename = "totalLines")]
|
||||
pub total_lines: usize,
|
||||
/// True when the file has more lines past this page. Signals the caller that it has NOT seen
|
||||
/// the whole file, so it does not silently reason over a partial view.
|
||||
#[serde(default)]
|
||||
pub truncated: bool,
|
||||
/// Line index to pass back as `offset` to continue reading. Absent once the file is exhausted.
|
||||
#[serde(
|
||||
rename = "nextOffset",
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub next_offset: Option<usize>,
|
||||
}
|
||||
|
||||
/// Output envelope for the `read_file` tool.
|
||||
@@ -228,10 +279,28 @@ pub fn read_file(
|
||||
let content = fs::read_to_string(&absolute_path)?;
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let start_index = offset.unwrap_or(0).min(lines.len());
|
||||
let end_index = limit.map_or(lines.len(), |limit| {
|
||||
start_index.saturating_add(limit).min(lines.len())
|
||||
});
|
||||
// An absent `limit` pages rather than reading to EOF: see DEFAULT_READ_LINES.
|
||||
let requested_end = start_index
|
||||
.saturating_add(limit.unwrap_or(DEFAULT_READ_LINES))
|
||||
.min(lines.len());
|
||||
|
||||
// Apply the character ceiling on top of the line window, so a file with very long lines
|
||||
// cannot blow the budget the line cap was meant to enforce. Always yield at least one line,
|
||||
// otherwise an oversized single line would return nothing and the caller could not advance.
|
||||
let mut end_index = start_index;
|
||||
let mut chars = 0usize;
|
||||
let char_ceiling = max_read_chars();
|
||||
for line in &lines[start_index..requested_end] {
|
||||
let next = chars + line.chars().count() + 1;
|
||||
if next > char_ceiling && end_index > start_index {
|
||||
break;
|
||||
}
|
||||
chars = next;
|
||||
end_index += 1;
|
||||
}
|
||||
|
||||
let selected = lines[start_index..end_index].join("\n");
|
||||
let truncated = end_index < lines.len();
|
||||
|
||||
Ok(ReadFileOutput {
|
||||
kind: String::from("text"),
|
||||
@@ -241,6 +310,8 @@ pub fn read_file(
|
||||
num_lines: end_index.saturating_sub(start_index),
|
||||
start_line: start_index.saturating_add(1),
|
||||
total_lines: lines.len(),
|
||||
truncated,
|
||||
next_offset: truncated.then_some(end_index),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -812,11 +883,18 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
component_contains_glob, derive_glob_walk_root, edit_file, expand_braces, glob_search,
|
||||
grep_search, is_symlink_escape, normalize_for_comparison, read_file,
|
||||
grep_search, is_symlink_escape, max_read_chars, normalize_for_comparison, read_file,
|
||||
read_file_in_workspace, write_file, write_file_in_workspace, GrepSearchInput,
|
||||
MAX_WRITE_SIZE,
|
||||
DEFAULT_MAX_READ_CHARS, DEFAULT_READ_LINES, MAX_READ_CHARS_ENV_VAR, MAX_WRITE_SIZE,
|
||||
};
|
||||
|
||||
/// Serializes the tests that mutate `CLAW_MAX_READ_CHARS`; env vars are process-global.
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn temp_path(name: &str) -> std::path::PathBuf {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -837,6 +915,132 @@ mod tests {
|
||||
assert_eq!(read_output.file.content, "two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbounded_read_of_a_large_file_pages_instead_of_returning_everything() {
|
||||
// Regression: an unbounded read used to return the whole file, which on a small-context
|
||||
// backend overflows the window and the request is rejected outright — the session dies.
|
||||
let path = temp_path("paged-read.txt");
|
||||
let total = DEFAULT_READ_LINES + 500;
|
||||
let body = (0..total)
|
||||
.map(|i| format!("line {i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
write_file(path.to_string_lossy().as_ref(), &body).expect("write should succeed");
|
||||
|
||||
let first = read_file(path.to_string_lossy().as_ref(), None, None).expect("first page");
|
||||
assert_eq!(first.file.num_lines, DEFAULT_READ_LINES);
|
||||
assert_eq!(first.file.total_lines, total);
|
||||
assert!(first.file.truncated, "caller must be told it is partial");
|
||||
assert_eq!(first.file.next_offset, Some(DEFAULT_READ_LINES));
|
||||
assert!(first.file.content.starts_with("line 0\n"));
|
||||
|
||||
// The advertised next_offset must actually resume where the last page stopped.
|
||||
let second = read_file(
|
||||
path.to_string_lossy().as_ref(),
|
||||
first.file.next_offset,
|
||||
None,
|
||||
)
|
||||
.expect("second page");
|
||||
assert_eq!(second.file.num_lines, 500);
|
||||
assert!(!second.file.truncated, "the tail is the final page");
|
||||
assert_eq!(second.file.next_offset, None);
|
||||
assert!(second
|
||||
.file
|
||||
.content
|
||||
.starts_with(&format!("line {DEFAULT_READ_LINES}\n")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_ceiling_bounds_files_whose_lines_are_enormous() {
|
||||
// A line cap alone does not bound the payload: minified bundles put megabytes on a few
|
||||
// lines, so the byte ceiling has to apply too — and must still yield forward progress.
|
||||
let _guard = env_lock();
|
||||
std::env::remove_var(MAX_READ_CHARS_ENV_VAR);
|
||||
let path = temp_path("long-lines.txt");
|
||||
let huge = "x".repeat(DEFAULT_MAX_READ_CHARS);
|
||||
let body = format!("{huge}\n{huge}\n{huge}");
|
||||
write_file(path.to_string_lossy().as_ref(), &body).expect("write should succeed");
|
||||
|
||||
let page = read_file(path.to_string_lossy().as_ref(), None, None).expect("first page");
|
||||
assert_eq!(page.file.num_lines, 1, "one oversized line still advances");
|
||||
assert!(page.file.truncated);
|
||||
assert_eq!(page.file.next_offset, Some(1));
|
||||
|
||||
// An explicit limit must not be able to defeat the ceiling.
|
||||
let greedy = read_file(path.to_string_lossy().as_ref(), None, Some(3)).expect("greedy");
|
||||
assert_eq!(greedy.file.num_lines, 1);
|
||||
assert!(greedy.file.truncated);
|
||||
}
|
||||
|
||||
/// The 64 KB default is a 200 k-context number. A small self-hosted backend must be able to
|
||||
/// shrink it, or two reads overflow a 32 k window in a single turn.
|
||||
#[test]
|
||||
fn read_char_ceiling_is_configurable_via_the_environment() {
|
||||
let _guard = env_lock();
|
||||
let path = temp_path("configurable-ceiling.txt");
|
||||
// 40 lines of 100 chars = ~4 KB: far under the default ceiling, over a 1 KB one.
|
||||
let body = (0..40)
|
||||
.map(|_| "y".repeat(100))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
write_file(path.to_string_lossy().as_ref(), &body).expect("write should succeed");
|
||||
|
||||
std::env::remove_var(MAX_READ_CHARS_ENV_VAR);
|
||||
let whole = read_file(path.to_string_lossy().as_ref(), None, None).expect("default read");
|
||||
assert_eq!(
|
||||
whole.file.num_lines, 40,
|
||||
"default ceiling returns the file whole"
|
||||
);
|
||||
assert!(!whole.file.truncated);
|
||||
|
||||
std::env::set_var(MAX_READ_CHARS_ENV_VAR, "1024");
|
||||
let clipped = read_file(path.to_string_lossy().as_ref(), None, None).expect("clipped read");
|
||||
std::env::remove_var(MAX_READ_CHARS_ENV_VAR);
|
||||
assert!(
|
||||
clipped.file.num_lines < 40,
|
||||
"a smaller ceiling must actually clip, got {} lines",
|
||||
clipped.file.num_lines
|
||||
);
|
||||
assert!(
|
||||
clipped.file.truncated,
|
||||
"a clipped page must report truncated"
|
||||
);
|
||||
assert_eq!(
|
||||
clipped.file.next_offset,
|
||||
Some(clipped.file.num_lines),
|
||||
"the caller must be told where to resume"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_char_ceiling_ignores_junk_and_zero_values() {
|
||||
let _guard = env_lock();
|
||||
for value in ["", "0", "-5", "not-a-number"] {
|
||||
std::env::set_var(MAX_READ_CHARS_ENV_VAR, value);
|
||||
assert_eq!(
|
||||
max_read_chars(),
|
||||
DEFAULT_MAX_READ_CHARS,
|
||||
"{value:?} must fall back to the default rather than disabling reads"
|
||||
);
|
||||
}
|
||||
std::env::set_var(MAX_READ_CHARS_ENV_VAR, "24000");
|
||||
assert_eq!(max_read_chars(), 24_000);
|
||||
std::env::remove_var(MAX_READ_CHARS_ENV_VAR);
|
||||
assert_eq!(max_read_chars(), DEFAULT_MAX_READ_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_files_are_returned_whole_and_not_marked_truncated() {
|
||||
let path = temp_path("small-read.txt");
|
||||
write_file(path.to_string_lossy().as_ref(), "one\ntwo\nthree").expect("write");
|
||||
|
||||
let output = read_file(path.to_string_lossy().as_ref(), None, None).expect("read");
|
||||
assert_eq!(output.file.content, "one\ntwo\nthree");
|
||||
assert_eq!(output.file.num_lines, 3);
|
||||
assert!(!output.file.truncated);
|
||||
assert_eq!(output.file.next_offset, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_file_contents() {
|
||||
let path = temp_path("edit.txt");
|
||||
|
||||
@@ -66,16 +66,15 @@ pub use compact::{
|
||||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||
};
|
||||
pub use config::{
|
||||
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,
|
||||
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,
|
||||
};
|
||||
pub use config_validate::{
|
||||
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
|
||||
@@ -83,8 +82,8 @@ pub use config_validate::{
|
||||
};
|
||||
pub use conversation::{
|
||||
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
|
||||
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolCall, ToolError,
|
||||
ToolExecutor, ToolResult, TurnProgressReporter, TurnSummary,
|
||||
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError,
|
||||
ToolExecutor, TurnSummary,
|
||||
};
|
||||
pub use file_ops::{
|
||||
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
|
||||
|
||||
@@ -42,7 +42,25 @@ pub const SYSTEM_PROMPT_DYNAMIC_BOUNDARY: &str = "__SYSTEM_PROMPT_DYNAMIC_BOUNDA
|
||||
pub const FRONTIER_MODEL_NAME: &str = "Claude Opus 4.6";
|
||||
const MAX_INSTRUCTION_FILE_CHARS: usize = 4_000;
|
||||
const MAX_TOTAL_INSTRUCTION_CHARS: usize = 12_000;
|
||||
const MAX_GIT_DIFF_CHARS: usize = 50_000;
|
||||
/// Character budget for the git diff snapshot embedded in the system prompt.
|
||||
///
|
||||
/// This lives in the *system prompt*, so its cost is paid on every session whose
|
||||
/// working tree has changed — and a diff changes on essentially every edit. The
|
||||
/// previous 50_000 budget was four times the entire instruction-file budget and
|
||||
/// could add ~15_000 tokens to the prompt from a single modified file, which is
|
||||
/// affordable against a hosted frontier model and ruinous against a local one.
|
||||
///
|
||||
/// Override with `CLAW_MAX_GIT_DIFF_CHARS`. `0` drops the diff snapshot entirely;
|
||||
/// git status and the recent-commit list are unaffected.
|
||||
const DEFAULT_MAX_GIT_DIFF_CHARS: usize = 4_000;
|
||||
const MAX_GIT_DIFF_CHARS_ENV_VAR: &str = "CLAW_MAX_GIT_DIFF_CHARS";
|
||||
|
||||
fn max_git_diff_chars() -> usize {
|
||||
std::env::var(MAX_GIT_DIFF_CHARS_ENV_VAR)
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse().ok())
|
||||
.unwrap_or(DEFAULT_MAX_GIT_DIFF_CHARS)
|
||||
}
|
||||
|
||||
/// Neutral identity for the model family line in generated prompts.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
@@ -498,8 +516,15 @@ fn read_git_diff(cwd: &Path) -> Option<String> {
|
||||
}
|
||||
|
||||
fn truncate_diff(mut diff: String) -> String {
|
||||
if diff.len() > MAX_GIT_DIFF_CHARS {
|
||||
let mut end = MAX_GIT_DIFF_CHARS;
|
||||
let budget = max_git_diff_chars();
|
||||
// A zero budget means "omit the diff", not "truncate to nothing" -- returning
|
||||
// an empty string here would still push an empty "Git diff snapshot:" heading
|
||||
// into the prompt.
|
||||
if budget == 0 {
|
||||
return String::new();
|
||||
}
|
||||
if diff.len() > budget {
|
||||
let mut end = budget;
|
||||
while !diff.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
@@ -758,6 +783,7 @@ fn get_simple_doing_tasks_section() -> String {
|
||||
"Do not add speculative abstractions, compatibility shims, or unrelated cleanup.".to_string(),
|
||||
"Do not create files unless they are required to complete the task.".to_string(),
|
||||
"If an approach fails, diagnose the failure before switching tactics.".to_string(),
|
||||
"Act in the turn you plan in: when the next step is a tool call, make the call. Never end a turn describing a step you have not taken, and do not stop to ask for a go-ahead on work already requested.".to_string(),
|
||||
"Be careful not to introduce security vulnerabilities such as command injection, XSS, or SQL injection.".to_string(),
|
||||
"Report outcomes faithfully: if verification fails or was not run, say so explicitly.".to_string(),
|
||||
]);
|
||||
@@ -782,7 +808,8 @@ mod tests {
|
||||
collapse_blank_lines, display_context_path, normalize_instruction_content,
|
||||
render_instruction_content, render_instruction_files, truncate_diff,
|
||||
truncate_instruction_content, ContextFile, ModelFamilyIdentity, ProjectContext,
|
||||
SystemPromptBuilder, MAX_GIT_DIFF_CHARS, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
SystemPromptBuilder, DEFAULT_MAX_GIT_DIFF_CHARS, MAX_GIT_DIFF_CHARS_ENV_VAR,
|
||||
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
};
|
||||
use crate::config::ConfigLoader;
|
||||
use std::fs;
|
||||
@@ -1444,33 +1471,69 @@ mod tests {
|
||||
assert!(rendered.contains("Project rules"));
|
||||
}
|
||||
|
||||
/// Serializes the tests that read or mutate `CLAW_MAX_GIT_DIFF_CHARS`; env vars
|
||||
/// are process-global and the default-budget tests fail if another test leaves
|
||||
/// an override set.
|
||||
fn diff_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_preserves_short_content() {
|
||||
let _guard = diff_env_lock();
|
||||
std::env::remove_var(MAX_GIT_DIFF_CHARS_ENV_VAR);
|
||||
let short = "a".repeat(1_000);
|
||||
let result = truncate_diff(short.clone());
|
||||
assert_eq!(result, short);
|
||||
assert!(!result.contains("[diff truncated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_honors_env_override() {
|
||||
let _guard = diff_env_lock();
|
||||
std::env::set_var(MAX_GIT_DIFF_CHARS_ENV_VAR, "100");
|
||||
let result = truncate_diff("z".repeat(1_000));
|
||||
std::env::remove_var(MAX_GIT_DIFF_CHARS_ENV_VAR);
|
||||
let marker = "\n\n... [diff truncated — too large for system prompt]";
|
||||
assert!(result.ends_with(marker));
|
||||
assert_eq!(result.len() - marker.len(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_zero_budget_omits_the_snapshot_entirely() {
|
||||
// Zero must yield nothing at all -- a truncated-to-empty body would still
|
||||
// drag a "Git diff snapshot:" heading and the truncation marker into the
|
||||
// prompt, which is the opposite of what the operator asked for.
|
||||
let _guard = diff_env_lock();
|
||||
std::env::set_var(MAX_GIT_DIFF_CHARS_ENV_VAR, "0");
|
||||
let result = truncate_diff("z".repeat(1_000));
|
||||
std::env::remove_var(MAX_GIT_DIFF_CHARS_ENV_VAR);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_caps_oversized_content() {
|
||||
let large = "x".repeat(MAX_GIT_DIFF_CHARS + 5_000);
|
||||
let _guard = diff_env_lock();
|
||||
std::env::remove_var(MAX_GIT_DIFF_CHARS_ENV_VAR);
|
||||
let large = "x".repeat(DEFAULT_MAX_GIT_DIFF_CHARS + 5_000);
|
||||
let result = truncate_diff(large);
|
||||
assert!(result.contains("... [diff truncated — too large for system prompt]"));
|
||||
// The body before the marker must be at most MAX_GIT_DIFF_CHARS bytes
|
||||
// The body before the marker must be at most DEFAULT_MAX_GIT_DIFF_CHARS bytes
|
||||
let marker = "\n\n... [diff truncated — too large for system prompt]";
|
||||
let body_len = result.len() - marker.len();
|
||||
assert!(body_len <= MAX_GIT_DIFF_CHARS);
|
||||
assert!(body_len <= DEFAULT_MAX_GIT_DIFF_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_respects_utf8_char_boundaries() {
|
||||
// Build a string where MAX_GIT_DIFF_CHARS falls in the middle of a
|
||||
// Build a string where DEFAULT_MAX_GIT_DIFF_CHARS falls in the middle of a
|
||||
// multi-byte character (U+1F600 = 4 bytes in UTF-8).
|
||||
let prefix_len = MAX_GIT_DIFF_CHARS - 2;
|
||||
let prefix_len = DEFAULT_MAX_GIT_DIFF_CHARS - 2;
|
||||
let mut input = "a".repeat(prefix_len);
|
||||
// Append a 4-byte emoji so bytes [prefix_len..prefix_len+4] are the
|
||||
// emoji. MAX_GIT_DIFF_CHARS lands at prefix_len+2, inside the emoji.
|
||||
// emoji. DEFAULT_MAX_GIT_DIFF_CHARS lands at prefix_len+2, inside the emoji.
|
||||
input.push('\u{1F600}');
|
||||
input.push_str(&"b".repeat(10_000));
|
||||
|
||||
@@ -1482,7 +1545,7 @@ mod tests {
|
||||
// inside it would be invalid UTF-8.
|
||||
let marker = "\n\n... [diff truncated — too large for system prompt]";
|
||||
let body = &result[..result.len() - marker.len()];
|
||||
assert!(body.len() <= MAX_GIT_DIFF_CHARS);
|
||||
assert!(body.len() <= DEFAULT_MAX_GIT_DIFF_CHARS);
|
||||
assert!(body.is_char_boundary(body.len()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,6 +718,20 @@ impl ConversationMessage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text the harness injected on its own initiative, not something the user typed.
|
||||
///
|
||||
/// Reaches the model as an ordinary turn — the wire protocol has nowhere else to put it — but
|
||||
/// stays labelled in the transcript, so a replay, an export, or a compaction summary does not
|
||||
/// present the harness's own prompting back as the user's words.
|
||||
#[must_use]
|
||||
pub fn system_text(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: MessageRole::System,
|
||||
blocks: vec![ContentBlock::Text { text: text.into() }],
|
||||
usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn assistant(blocks: Vec<ContentBlock>) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -190,10 +190,8 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
|
||||
|
||||
if let Some(last_write) = last_write_idx {
|
||||
for op in ops {
|
||||
if (op.op_type == FileOp::Read
|
||||
|| op.op_type == FileOp::Write
|
||||
|| op.op_type == FileOp::Edit)
|
||||
&& op.index < last_write
|
||||
if op.index < last_write
|
||||
&& matches!(op.op_type, FileOp::Read | FileOp::Write | FileOp::Edit)
|
||||
{
|
||||
obsolete_indices.insert(op.index);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,19 @@ impl TokenUsage {
|
||||
+ self.cache_read_input_tokens
|
||||
}
|
||||
|
||||
/// Total tokens the provider had to read to serve this call, i.e. the live size of the
|
||||
/// context window.
|
||||
///
|
||||
/// `input_tokens` alone is NOT that number. With prompt caching a warm call reports
|
||||
/// `input_tokens: 2` and puts the real bulk in `cache_read_input_tokens`, so anything
|
||||
/// budgeting against the context window must sum all three input counters.
|
||||
#[must_use]
|
||||
pub fn total_input_tokens(self) -> u32 {
|
||||
self.input_tokens
|
||||
.saturating_add(self.cache_creation_input_tokens)
|
||||
.saturating_add(self.cache_read_input_tokens)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn estimate_cost_usd(self) -> UsageCostEstimate {
|
||||
self.estimate_cost_usd_with_pricing(ModelPricing::default_sonnet_tier())
|
||||
@@ -243,6 +256,41 @@ mod tests {
|
||||
assert_eq!(tracker.cumulative_usage().total_tokens(), 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_input_tokens_includes_cached_context() {
|
||||
// The shape a warm prompt-cached call actually reports: input_tokens is ~nothing and
|
||||
// the real context size is in the cache counters.
|
||||
let cached = TokenUsage {
|
||||
input_tokens: 2,
|
||||
output_tokens: 1_133,
|
||||
cache_creation_input_tokens: 1_453,
|
||||
cache_read_input_tokens: 111_186,
|
||||
};
|
||||
assert_eq!(cached.total_input_tokens(), 112_641);
|
||||
// Output tokens are not part of the input budget.
|
||||
assert_ne!(cached.total_input_tokens(), cached.total_tokens());
|
||||
|
||||
// A backend without prompt caching reports the whole prompt as input_tokens.
|
||||
let uncached = TokenUsage {
|
||||
input_tokens: 29_056,
|
||||
output_tokens: 73,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
};
|
||||
assert_eq!(uncached.total_input_tokens(), 29_056);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_input_tokens_saturates_instead_of_overflowing() {
|
||||
let usage = TokenUsage {
|
||||
input_tokens: u32::MAX,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: u32::MAX,
|
||||
cache_read_input_tokens: u32::MAX,
|
||||
};
|
||||
assert_eq!(usage.total_input_tokens(), u32::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computes_cost_summary_lines() {
|
||||
let usage = TokenUsage {
|
||||
|
||||
@@ -14,7 +14,6 @@ api = { path = "../api" }
|
||||
commands = { path = "../commands" }
|
||||
crossterm = "0.28"
|
||||
pulldown-cmark = "0.13"
|
||||
once_cell = "1"
|
||||
rustyline = "15"
|
||||
runtime = { path = "../runtime" }
|
||||
plugins = { path = "../plugins" }
|
||||
@@ -26,15 +25,8 @@ tools = { path = "../tools" }
|
||||
log = "0.4"
|
||||
|
||||
|
||||
[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"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
mock-anthropic-service = { path = "../mock-anthropic-service" }
|
||||
|
||||
@@ -18,8 +18,6 @@ pub enum ReadOutcome {
|
||||
Submit(String),
|
||||
Cancel,
|
||||
Exit,
|
||||
ProviderSwap,
|
||||
TeamToggle,
|
||||
}
|
||||
|
||||
struct SlashCommandHelper {
|
||||
@@ -88,19 +86,12 @@ 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);
|
||||
// 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)
|
||||
}
|
||||
Cow::Borrowed(line)
|
||||
}
|
||||
|
||||
fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool {
|
||||
self.set_current_line(line);
|
||||
// Re-highlight when sentinel is present to show the prompt
|
||||
line.contains('\x01')
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,18 +115,6 @@ 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(),
|
||||
@@ -168,18 +147,7 @@ impl LineEditor {
|
||||
}
|
||||
|
||||
match self.editor.readline(&self.prompt) {
|
||||
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))
|
||||
}
|
||||
Ok(line) => Ok(ReadOutcome::Submit(line)),
|
||||
Err(ReadlineError::Interrupted) => {
|
||||
let has_input = !self.current_line().is_empty();
|
||||
self.finish_interrupted_read()?;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -249,16 +249,6 @@ impl TerminalRenderer {
|
||||
|
||||
#[must_use]
|
||||
pub fn render_markdown(&self, markdown: &str) -> String {
|
||||
self.render_markdown_raw(markdown).trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Render markdown to ANSI without trimming trailing whitespace.
|
||||
///
|
||||
/// Block-level events (paragraphs, headings, lists, code blocks) emit their
|
||||
/// trailing newline separators here. `render_markdown` trims them for
|
||||
/// one-shot output, while streaming keeps them so that successive chunks
|
||||
/// stay on their own lines instead of running together.
|
||||
fn render_markdown_raw(&self, markdown: &str) -> String {
|
||||
let normalized = normalize_nested_fences(markdown);
|
||||
let mut output = String::new();
|
||||
let mut state = RenderState::default();
|
||||
@@ -277,7 +267,30 @@ impl TerminalRenderer {
|
||||
);
|
||||
}
|
||||
|
||||
output
|
||||
output.trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Ensures the output ends with at least `required` newlines.
|
||||
/// This is idempotent: calling it multiple times with the same output
|
||||
/// will not add extra newlines beyond what is needed.
|
||||
/// The function is crucial for streaming rendering where chunks may
|
||||
/// split at block boundaries, causing the parser to lose newlines
|
||||
/// that normally separate blocks.
|
||||
fn ensure_newlines(output: &mut String, required: usize) {
|
||||
if output.is_empty() {
|
||||
// Don't start with blank lines
|
||||
return;
|
||||
}
|
||||
|
||||
// Count trailing newlines
|
||||
let trailing_newlines = output.chars().rev().take_while(|&c| c == '\n').count();
|
||||
|
||||
// Add newlines if we don't have enough
|
||||
if trailing_newlines < required {
|
||||
for _ in 0..(required - trailing_newlines) {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -285,18 +298,6 @@ impl TerminalRenderer {
|
||||
self.render_markdown(markdown)
|
||||
}
|
||||
|
||||
/// Stream-friendly variant of `markdown_to_ansi` that preserves trailing
|
||||
/// block separators (such as a paragraph's closing newlines).
|
||||
///
|
||||
/// `MarkdownStreamState::push` writes each rendered chunk directly to the
|
||||
/// terminal with no separator of its own, so the trailing newlines must
|
||||
/// survive here to keep a paragraph from sticking to the block that follows
|
||||
/// it. The final `flush` still uses `markdown_to_ansi` to trim the tail.
|
||||
#[must_use]
|
||||
pub fn markdown_to_ansi_stream(&self, markdown: &str) -> String {
|
||||
self.render_markdown_raw(markdown)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn render_event(
|
||||
&self,
|
||||
@@ -309,10 +310,14 @@ impl TerminalRenderer {
|
||||
) {
|
||||
match event {
|
||||
Event::Start(Tag::Heading { level, .. }) => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
Self::start_heading(state, level as u8, output);
|
||||
}
|
||||
Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
|
||||
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
|
||||
Event::Start(Tag::BlockQuote(..)) => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
self.start_quote(state, output);
|
||||
}
|
||||
Event::End(TagEnd::BlockQuote(..)) => {
|
||||
state.quote = state.quote.saturating_sub(1);
|
||||
output.push('\n');
|
||||
@@ -325,6 +330,7 @@ impl TerminalRenderer {
|
||||
state.append_raw(output, "\n");
|
||||
}
|
||||
Event::Start(Tag::List(first_item)) => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
let kind = match first_item {
|
||||
Some(index) => ListKind::Ordered { next_index: index },
|
||||
None => ListKind::Unordered,
|
||||
@@ -337,6 +343,7 @@ impl TerminalRenderer {
|
||||
}
|
||||
Event::Start(Tag::Item) => Self::start_item(state, output),
|
||||
Event::Start(Tag::CodeBlock(kind)) => {
|
||||
Self::ensure_newlines(output, 2);
|
||||
*in_code_block = true;
|
||||
*code_language = match kind {
|
||||
CodeBlockKind::Indented => String::from("text"),
|
||||
@@ -360,7 +367,10 @@ impl TerminalRenderer {
|
||||
format!("{}", format!("`{code}`").with(self.color_theme.inline_code));
|
||||
state.append_raw(output, &rendered);
|
||||
}
|
||||
Event::Rule => output.push_str("---\n"),
|
||||
Event::Rule => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
output.push_str("---\n");
|
||||
}
|
||||
Event::Text(text) => {
|
||||
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
|
||||
}
|
||||
@@ -405,7 +415,10 @@ impl TerminalRenderer {
|
||||
);
|
||||
state.append_raw(output, &rendered);
|
||||
}
|
||||
Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()),
|
||||
Event::Start(Tag::Table(..)) => {
|
||||
Self::ensure_newlines(output, 1);
|
||||
state.table = Some(TableState::default());
|
||||
}
|
||||
Event::End(TagEnd::Table) => {
|
||||
if let Some(table) = state.table.take() {
|
||||
output.push_str(&self.render_table(&table));
|
||||
@@ -619,9 +632,21 @@ impl TerminalRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarkdownStreamState {
|
||||
pending: String,
|
||||
/// Track whether the last emitted output ended with a newline.
|
||||
/// Used to ensure proper spacing when resuming from a buffer.
|
||||
last_output_ends_with_newline: bool,
|
||||
}
|
||||
|
||||
impl Default for MarkdownStreamState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pending: String::new(),
|
||||
last_output_ends_with_newline: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MarkdownStreamState {
|
||||
@@ -631,7 +656,40 @@ impl MarkdownStreamState {
|
||||
let split = find_stream_safe_boundary(&self.pending)?;
|
||||
let ready = self.pending[..split].to_string();
|
||||
self.pending.drain(..split);
|
||||
Some(renderer.markdown_to_ansi_stream(&ready))
|
||||
|
||||
let mut rendered = renderer.markdown_to_ansi(&ready);
|
||||
let stripped = strip_ansi(&rendered);
|
||||
|
||||
// If the previous output didn't end with a newline and this chunk starts
|
||||
// with a block-level element, ensure proper spacing before it.
|
||||
if !self.last_output_ends_with_newline && !rendered.is_empty() {
|
||||
if stripped.starts_with("\u{256d}\u{2500}")
|
||||
|| stripped.starts_with("```")
|
||||
|| stripped.starts_with("|")
|
||||
|| stripped.starts_with("• ")
|
||||
|| stripped.starts_with("1. ")
|
||||
|| stripped.starts_with("-")
|
||||
{
|
||||
rendered = format!("\n\n{}", rendered);
|
||||
}
|
||||
}
|
||||
|
||||
// When markdown ends with a code block closing marker or other block-level element,
|
||||
// render_markdown() trims the trailing newlines that finish_code_block() added.
|
||||
// For streaming, we need to restore these newlines so proper spacing is preserved
|
||||
// when chunks are combined. This is critical for idempotence across re-renders.
|
||||
if stripped.ends_with("\u{2570}\u{2500}")
|
||||
|| stripped.ends_with("---")
|
||||
|| stripped.ends_with("|")
|
||||
{
|
||||
// Block element that should have trailing spacing
|
||||
if !rendered.ends_with('\n') {
|
||||
rendered.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
self.last_output_ends_with_newline = rendered.ends_with('\n');
|
||||
Some(rendered)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -641,7 +699,26 @@ impl MarkdownStreamState {
|
||||
None
|
||||
} else {
|
||||
let pending = std::mem::take(&mut self.pending);
|
||||
Some(renderer.markdown_to_ansi(&pending))
|
||||
let mut rendered = renderer.markdown_to_ansi(&pending);
|
||||
|
||||
// If the previous output didn't end with a newline and this output
|
||||
// starts with a block-level element, ensure proper spacing.
|
||||
if !self.last_output_ends_with_newline && !rendered.is_empty() {
|
||||
// Check if output starts with a block element by looking for common markers
|
||||
let stripped = strip_ansi(&rendered);
|
||||
if stripped.starts_with("\u{256d}\u{2500}")
|
||||
|| stripped.starts_with("```")
|
||||
|| stripped.starts_with("|")
|
||||
|| stripped.starts_with("• ")
|
||||
|| stripped.starts_with("1. ")
|
||||
|| stripped.starts_with("-")
|
||||
{
|
||||
rendered = format!("\n\n{}", rendered);
|
||||
}
|
||||
}
|
||||
|
||||
self.last_output_ends_with_newline = rendered.ends_with('\n');
|
||||
Some(rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -835,6 +912,11 @@ fn normalize_nested_fences(markdown: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Find a safe boundary for streaming markdown rendering.
|
||||
/// Safe boundaries are:
|
||||
/// - After a closing fence line (when fence is closed)
|
||||
/// - After an empty line
|
||||
/// This function returns the position of the last safe boundary found.
|
||||
fn find_stream_safe_boundary(markdown: &str) -> Option<usize> {
|
||||
let mut open_fence: Option<FenceMarker> = None;
|
||||
let mut last_boundary = None;
|
||||
@@ -1074,34 +1156,6 @@ mod tests {
|
||||
assert!(plain_text.contains("fn nested()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_rendering_preserves_block_separators() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
|
||||
// The stream variant keeps the paragraph-ending newlines that the
|
||||
// one-shot variant trims, so consecutive streamed chunks do not run
|
||||
// into each other when written back-to-back to the terminal.
|
||||
let streamed = renderer.markdown_to_ansi_stream("Intro paragraph.\n\n");
|
||||
assert!(streamed.ends_with('\n'));
|
||||
assert!(!renderer
|
||||
.markdown_to_ansi("Intro paragraph.\n\n")
|
||||
.ends_with('\n'));
|
||||
|
||||
let mut state = MarkdownStreamState::default();
|
||||
let first = state
|
||||
.push(&renderer, "Intro paragraph.\n\n")
|
||||
.expect("blank line closes the paragraph block");
|
||||
let second = state
|
||||
.push(&renderer, "1. first item\n\n")
|
||||
.expect("blank line closes the list block");
|
||||
|
||||
let combined = strip_ansi(&format!("{first}{second}"));
|
||||
assert!(combined.contains("Intro paragraph."));
|
||||
assert!(combined.contains("1. first item"));
|
||||
// The two blocks stay on separate lines instead of sticking together.
|
||||
assert!(!combined.contains("Intro paragraph.1."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_advances_frames() {
|
||||
let terminal_renderer = TerminalRenderer::new();
|
||||
@@ -1117,4 +1171,189 @@ mod tests {
|
||||
let output = String::from_utf8_lossy(&out);
|
||||
assert!(output.contains("Working"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_text_before_code_block_preserves_newline_boundary() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let mut state = MarkdownStreamState::default();
|
||||
|
||||
// Simulate streaming: paragraph text arrives in one chunk
|
||||
assert_eq!(
|
||||
state.push(&renderer, "This is some text."),
|
||||
None,
|
||||
"incomplete paragraph should not flush"
|
||||
);
|
||||
|
||||
// Then code block arrives in the next chunk
|
||||
let rendered = state
|
||||
.push(&renderer, "\n\n```rust\nfn main() {}\n```\n")
|
||||
.expect("fenced block should trigger flush");
|
||||
let plain_text = strip_ansi(&rendered);
|
||||
|
||||
// Verify the paragraph text and code block are present
|
||||
assert!(plain_text.contains("This is some text."));
|
||||
assert!(plain_text.contains("fn main()"));
|
||||
|
||||
// The critical check: there should be a blank line between the paragraph and code block
|
||||
// The paragraph ends, then there should be newlines before the code block marker
|
||||
let lines: Vec<&str> = plain_text.lines().collect();
|
||||
let text_line_idx = lines
|
||||
.iter()
|
||||
.position(|line| line.contains("This is some text."))
|
||||
.expect("find text line");
|
||||
// Skip past the text line and find the code block
|
||||
let code_marker_idx = lines[text_line_idx + 1..]
|
||||
.iter()
|
||||
.position(|line| line.contains("╭─ rust"))
|
||||
.map(|i| i + text_line_idx + 1);
|
||||
|
||||
// There should be at least one empty line between the text and the code block marker
|
||||
if let Some(code_idx) = code_marker_idx {
|
||||
assert!(
|
||||
code_idx > text_line_idx + 1,
|
||||
"there should be spacing between paragraph and code block"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_paragraph_immediately_before_code_block_has_blank_line() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let markdown = "This is a paragraph.\n\n```rust\nfn main() {}\n```";
|
||||
let output = renderer.markdown_to_ansi(markdown);
|
||||
let plain_text = strip_ansi(&output);
|
||||
let lines: Vec<&str> = plain_text.lines().collect();
|
||||
|
||||
// Find the paragraph and code block
|
||||
let text_idx = lines
|
||||
.iter()
|
||||
.position(|l| l.contains("This is a paragraph."))
|
||||
.expect("find paragraph");
|
||||
let code_idx = lines[text_idx + 1..]
|
||||
.iter()
|
||||
.position(|l| l.contains("╭─ rust"))
|
||||
.map(|i| i + text_idx + 1)
|
||||
.expect("find code block");
|
||||
|
||||
// There should be at least one blank line between them
|
||||
assert!(
|
||||
code_idx > text_idx + 1,
|
||||
"code block should not immediately follow paragraph"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_block_ending_with_trailing_text_preserves_spacing() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let markdown = "```rust\nfn main() {}\n```\n\nText after.";
|
||||
let output = renderer.markdown_to_ansi(markdown);
|
||||
let plain_text = strip_ansi(&output);
|
||||
|
||||
// The code block should end with "╰─" followed by blank lines, then text
|
||||
assert!(
|
||||
plain_text.contains("\u{256d}\u{2500}"),
|
||||
"should contain code block close marker"
|
||||
);
|
||||
assert!(
|
||||
plain_text.contains("\n\nText"),
|
||||
"should have blank line before text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_code_block_preserves_trailing_newlines() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let mut state = MarkdownStreamState::default();
|
||||
let markdown = "Text before.\n\n```rust\nfn test() {}\n```\n\nText after.";
|
||||
|
||||
// Debug: let's check what the code block part alone renders to
|
||||
let code_part = "```rust\nfn test() {}\n```\n\nText after.";
|
||||
let code_rendered = strip_ansi(&renderer.markdown_to_ansi(code_part));
|
||||
|
||||
// Simulate the streaming at split position 1
|
||||
let r1 = state.push(&renderer, "T").map(|s| strip_ansi(&s));
|
||||
let r2 = state
|
||||
.push(
|
||||
&renderer,
|
||||
"ext before.\n\n```rust\nfn test() {}\n```\n\nText after.",
|
||||
)
|
||||
.map(|s| strip_ansi(&s));
|
||||
|
||||
// After r2, the pending should contain the code block part
|
||||
let pending_before_r3 = state.pending.clone();
|
||||
|
||||
let r3 = state.flush(&renderer).map(|s| strip_ansi(&s));
|
||||
|
||||
// Combine the results
|
||||
let mut full_streamed = String::new();
|
||||
if let Some(s) = &r1 {
|
||||
full_streamed.push_str(s);
|
||||
}
|
||||
if let Some(s) = &r2 {
|
||||
full_streamed.push_str(s);
|
||||
}
|
||||
if let Some(s) = &r3 {
|
||||
full_streamed.push_str(s);
|
||||
}
|
||||
|
||||
let full_rendered = strip_ansi(&renderer.markdown_to_ansi(markdown));
|
||||
|
||||
assert_eq!(full_streamed, full_rendered,
|
||||
"Streaming mismatch.\n\nCode part alone renders to: {:?}\nPending before r3: {:?}\n\nBreakdown:\n r1: {:?}\n r2: {:?}\n r3: {:?}\n\nExpected:\n{:?}\n\nGot:\n{:?}",
|
||||
code_rendered, pending_before_r3, r1, r2, r3, full_rendered, full_streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_with_split_at_every_byte_position_produces_consistent_output() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let markdown = "Text before.\n\n```rust\nfn test() {}\n```\n\nText after.";
|
||||
|
||||
// First, render the full document
|
||||
let full_output = renderer.markdown_to_ansi(markdown);
|
||||
let full_plain = strip_ansi(&full_output);
|
||||
|
||||
// Now simulate streaming by splitting at different positions
|
||||
for split_pos in 1..markdown.len() {
|
||||
let mut state = MarkdownStreamState::default();
|
||||
let part1 = &markdown[..split_pos];
|
||||
let part2 = &markdown[split_pos..];
|
||||
|
||||
// Try to push both parts
|
||||
let r1 = state.push(&renderer, part1);
|
||||
let r2 = state.push(&renderer, part2);
|
||||
let final_r = state.flush(&renderer);
|
||||
|
||||
// Reconstruct the streamed output
|
||||
let mut streamed_output = String::new();
|
||||
if let Some(out) = r1 {
|
||||
streamed_output.push_str(&out);
|
||||
}
|
||||
if let Some(out) = r2 {
|
||||
streamed_output.push_str(&out);
|
||||
}
|
||||
if let Some(out) = final_r {
|
||||
streamed_output.push_str(&out);
|
||||
}
|
||||
|
||||
let streamed_plain = strip_ansi(&streamed_output);
|
||||
|
||||
if streamed_plain != full_plain {
|
||||
eprintln!("\n=== MISMATCH at split position {} ===", split_pos);
|
||||
eprintln!("Part 1: {:?}", part1);
|
||||
eprintln!("Part 2: {:?}", part2);
|
||||
eprintln!("\nExpected (full):");
|
||||
eprintln!("{:?}", full_plain);
|
||||
eprintln!("\nGot (streamed):");
|
||||
eprintln!("{:?}", streamed_plain);
|
||||
eprintln!("\nStreamed rendered output:");
|
||||
eprintln!("{:?}", streamed_output);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
streamed_plain, full_plain,
|
||||
"streaming with split at position {} produces different output",
|
||||
split_pos
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ use std::io::{self, IsTerminal, Write};
|
||||
|
||||
use runtime::{save_user_provider_settings, ConfigLoader, RuntimeProviderConfig};
|
||||
|
||||
use serde_json;
|
||||
|
||||
const PROVIDERS: &[(&str, &str, &str)] = &[
|
||||
("1", "Anthropic", "anthropic"),
|
||||
("2", "xAI / Grok", "xai"),
|
||||
|
||||
@@ -451,11 +451,16 @@ fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
|
||||
.output()
|
||||
.expect("git init should launch");
|
||||
|
||||
for (command, expected_kind, expected_status) in [
|
||||
("/version", "version", "ok"),
|
||||
("/sandbox", "sandbox", "warn"),
|
||||
("/diff", "diff", "ok"),
|
||||
("/status", "status", "ok"),
|
||||
// `/sandbox`'s status reflects the host's real sandbox capability, so it is the one
|
||||
// entry with more than one legitimate value: "ok" where namespace isolation works,
|
||||
// "warn" where it is unsupported and only the filesystem sandbox is active (#731).
|
||||
// This test is about #831 routing — that these commands produce a local JSON action
|
||||
// instead of `interactive_only` — not about the kernel the suite happens to run on.
|
||||
for (command, expected_kind, expected_statuses) in [
|
||||
("/version", "version", &["ok"][..]),
|
||||
("/sandbox", "sandbox", &["ok", "warn"][..]),
|
||||
("/diff", "diff", &["ok"][..]),
|
||||
("/status", "status", &["ok"][..]),
|
||||
] {
|
||||
let output = run_claw(&root, &["--output-format", "json", command], &[]);
|
||||
assert!(
|
||||
@@ -470,9 +475,9 @@ fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
|
||||
.unwrap_or_else(|_| panic!("{command} must emit JSON (#831), got: {stdout:?}"));
|
||||
|
||||
assert_eq!(parsed["kind"], expected_kind, "{command} kind: {parsed}");
|
||||
assert_eq!(
|
||||
parsed["status"], expected_status,
|
||||
"{command} status: {parsed}"
|
||||
assert!(
|
||||
expected_statuses.contains(&parsed["status"].as_str().unwrap_or_default()),
|
||||
"{command} status should be one of {expected_statuses:?}: {parsed}"
|
||||
);
|
||||
assert_ne!(
|
||||
parsed["error_kind"], "interactive_only",
|
||||
@@ -1007,13 +1012,13 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
||||
assert!(
|
||||
!plugins
|
||||
.as_object()
|
||||
.map_or(false, |o| o.contains_key("reload_runtime")),
|
||||
.is_some_and(|o| o.contains_key("reload_runtime")),
|
||||
"plugins list should not include reload_runtime"
|
||||
);
|
||||
assert!(
|
||||
!plugins
|
||||
.as_object()
|
||||
.map_or(false, |o| o.contains_key("target")),
|
||||
.is_some_and(|o| o.contains_key("target")),
|
||||
"plugins list should not include target"
|
||||
);
|
||||
// #703: structured summary replaces prose message
|
||||
@@ -1707,13 +1712,13 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
|
||||
assert!(
|
||||
!plugins
|
||||
.as_object()
|
||||
.map_or(false, |o| o.contains_key("reload_runtime")),
|
||||
.is_some_and(|o| o.contains_key("reload_runtime")),
|
||||
"plugins list should not include reload_runtime"
|
||||
);
|
||||
assert!(
|
||||
!plugins
|
||||
.as_object()
|
||||
.map_or(false, |o| o.contains_key("target")),
|
||||
.is_some_and(|o| o.contains_key("target")),
|
||||
"plugins list should not include target"
|
||||
);
|
||||
assert!(
|
||||
@@ -2946,7 +2951,7 @@ fn prompt_empty_arg_json_stdout_missing_prompt_823() {
|
||||
"claw prompt empty arg must retain abort action (#823); got: {parsed}"
|
||||
);
|
||||
assert!(
|
||||
parsed["hint"].as_str().map_or(false, |h| !h.is_empty()),
|
||||
parsed["hint"].as_str().is_some_and(|h| !h.is_empty()),
|
||||
"claw prompt empty arg missing_prompt hint must be non-empty (#823)"
|
||||
);
|
||||
}
|
||||
@@ -2984,9 +2989,9 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
|
||||
"invalid --reasoning-effort must be invalid_flag_value (#756): {parsed}"
|
||||
);
|
||||
assert!(
|
||||
parsed["hint"].as_str().map_or(false, |h| h.contains("low")
|
||||
|| h.contains("medium")
|
||||
|| h.contains("high")),
|
||||
parsed["hint"]
|
||||
.as_str()
|
||||
.is_some_and(|h| h.contains("low") || h.contains("medium") || h.contains("high")),
|
||||
"hint must mention valid values (#756): {parsed}"
|
||||
);
|
||||
|
||||
@@ -3012,7 +3017,7 @@ fn flag_value_errors_have_error_kind_and_hint_756() {
|
||||
"missing --model value must be missing_flag_value (#756): {parsed2}"
|
||||
);
|
||||
assert!(
|
||||
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()),
|
||||
parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()),
|
||||
"missing --model hint must be non-empty (#756): {parsed2}"
|
||||
);
|
||||
}
|
||||
@@ -3256,7 +3261,7 @@ fn short_p_flag_swallows_no_flags_755() {
|
||||
"flag-like token after -p must be rejected as missing_prompt (#755): {parsed2}"
|
||||
);
|
||||
assert!(
|
||||
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()),
|
||||
parsed2["hint"].as_str().is_some_and(|h| !h.is_empty()),
|
||||
"missing_prompt hint must be non-empty (#755)"
|
||||
);
|
||||
}
|
||||
@@ -3398,7 +3403,7 @@ fn config_unsupported_section_json_hint_741() {
|
||||
assert!(
|
||||
parsed["supported_sections"]
|
||||
.as_array()
|
||||
.map_or(false, |a| !a.is_empty()),
|
||||
.is_some_and(|a| !a.is_empty()),
|
||||
"config {section} JSON must include supported_sections (#741)"
|
||||
);
|
||||
}
|
||||
@@ -3455,7 +3460,7 @@ fn export_json_has_kind_702() {
|
||||
// On success stdout has kind:export; on failure stderr has type:error.
|
||||
// Either way, both envelopes must be valid JSON.
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr)
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr)
|
||||
.lines()
|
||||
.filter(|l| l.starts_with('{'))
|
||||
.collect::<Vec<_>>()
|
||||
@@ -3547,8 +3552,8 @@ fn config_parse_error_has_typed_error_kind_and_hint_764() {
|
||||
!output.status.success(),
|
||||
"malformed settings.json should cause non-zero exit"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
@@ -3582,8 +3587,8 @@ fn login_logout_removed_subcommands_have_error_kind_and_hint_765() {
|
||||
!output.status.success(),
|
||||
"claw {subcmd} should exit non-zero"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
@@ -3722,8 +3727,8 @@ fn resume_non_slash_trailing_arg_has_typed_error_kind_and_hint_768() {
|
||||
!output.status.success(),
|
||||
"claw --resume latest compact should exit non-zero"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
@@ -3762,8 +3767,8 @@ fn session_with_unknown_subcommand_returns_interactive_only_not_credentials_767(
|
||||
!output.status.success(),
|
||||
"claw session {sub} should exit non-zero"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
@@ -3816,7 +3821,7 @@ fn slash_only_verbs_with_args_return_interactive_only_not_credentials_770() {
|
||||
"claw {} should exit non-zero",
|
||||
args.join(" ")
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
@@ -3858,8 +3863,8 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
|
||||
{
|
||||
let output = run_claw(&root, &["--output-format", "json", "agents", "bogus"], &[]);
|
||||
assert!(!output.status.success(), "agents bogus should fail");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
@@ -3882,8 +3887,8 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
|
||||
{
|
||||
let output = run_claw(&root, &["--output-format", "json", "plugins", "bogus"], &[]);
|
||||
assert!(!output.status.success(), "plugins bogus should fail");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
@@ -3902,7 +3907,7 @@ fn agents_plugins_mcp_unknown_subcommand_have_hint_774() {
|
||||
{
|
||||
let output = run_claw(&root, &["--output-format", "json", "mcp", "bogus"], &[]);
|
||||
assert!(!output.status.success(), "mcp bogus should fail");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_str = if stdout.trim().starts_with('{') {
|
||||
@@ -4017,7 +4022,7 @@ fn interactive_only_guard_batch_769_to_771() {
|
||||
"claw {} should exit non-zero",
|
||||
args.join(" ")
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
@@ -4081,7 +4086,7 @@ fn resume_plugin_mutations_are_typed_interactive_only_777() {
|
||||
!output.status.success(),
|
||||
"/plugins {mutation} in resume mode should exit non-zero"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
@@ -4138,7 +4143,7 @@ fn resume_skills_invocation_is_typed_interactive_only_779() {
|
||||
!output.status.success(),
|
||||
"/skills <skill> in resume mode should exit non-zero"
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
@@ -4179,8 +4184,8 @@ fn acp_unsupported_invocation_has_hint_782() {
|
||||
|
||||
let output = run_claw(&root, &["--output-format", "json", "acp", "start"], &[]);
|
||||
assert!(!output.status.success(), "acp start should fail");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json_line = stdout
|
||||
.lines()
|
||||
@@ -4217,7 +4222,7 @@ fn init_json_envelope_has_hint_and_already_initialized_783() {
|
||||
// Fresh init — already_initialized should be false, hint should mention CLAUDE.md
|
||||
let output = run_claw(&root, &["--output-format", "json", "init"], &[]);
|
||||
assert!(output.status.success(), "init should succeed");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let raw = if stdout.trim_start().starts_with('{') {
|
||||
@@ -4286,7 +4291,7 @@ fn init_json_envelope_has_hint_and_already_initialized_783() {
|
||||
// Idempotent re-init — already_initialized should be true
|
||||
let output2 = run_claw(&root, &["--output-format", "json", "init"], &[]);
|
||||
assert!(output2.status.success(), "re-init should succeed");
|
||||
let stdout2 = String::from_utf8_lossy(&output2.stdout);
|
||||
let _stdout2 = String::from_utf8_lossy(&output2.stdout);
|
||||
let stderr2 = String::from_utf8_lossy(&output2.stderr);
|
||||
let stdout2 = String::from_utf8_lossy(&output2.stdout);
|
||||
let raw2 = if stdout2.trim_start().starts_with('{') {
|
||||
@@ -4367,7 +4372,7 @@ fn export_arg_errors_have_typed_kind_and_hint_784() {
|
||||
&[],
|
||||
);
|
||||
assert!(!out1.status.success(), "--output with no value should fail");
|
||||
let stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let _stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let stdout1 = String::from_utf8_lossy(&out1.stdout);
|
||||
let j1: serde_json::Value = stdout1
|
||||
.lines()
|
||||
@@ -4394,7 +4399,7 @@ fn export_arg_errors_have_typed_kind_and_hint_784() {
|
||||
&[],
|
||||
);
|
||||
assert!(!out2.status.success(), "extra positional should fail");
|
||||
let stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let _stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let stdout2 = String::from_utf8_lossy(&out2.stdout);
|
||||
let j2: serde_json::Value = stdout2
|
||||
.lines()
|
||||
@@ -4431,7 +4436,7 @@ fn unknown_subcommand_returns_typed_kind_785() {
|
||||
// "dump" is close enough to "dump-manifests" to trigger the typo suggestion path
|
||||
let output = run_claw(&root, &["--output-format", "json", "dump"], &[]);
|
||||
assert!(!output.status.success(), "unknown subcommand should fail");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
@@ -4479,7 +4484,7 @@ fn dump_manifests_missing_dir_has_typed_kind_and_hint_786() {
|
||||
&[],
|
||||
);
|
||||
assert!(!out1.status.success());
|
||||
let stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let _stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let stdout1 = String::from_utf8_lossy(&out1.stdout);
|
||||
let j1: serde_json::Value = stdout1
|
||||
.lines()
|
||||
@@ -4511,7 +4516,7 @@ fn dump_manifests_missing_dir_has_typed_kind_and_hint_786() {
|
||||
&[],
|
||||
);
|
||||
assert!(!out2.status.success());
|
||||
let stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let _stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let stdout2 = String::from_utf8_lossy(&out2.stdout);
|
||||
let j2: serde_json::Value = stdout2
|
||||
.lines()
|
||||
@@ -4561,7 +4566,7 @@ fn resume_directory_path_returns_typed_kind_and_hint_787() {
|
||||
!output.status.success(),
|
||||
"resume with directory should fail"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
@@ -4611,7 +4616,7 @@ fn skills_show_not_found_emits_single_json_object_788() {
|
||||
assert!(!output.status.success(), "skills show unknown should fail");
|
||||
// Skills handler emits JSON to stdout; the duplicate was on stderr from the main error path.
|
||||
// After fix: stdout has 1 JSON object, stderr has none (no duplicate).
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
@@ -4773,7 +4778,7 @@ fn system_prompt_unknown_option_returns_typed_kind_790() {
|
||||
&[],
|
||||
);
|
||||
assert!(!out1.status.success());
|
||||
let stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let _stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let stdout1 = String::from_utf8_lossy(&out1.stdout);
|
||||
let j1: serde_json::Value = stdout1
|
||||
.lines()
|
||||
@@ -4800,7 +4805,7 @@ fn system_prompt_unknown_option_returns_typed_kind_790() {
|
||||
&[],
|
||||
);
|
||||
assert!(!out2.status.success());
|
||||
let stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let _stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let stdout2 = String::from_utf8_lossy(&out2.stdout);
|
||||
let j2: serde_json::Value = stdout2
|
||||
.lines()
|
||||
@@ -4838,7 +4843,7 @@ fn config_extra_args_have_non_null_hint_791() {
|
||||
&[],
|
||||
);
|
||||
assert!(!out1.status.success());
|
||||
let stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let _stderr1 = String::from_utf8_lossy(&out1.stderr);
|
||||
let stdout1 = String::from_utf8_lossy(&out1.stdout);
|
||||
let j1: serde_json::Value = stdout1
|
||||
.lines()
|
||||
@@ -4872,7 +4877,7 @@ fn config_extra_args_have_non_null_hint_791() {
|
||||
&[],
|
||||
);
|
||||
assert!(!out2.status.success());
|
||||
let stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let _stderr2 = String::from_utf8_lossy(&out2.stderr);
|
||||
let stdout2 = String::from_utf8_lossy(&out2.stdout);
|
||||
let j2: serde_json::Value = stdout2
|
||||
.lines()
|
||||
@@ -5058,7 +5063,7 @@ fn plugins_uninstall_not_found_has_hint_793() {
|
||||
"plugins uninstall not-found must exit non-zero (#793)"
|
||||
);
|
||||
// Error envelope goes to stderr (propagated via ? to main error handler)
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
@@ -5103,7 +5108,7 @@ fn plugins_install_not_found_path_returns_typed_kind_794() {
|
||||
!output.status.success(),
|
||||
"plugins install not-found-path must exit non-zero (#794)"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
@@ -5367,7 +5372,7 @@ fn agents_create_scaffolds_toml_and_lists_locally_431() {
|
||||
.iter()
|
||||
.any(|agent| {
|
||||
agent["name"] == "my-agent"
|
||||
&& PathBuf::from(agent["path"].as_str().expect("listed agent path"))
|
||||
&& Path::new(agent["path"].as_str().expect("listed agent path"))
|
||||
== fs::canonicalize(&agent_path).expect("canonical listed agent path")
|
||||
}));
|
||||
}
|
||||
@@ -5492,7 +5497,7 @@ fn plugins_extra_args_have_non_null_hint_797() {
|
||||
!output.status.success(),
|
||||
"plugins show with extra arg must exit non-zero (#797)"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
@@ -5558,7 +5563,7 @@ fn plugins_list_trailing_dash_text_error_stays_on_stderr_817() {
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stderr.contains("[error-kind: cli_parse]"), "{stderr}");
|
||||
assert!(
|
||||
stderr.contains("unknown option for `claw plugins list`: --"),
|
||||
@@ -5583,7 +5588,7 @@ fn empty_prompt_has_non_null_hint_798() {
|
||||
!output.status.success(),
|
||||
"empty prompt must exit non-zero (#798)"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let j: serde_json::Value = stdout
|
||||
.lines()
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
# Multi-Tool Execution & Sub-Agent Delegation
|
||||
|
||||
Two complementary features that dramatically reduce latency and token usage when the model needs to perform multiple operations or gather context.
|
||||
|
||||
## Feature 1: Parallel Tool Execution
|
||||
|
||||
When the model returns multiple tool_use blocks in a single response, read-only tools now execute concurrently instead of sequentially.
|
||||
|
||||
### How it works
|
||||
|
||||
The `run_turn` loop is refactored into 3 phases:
|
||||
|
||||
1. **Pre-hooks + permission checks** (sequential — hooks may mutate state)
|
||||
2. **Tool execution** (batch — parallel for read-only tools via `std::thread::scope`)
|
||||
3. **Post-hooks + session updates** (sequential — preserves original ordering)
|
||||
|
||||
### Parallel-safe tools
|
||||
|
||||
These tools are safe to run concurrently because they only read state and dispatch through the stateless tool registry:
|
||||
|
||||
- `read_file`, `glob_search`, `grep_search`
|
||||
- `WebFetch`, `WebSearch`
|
||||
- `ToolSearch`, `Skill`
|
||||
- `LSP`
|
||||
- `GitStatus`, `GitDiff`, `GitLog`, `GitShow`, `GitBlame`
|
||||
|
||||
### Sequential-only tools
|
||||
|
||||
Tools that require `&mut self` or have side effects continue to run one at a time:
|
||||
|
||||
- `bash`, `write_file`, `edit_file` (side effects)
|
||||
- `MCP`, `McpAuth`, `RemoteTrigger` (network state)
|
||||
- `Agent`, `TaskCreate`, `WorkerCreate` (stateful)
|
||||
- `NotebookEdit`, `REPL`, `PowerShell` (side effects)
|
||||
|
||||
### Safety guarantees
|
||||
|
||||
- Pre/post hooks always run sequentially
|
||||
- Permission checks complete before any tool executes
|
||||
- Tool results are pushed to the session in the original model order
|
||||
- Falls back to sequential for single-tool batches
|
||||
- Thread scopes ensure all parallel work completes before `execute_batch` returns
|
||||
|
||||
### Impact
|
||||
|
||||
For a response with 5 `read_file` calls: **~5x faster** execution. The main model still sees all results in order.
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: SubAgent Delegation
|
||||
|
||||
A `SubAgent` tool that lets the main model delegate multi-step tasks to a fast sub-agent. The sub-agent runs autonomously with its own `ConversationRuntime`, making multiple tool calls without round-tripping through the main model.
|
||||
|
||||
### Tool parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `prompt` | string | yes | Task description for the sub-agent |
|
||||
| `task_type` | string | no | `Explore` (default), `Plan`, or `Verify` |
|
||||
| `model` | string | no | Override the sub-agent model |
|
||||
|
||||
### Task types
|
||||
|
||||
| Type | Available tools | Use for |
|
||||
|------|----------------|---------|
|
||||
| `Explore` | read_file, glob_search, grep_search, WebFetch, WebSearch, ToolSearch, StructuredOutput | Searching code, reading files, gathering context |
|
||||
| `Plan` | Explore + TodoWrite | Planning approaches with structured todo output |
|
||||
| `Verify` | Plan + bash | Running tests, checking builds, verifying changes |
|
||||
|
||||
### Configuration
|
||||
|
||||
Set the sub-agent model in `~/.claw/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "openai/glm-5.1-fast",
|
||||
"subagentModel": "openai/qwen3.6-35b-fast"
|
||||
}
|
||||
```
|
||||
|
||||
If `subagentModel` is not set, the sub-agent uses the same model as the main session (or the `model` override parameter on the tool call).
|
||||
|
||||
### Example
|
||||
|
||||
Main model prompt: "Find all Rust files that import `ConversationRuntime` and list their paths"
|
||||
|
||||
Without SubAgent: 5–10 sequential tool calls (grep → read each file → summarize)
|
||||
With SubAgent: 1 SubAgent call → sub-agent does all the work autonomously → returns summary
|
||||
|
||||
**Result**: ~10x fewer tokens consumed by the main model, faster overall completion.
|
||||
|
||||
### Architecture
|
||||
|
||||
The sub-agent reuses the same building blocks as the existing `Agent` tool:
|
||||
|
||||
- `ProviderRuntimeClient` — API client with fallback chain
|
||||
- `SubagentToolExecutor` — filtered tool access with permission enforcement
|
||||
- `ConversationRuntime` — full conversation loop with hooks and compaction
|
||||
- `agent_permission_policy()` — auto-approve read-only, deny write tools
|
||||
|
||||
Key differences from the `Agent` tool:
|
||||
- **Synchronous** — blocks until complete, returns result directly
|
||||
- **Lighter** — fewer default tools, focused on the task type
|
||||
- **Configurable model** — uses `subagentModel` or the tool's `model` param
|
||||
- **Structured output** — returns `result`, `tool_calls`, and `iterations`
|
||||
|
||||
---
|
||||
|
||||
## Changed files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `rust/crates/runtime/src/conversation.rs` | `ToolCall`, `ToolResult` types; `execute_batch` on `ToolExecutor`; 3-phase `run_turn` |
|
||||
| `rust/crates/runtime/src/lib.rs` | Exports for `ToolCall`, `ToolResult` |
|
||||
| `rust/crates/runtime/src/config.rs` | `subagent_model` field, `parse_optional_subagent_model()`, accessor |
|
||||
| `rust/crates/runtime/src/config_validate.rs` | `subagentModel` field spec |
|
||||
| `rust/crates/rusty-claude-cli/src/main.rs` | `CliToolExecutor::execute_batch` with parallel-safe classification |
|
||||
| `rust/crates/tools/src/lib.rs` | `SubAgent` tool spec, `SubAgentInput`, `run_sub_agent()`, `load_subagent_model_from_config()`, `build_sub_agent_system_prompt()` |
|
||||
@@ -116,8 +116,6 @@ mod tests {
|
||||
lane_events: vec![],
|
||||
derived_state: "working".to_string(),
|
||||
current_blocker: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
+63
-1862
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user