feat(team): team enhancements, subagentModel, parallel tool exec
Port the agent-team enhancement layer onto upstream/main so the model
can spin up coordinated sub-agent teams for parallel work.
subagentModel config wiring (fix):
- Add subagent_model field to RuntimeFeatureConfig + RuntimeConfig::subagent_model()
accessor so the subagentModel setting (already validated by config_validate.rs)
is now actually read and stored.
- Agent tool's resolve_agent_model falls back to subagentModel from config
when no explicit model is passed.
Provider namespace separation:
- New 'custom-openai' provider kind with dedicated env vars
(CLAWCUSTOMOPENAI_API_KEY / CLAWCUSTOMOPENAI_BASE_URL)
- /setup wizard saves kind: 'custom-openai' for option 5
- Bare model name normalized to 'custom/' prefix to avoid proxy 404s
- Sub-agents inherit /setup-saved provider config via inject_config_as_env_fallbacks
Parallel tool execution:
- Override execute_batch to classify read-only tools as parallel-safe
and run them concurrently via std:🧵:scope
- Results return in original model order
Team coordination layer:
- AgentMessage, TaskClaim, TeamStatus tools + shared mailbox directory
- Mode presets (tiny/1x ... mega/6x) + enriched TeamCreate/Agent descriptions
- Background team watcher
- /team slash command: on/off/status/toggle
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4ea31c1bc9
commit
d500c65678
|
|
@ -29,6 +29,12 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "anes"
|
||||
version = "0.1.6"
|
||||
|
|
@ -118,15 +124,15 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
|||
|
||||
[[package]]
|
||||
name = "aspect-core"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70188b9bf884266a6c7117e30af44f38229bc5ac56916bd16512b3e49f90fe20"
|
||||
checksum = "4e94aecae4a10eea63983b608beb48998578925d0660796140d69d8e8276f215"
|
||||
|
||||
[[package]]
|
||||
name = "aspect-macros"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "176e7db9b6a7bb4f117b8d97054d2d5a7bdc43b95c19c15c751fb8dcb9bc8a5c"
|
||||
checksum = "5d56334ff035ccdeed6b6c159b9089ae1baba29f93eb6fa8bd89312e9fa3f136"
|
||||
dependencies = [
|
||||
"aspect-core",
|
||||
"proc-macro2",
|
||||
|
|
@ -136,9 +142,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "aspect-std"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba7d130884fda30ec0acabcadc8f4711267d1cc21edc8f85283e91a011d699fa"
|
||||
checksum = "059c997171f851dcd36d5eb14cf9ec831884ecafa2b753629379ff4b71971008"
|
||||
dependencies = [
|
||||
"aspect-core",
|
||||
"log",
|
||||
|
|
@ -355,12 +361,27 @@ version = "1.11.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cassowary"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.58"
|
||||
|
|
@ -521,6 +542,20 @@ dependencies = [
|
|||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
"itoa",
|
||||
"rustversion",
|
||||
"ryu",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compat-harness"
|
||||
version = "0.1.3"
|
||||
|
|
@ -591,7 +626,7 @@ dependencies = [
|
|||
"clap",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools",
|
||||
"itertools 0.10.5",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
|
|
@ -612,7 +647,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools",
|
||||
"itertools 0.10.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -687,8 +731,18 @@ version = "0.20.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
"darling_core 0.20.11",
|
||||
"darling_macro 0.20.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core 0.23.0",
|
||||
"darling_macro 0.23.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -705,13 +759,37 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_core 0.20.11",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core 0.23.0",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
|
@ -740,7 +818,7 @@ version = "0.20.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.20.11",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
|
|
@ -846,6 +924,17 @@ dependencies = [
|
|||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"thiserror 1.0.69",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
|
|
@ -868,6 +957,12 @@ version = "1.0.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
|
|
@ -965,6 +1060,16 @@ dependencies = [
|
|||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gag"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a713bee13966e9fbffdf7193af71d54a6b35a0bb34997cd6c9519ebeb5005972"
|
||||
dependencies = [
|
||||
"filedescriptor",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
|
|
@ -981,7 +1086,7 @@ version = "0.2.24"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1075,6 +1180,17 @@ dependencies = [
|
|||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
|
|
@ -1375,6 +1491,28 @@ dependencies = [
|
|||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
|
|
@ -1417,6 +1555,15 @@ dependencies = [
|
|||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
|
|
@ -1491,6 +1638,15 @@ version = "0.4.29"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
|
|
@ -1657,6 +1813,12 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
|
|
@ -1794,7 +1956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools",
|
||||
"itertools 0.13.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
|
|
@ -1998,6 +2160,27 @@ dependencies = [
|
|||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cassowary",
|
||||
"compact_str",
|
||||
"crossterm",
|
||||
"indoc",
|
||||
"instability",
|
||||
"itertools 0.13.0",
|
||||
"lru",
|
||||
"paste",
|
||||
"strum",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
|
|
@ -2192,9 +2375,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
|
|
@ -2223,9 +2406,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
|
|
@ -2244,11 +2427,16 @@ version = "0.1.3"
|
|||
dependencies = [
|
||||
"api",
|
||||
"commands",
|
||||
"crossbeam-channel",
|
||||
"crossterm",
|
||||
"gag",
|
||||
"libc",
|
||||
"log",
|
||||
"mock-anthropic-service",
|
||||
"once_cell",
|
||||
"plugins",
|
||||
"pulldown-cmark",
|
||||
"ratatui",
|
||||
"runtime",
|
||||
"rustyline",
|
||||
"serde",
|
||||
|
|
@ -2256,6 +2444,8 @@ dependencies = [
|
|||
"syntect",
|
||||
"tokio",
|
||||
"tools",
|
||||
"tui-textarea",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2275,7 +2465,7 @@ dependencies = [
|
|||
"nix",
|
||||
"radix_trie",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
"unicode-width 0.2.0",
|
||||
"utf8parse",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
|
@ -2506,12 +2696,40 @@ version = "1.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.26.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
|
|
@ -2956,6 +3174,17 @@ version = "0.2.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tui-textarea"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae"
|
||||
dependencies = [
|
||||
"crossterm",
|
||||
"ratatui",
|
||||
"unicode-width 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
|
|
@ -2981,10 +3210,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
name = "unicode-truncate"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||
dependencies = [
|
||||
"itertools 0.13.0",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ publish = false
|
|||
serde_json = "1"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
# rusty-claude-cli needs unsafe for TUI stdout suppression (dup/dup2),
|
||||
# all other crates should avoid it — override per-crate if needed.
|
||||
unsafe_code = "warn"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
all = { level = "warn", priority = -1 }
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ impl ProviderClient {
|
|||
Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => {
|
||||
OpenAiCompatConfig::dashscope()
|
||||
}
|
||||
Some(meta) if meta.auth_env == "CLAWCUSTOMOPENAI_API_KEY" => {
|
||||
OpenAiCompatConfig::custom_openai()
|
||||
}
|
||||
_ => OpenAiCompatConfig::openai(),
|
||||
};
|
||||
Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?))
|
||||
|
|
|
|||
|
|
@ -180,6 +180,49 @@ impl ApiError {
|
|||
}
|
||||
}
|
||||
|
||||
/// HTTP status code for an `Api` error, if any. Used by callers (e.g. the
|
||||
/// sub-agent provider chain) to decide whether to advance to the next
|
||||
/// configured model — a 404 "model not found" on the primary should fall
|
||||
/// through to the next fallback rather than killing the whole chain.
|
||||
#[must_use]
|
||||
pub fn status_code(&self) -> Option<reqwest::StatusCode> {
|
||||
match self {
|
||||
Self::Api { status, .. } => Some(*status),
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.status_code(),
|
||||
Self::MissingCredentials { .. }
|
||||
| Self::ContextWindowExceeded { .. }
|
||||
| Self::ExpiredOAuthToken
|
||||
| Self::Auth(_)
|
||||
| Self::InvalidApiKeyEnv(_)
|
||||
| Self::Http(_)
|
||||
| Self::Io(_)
|
||||
| Self::Json { .. }
|
||||
| Self::InvalidSseFrame(_)
|
||||
| Self::BackoffOverflow { .. }
|
||||
| Self::RequestBodySizeExceeded { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Response body (best-effort) for an `Api` error, if any.
|
||||
#[must_use]
|
||||
pub fn response_body(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Api { body, .. } => Some(body.as_str()),
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.response_body(),
|
||||
Self::MissingCredentials { .. }
|
||||
| Self::ContextWindowExceeded { .. }
|
||||
| Self::ExpiredOAuthToken
|
||||
| Self::Auth(_)
|
||||
| Self::InvalidApiKeyEnv(_)
|
||||
| Self::Http(_)
|
||||
| Self::Io(_)
|
||||
| Self::Json { .. }
|
||||
| Self::InvalidSseFrame(_)
|
||||
| Self::BackoffOverflow { .. }
|
||||
| Self::RequestBodySizeExceeded { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn safe_failure_class(&self) -> &'static str {
|
||||
match self {
|
||||
|
|
|
|||
|
|
@ -28,8 +28,9 @@ pub use providers::openai_compat::{
|
|||
};
|
||||
pub use providers::{
|
||||
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
|
||||
model_family_identity_for, model_family_identity_for_kind, provider_diagnostics_for_model,
|
||||
resolve_model_alias, ProviderDiagnostics, ProviderKind,
|
||||
model_family_identity_for, model_family_identity_for_kind, model_token_limit,
|
||||
provider_diagnostics_for_model, resolve_model_alias, ModelTokenLimit, ProviderDiagnostics,
|
||||
ProviderKind,
|
||||
};
|
||||
pub use sse::{parse_frame, SseParser};
|
||||
pub use types::{
|
||||
|
|
|
|||
|
|
@ -270,6 +270,14 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
|
|||
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
|
||||
});
|
||||
}
|
||||
if canonical.starts_with("custom/") {
|
||||
return Some(ProviderMetadata {
|
||||
provider: ProviderKind::OpenAi,
|
||||
auth_env: "CLAWCUSTOMOPENAI_API_KEY",
|
||||
base_url_env: "CLAWCUSTOMOPENAI_BASE_URL",
|
||||
default_base_url: openai_compat::DEFAULT_CUSTOM_OPENAI_BASE_URL,
|
||||
});
|
||||
}
|
||||
// Alibaba DashScope compatible-mode endpoint. Routes qwen/* and bare
|
||||
// qwen-* model names (qwen-max, qwen-plus, qwen-turbo, qwen-qwq, etc.)
|
||||
// to the OpenAI-compat client pointed at DashScope's /compatible-mode/v1.
|
||||
|
|
@ -377,6 +385,11 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
|
|||
{
|
||||
return ProviderKind::OpenAi;
|
||||
}
|
||||
// Explicit `custom/` prefix selects the Claw custom OpenAI-compat provider
|
||||
// even when no other credentials are present.
|
||||
if resolved_model.starts_with("custom/") {
|
||||
return ProviderKind::OpenAi;
|
||||
}
|
||||
if anthropic::has_auth_from_env_or_saved().unwrap_or(false) {
|
||||
return ProviderKind::Anthropic;
|
||||
}
|
||||
|
|
@ -666,15 +679,22 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
|
|||
max_output_tokens: 16_384,
|
||||
context_window_tokens: 256_000,
|
||||
}),
|
||||
"qwen-max" => Some(ModelTokenLimit {
|
||||
// Qwen models via DashScope / OpenAI-compat
|
||||
"qwen3.6-35b-fast" | "qwen3-235b-a22b" | "qwen-max" | "qwen-plus" | "qwen-turbo"
|
||||
| "qwen-qwq" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 16_384,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
"glm-5.1-fast" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 16_384,
|
||||
context_window_tokens: 200_000,
|
||||
}),
|
||||
// Generic fallback for any model: assume 128K context, 8K output.
|
||||
// This prevents the "unknown model → no limit check → context overflow" bug.
|
||||
_ => Some(ModelTokenLimit {
|
||||
max_output_tokens: 8_192,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
"qwen-plus" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 8_192,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1138,6 +1158,21 @@ mod tests {
|
|||
assert_eq!(super::resolve_model_alias("KIMI"), "kimi-k2.5"); // case insensitive
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_prefix_routes_to_custom_openai_env_vars() {
|
||||
let meta = super::metadata_for_model("custom/openclaw_3750")
|
||||
.expect("custom/ prefix must resolve to custom OpenAI metadata");
|
||||
assert_eq!(meta.provider, ProviderKind::OpenAi);
|
||||
assert_eq!(meta.auth_env, "CLAWCUSTOMOPENAI_API_KEY");
|
||||
assert_eq!(meta.base_url_env, "CLAWCUSTOMOPENAI_BASE_URL");
|
||||
|
||||
assert_eq!(
|
||||
detect_provider_kind("custom/openclaw_3750"),
|
||||
ProviderKind::OpenAi,
|
||||
"custom/ prefix must select OpenAi provider kind"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_diagnostics_explain_openai_compatible_capabilities() {
|
||||
let diagnostics = super::provider_diagnostics_for_model("openai/deepseek-v4-pro");
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ use super::{preflight_message_request, resolve_model_alias, Provider, ProviderFu
|
|||
pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
|
||||
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
pub const DEFAULT_DASHSCOPE_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
/// Default base URL for Claw's custom OpenAI-compatible provider.
|
||||
/// Intentionally left empty: a custom endpoint must set
|
||||
/// `CLAWCUSTOMOPENAI_BASE_URL`; otherwise requests will fail at URL build
|
||||
/// time rather than leaking credentials to the real OpenAI endpoint.
|
||||
pub const DEFAULT_CUSTOM_OPENAI_BASE_URL: &str = "";
|
||||
const REQUEST_ID_HEADER: &str = "request-id";
|
||||
const ALT_REQUEST_ID_HEADER: &str = "x-request-id";
|
||||
const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
||||
|
|
@ -43,6 +48,7 @@ pub struct OpenAiCompatConfig {
|
|||
const XAI_ENV_VARS: &[&str] = &["XAI_API_KEY"];
|
||||
const OPENAI_ENV_VARS: &[&str] = &["OPENAI_API_KEY"];
|
||||
const DASHSCOPE_ENV_VARS: &[&str] = &["DASHSCOPE_API_KEY"];
|
||||
const CUSTOM_OPENAI_ENV_VARS: &[&str] = &["CLAWCUSTOMOPENAI_API_KEY"];
|
||||
|
||||
// Provider-specific request body size limits in bytes
|
||||
const XAI_MAX_REQUEST_BODY_BYTES: usize = 52_428_800; // 50MB
|
||||
|
|
@ -95,12 +101,27 @@ impl OpenAiCompatConfig {
|
|||
}
|
||||
}
|
||||
|
||||
/// Claw-specific custom OpenAI-compatible endpoint.
|
||||
/// Reads `CLAWCUSTOMOPENAI_API_KEY` / `CLAWCUSTOMOPENAI_BASE_URL` so it
|
||||
/// can coexist with real OpenAI/NeuralWatt `OPENAI_*` environment vars.
|
||||
#[must_use]
|
||||
pub const fn custom_openai() -> Self {
|
||||
Self {
|
||||
provider_name: "Custom OpenAI",
|
||||
api_key_env: "CLAWCUSTOMOPENAI_API_KEY",
|
||||
base_url_env: "CLAWCUSTOMOPENAI_BASE_URL",
|
||||
default_base_url: DEFAULT_CUSTOM_OPENAI_BASE_URL,
|
||||
max_request_body_bytes: OPENAI_MAX_REQUEST_BODY_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn credential_env_vars(self) -> &'static [&'static str] {
|
||||
match self.provider_name {
|
||||
"xAI" => XAI_ENV_VARS,
|
||||
"OpenAI" => OPENAI_ENV_VARS,
|
||||
"DashScope" => DASHSCOPE_ENV_VARS,
|
||||
"Custom OpenAI" => CUSTOM_OPENAI_ENV_VARS,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
|
@ -1066,7 +1087,7 @@ fn wire_model_for_base_url<'a>(
|
|||
if matches!(lowered_prefix.as_str(), "xai" | "grok" | "qwen" | "kimi") {
|
||||
return Cow::Borrowed(&model[pos + 1..]);
|
||||
}
|
||||
if lowered_prefix == "local" {
|
||||
if matches!(lowered_prefix.as_str(), "local" | "custom") {
|
||||
return Cow::Borrowed(&model[pos + 1..]);
|
||||
}
|
||||
|
||||
|
|
@ -2278,6 +2299,29 @@ mod tests {
|
|||
assert_eq!(parse_tool_arguments("not-json"), json!({"raw": "not-json"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_routing_prefix_strips_on_wire() {
|
||||
let payload = build_chat_completion_request(
|
||||
&MessageRequest {
|
||||
model: "custom/openclaw_3750".to_string(),
|
||||
max_tokens: 64,
|
||||
messages: vec![InputMessage::user_text("hello")],
|
||||
..Default::default()
|
||||
},
|
||||
OpenAiCompatConfig::custom_openai(),
|
||||
);
|
||||
|
||||
assert_eq!(payload["model"], json!("openclaw_3750"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_openai_config_uses_separate_env_vars() {
|
||||
let config = OpenAiCompatConfig::custom_openai();
|
||||
assert_eq!(config.provider_name, "Custom OpenAI");
|
||||
assert_eq!(config.api_key_env, "CLAWCUSTOMOPENAI_API_KEY");
|
||||
assert_eq!(config.base_url_env, "CLAWCUSTOMOPENAI_BASE_URL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_xai_api_key_is_provider_specific() {
|
||||
let _lock = env_lock();
|
||||
|
|
|
|||
|
|
@ -65,6 +65,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "tui",
|
||||
aliases: &[],
|
||||
summary: "Switch to the split-pane TUI dashboard",
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "status",
|
||||
aliases: &[],
|
||||
|
|
@ -808,7 +815,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||
name: "team",
|
||||
aliases: &[],
|
||||
summary: "Manage agent teams",
|
||||
argument_hint: Some("[list|create|delete]"),
|
||||
argument_hint: Some("[on|off|status]"),
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
|
|
@ -1188,10 +1195,14 @@ pub enum SlashCommand {
|
|||
History {
|
||||
count: Option<String>,
|
||||
},
|
||||
Unknown(String),
|
||||
Lsp {
|
||||
action: Option<String>,
|
||||
target: Option<String>,
|
||||
},
|
||||
Team {
|
||||
action: Option<String>,
|
||||
},
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -1509,6 +1520,9 @@ pub fn validate_slash_command_input(
|
|||
"history" => SlashCommand::History {
|
||||
count: optional_single_arg(command, &args, "[count]")?,
|
||||
},
|
||||
"team" => SlashCommand::Team {
|
||||
action: optional_single_arg(command, &args, "[list|create|delete]")?,
|
||||
},
|
||||
other => SlashCommand::Unknown(other.to_string()),
|
||||
}))
|
||||
}
|
||||
|
|
@ -5393,6 +5407,7 @@ pub fn handle_slash_command(
|
|||
| SlashCommand::OutputStyle { .. }
|
||||
| SlashCommand::AddDir { .. }
|
||||
| SlashCommand::History { .. }
|
||||
| SlashCommand::Lsp { .. }
|
||||
| SlashCommand::Team { .. }
|
||||
| SlashCommand::Setup
|
||||
| SlashCommand::Unknown(_) => None,
|
||||
|
|
@ -5525,6 +5540,16 @@ mod tests {
|
|||
#[test]
|
||||
fn parses_supported_slash_commands() {
|
||||
assert_eq!(SlashCommand::parse("/help"), Ok(Some(SlashCommand::Help)));
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/team"),
|
||||
Ok(Some(SlashCommand::Team { action: None }))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/team on"),
|
||||
Ok(Some(SlashCommand::Team {
|
||||
action: Some("on".to_string())
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse(" /status "),
|
||||
Ok(Some(SlashCommand::Status))
|
||||
|
|
@ -6012,7 +6037,7 @@ mod tests {
|
|||
assert!(!help.contains("/login"));
|
||||
assert!(!help.contains("/logout"));
|
||||
assert!(help.contains("/setup"));
|
||||
assert_eq!(slash_command_specs().len(), 140);
|
||||
assert_eq!(slash_command_specs().len(), 141);
|
||||
assert!(resume_supported_slash_commands().len() >= 39);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -163,6 +163,10 @@ pub struct RuntimeFeatureConfig {
|
|||
api_timeout: ApiTimeoutConfig,
|
||||
rules_import: RulesImportConfig,
|
||||
provider: RuntimeProviderConfig,
|
||||
/// Model override used when spawning sub-agents via the Agent tool.
|
||||
/// Read from `subagentModel` (or `subagent_model`) in settings; falls
|
||||
/// back to the default model when unset.
|
||||
subagent_model: Option<String>,
|
||||
}
|
||||
|
||||
/// Controls which external AI coding framework rules are imported into the system prompt.
|
||||
|
|
@ -801,6 +805,7 @@ fn build_runtime_config(
|
|||
api_timeout: parse_optional_api_timeout_config(&merged_value)?,
|
||||
rules_import: parse_optional_rules_import(&merged_value)?,
|
||||
provider: parse_optional_provider_config(&merged_value)?,
|
||||
subagent_model: parse_optional_subagent_model(&merged_value),
|
||||
};
|
||||
|
||||
Ok(RuntimeConfig {
|
||||
|
|
@ -880,6 +885,13 @@ impl RuntimeConfig {
|
|||
self.feature_config.model.as_deref()
|
||||
}
|
||||
|
||||
/// Model override used when spawning sub-agents via the Agent tool.
|
||||
/// Read from `subagentModel` in settings; `None` means "use default model".
|
||||
#[must_use]
|
||||
pub fn subagent_model(&self) -> Option<&str> {
|
||||
self.feature_config.subagent_model.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn aliases(&self) -> &BTreeMap<String, String> {
|
||||
&self.feature_config.aliases
|
||||
|
|
@ -1717,6 +1729,21 @@ fn parse_optional_model(root: &JsonValue) -> Option<String> {
|
|||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
/// Reads `subagentModel` (or the snake_case `subagent_model` alias) from
|
||||
/// merged settings. Returns `None` when absent or blank so the Agent tool
|
||||
/// falls back to the default model.
|
||||
fn parse_optional_subagent_model(root: &JsonValue) -> Option<String> {
|
||||
root.as_object()
|
||||
.and_then(|object| {
|
||||
object
|
||||
.get("subagentModel")
|
||||
.or_else(|| object.get("subagent_model"))
|
||||
})
|
||||
.and_then(JsonValue::as_str)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
fn parse_optional_aliases(root: &JsonValue) -> Result<BTreeMap<String, String>, ConfigError> {
|
||||
let Some(object) = root.as_object() else {
|
||||
return Ok(BTreeMap::new());
|
||||
|
|
@ -2581,6 +2608,49 @@ fn deep_merge_objects(
|
|||
}
|
||||
}
|
||||
|
||||
/// Read the provider config saved by `/setup` and inject its credentials
|
||||
/// into the environment so `ProviderClient::from_model()` can find them via
|
||||
/// the env-var-based provider dispatch. Only sets vars that aren't already
|
||||
/// present (preserves explicit env). Idempotent.
|
||||
///
|
||||
/// Called by the main CLI at startup and by sub-agent threads in the tools
|
||||
/// crate so that custom/ exotic providers work for spawned agents too.
|
||||
pub fn inject_config_as_env_fallbacks() {
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
let Ok(config) = ConfigLoader::default_for(&cwd).load() else {
|
||||
return;
|
||||
};
|
||||
let provider = config.provider();
|
||||
|
||||
// Map provider kind to the expected env var names
|
||||
let (api_key_env, base_url_env) = match provider.kind().unwrap_or("anthropic") {
|
||||
"anthropic" => ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"),
|
||||
"xai" => ("XAI_API_KEY", "XAI_BASE_URL"),
|
||||
"openai" => ("OPENAI_API_KEY", "OPENAI_BASE_URL"),
|
||||
"dashscope" => ("DASHSCOPE_API_KEY", "DASHSCOPE_BASE_URL"),
|
||||
"custom-openai" => ("CLAWCUSTOMOPENAI_API_KEY", "CLAWCUSTOMOPENAI_BASE_URL"),
|
||||
_ => return, // unknown provider kind — don't inject
|
||||
};
|
||||
|
||||
// Only set env vars that aren't already set (preserve user's explicit env)
|
||||
if let Some(api_key) = provider.api_key() {
|
||||
if std::env::var(api_key_env).is_err() {
|
||||
std::env::set_var(api_key_env, api_key);
|
||||
}
|
||||
}
|
||||
if let Some(base_url) = provider.base_url() {
|
||||
if std::env::var(base_url_env).is_err() {
|
||||
std::env::set_var(base_url_env, base_url);
|
||||
}
|
||||
}
|
||||
// Also inject the saved model so resolve_model_alias sees it
|
||||
if let Some(model) = provider.model() {
|
||||
if std::env::var("CLAWD_PROVIDER_MODEL").is_err() {
|
||||
std::env::set_var("CLAWD_PROVIDER_MODEL", model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
|
|
|
|||
|
|
@ -61,6 +61,40 @@ pub trait ApiClient {
|
|||
/// Trait implemented by tool dispatchers that execute model-requested tools.
|
||||
pub trait ToolExecutor {
|
||||
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError>;
|
||||
|
||||
/// Execute a batch of tool calls, potentially in parallel.
|
||||
/// Returns results in the same order as the input calls.
|
||||
/// The default implementation executes sequentially via `execute`.
|
||||
/// Override this to provide parallel execution for read-only tools.
|
||||
fn execute_batch(&mut self, calls: Vec<ToolCall>) -> Vec<ToolResult> {
|
||||
calls
|
||||
.into_iter()
|
||||
.map(|call| {
|
||||
let result = self.execute(&call.tool_name, &call.input);
|
||||
ToolResult {
|
||||
tool_use_id: call.tool_use_id,
|
||||
tool_name: call.tool_name,
|
||||
result,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A single tool call to execute.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolCall {
|
||||
pub tool_use_id: String,
|
||||
pub tool_name: String,
|
||||
pub input: String,
|
||||
}
|
||||
|
||||
/// The result of executing a tool call.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolResult {
|
||||
pub tool_use_id: String,
|
||||
pub tool_name: String,
|
||||
pub result: Result<String, ToolError>,
|
||||
}
|
||||
|
||||
/// Error returned when a tool invocation fails locally.
|
||||
|
|
@ -86,6 +120,22 @@ impl Display for ToolError {
|
|||
|
||||
impl std::error::Error for ToolError {}
|
||||
|
||||
/// Callback trait for reporting tool execution progress during a turn.
|
||||
/// Implementations can post progress to a team inbox, log to stderr, etc.
|
||||
/// Called after each tool call completes (success or failure).
|
||||
pub trait TurnProgressReporter: Send + Sync {
|
||||
/// Called after a tool execution completes.
|
||||
/// `iteration` is 1-based index of the tool call within this turn.
|
||||
fn on_tool_result(
|
||||
&self,
|
||||
iteration: usize,
|
||||
max_iterations: usize,
|
||||
tool_name: &str,
|
||||
input: &str,
|
||||
result: Result<&str, &str>,
|
||||
);
|
||||
}
|
||||
|
||||
/// Error returned when a conversation turn cannot be completed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeError {
|
||||
|
|
@ -140,6 +190,7 @@ pub struct ConversationRuntime<C, T> {
|
|||
hook_abort_signal: HookAbortSignal,
|
||||
hook_progress_reporter: Option<Box<dyn HookProgressReporter>>,
|
||||
session_tracer: Option<SessionTracer>,
|
||||
turn_progress_reporter: Option<Box<dyn TurnProgressReporter>>,
|
||||
}
|
||||
|
||||
impl<C, T> ConversationRuntime<C, T>
|
||||
|
|
@ -189,6 +240,7 @@ where
|
|||
hook_abort_signal: HookAbortSignal::default(),
|
||||
hook_progress_reporter: None,
|
||||
session_tracer: None,
|
||||
turn_progress_reporter: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,6 +284,11 @@ where
|
|||
self
|
||||
}
|
||||
|
||||
pub fn with_turn_progress_reporter(mut self, reporter: Box<dyn TurnProgressReporter>) -> Self {
|
||||
self.turn_progress_reporter = Some(reporter);
|
||||
self
|
||||
}
|
||||
|
||||
fn run_pre_tool_use_hook(&mut self, tool_name: &str, input: &str) -> HookRunResult {
|
||||
if let Some(reporter) = self.hook_progress_reporter.as_mut() {
|
||||
self.hook_runner.run_pre_tool_use_with_context(
|
||||
|
|
@ -415,11 +472,24 @@ where
|
|||
break;
|
||||
}
|
||||
|
||||
for (tool_use_id, tool_name, input) in pending_tool_uses {
|
||||
let pre_hook_result = self.run_pre_tool_use_hook(&tool_name, &input);
|
||||
// Phase 1: Pre-hooks and permission checks (sequential).
|
||||
// Hooks may mutate state and must run in order.
|
||||
struct PendingTool {
|
||||
tool_use_id: String,
|
||||
tool_name: String,
|
||||
effective_input: String,
|
||||
pre_hook_messages: Vec<String>,
|
||||
allowed: bool,
|
||||
deny_reason: Option<String>,
|
||||
}
|
||||
|
||||
let mut pending = Vec::with_capacity(pending_tool_uses.len());
|
||||
for (tool_use_id, tool_name, input) in &pending_tool_uses {
|
||||
let pre_hook_result = self.run_pre_tool_use_hook(tool_name, input);
|
||||
let effective_input = pre_hook_result
|
||||
.updated_input()
|
||||
.map_or_else(|| input.clone(), ToOwned::to_owned);
|
||||
let pre_hook_messages = pre_hook_result.messages().to_vec();
|
||||
let permission_context = PermissionContext::new(
|
||||
pre_hook_result.permission_override(),
|
||||
pre_hook_result.permission_reason().map(ToOwned::to_owned),
|
||||
|
|
@ -448,71 +518,157 @@ where
|
|||
}
|
||||
} else if let Some(prompt) = prompter.as_mut() {
|
||||
self.permission_policy.authorize_with_context(
|
||||
&tool_name,
|
||||
tool_name,
|
||||
&effective_input,
|
||||
&permission_context,
|
||||
Some(*prompt),
|
||||
)
|
||||
} else {
|
||||
self.permission_policy.authorize_with_context(
|
||||
&tool_name,
|
||||
tool_name,
|
||||
&effective_input,
|
||||
&permission_context,
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
let result_message = match permission_outcome {
|
||||
match permission_outcome {
|
||||
PermissionOutcome::Allow => {
|
||||
self.record_tool_started(iterations, &tool_name);
|
||||
let (mut output, mut is_error) =
|
||||
match self.tool_executor.execute(&tool_name, &effective_input) {
|
||||
Ok(output) => (output, false),
|
||||
Err(error) => (error.to_string(), true),
|
||||
};
|
||||
output = merge_hook_feedback(pre_hook_result.messages(), output, false);
|
||||
|
||||
let post_hook_result = if is_error {
|
||||
self.run_post_tool_use_failure_hook(
|
||||
&tool_name,
|
||||
&effective_input,
|
||||
&output,
|
||||
)
|
||||
} else {
|
||||
self.run_post_tool_use_hook(
|
||||
&tool_name,
|
||||
&effective_input,
|
||||
&output,
|
||||
false,
|
||||
)
|
||||
};
|
||||
if post_hook_result.is_denied()
|
||||
|| post_hook_result.is_failed()
|
||||
|| post_hook_result.is_cancelled()
|
||||
{
|
||||
is_error = true;
|
||||
}
|
||||
output = merge_hook_feedback(
|
||||
post_hook_result.messages(),
|
||||
output,
|
||||
post_hook_result.is_denied()
|
||||
|| post_hook_result.is_failed()
|
||||
|| post_hook_result.is_cancelled(),
|
||||
);
|
||||
|
||||
ConversationMessage::tool_result(tool_use_id, tool_name, output, is_error)
|
||||
pending.push(PendingTool {
|
||||
tool_use_id: tool_use_id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
effective_input,
|
||||
pre_hook_messages,
|
||||
allowed: true,
|
||||
deny_reason: None,
|
||||
});
|
||||
}
|
||||
PermissionOutcome::Deny { reason } => ConversationMessage::tool_result(
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
merge_hook_feedback(pre_hook_result.messages(), reason, true),
|
||||
PermissionOutcome::Deny { reason } => {
|
||||
pending.push(PendingTool {
|
||||
tool_use_id: tool_use_id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
effective_input: String::new(),
|
||||
pre_hook_messages,
|
||||
allowed: false,
|
||||
deny_reason: Some(reason),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Execute allowed tools (batch, may run in parallel).
|
||||
let allowed_calls: Vec<ToolCall> = pending
|
||||
.iter()
|
||||
.filter(|p| p.allowed)
|
||||
.map(|p| {
|
||||
self.record_tool_started(iterations, &p.tool_name);
|
||||
ToolCall {
|
||||
tool_use_id: p.tool_use_id.clone(),
|
||||
tool_name: p.tool_name.clone(),
|
||||
input: p.effective_input.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let batch_results = self.tool_executor.execute_batch(allowed_calls);
|
||||
let mut batch_index = 0;
|
||||
|
||||
// Phase 3: Post-hooks and session updates (sequential, original order).
|
||||
for p in &pending {
|
||||
// Capture progress data for the reporter.
|
||||
let (
|
||||
progress_tool_name,
|
||||
progress_input,
|
||||
progress_output,
|
||||
progress_is_error,
|
||||
result_message,
|
||||
) = if p.allowed {
|
||||
let batch_result = &batch_results[batch_index];
|
||||
batch_index += 1;
|
||||
let (mut output, mut is_error) = match &batch_result.result {
|
||||
Ok(output) => (output.clone(), false),
|
||||
Err(error) => (error.to_string(), true),
|
||||
};
|
||||
output = merge_hook_feedback(&p.pre_hook_messages, output, false);
|
||||
|
||||
let post_hook_result = if is_error {
|
||||
self.run_post_tool_use_failure_hook(
|
||||
&p.tool_name,
|
||||
&p.effective_input,
|
||||
&output,
|
||||
)
|
||||
} else {
|
||||
self.run_post_tool_use_hook(
|
||||
&p.tool_name,
|
||||
&p.effective_input,
|
||||
&output,
|
||||
false,
|
||||
)
|
||||
};
|
||||
if post_hook_result.is_denied()
|
||||
|| post_hook_result.is_failed()
|
||||
|| post_hook_result.is_cancelled()
|
||||
{
|
||||
is_error = true;
|
||||
}
|
||||
output = merge_hook_feedback(
|
||||
post_hook_result.messages(),
|
||||
output,
|
||||
post_hook_result.is_denied()
|
||||
|| post_hook_result.is_failed()
|
||||
|| post_hook_result.is_cancelled(),
|
||||
);
|
||||
let progress_output = output.clone();
|
||||
let result_message = ConversationMessage::tool_result(
|
||||
p.tool_use_id.clone(),
|
||||
p.tool_name.clone(),
|
||||
output,
|
||||
is_error,
|
||||
);
|
||||
(
|
||||
p.tool_name.clone(),
|
||||
p.effective_input.clone(),
|
||||
progress_output,
|
||||
is_error,
|
||||
result_message,
|
||||
)
|
||||
} else {
|
||||
let denied_output = merge_hook_feedback(
|
||||
&p.pre_hook_messages,
|
||||
p.deny_reason.clone().unwrap_or_default(),
|
||||
true,
|
||||
),
|
||||
);
|
||||
let result_message = ConversationMessage::tool_result(
|
||||
p.tool_use_id.clone(),
|
||||
p.tool_name.clone(),
|
||||
denied_output,
|
||||
true,
|
||||
);
|
||||
(
|
||||
p.tool_name.clone(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
true,
|
||||
result_message,
|
||||
)
|
||||
};
|
||||
self.session
|
||||
.push_message(result_message.clone())
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
self.record_tool_finished(iterations, &result_message);
|
||||
if let Some(ref reporter) = self.turn_progress_reporter {
|
||||
let report_result = if progress_is_error {
|
||||
Err(progress_output.as_str())
|
||||
} else {
|
||||
Ok(progress_output.as_str())
|
||||
};
|
||||
reporter.on_tool_result(
|
||||
iterations,
|
||||
self.max_iterations,
|
||||
&progress_tool_name,
|
||||
&progress_input,
|
||||
report_result,
|
||||
);
|
||||
}
|
||||
tool_results.push(result_message);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,15 +65,16 @@ pub use compact::{
|
|||
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
|
||||
};
|
||||
pub use config::{
|
||||
clear_user_provider_settings, default_config_home, save_user_provider_settings,
|
||||
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
|
||||
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
|
||||
McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig,
|
||||
McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
|
||||
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
|
||||
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
|
||||
RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
|
||||
RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
|
||||
clear_user_provider_settings, default_config_home, inject_config_as_env_fallbacks,
|
||||
save_user_provider_settings, suppress_config_warnings_for_json_mode, ApiTimeoutConfig,
|
||||
ConfigEntry, ConfigError, ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader,
|
||||
ConfigSource, McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig,
|
||||
McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig,
|
||||
McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
|
||||
ProviderFallbackConfig, ResolvedPermissionMode, RulesImportConfig, RuntimeConfig,
|
||||
RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, RuntimeInvalidHookConfig,
|
||||
RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig, ScopedMcpServerConfig,
|
||||
CLAW_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
pub use config_validate::{
|
||||
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
|
||||
|
|
@ -81,8 +82,8 @@ pub use config_validate::{
|
|||
};
|
||||
pub use conversation::{
|
||||
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
|
||||
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError,
|
||||
ToolExecutor, TurnSummary,
|
||||
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolCall, ToolError,
|
||||
ToolExecutor, ToolResult, TurnProgressReporter, TurnSummary,
|
||||
};
|
||||
pub use file_ops::{
|
||||
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
|
||||
|
|
|
|||
|
|
@ -13,8 +13,13 @@ path = "src/main.rs"
|
|||
api = { path = "../api" }
|
||||
commands = { path = "../commands" }
|
||||
crossterm = "0.28"
|
||||
crossbeam-channel = "0.5"
|
||||
gag = "1"
|
||||
pulldown-cmark = "0.13"
|
||||
once_cell = "1"
|
||||
ratatui = "0.29"
|
||||
rustyline = "15"
|
||||
tui-textarea = "0.7"
|
||||
runtime = { path = "../runtime" }
|
||||
plugins = { path = "../plugins" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
|
@ -23,10 +28,19 @@ syntect = "5"
|
|||
tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
|
||||
tools = { path = "../tools" }
|
||||
log = "0.4"
|
||||
libc = "0.2"
|
||||
unicode-width = "0.2"
|
||||
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
[lints.rust]
|
||||
unsafe_code = "allow"
|
||||
|
||||
[lints.clippy]
|
||||
all = { level = "warn", priority = -1 }
|
||||
pedantic = { level = "allow", priority = -1 }
|
||||
module_name_repetitions = "allow"
|
||||
missing_panics_doc = "allow"
|
||||
missing_errors_doc = "allow"
|
||||
|
||||
[dev-dependencies]
|
||||
mock-anthropic-service = { path = "../mock-anthropic-service" }
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ pub enum ReadOutcome {
|
|||
Submit(String),
|
||||
Cancel,
|
||||
Exit,
|
||||
ProviderSwap,
|
||||
TeamToggle,
|
||||
}
|
||||
|
||||
struct SlashCommandHelper {
|
||||
|
|
@ -86,12 +88,19 @@ impl Hinter for SlashCommandHelper {
|
|||
impl Highlighter for SlashCommandHelper {
|
||||
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
|
||||
self.set_current_line(line);
|
||||
Cow::Borrowed(line)
|
||||
// When sentinel is present, show visible prompt instead of invisible char
|
||||
if line.contains('\x01') {
|
||||
let display = line.replace('\x01', "\x1b[36m[Provider Swap]\x1b[0m ");
|
||||
Cow::Owned(display)
|
||||
} else {
|
||||
Cow::Borrowed(line)
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool {
|
||||
self.set_current_line(line);
|
||||
false
|
||||
// Re-highlight when sentinel is present to show the prompt
|
||||
line.contains('\x01')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,6 +124,18 @@ impl LineEditor {
|
|||
editor.set_helper(Some(SlashCommandHelper::new(completions)));
|
||||
editor.bind_sequence(KeyEvent(KeyCode::Char('J'), Modifiers::CTRL), Cmd::Newline);
|
||||
editor.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::SHIFT), Cmd::Newline);
|
||||
// Ctrl+P inserts a sentinel character that triggers provider swap.
|
||||
// The sentinel is invisible but the highlighter shows "[Provider Swap]" prompt.
|
||||
// User must press Enter to confirm (rustyline cannot chain commands).
|
||||
editor.bind_sequence(
|
||||
KeyEvent(KeyCode::Char('P'), Modifiers::CTRL),
|
||||
Cmd::SelfInsert(1, '\x01'),
|
||||
);
|
||||
// Ctrl+T inserts a sentinel character that toggles agent teams mode.
|
||||
editor.bind_sequence(
|
||||
KeyEvent(KeyCode::Char('T'), Modifiers::CTRL),
|
||||
Cmd::SelfInsert(1, '\x02'),
|
||||
);
|
||||
|
||||
Self {
|
||||
prompt: prompt.into(),
|
||||
|
|
@ -147,7 +168,18 @@ impl LineEditor {
|
|||
}
|
||||
|
||||
match self.editor.readline(&self.prompt) {
|
||||
Ok(line) => Ok(ReadOutcome::Submit(line)),
|
||||
Ok(line) => {
|
||||
// Ctrl+P inserts \x01 sentinel — triggers provider swap wizard.
|
||||
// The sentinel is stripped and we return ProviderSwap to the REPL loop.
|
||||
if line.contains('\x01') {
|
||||
return Ok(ReadOutcome::ProviderSwap);
|
||||
}
|
||||
// Ctrl+T inserts \x02 sentinel — toggles team mode.
|
||||
if line.contains('\x02') {
|
||||
return Ok(ReadOutcome::TeamToggle);
|
||||
}
|
||||
Ok(ReadOutcome::Submit(line))
|
||||
}
|
||||
Err(ReadlineError::Interrupted) => {
|
||||
let has_input = !self.current_line().is_empty();
|
||||
self.finish_interrupted_read()?;
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@
|
|||
clippy::unnecessary_wraps,
|
||||
clippy::unused_self
|
||||
)]
|
||||
|
||||
mod init;
|
||||
mod input;
|
||||
|
||||
mod render;
|
||||
mod setup_wizard;
|
||||
|
||||
|
|
@ -1003,6 +1005,13 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
}
|
||||
let (args, cwd) = split_global_cwd_args(&args)?;
|
||||
apply_global_cwd(cwd)?;
|
||||
// Inject saved provider settings (from /setup wizard) as env var fallbacks.
|
||||
// The API client constructors read env vars only, so we set them once at
|
||||
// process startup before any runtime threads are spawned. Already-set env
|
||||
// vars are preserved — resolution order remains: env var > .env file >
|
||||
// stored config. This only runs in the real binary (run() is not called by
|
||||
// unit tests) to avoid leaking user config into the test suite.
|
||||
runtime::inject_config_as_env_fallbacks();
|
||||
match parse_args(&args)? {
|
||||
CliAction::DumpManifests {
|
||||
output_format,
|
||||
|
|
@ -2986,6 +2995,8 @@ fn is_local_openai_model_syntax(model: &str) -> bool {
|
|||
if let Some(rest) = model.strip_prefix("local/") {
|
||||
return !rest.is_empty() && rest.split('/').all(|segment| !segment.is_empty());
|
||||
}
|
||||
// Ollama-style tags (contain ':' or '.') are accepted when a custom
|
||||
// OpenAI-compatible base URL is configured via OPENAI_BASE_URL.
|
||||
std::env::var_os("OPENAI_BASE_URL").is_some() && (model.contains(':') || model.contains('.'))
|
||||
}
|
||||
|
||||
|
|
@ -3111,7 +3122,25 @@ fn config_permission_mode_for_current_dir() -> Option<PermissionMode> {
|
|||
fn config_model_for_current_dir() -> Option<String> {
|
||||
let cwd = env::current_dir().ok()?;
|
||||
let loader = ConfigLoader::default_for(&cwd);
|
||||
loader.load().ok()?.model().map(ToOwned::to_owned)
|
||||
let config = loader.load().ok()?;
|
||||
let model = config.model()?;
|
||||
|
||||
// If the user configured a custom OpenAI-compatible endpoint with a bare
|
||||
// model name (e.g. "openclaw"), route it through the custom/ prefix so it
|
||||
// passes validation, maps to the custom OpenAI-compat client, and gets
|
||||
// stripped down to the bare model id on the wire (avoiding a proxy 404).
|
||||
if is_custom_openai_provider(&config) && !model.contains('/') {
|
||||
return Some(format!("custom/{model}"));
|
||||
}
|
||||
|
||||
Some(model.to_owned())
|
||||
}
|
||||
|
||||
/// Check whether the loaded config uses the Claw custom OpenAI-compatible
|
||||
/// provider, which has its own env var namespace so it can coexist with real
|
||||
/// OpenAI/NeuralWatt credentials.
|
||||
fn is_custom_openai_provider(config: &runtime::RuntimeConfig) -> bool {
|
||||
config.provider().kind() == Some("custom-openai")
|
||||
}
|
||||
|
||||
fn resolve_repl_model(cli_model: String) -> Result<String, String> {
|
||||
|
|
@ -6926,6 +6955,7 @@ fn run_resume_command(
|
|||
| SlashCommand::Tag { .. }
|
||||
| SlashCommand::OutputStyle { .. }
|
||||
| SlashCommand::AddDir { .. }
|
||||
| SlashCommand::Lsp { .. }
|
||||
| SlashCommand::Team { .. }
|
||||
| SlashCommand::Setup => Err("unsupported resumed slash command".into()),
|
||||
}
|
||||
|
|
@ -7062,7 +7092,6 @@ fn run_repl(
|
|||
input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default());
|
||||
println!("{}", cli.startup_banner());
|
||||
println!("{}", format_connected_line(&cli.model));
|
||||
|
||||
loop {
|
||||
editor.set_completions(cli.repl_completion_candidates().unwrap_or_default());
|
||||
match editor.read_line()? {
|
||||
|
|
@ -7102,11 +7131,109 @@ fn run_repl(
|
|||
cli.record_prompt_history(&trimmed);
|
||||
cli.run_turn(&trimmed)?;
|
||||
}
|
||||
input::ReadOutcome::TeamToggle => {
|
||||
// Ctrl+T toggles agent teams mode
|
||||
let current = std::env::var("CLAWD_AGENT_TEAMS").unwrap_or_default();
|
||||
if current == "1" {
|
||||
std::env::set_var("CLAWD_AGENT_TEAMS", "0");
|
||||
eprintln!("[team] Agent teams disabled");
|
||||
} else {
|
||||
std::env::set_var("CLAWD_AGENT_TEAMS", "1");
|
||||
eprintln!("[team] Agent teams enabled (TeamCreate now available)");
|
||||
}
|
||||
}
|
||||
input::ReadOutcome::Cancel => {}
|
||||
input::ReadOutcome::Exit => {
|
||||
cli.persist_session()?;
|
||||
break;
|
||||
}
|
||||
input::ReadOutcome::ProviderSwap => {
|
||||
let _ = setup_wizard::run_setup_wizard();
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
let config = runtime::ConfigLoader::default_for(&cwd).load().ok();
|
||||
if let Some(new_model) = config
|
||||
.as_ref()
|
||||
.and_then(|c| c.provider().model().map(str::to_string))
|
||||
{
|
||||
let _ = cli.set_model(Some(new_model));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the plain REPL starting from an existing LiveCli.
|
||||
/// This is the same loop as `run_repl` but takes ownership
|
||||
/// of a pre-existing `LiveCli` instead of creating one from CLI args.
|
||||
fn run_repl_from_cli(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut editor =
|
||||
input::LineEditor::new("> ", cli.repl_completion_candidates().unwrap_or_default());
|
||||
println!();
|
||||
println!("{}", format_connected_line(&cli.model));
|
||||
loop {
|
||||
editor.set_completions(cli.repl_completion_candidates().unwrap_or_default());
|
||||
match editor.read_line()? {
|
||||
input::ReadOutcome::Submit(input) => {
|
||||
let trimmed = input.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if matches!(trimmed.as_str(), "/exit" | "/quit") {
|
||||
cli.persist_session()?;
|
||||
break;
|
||||
}
|
||||
match SlashCommand::parse(&trimmed) {
|
||||
Ok(Some(command)) => {
|
||||
if cli.handle_repl_command(command)? {
|
||||
cli.persist_session()?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
if let Some(prompt) = try_resolve_bare_skill_prompt(&cwd, &trimmed) {
|
||||
editor.push_history(input);
|
||||
cli.record_prompt_history(&trimmed);
|
||||
cli.run_turn(&prompt)?;
|
||||
continue;
|
||||
}
|
||||
editor.push_history(input);
|
||||
cli.record_prompt_history(&trimmed);
|
||||
cli.run_turn(&trimmed)?;
|
||||
}
|
||||
input::ReadOutcome::Cancel => {}
|
||||
input::ReadOutcome::Exit => {
|
||||
cli.persist_session()?;
|
||||
break;
|
||||
}
|
||||
input::ReadOutcome::ProviderSwap => {
|
||||
let _ = setup_wizard::run_setup_wizard();
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
let config = runtime::ConfigLoader::default_for(&cwd).load().ok();
|
||||
if let Some(new_model) = config
|
||||
.as_ref()
|
||||
.and_then(|c| c.provider().model().map(str::to_string))
|
||||
{
|
||||
let _ = cli.set_model(Some(new_model));
|
||||
}
|
||||
}
|
||||
input::ReadOutcome::TeamToggle => {
|
||||
let current = std::env::var("CLAWD_AGENT_TEAMS").unwrap_or_default();
|
||||
if current == "1" {
|
||||
std::env::set_var("CLAWD_AGENT_TEAMS", "0");
|
||||
eprintln!("[team] Agent teams disabled");
|
||||
} else {
|
||||
std::env::set_var("CLAWD_AGENT_TEAMS", "1");
|
||||
eprintln!("[team] Agent teams enabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -7749,35 +7876,38 @@ impl LiveCli {
|
|||
}
|
||||
|
||||
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
|
||||
self.run_turn_to(input, &mut io::stdout(), true)
|
||||
}
|
||||
|
||||
/// Core turn execution with a custom output writer.
|
||||
/// In the plain REPL, `out` is `io::stdout()`.
|
||||
fn run_turn_to<W: io::Write>(
|
||||
&mut self,
|
||||
input: &str,
|
||||
out: &mut W,
|
||||
emit_output: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(emit_output)?;
|
||||
let mut spinner = Spinner::new();
|
||||
let mut stdout = io::stdout();
|
||||
spinner.tick(
|
||||
"🦀 Thinking...",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
)?;
|
||||
spinner.tick("🦀 Thinking...", TerminalRenderer::new().color_theme(), out)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
let result = runtime.run_turn(input, Some(&mut permission_prompter));
|
||||
hook_abort_monitor.stop();
|
||||
match result {
|
||||
Ok(summary) => {
|
||||
self.replace_runtime(runtime)?;
|
||||
spinner.finish(
|
||||
"✨ Done",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
)?;
|
||||
spinner.finish("✨ Done", TerminalRenderer::new().color_theme(), out)?;
|
||||
let final_text = final_assistant_text(&summary);
|
||||
if !final_text.is_empty() {
|
||||
println!("{final_text}");
|
||||
writeln!(out, "{final_text}")?;
|
||||
}
|
||||
println!();
|
||||
writeln!(out)?;
|
||||
if let Some(event) = summary.auto_compaction {
|
||||
println!(
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
format_auto_compaction_notice(event.removed_message_count)
|
||||
);
|
||||
)?;
|
||||
}
|
||||
self.persist_session()?;
|
||||
Ok(())
|
||||
|
|
@ -7787,34 +7917,14 @@ impl LiveCli {
|
|||
spinner.fail(
|
||||
"❌ Request failed",
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
out,
|
||||
)?;
|
||||
|
||||
// ============================================================================
|
||||
// Auto-compact retry on context window errors
|
||||
// ============================================================================
|
||||
// When the model API returns a context_window_blocked error (because the request
|
||||
// exceeds the model's context window), we automatically:
|
||||
// 1. Compact the session (remove old messages to free up space)
|
||||
// 2. Retry the original request with the compacted session
|
||||
// 3. Report results to the user
|
||||
//
|
||||
// This eliminates the need for users to manually run /compact when they
|
||||
// hit context limits - the recovery happens automatically.
|
||||
//
|
||||
// Detection: We look for "context_window" or "Context window" in the error
|
||||
// message, which covers error types like:
|
||||
// - "context_window_blocked"
|
||||
// - "Context window blocked"
|
||||
// - "This model's maximum context length is X tokens..."
|
||||
// ============================================================================
|
||||
|
||||
let error_str = error.to_string();
|
||||
// Detect context window overflow. Some providers (e.g. OpenAI-compat backends)
|
||||
// return 400 with "no parseable body" instead of a proper context_length_exceeded
|
||||
// error when the request is too large to even parse — treat that as context overflow too.
|
||||
// Also detect model-specific context error markers (e.g. llama.cpp returns
|
||||
// "Context size has been exceeded." / "exceed_context_size_error" / "exceeds the available context size").
|
||||
let is_context_window = error_str.contains("context_window")
|
||||
|| error_str.contains("Context window")
|
||||
|| error_str.contains("no parseable body")
|
||||
|
|
@ -7824,44 +7934,33 @@ impl LiveCli {
|
|||
.to_ascii_lowercase()
|
||||
.contains("context size has been exceeded");
|
||||
|
||||
// Also treat "assistant stream produced no content" and reqwest decode failures
|
||||
// as recoverable errors that may benefit from auto-compaction. Some backends (e.g.
|
||||
// llama.cpp) return a non-SSE HTTP 500 body when context overflows, causing
|
||||
// reqwest to fail with "error decoding response body" — treat that as context overflow too.
|
||||
let is_no_content = error_str.contains("assistant stream produced no content")
|
||||
|| error_str.contains("Failed to parse input at pos")
|
||||
|| error_str.contains("error decoding response body");
|
||||
|
||||
if is_context_window || is_no_content {
|
||||
// If the error tells us the server's actual context window, adapt our
|
||||
// auto-compaction threshold so future auto-compact-trigger checks are accurate.
|
||||
if let Some(window) = extract_context_window_tokens_from_error(&error_str) {
|
||||
// Set threshold at 70% of the reported window to leave headroom.
|
||||
let threshold: u32 = (window as f64 * 0.7).round() as u32;
|
||||
println!(
|
||||
writeln!(
|
||||
out,
|
||||
" Server context window: {} tokens — setting auto-compaction threshold to {}",
|
||||
window, threshold
|
||||
);
|
||||
)?;
|
||||
runtime.set_auto_compaction_input_tokens_threshold(threshold);
|
||||
}
|
||||
|
||||
// A single compaction pass may not free enough context space.
|
||||
// Progressive retry: each round preserves fewer recent messages (4→2→1→0),
|
||||
// trading conversation continuity for a smaller payload until it fits.
|
||||
// Max 4 rounds before giving up and surfacing the error to the user.
|
||||
let max_compact_rounds = 4;
|
||||
let preserve_schedule = [4, 2, 1, 0];
|
||||
|
||||
for round in 0..max_compact_rounds {
|
||||
let preserve = preserve_schedule[round];
|
||||
println!(
|
||||
writeln!(
|
||||
out,
|
||||
" Auto-compacting session (round {}/{}, preserving {} recent messages)...",
|
||||
round + 1,
|
||||
max_compact_rounds,
|
||||
preserve
|
||||
);
|
||||
|
||||
// Run Trident pipeline then summary-based compaction
|
||||
)?;
|
||||
let result = runtime::trident::trident_compact_session(
|
||||
runtime.session(),
|
||||
CompactionConfig {
|
||||
|
|
@ -7874,19 +7973,20 @@ impl LiveCli {
|
|||
|
||||
if removed == 0 && round > 0 {
|
||||
// No more messages to compact — further rounds won't help
|
||||
println!(" No further compaction possible.");
|
||||
writeln!(out, " No further compaction possible.")?;
|
||||
break;
|
||||
}
|
||||
|
||||
if removed > 0 {
|
||||
println!(
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
format_compact_report(
|
||||
removed,
|
||||
result.compacted_session.messages.len(),
|
||||
false
|
||||
)
|
||||
);
|
||||
)?;
|
||||
}
|
||||
|
||||
// Without this, prepare_turn_runtime() reads from self.runtime.session()
|
||||
|
|
@ -7896,7 +7996,7 @@ impl LiveCli {
|
|||
|
||||
// Build a new runtime with the compacted session and retry
|
||||
let (mut new_runtime, hook_abort_monitor) =
|
||||
self.prepare_turn_runtime(true)?;
|
||||
self.prepare_turn_runtime(emit_output)?;
|
||||
drop(hook_abort_monitor);
|
||||
|
||||
let mut rp = CliPermissionPrompter::new(self.permission_mode);
|
||||
|
|
@ -7910,14 +8010,15 @@ impl LiveCli {
|
|||
"✨ Done (after aggressive auto-compact)"
|
||||
},
|
||||
TerminalRenderer::new().color_theme(),
|
||||
&mut stdout,
|
||||
out,
|
||||
)?;
|
||||
println!();
|
||||
writeln!(out)?;
|
||||
if let Some(event) = summary.auto_compaction {
|
||||
println!(
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
format_auto_compaction_notice(event.removed_message_count)
|
||||
);
|
||||
)?;
|
||||
}
|
||||
self.persist_session()?;
|
||||
return Ok(());
|
||||
|
|
@ -8140,6 +8241,56 @@ impl LiveCli {
|
|||
run_init(CliOutputFormat::Text)?;
|
||||
false
|
||||
}
|
||||
SlashCommand::Team { action } => {
|
||||
match action.as_deref().unwrap_or("") {
|
||||
"on" | "enable" => {
|
||||
std::env::set_var("CLAWD_AGENT_TEAMS", "1");
|
||||
eprintln!("[team] Agent teams enabled (TeamCreate now available)");
|
||||
}
|
||||
"off" | "disable" => {
|
||||
std::env::set_var("CLAWD_AGENT_TEAMS", "0");
|
||||
eprintln!("[team] Agent teams disabled");
|
||||
}
|
||||
"status" => {
|
||||
let current = std::env::var("CLAWD_AGENT_TEAMS").unwrap_or_default();
|
||||
if current == "1" {
|
||||
eprintln!("[team] Agent teams: ENABLED");
|
||||
} else {
|
||||
eprintln!(
|
||||
"[team] Agent teams: DISABLED (use /team on or Ctrl+T to enable)"
|
||||
);
|
||||
}
|
||||
}
|
||||
"" => {
|
||||
// Toggle
|
||||
let current = std::env::var("CLAWD_AGENT_TEAMS").unwrap_or_default();
|
||||
if current == "1" {
|
||||
std::env::set_var("CLAWD_AGENT_TEAMS", "0");
|
||||
eprintln!("[team] Agent teams disabled");
|
||||
} else {
|
||||
std::env::set_var("CLAWD_AGENT_TEAMS", "1");
|
||||
eprintln!("[team] Agent teams enabled (TeamCreate now available)");
|
||||
}
|
||||
}
|
||||
other => {
|
||||
eprintln!("[team] unknown action: {other}. Use: /team [on|off|status]")
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
SlashCommand::Setup => {
|
||||
setup_wizard::run_setup_wizard()?;
|
||||
// Reload the model from config after wizard saves
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
let config = runtime::ConfigLoader::default_for(&cwd).load().ok();
|
||||
if let Some(new_model) = config
|
||||
.as_ref()
|
||||
.and_then(|c| c.provider().model().map(str::to_string))
|
||||
{
|
||||
self.set_model(Some(new_model))?;
|
||||
}
|
||||
false
|
||||
}
|
||||
SlashCommand::Diff => {
|
||||
Self::print_diff()?;
|
||||
false
|
||||
|
|
@ -8188,12 +8339,6 @@ impl LiveCli {
|
|||
);
|
||||
false
|
||||
}
|
||||
SlashCommand::Setup => {
|
||||
if let Err(e) = setup_wizard::run_setup_wizard() {
|
||||
eprintln!("Setup wizard failed: {e}");
|
||||
}
|
||||
false
|
||||
}
|
||||
SlashCommand::History { count } => {
|
||||
self.print_prompt_history(count.as_deref());
|
||||
false
|
||||
|
|
@ -8241,7 +8386,7 @@ impl LiveCli {
|
|||
| SlashCommand::Tag { .. }
|
||||
| SlashCommand::OutputStyle { .. }
|
||||
| SlashCommand::AddDir { .. }
|
||||
| SlashCommand::Team { .. } => {
|
||||
| SlashCommand::Lsp { .. } => {
|
||||
let cmd_name = command.slash_name();
|
||||
eprintln!("{cmd_name} is not yet implemented in this build.");
|
||||
false
|
||||
|
|
@ -12624,6 +12769,45 @@ fn resolve_cli_auth_source_for_cwd() -> Result<AuthSource, api::ApiError> {
|
|||
resolve_startup_auth_source(|| Ok(None))
|
||||
}
|
||||
|
||||
/// Inject provider settings from `~/.claw/settings.json` as environment
|
||||
/// variable fallbacks so the API client constructors (which only read env
|
||||
/// vars) can find them.
|
||||
///
|
||||
/// This bridges the gap between `/setup` (which saves apiKey/baseUrl to
|
||||
/// the config file) and the API crate (which reads env vars only).
|
||||
/// Already-set env vars are never overwritten — the 3-tier resolution
|
||||
/// order is preserved: env var > .env file > stored config.
|
||||
fn inject_config_as_env_fallbacks() {
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
let Ok(config) = runtime::ConfigLoader::default_for(&cwd).load() else {
|
||||
return;
|
||||
};
|
||||
let provider = config.provider();
|
||||
|
||||
// Map provider kind to the expected env var names
|
||||
let (api_key_env, base_url_env) = match provider.kind().unwrap_or("anthropic") {
|
||||
"anthropic" => ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"),
|
||||
"xai" => ("XAI_API_KEY", "XAI_BASE_URL"),
|
||||
"openai" => ("OPENAI_API_KEY", "OPENAI_BASE_URL"),
|
||||
"dashscope" => ("DASHSCOPE_API_KEY", "DASHSCOPE_BASE_URL"),
|
||||
"custom-openai" => ("CLAWCUSTOMOPENAI_API_KEY", "CLAWCUSTOMOPENAI_BASE_URL"),
|
||||
_ => return, // unknown provider kind — don't inject
|
||||
};
|
||||
|
||||
// Only set env vars that aren't already set (preserve user's explicit env)
|
||||
if let Some(api_key) = provider.api_key() {
|
||||
if !api_key.is_empty() && std::env::var(api_key_env).is_err() {
|
||||
std::env::set_var(api_key_env, api_key);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(base_url) = provider.base_url() {
|
||||
if !base_url.is_empty() && std::env::var(base_url_env).is_err() {
|
||||
std::env::set_var(base_url_env, base_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiClient for AnthropicRuntimeClient {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
|
|
@ -13982,6 +14166,153 @@ impl ToolExecutor for CliToolExecutor {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_batch(&mut self, calls: Vec<runtime::ToolCall>) -> Vec<runtime::ToolResult> {
|
||||
if calls.len() <= 1 {
|
||||
return calls
|
||||
.into_iter()
|
||||
.map(|call| {
|
||||
let result = self.execute(&call.tool_name, &call.input);
|
||||
runtime::ToolResult {
|
||||
tool_use_id: call.tool_use_id,
|
||||
tool_name: call.tool_name,
|
||||
result,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Tools that are safe to run in parallel because they only read
|
||||
/// state and dispatch through the stateless tool registry.
|
||||
const PARALLEL_SAFE_TOOLS: &[&str] = &[
|
||||
"read_file",
|
||||
"glob_search",
|
||||
"grep_search",
|
||||
"WebFetch",
|
||||
"WebSearch",
|
||||
"ToolSearch",
|
||||
"Skill",
|
||||
"LSP",
|
||||
"Agent",
|
||||
"AgentMessage",
|
||||
"TeamStatus",
|
||||
"TaskClaim",
|
||||
"AgentSuggestion",
|
||||
"ContextRequest",
|
||||
"TaskGet",
|
||||
"TaskList",
|
||||
"TaskOutput",
|
||||
"GitStatus",
|
||||
"GitDiff",
|
||||
"GitLog",
|
||||
"GitShow",
|
||||
"GitBlame",
|
||||
];
|
||||
|
||||
let emit_output = self.emit_output;
|
||||
let mut results: Vec<Option<runtime::ToolResult>> = vec![None; calls.len()];
|
||||
let mut parallel_calls: Vec<(usize, String, String, String)> = Vec::new();
|
||||
let mut sequential_indices: Vec<usize> = Vec::new();
|
||||
|
||||
// Classify calls as parallel-safe or sequential
|
||||
for (i, call) in calls.iter().enumerate() {
|
||||
if self.allowed_tools.as_ref().is_some_and(|allowed| {
|
||||
!allowed.contains(&canonical_allowed_tool_name(&call.tool_name))
|
||||
}) {
|
||||
results[i] = Some(runtime::ToolResult {
|
||||
tool_use_id: call.tool_use_id.clone(),
|
||||
tool_name: call.tool_name.clone(),
|
||||
result: Err(ToolError::new(format!(
|
||||
"tool `{}` is not enabled by the current --allowedTools setting",
|
||||
call.tool_name
|
||||
))),
|
||||
});
|
||||
} else if PARALLEL_SAFE_TOOLS.contains(&call.tool_name.as_str())
|
||||
&& !self.tool_registry.has_runtime_tool(&call.tool_name)
|
||||
{
|
||||
parallel_calls.push((
|
||||
i,
|
||||
call.tool_use_id.clone(),
|
||||
call.tool_name.clone(),
|
||||
call.input.clone(),
|
||||
));
|
||||
} else {
|
||||
sequential_indices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute parallel-safe tools concurrently
|
||||
if !parallel_calls.is_empty() {
|
||||
let registry = self.tool_registry.clone();
|
||||
let parallel_results: Vec<(usize, String, String, Result<String, ToolError>)> =
|
||||
std::thread::scope(|s| {
|
||||
let mut handles = Vec::new();
|
||||
for (idx, tool_use_id, tool_name, input) in ¶llel_calls {
|
||||
let registry = ®istry;
|
||||
let tool_use_id = tool_use_id.clone();
|
||||
let tool_name = tool_name.clone();
|
||||
let input = input.clone();
|
||||
let idx = *idx;
|
||||
handles.push(s.spawn(move || {
|
||||
let value = serde_json::from_str(&input).map_err(|error| {
|
||||
ToolError::new(format!("invalid tool input JSON: {error}"))
|
||||
});
|
||||
let result = match value {
|
||||
Ok(v) => registry.execute(&tool_name, &v).map_err(ToolError::new),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
(idx, tool_use_id, tool_name, result)
|
||||
}));
|
||||
}
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|h| {
|
||||
h.join().unwrap_or_else(|_| {
|
||||
(
|
||||
0,
|
||||
String::new(),
|
||||
String::new(),
|
||||
Err(ToolError::new("parallel thread panicked")),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
for (idx, tool_use_id, tool_name, result) in parallel_results {
|
||||
if emit_output {
|
||||
let output_str = match &result {
|
||||
Ok(o) => o.clone(),
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
let is_error = result.is_err();
|
||||
let markdown = format_tool_result(&tool_name, &output_str, is_error);
|
||||
self.renderer
|
||||
.stream_markdown(&markdown, &mut io::stdout())
|
||||
.map_err(|error| ToolError::new(error.to_string()))
|
||||
.ok();
|
||||
}
|
||||
results[idx] = Some(runtime::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
result,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Execute sequential tools one at a time
|
||||
for idx in sequential_indices {
|
||||
let call = &calls[idx];
|
||||
let result = self.execute(&call.tool_name, &call.input);
|
||||
results[idx] = Some(runtime::ToolResult {
|
||||
tool_use_id: call.tool_use_id.clone(),
|
||||
tool_name: call.tool_name.clone(),
|
||||
result,
|
||||
});
|
||||
}
|
||||
|
||||
results.into_iter().map(|r| r.unwrap()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_policy(
|
||||
|
|
@ -14645,6 +14976,178 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_model_normalizes_bare_name_to_custom_prefix_for_custom_openai_provider() {
|
||||
let _guard = env_lock();
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let config_home = root.join("config-home");
|
||||
std::fs::create_dir_all(&cwd).expect("project dir should exist");
|
||||
std::fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
std::fs::write(
|
||||
config_home.join("settings.json"),
|
||||
r#"{
|
||||
"provider": {
|
||||
"kind": "custom-openai",
|
||||
"apiKey": "sk-test",
|
||||
"baseUrl": "http://localhost:9999/v1"
|
||||
},
|
||||
"model": "openclaw"
|
||||
}"#,
|
||||
)
|
||||
.expect("user settings should write");
|
||||
|
||||
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||
|
||||
let resolved = with_current_dir(&cwd, super::config_model_for_current_dir);
|
||||
|
||||
match original_config_home {
|
||||
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
|
||||
None => std::env::remove_var("CLAW_CONFIG_HOME"),
|
||||
}
|
||||
std::fs::remove_dir_all(root).expect("temp config root should clean up");
|
||||
|
||||
assert_eq!(resolved, Some("custom/openclaw".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_model_leaves_openai_kind_bare_model_unnormalized() {
|
||||
let _guard = env_lock();
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let config_home = root.join("config-home");
|
||||
std::fs::create_dir_all(&cwd).expect("project dir should exist");
|
||||
std::fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
std::fs::write(
|
||||
config_home.join("settings.json"),
|
||||
r#"{
|
||||
"provider": {
|
||||
"kind": "openai",
|
||||
"apiKey": "sk-test",
|
||||
"baseUrl": "http://localhost:9999/v1"
|
||||
},
|
||||
"model": "openclaw"
|
||||
}"#,
|
||||
)
|
||||
.expect("user settings should write");
|
||||
|
||||
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||
|
||||
let resolved = with_current_dir(&cwd, super::config_model_for_current_dir);
|
||||
|
||||
match original_config_home {
|
||||
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
|
||||
None => std::env::remove_var("CLAW_CONFIG_HOME"),
|
||||
}
|
||||
std::fs::remove_dir_all(root).expect("temp config root should clean up");
|
||||
|
||||
assert_eq!(resolved, Some("openclaw".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_config_as_env_fallbacks_sets_openai_provider_env_vars() {
|
||||
let _guard = env_lock();
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let config_home = root.join("config-home");
|
||||
std::fs::create_dir_all(&cwd).expect("project dir should exist");
|
||||
std::fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
std::fs::write(
|
||||
config_home.join("settings.json"),
|
||||
r#"{
|
||||
"provider": {
|
||||
"kind": "openai",
|
||||
"apiKey": "sk-from-config",
|
||||
"baseUrl": "http://localhost:9999/v1"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("user settings should write");
|
||||
|
||||
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||
let original_api_key = std::env::var("OPENAI_API_KEY").ok();
|
||||
let original_base_url = std::env::var("OPENAI_BASE_URL").ok();
|
||||
|
||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||
std::env::remove_var("OPENAI_API_KEY");
|
||||
std::env::remove_var("OPENAI_BASE_URL");
|
||||
|
||||
with_current_dir(&cwd, super::inject_config_as_env_fallbacks);
|
||||
|
||||
let api_key = std::env::var("OPENAI_API_KEY").ok();
|
||||
let base_url = std::env::var("OPENAI_BASE_URL").ok();
|
||||
|
||||
match original_config_home {
|
||||
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
|
||||
None => std::env::remove_var("CLAW_CONFIG_HOME"),
|
||||
}
|
||||
match original_api_key {
|
||||
Some(value) => std::env::set_var("OPENAI_API_KEY", value),
|
||||
None => std::env::remove_var("OPENAI_API_KEY"),
|
||||
}
|
||||
match original_base_url {
|
||||
Some(value) => std::env::set_var("OPENAI_BASE_URL", value),
|
||||
None => std::env::remove_var("OPENAI_BASE_URL"),
|
||||
}
|
||||
std::fs::remove_dir_all(root).expect("temp config root should clean up");
|
||||
|
||||
assert_eq!(api_key, Some("sk-from-config".to_string()));
|
||||
assert_eq!(base_url, Some("http://localhost:9999/v1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_config_as_env_fallbacks_sets_custom_openai_provider_env_vars() {
|
||||
let _guard = env_lock();
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let config_home = root.join("config-home");
|
||||
std::fs::create_dir_all(&cwd).expect("project dir should exist");
|
||||
std::fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
std::fs::write(
|
||||
config_home.join("settings.json"),
|
||||
r#"{
|
||||
"provider": {
|
||||
"kind": "custom-openai",
|
||||
"apiKey": "sk-from-config",
|
||||
"baseUrl": "http://localhost:9999/v1"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("user settings should write");
|
||||
|
||||
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||
let original_api_key = std::env::var("CLAWCUSTOMOPENAI_API_KEY").ok();
|
||||
let original_base_url = std::env::var("CLAWCUSTOMOPENAI_BASE_URL").ok();
|
||||
|
||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||
std::env::remove_var("CLAWCUSTOMOPENAI_API_KEY");
|
||||
std::env::remove_var("CLAWCUSTOMOPENAI_BASE_URL");
|
||||
|
||||
with_current_dir(&cwd, super::inject_config_as_env_fallbacks);
|
||||
|
||||
let api_key = std::env::var("CLAWCUSTOMOPENAI_API_KEY").ok();
|
||||
let base_url = std::env::var("CLAWCUSTOMOPENAI_BASE_URL").ok();
|
||||
|
||||
match original_config_home {
|
||||
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
|
||||
None => std::env::remove_var("CLAW_CONFIG_HOME"),
|
||||
}
|
||||
match original_api_key {
|
||||
Some(value) => std::env::set_var("CLAWCUSTOMOPENAI_API_KEY", value),
|
||||
None => std::env::remove_var("CLAWCUSTOMOPENAI_API_KEY"),
|
||||
}
|
||||
match original_base_url {
|
||||
Some(value) => std::env::set_var("CLAWCUSTOMOPENAI_BASE_URL", value),
|
||||
None => std::env::remove_var("CLAWCUSTOMOPENAI_BASE_URL"),
|
||||
}
|
||||
std::fs::remove_dir_all(root).expect("temp config root should clean up");
|
||||
|
||||
assert_eq!(api_key, Some("sk-from-config".to_string()));
|
||||
assert_eq!(base_url, Some("http://localhost:9999/v1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_permission_mode_uses_project_config_when_env_is_unset() {
|
||||
let _guard = env_lock();
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const PROVIDERS: &[(&str, &str, &str)] = &[
|
|||
("2", "xAI / Grok", "xai"),
|
||||
("3", "OpenAI", "openai"),
|
||||
("4", "DashScope (Qwen/Kimi)", "dashscope"),
|
||||
("5", "Custom (OpenAI-compat)", "openai"),
|
||||
("5", "Custom (OpenAI-compat)", "custom-openai"),
|
||||
];
|
||||
|
||||
const PROVIDER_MODELS: &[(&str, &[&str])] = &[
|
||||
|
|
@ -27,6 +27,8 @@ const DEFAULT_BASE_URLS: &[(&str, &str)] = &[
|
|||
"dashscope",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
),
|
||||
// Custom OpenAI has no default base URL; the user must supply one.
|
||||
("custom-openai", ""),
|
||||
];
|
||||
|
||||
const API_KEY_ENV_VARS: &[(&str, &str)] = &[
|
||||
|
|
@ -34,6 +36,7 @@ const API_KEY_ENV_VARS: &[(&str, &str)] = &[
|
|||
("xai", "XAI_API_KEY"),
|
||||
("openai", "OPENAI_API_KEY"),
|
||||
("dashscope", "DASHSCOPE_API_KEY"),
|
||||
("custom-openai", "CLAWCUSTOMOPENAI_API_KEY"),
|
||||
];
|
||||
|
||||
pub fn run_setup_wizard() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
|
@ -177,6 +180,7 @@ fn prompt_base_url(
|
|||
"xai" => "XAI_BASE_URL",
|
||||
"openai" => "OPENAI_BASE_URL",
|
||||
"dashscope" => "DASHSCOPE_BASE_URL",
|
||||
"custom-openai" => "CLAWCUSTOMOPENAI_BASE_URL",
|
||||
_ => "BASE_URL",
|
||||
};
|
||||
let env_set = std::env::var(env_var).ok().is_some_and(|v| !v.is_empty());
|
||||
|
|
@ -214,7 +218,11 @@ fn prompt_model(
|
|||
if !aliases.is_empty() {
|
||||
println!(" Common: {}", aliases.join(", "));
|
||||
}
|
||||
println!(" Or enter any model name (e.g. openai/gpt-4.1-mini for custom routing)");
|
||||
if kind == "custom-openai" {
|
||||
println!(" Or enter any model name (e.g. custom/gpt-4.1 for custom routing)");
|
||||
} else {
|
||||
println!(" Or enter any model name (e.g. openai/gpt-4.1-mini for custom routing)");
|
||||
}
|
||||
|
||||
let input = read_line(&format!(" Model [{current_model}]: "))?;
|
||||
if input.trim().is_empty() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
# Multi-Tool Execution & Sub-Agent Delegation
|
||||
|
||||
Two complementary features that dramatically reduce latency and token usage when the model needs to perform multiple operations or gather context.
|
||||
|
||||
## Feature 1: Parallel Tool Execution
|
||||
|
||||
When the model returns multiple tool_use blocks in a single response, read-only tools now execute concurrently instead of sequentially.
|
||||
|
||||
### How it works
|
||||
|
||||
The `run_turn` loop is refactored into 3 phases:
|
||||
|
||||
1. **Pre-hooks + permission checks** (sequential — hooks may mutate state)
|
||||
2. **Tool execution** (batch — parallel for read-only tools via `std::thread::scope`)
|
||||
3. **Post-hooks + session updates** (sequential — preserves original ordering)
|
||||
|
||||
### Parallel-safe tools
|
||||
|
||||
These tools are safe to run concurrently because they only read state and dispatch through the stateless tool registry:
|
||||
|
||||
- `read_file`, `glob_search`, `grep_search`
|
||||
- `WebFetch`, `WebSearch`
|
||||
- `ToolSearch`, `Skill`
|
||||
- `LSP`
|
||||
- `GitStatus`, `GitDiff`, `GitLog`, `GitShow`, `GitBlame`
|
||||
|
||||
### Sequential-only tools
|
||||
|
||||
Tools that require `&mut self` or have side effects continue to run one at a time:
|
||||
|
||||
- `bash`, `write_file`, `edit_file` (side effects)
|
||||
- `MCP`, `McpAuth`, `RemoteTrigger` (network state)
|
||||
- `Agent`, `TaskCreate`, `WorkerCreate` (stateful)
|
||||
- `NotebookEdit`, `REPL`, `PowerShell` (side effects)
|
||||
|
||||
### Safety guarantees
|
||||
|
||||
- Pre/post hooks always run sequentially
|
||||
- Permission checks complete before any tool executes
|
||||
- Tool results are pushed to the session in the original model order
|
||||
- Falls back to sequential for single-tool batches
|
||||
- Thread scopes ensure all parallel work completes before `execute_batch` returns
|
||||
|
||||
### Impact
|
||||
|
||||
For a response with 5 `read_file` calls: **~5x faster** execution. The main model still sees all results in order.
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: SubAgent Delegation
|
||||
|
||||
A `SubAgent` tool that lets the main model delegate multi-step tasks to a fast sub-agent. The sub-agent runs autonomously with its own `ConversationRuntime`, making multiple tool calls without round-tripping through the main model.
|
||||
|
||||
### Tool parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `prompt` | string | yes | Task description for the sub-agent |
|
||||
| `task_type` | string | no | `Explore` (default), `Plan`, or `Verify` |
|
||||
| `model` | string | no | Override the sub-agent model |
|
||||
|
||||
### Task types
|
||||
|
||||
| Type | Available tools | Use for |
|
||||
|------|----------------|---------|
|
||||
| `Explore` | read_file, glob_search, grep_search, WebFetch, WebSearch, ToolSearch, StructuredOutput | Searching code, reading files, gathering context |
|
||||
| `Plan` | Explore + TodoWrite | Planning approaches with structured todo output |
|
||||
| `Verify` | Plan + bash | Running tests, checking builds, verifying changes |
|
||||
|
||||
### Configuration
|
||||
|
||||
Set the sub-agent model in `~/.claw/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "openai/glm-5.1-fast",
|
||||
"subagentModel": "openai/qwen3.6-35b-fast"
|
||||
}
|
||||
```
|
||||
|
||||
If `subagentModel` is not set, the sub-agent uses the same model as the main session (or the `model` override parameter on the tool call).
|
||||
|
||||
### Example
|
||||
|
||||
Main model prompt: "Find all Rust files that import `ConversationRuntime` and list their paths"
|
||||
|
||||
Without SubAgent: 5–10 sequential tool calls (grep → read each file → summarize)
|
||||
With SubAgent: 1 SubAgent call → sub-agent does all the work autonomously → returns summary
|
||||
|
||||
**Result**: ~10x fewer tokens consumed by the main model, faster overall completion.
|
||||
|
||||
### Architecture
|
||||
|
||||
The sub-agent reuses the same building blocks as the existing `Agent` tool:
|
||||
|
||||
- `ProviderRuntimeClient` — API client with fallback chain
|
||||
- `SubagentToolExecutor` — filtered tool access with permission enforcement
|
||||
- `ConversationRuntime` — full conversation loop with hooks and compaction
|
||||
- `agent_permission_policy()` — auto-approve read-only, deny write tools
|
||||
|
||||
Key differences from the `Agent` tool:
|
||||
- **Synchronous** — blocks until complete, returns result directly
|
||||
- **Lighter** — fewer default tools, focused on the task type
|
||||
- **Configurable model** — uses `subagentModel` or the tool's `model` param
|
||||
- **Structured output** — returns `result`, `tool_calls`, and `iterations`
|
||||
|
||||
---
|
||||
|
||||
## Changed files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `rust/crates/runtime/src/conversation.rs` | `ToolCall`, `ToolResult` types; `execute_batch` on `ToolExecutor`; 3-phase `run_turn` |
|
||||
| `rust/crates/runtime/src/lib.rs` | Exports for `ToolCall`, `ToolResult` |
|
||||
| `rust/crates/runtime/src/config.rs` | `subagent_model` field, `parse_optional_subagent_model()`, accessor |
|
||||
| `rust/crates/runtime/src/config_validate.rs` | `subagentModel` field spec |
|
||||
| `rust/crates/rusty-claude-cli/src/main.rs` | `CliToolExecutor::execute_batch` with parallel-safe classification |
|
||||
| `rust/crates/tools/src/lib.rs` | `SubAgent` tool spec, `SubAgentInput`, `run_sub_agent()`, `load_subagent_model_from_config()`, `build_sub_agent_system_prompt()` |
|
||||
|
|
@ -116,6 +116,8 @@ mod tests {
|
|||
lane_events: vec![],
|
||||
derived_state: "working".to_string(),
|
||||
current_blocker: None,
|
||||
team_id: None,
|
||||
task_id: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue