mirror of
https://github.com/instructkr/claw-code.git
synced 2026-10-15 00:00:14 +08:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
590f5a2e09 | ||
|
|
0403fe8867 | ||
|
|
407f0be972 | ||
|
|
98c45990db | ||
|
|
beb5595551 | ||
|
|
05fb4d5f2f | ||
|
|
c6820b8f2f | ||
|
|
1e96b1643a | ||
|
|
53b3e63d99 | ||
|
|
0cf2c751a6 | ||
|
|
22b30c4557 | ||
|
|
5f08341d1a |
No files matched your search
@@ -15,3 +15,4 @@ status-help.txt
|
||||
.port_sessions/
|
||||
# Local agent session state
|
||||
.gjc/
|
||||
.claude/
|
||||
@@ -0,0 +1 @@
|
||||
{"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"}]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"pid":1,"start_time":"unknown","token":"8389ee99-c41c-47dd-9615-e76a5268967c","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"pid":1,"start_time":"unknown","token":"6e35ded8-fda1-421e-a727-42906ef57646","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
|
||||
Whitespace-only changes.
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"effectMarker":"7b92c148-d616-4eb5-95e8-4e95084bebe9","incarnation":"linux:159085489","pid":177576}
|
||||
@@ -0,0 +1 @@
|
||||
{"effectMarker":"7b92c148-d616-4eb5-95e8-4e95084bebe9","incarnation":"linux:159085489","pid":177576}
|
||||
+25
-15
@@ -118,15 +118,15 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "aspect-core"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70188b9bf884266a6c7117e30af44f38229bc5ac56916bd16512b3e49f90fe20"
|
||||
checksum = "4e94aecae4a10eea63983b608beb48998578925d0660796140d69d8e8276f215"
|
||||
|
||||
[[package]]
|
||||
name = "aspect-macros"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "176e7db9b6a7bb4f117b8d97054d2d5a7bdc43b95c19c15c751fb8dcb9bc8a5c"
|
||||
checksum = "5d56334ff035ccdeed6b6c159b9089ae1baba29f93eb6fa8bd89312e9fa3f136"
|
||||
dependencies = [
|
||||
"aspect-core",
|
||||
"proc-macro2",
|
||||
@@ -136,9 +136,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aspect-std"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba7d130884fda30ec0acabcadc8f4711267d1cc21edc8f85283e91a011d699fa"
|
||||
checksum = "059c997171f851dcd36d5eb14cf9ec831884ecafa2b753629379ff4b71971008"
|
||||
dependencies = [
|
||||
"aspect-core",
|
||||
"log",
|
||||
@@ -591,7 +591,7 @@ dependencies = [
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools",
|
||||
"itertools 0.10.5",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
@@ -612,7 +612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools",
|
||||
"itertools 0.10.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1417,6 +1417,15 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -1794,7 +1803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools",
|
||||
"itertools 0.13.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
@@ -2192,9 +2201,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
@@ -2223,9 +2232,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -2247,6 +2256,7 @@ dependencies = [
|
||||
"crossterm",
|
||||
"log",
|
||||
"mock-anthropic-service",
|
||||
"once_cell",
|
||||
"plugins",
|
||||
"pulldown-cmark",
|
||||
"runtime",
|
||||
@@ -2982,9 +2992,9 @@ checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
|
||||
+100
-99
@@ -52,13 +52,19 @@ pub enum ApiError {
|
||||
body_snippet: String,
|
||||
source: serde_json::Error,
|
||||
},
|
||||
/// 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>),
|
||||
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>,
|
||||
},
|
||||
RetriesExhausted {
|
||||
attempts: u32,
|
||||
last_error: Box<ApiError>,
|
||||
@@ -75,38 +81,7 @@ 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,
|
||||
@@ -162,7 +137,7 @@ impl ApiError {
|
||||
/// over the computed backoff delay when it exists.
|
||||
pub fn retry_after(&self) -> Option<Duration> {
|
||||
match self {
|
||||
Self::Api(details) => details.retry_after,
|
||||
Self::Api { retry_after, .. } => *retry_after,
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.retry_after(),
|
||||
_ => None,
|
||||
}
|
||||
@@ -171,7 +146,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(details) => details.retryable,
|
||||
Self::Api { retryable, .. } => *retryable,
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
|
||||
Self::MissingCredentials { .. }
|
||||
| Self::ContextWindowExceeded { .. }
|
||||
@@ -189,7 +164,7 @@ impl ApiError {
|
||||
#[must_use]
|
||||
pub fn request_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Api(details) => details.request_id.as_deref(),
|
||||
Self::Api { request_id, .. } => request_id.as_deref(),
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.request_id(),
|
||||
Self::MissingCredentials { .. }
|
||||
| Self::ContextWindowExceeded { .. }
|
||||
@@ -205,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 {
|
||||
@@ -216,12 +234,12 @@ impl ApiError {
|
||||
Self::MissingCredentials { .. } | Self::ExpiredOAuthToken | Self::Auth(_) => {
|
||||
"provider_auth"
|
||||
}
|
||||
Self::Api(details) if matches!(details.status.as_u16(), 401 | 403) => "provider_auth",
|
||||
Self::Api { status, .. } if matches!(status.as_u16(), 401 | 403) => "provider_auth",
|
||||
Self::ContextWindowExceeded { .. } => "context_window",
|
||||
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::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::Http(_) | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => {
|
||||
"provider_transport"
|
||||
}
|
||||
@@ -233,12 +251,11 @@ impl ApiError {
|
||||
#[must_use]
|
||||
pub fn is_generic_fatal_wrapper(&self) -> bool {
|
||||
match self {
|
||||
Self::Api(details) => {
|
||||
details
|
||||
.message
|
||||
Self::Api { message, body, .. } => {
|
||||
message
|
||||
.as_deref()
|
||||
.is_some_and(looks_like_generic_fatal_wrapper)
|
||||
|| looks_like_generic_fatal_wrapper(&details.body)
|
||||
|| looks_like_generic_fatal_wrapper(body)
|
||||
}
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.is_generic_fatal_wrapper(),
|
||||
Self::MissingCredentials { .. }
|
||||
@@ -255,36 +272,21 @@ 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(details) => {
|
||||
matches!(details.status.as_u16(), 400 | 413 | 422)
|
||||
&& (details
|
||||
.message
|
||||
Self::Api {
|
||||
status,
|
||||
message,
|
||||
body,
|
||||
..
|
||||
} => {
|
||||
matches!(status.as_u16(), 400 | 413 | 422)
|
||||
&& (message
|
||||
.as_deref()
|
||||
.is_some_and(looks_like_context_window_error)
|
||||
|| looks_like_context_window_error(&details.body))
|
||||
|| looks_like_context_window_error(body))
|
||||
}
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.is_context_window_failure(),
|
||||
Self::MissingCredentials { .. }
|
||||
@@ -368,14 +370,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(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,
|
||||
);
|
||||
Self::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
..
|
||||
} if matches!(status.as_u16(), 401 | 403) => {
|
||||
if let (Some(error_type), Some(message)) = (error_type, message) {
|
||||
write!(f, "api returned {status} ({error_type})")?;
|
||||
if let Some(request_id) = request_id {
|
||||
@@ -397,14 +399,14 @@ impl Display for ApiError {
|
||||
Run `claw doctor` to verify your credential configuration."
|
||||
)
|
||||
}
|
||||
Self::Api(details) => {
|
||||
let (status, error_type, message, request_id, body) = (
|
||||
&details.status,
|
||||
&details.error_type,
|
||||
&details.message,
|
||||
&details.request_id,
|
||||
&details.body,
|
||||
);
|
||||
Self::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
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 {
|
||||
@@ -510,7 +512,6 @@ fn truncate_body_snippet(body: &str, max_chars: usize) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ApiErrorDetails;
|
||||
use super::{truncate_body_snippet, ApiError};
|
||||
|
||||
#[test]
|
||||
@@ -575,7 +576,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() {
|
||||
let error = ApiError::Api(Box::new(ApiErrorDetails {
|
||||
let error = ApiError::Api {
|
||||
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
error_type: Some("api_error".to_string()),
|
||||
message: Some(
|
||||
@@ -587,7 +588,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");
|
||||
@@ -599,7 +600,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(Box::new(ApiErrorDetails {
|
||||
last_error: Box::new(ApiError::Api {
|
||||
status: reqwest::StatusCode::BAD_GATEWAY,
|
||||
error_type: Some("api_error".to_string()),
|
||||
message: Some(
|
||||
@@ -611,7 +612,7 @@ mod tests {
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
}))),
|
||||
}),
|
||||
};
|
||||
|
||||
assert!(error.is_generic_fatal_wrapper());
|
||||
@@ -621,7 +622,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn classifies_provider_context_window_errors() {
|
||||
let error = ApiError::Api(Box::new(ApiErrorDetails {
|
||||
let error = ApiError::Api {
|
||||
status: reqwest::StatusCode::BAD_REQUEST,
|
||||
error_type: Some("invalid_request_error".to_string()),
|
||||
message: Some(
|
||||
@@ -633,7 +634,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");
|
||||
@@ -642,7 +643,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn classifies_openai_configured_limit_errors_as_context_window_failures() {
|
||||
let error = ApiError::Api(Box::new(ApiErrorDetails {
|
||||
let error = ApiError::Api {
|
||||
status: reqwest::StatusCode::BAD_REQUEST,
|
||||
error_type: Some("invalid_request_error".to_string()),
|
||||
message: Some(
|
||||
@@ -654,7 +655,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");
|
||||
|
||||
@@ -10,7 +10,7 @@ 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,
|
||||
};
|
||||
pub use error::{ApiError, ApiErrorDetails};
|
||||
pub use error::ApiError;
|
||||
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 +27,10 @@ pub use providers::openai_compat::{
|
||||
OpenAiCompatClient, OpenAiCompatConfig,
|
||||
};
|
||||
pub use providers::{
|
||||
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,
|
||||
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,
|
||||
};
|
||||
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, ApiErrorDetails};
|
||||
use crate::error::ApiError;
|
||||
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(Box::new(ApiErrorDetails {
|
||||
Err(ApiError::Api {
|
||||
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,30 +952,86 @@ 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(mut details) = error else {
|
||||
let ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
..
|
||||
} = error
|
||||
else {
|
||||
return error;
|
||||
};
|
||||
if details.status.as_u16() != 401 {
|
||||
return ApiError::Api(details);
|
||||
if status.as_u16() != 401 {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
}
|
||||
let Some(bearer_token) = auth.bearer_token() else {
|
||||
return ApiError::Api(details);
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
};
|
||||
if !bearer_token.starts_with("sk-ant-") {
|
||||
return ApiError::Api(details);
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
}
|
||||
// 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(details);
|
||||
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,
|
||||
}
|
||||
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 {
|
||||
@@ -1027,7 +1083,6 @@ 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};
|
||||
@@ -1592,7 +1647,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(Box::new(ApiErrorDetails {
|
||||
let error = crate::error::ApiError::Api {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid bearer token".to_string()),
|
||||
@@ -1601,7 +1656,7 @@ mod tests {
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
}));
|
||||
};
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
@@ -1623,8 +1678,8 @@ mod tests {
|
||||
"request id should still flow through the enriched error: {rendered}"
|
||||
);
|
||||
match enriched {
|
||||
crate::error::ApiError::Api(details) => {
|
||||
assert_eq!(details.status, reqwest::StatusCode::UNAUTHORIZED);
|
||||
crate::error::ApiError::Api { status, .. } => {
|
||||
assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
other => panic!("expected Api variant, got {other:?}"),
|
||||
}
|
||||
@@ -1634,7 +1689,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(Box::new(ApiErrorDetails {
|
||||
let error = crate::error::ApiError::Api {
|
||||
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
error_type: Some("api_error".to_string()),
|
||||
message: Some("internal server error".to_string()),
|
||||
@@ -1643,7 +1698,7 @@ mod tests {
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
}));
|
||||
};
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
@@ -1664,7 +1719,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(Box::new(ApiErrorDetails {
|
||||
let error = crate::error::ApiError::Api {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid bearer token".to_string()),
|
||||
@@ -1673,7 +1728,7 @@ mod tests {
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
}));
|
||||
};
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
@@ -1693,7 +1748,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(Box::new(ApiErrorDetails {
|
||||
let error = crate::error::ApiError::Api {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid bearer token".to_string()),
|
||||
@@ -1702,7 +1757,7 @@ mod tests {
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
}));
|
||||
};
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
@@ -1719,7 +1774,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(Box::new(ApiErrorDetails {
|
||||
let error = crate::error::ApiError::Api {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid x-api-key".to_string()),
|
||||
@@ -1728,7 +1783,7 @@ mod tests {
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
}));
|
||||
};
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
|
||||
@@ -619,43 +619,16 @@ 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 {
|
||||
// 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),
|
||||
)
|
||||
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))
|
||||
}
|
||||
|
||||
/// Returns the effective max output tokens for a model, preferring a plugin
|
||||
@@ -668,33 +641,6 @@ 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 {
|
||||
@@ -737,13 +683,15 @@ fn model_token_limit_by_name(model: &str) -> Option<ModelTokenLimit> {
|
||||
max_output_tokens: 8_192,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
"qwen-max" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 8_192,
|
||||
// Qwen models via DashScope / OpenAI-compat
|
||||
"qwen3.6-35b-fast" | "qwen3-235b-a22b" | "qwen-max" | "qwen-plus" | "qwen-turbo"
|
||||
| "qwen-qwq" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 16_384,
|
||||
context_window_tokens: 131_072,
|
||||
}),
|
||||
"qwen-plus" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 8_192,
|
||||
context_window_tokens: 131_072,
|
||||
"glm-5.1-fast" => Some(ModelTokenLimit {
|
||||
max_output_tokens: 16_384,
|
||||
context_window_tokens: 200_000,
|
||||
}),
|
||||
// Groq compound systems are exposed through an OpenAI-compatible API
|
||||
// but cap completions at 8,192 output tokens. Without this metadata
|
||||
@@ -753,6 +701,8 @@ fn model_token_limit_by_name(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,
|
||||
}
|
||||
}
|
||||
@@ -777,7 +727,7 @@ pub fn preflight_message_request(request: &MessageRequest) -> Result<(), ApiErro
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn estimate_message_request_input_tokens(request: &MessageRequest) -> u32 {
|
||||
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, ApiErrorDetails};
|
||||
use crate::error::ApiError;
|
||||
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(Box::new(ApiErrorDetails {
|
||||
return Err(ApiError::Api {
|
||||
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(Box::new(ApiErrorDetails {
|
||||
return Err(ApiError::Api {
|
||||
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(Box::new(ApiErrorDetails {
|
||||
return Err(ApiError::Api {
|
||||
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(Box::new(ApiErrorDetails {
|
||||
return Err(ApiError::Api {
|
||||
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(Box::new(ApiErrorDetails {
|
||||
return Err(ApiError::Api {
|
||||
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(Box::new(ApiErrorDetails {
|
||||
Err(ApiError::Api {
|
||||
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,11 +585,14 @@ async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
|
||||
last_error,
|
||||
} => {
|
||||
assert_eq!(attempts, 2);
|
||||
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);
|
||||
assert!(matches!(
|
||||
*last_error,
|
||||
ApiError::Api {
|
||||
status: reqwest::StatusCode::SERVICE_UNAVAILABLE,
|
||||
retryable: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
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.chunks_exact(4) {
|
||||
v.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
|
||||
for chunk in blob.as_chunks::<4>().0 {
|
||||
v.push(f32::from_le_bytes(*chunk));
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
|
||||
@@ -808,7 +808,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||
name: "team",
|
||||
aliases: &[],
|
||||
summary: "Manage agent teams",
|
||||
argument_hint: Some("[list|create|delete]"),
|
||||
argument_hint: Some("[on|off|status]"),
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
@@ -1188,10 +1188,14 @@ pub enum SlashCommand {
|
||||
History {
|
||||
count: Option<String>,
|
||||
},
|
||||
Unknown(String),
|
||||
Lsp {
|
||||
action: Option<String>,
|
||||
target: Option<String>,
|
||||
},
|
||||
Team {
|
||||
action: Option<String>,
|
||||
},
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -1509,6 +1513,9 @@ pub fn validate_slash_command_input(
|
||||
"history" => SlashCommand::History {
|
||||
count: optional_single_arg(command, &args, "[count]")?,
|
||||
},
|
||||
"team" => SlashCommand::Team {
|
||||
action: optional_single_arg(command, &args, "[list|create|delete]")?,
|
||||
},
|
||||
other => SlashCommand::Unknown(other.to_string()),
|
||||
}))
|
||||
}
|
||||
@@ -5393,6 +5400,7 @@ pub fn handle_slash_command(
|
||||
| SlashCommand::OutputStyle { .. }
|
||||
| SlashCommand::AddDir { .. }
|
||||
| SlashCommand::History { .. }
|
||||
| SlashCommand::Lsp { .. }
|
||||
| SlashCommand::Team { .. }
|
||||
| SlashCommand::Setup
|
||||
| SlashCommand::Unknown(_) => None,
|
||||
@@ -5525,6 +5533,16 @@ mod tests {
|
||||
#[test]
|
||||
fn parses_supported_slash_commands() {
|
||||
assert_eq!(SlashCommand::parse("/help"), Ok(Some(SlashCommand::Help)));
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/team"),
|
||||
Ok(Some(SlashCommand::Team { action: None }))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/team on"),
|
||||
Ok(Some(SlashCommand::Team {
|
||||
action: Some("on".to_string())
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse(" /status "),
|
||||
Ok(Some(SlashCommand::Status))
|
||||
|
||||
@@ -95,21 +95,10 @@ 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: trimmed_session,
|
||||
compacted_session: session.clone(),
|
||||
removed_message_count: 0,
|
||||
};
|
||||
}
|
||||
@@ -176,14 +165,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
||||
k
|
||||
};
|
||||
let removed = &session.messages[compacted_prefix_len..keep_from];
|
||||
// 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 preserved = session.messages[keep_from..].to_vec();
|
||||
let summary =
|
||||
merge_compact_summaries(existing_summary.as_deref(), &summarize_messages(removed));
|
||||
let formatted_summary = format_compact_summary(&summary);
|
||||
@@ -208,78 +190,6 @@ 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
|
||||
@@ -663,7 +573,7 @@ fn extract_summary_timeline(summary: &str) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
|
||||
collect_key_files, compact_session, format_compact_summary,
|
||||
get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig,
|
||||
};
|
||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
@@ -853,185 +763,6 @@ 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};
|
||||
@@ -1095,7 +826,7 @@ mod tests {
|
||||
"message[{}] is a ToolResult but message[{}] has no ToolUse: {:?}",
|
||||
i,
|
||||
i - 1,
|
||||
&messages[i - 1].blocks
|
||||
messages[i - 1].blocks
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,20 @@ 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.
|
||||
@@ -832,6 +846,15 @@ 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 {
|
||||
@@ -914,6 +937,29 @@ 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
|
||||
@@ -974,6 +1020,20 @@ 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;
|
||||
@@ -1756,6 +1816,51 @@ 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());
|
||||
@@ -2627,6 +2732,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::{
|
||||
@@ -3988,4 +4136,151 @@ 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,48 +11,8 @@ 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;
|
||||
|
||||
@@ -120,17 +80,6 @@ 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.
|
||||
@@ -279,28 +228,10 @@ 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());
|
||||
// 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 end_index = limit.map_or(lines.len(), |limit| {
|
||||
start_index.saturating_add(limit).min(lines.len())
|
||||
});
|
||||
let selected = lines[start_index..end_index].join("\n");
|
||||
let truncated = end_index < lines.len();
|
||||
|
||||
Ok(ReadFileOutput {
|
||||
kind: String::from("text"),
|
||||
@@ -310,8 +241,6 @@ 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),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -883,18 +812,11 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
component_contains_glob, derive_glob_walk_root, edit_file, expand_braces, glob_search,
|
||||
grep_search, is_symlink_escape, max_read_chars, normalize_for_comparison, read_file,
|
||||
grep_search, is_symlink_escape, normalize_for_comparison, read_file,
|
||||
read_file_in_workspace, write_file, write_file_in_workspace, GrepSearchInput,
|
||||
DEFAULT_MAX_READ_CHARS, DEFAULT_READ_LINES, MAX_READ_CHARS_ENV_VAR, MAX_WRITE_SIZE,
|
||||
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)
|
||||
@@ -915,132 +837,6 @@ 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,15 +66,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,
|
||||
@@ -82,8 +83,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,
|
||||
|
||||
@@ -42,25 +42,7 @@ 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;
|
||||
/// 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)
|
||||
}
|
||||
const MAX_GIT_DIFF_CHARS: usize = 50_000;
|
||||
|
||||
/// Neutral identity for the model family line in generated prompts.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
@@ -516,15 +498,8 @@ fn read_git_diff(cwd: &Path) -> Option<String> {
|
||||
}
|
||||
|
||||
fn truncate_diff(mut diff: String) -> String {
|
||||
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;
|
||||
if diff.len() > MAX_GIT_DIFF_CHARS {
|
||||
let mut end = MAX_GIT_DIFF_CHARS;
|
||||
while !diff.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
@@ -783,7 +758,6 @@ 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(),
|
||||
]);
|
||||
@@ -808,8 +782,7 @@ 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, DEFAULT_MAX_GIT_DIFF_CHARS, MAX_GIT_DIFF_CHARS_ENV_VAR,
|
||||
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
SystemPromptBuilder, MAX_GIT_DIFF_CHARS, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
|
||||
};
|
||||
use crate::config::ConfigLoader;
|
||||
use std::fs;
|
||||
@@ -1471,69 +1444,33 @@ 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 _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 large = "x".repeat(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 DEFAULT_MAX_GIT_DIFF_CHARS bytes
|
||||
// The body before the marker must be at most 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 <= DEFAULT_MAX_GIT_DIFF_CHARS);
|
||||
assert!(body_len <= MAX_GIT_DIFF_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_diff_respects_utf8_char_boundaries() {
|
||||
// Build a string where DEFAULT_MAX_GIT_DIFF_CHARS falls in the middle of a
|
||||
// Build a string where MAX_GIT_DIFF_CHARS falls in the middle of a
|
||||
// multi-byte character (U+1F600 = 4 bytes in UTF-8).
|
||||
let prefix_len = DEFAULT_MAX_GIT_DIFF_CHARS - 2;
|
||||
let prefix_len = 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. DEFAULT_MAX_GIT_DIFF_CHARS lands at prefix_len+2, inside the emoji.
|
||||
// emoji. MAX_GIT_DIFF_CHARS lands at prefix_len+2, inside the emoji.
|
||||
input.push('\u{1F600}');
|
||||
input.push_str(&"b".repeat(10_000));
|
||||
|
||||
@@ -1545,7 +1482,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() <= DEFAULT_MAX_GIT_DIFF_CHARS);
|
||||
assert!(body.len() <= MAX_GIT_DIFF_CHARS);
|
||||
assert!(body.is_char_boundary(body.len()));
|
||||
}
|
||||
}
|
||||
@@ -718,20 +718,6 @@ 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,8 +190,10 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
|
||||
|
||||
if let Some(last_write) = last_write_idx {
|
||||
for op in ops {
|
||||
if op.index < last_write
|
||||
&& matches!(op.op_type, FileOp::Read | FileOp::Write | FileOp::Edit)
|
||||
if (op.op_type == FileOp::Read
|
||||
|| op.op_type == FileOp::Write
|
||||
|| op.op_type == FileOp::Edit)
|
||||
&& op.index < last_write
|
||||
{
|
||||
obsolete_indices.insert(op.index);
|
||||
}
|
||||
|
||||
@@ -89,19 +89,6 @@ 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())
|
||||
@@ -256,41 +243,6 @@ 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,6 +14,7 @@ api = { path = "../api" }
|
||||
commands = { path = "../commands" }
|
||||
crossterm = "0.28"
|
||||
pulldown-cmark = "0.13"
|
||||
once_cell = "1"
|
||||
rustyline = "15"
|
||||
runtime = { path = "../runtime" }
|
||||
plugins = { path = "../plugins" }
|
||||
@@ -25,8 +26,15 @@ tools = { path = "../tools" }
|
||||
log = "0.4"
|
||||
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
[lints.rust]
|
||||
unsafe_code = "allow"
|
||||
|
||||
[lints.clippy]
|
||||
all = { level = "warn", priority = -1 }
|
||||
pedantic = { level = "allow", priority = -1 }
|
||||
module_name_repetitions = "allow"
|
||||
missing_panics_doc = "allow"
|
||||
missing_errors_doc = "allow"
|
||||
|
||||
[dev-dependencies]
|
||||
mock-anthropic-service = { path = "../mock-anthropic-service" }
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,8 @@ 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,16 +451,11 @@ fn direct_resume_safe_slash_commands_route_to_local_json_actions_831() {
|
||||
.output()
|
||||
.expect("git init should launch");
|
||||
|
||||
// `/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"][..]),
|
||||
for (command, expected_kind, expected_status) in [
|
||||
("/version", "version", "ok"),
|
||||
("/sandbox", "sandbox", "warn"),
|
||||
("/diff", "diff", "ok"),
|
||||
("/status", "status", "ok"),
|
||||
] {
|
||||
let output = run_claw(&root, &["--output-format", "json", command], &[]);
|
||||
assert!(
|
||||
@@ -475,9 +470,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!(
|
||||
expected_statuses.contains(&parsed["status"].as_str().unwrap_or_default()),
|
||||
"{command} status should be one of {expected_statuses:?}: {parsed}"
|
||||
assert_eq!(
|
||||
parsed["status"], expected_status,
|
||||
"{command} status: {parsed}"
|
||||
);
|
||||
assert_ne!(
|
||||
parsed["error_kind"], "interactive_only",
|
||||
@@ -1012,13 +1007,13 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
||||
assert!(
|
||||
!plugins
|
||||
.as_object()
|
||||
.is_some_and(|o| o.contains_key("reload_runtime")),
|
||||
.map_or(false, |o| o.contains_key("reload_runtime")),
|
||||
"plugins list should not include reload_runtime"
|
||||
);
|
||||
assert!(
|
||||
!plugins
|
||||
.as_object()
|
||||
.is_some_and(|o| o.contains_key("target")),
|
||||
.map_or(false, |o| o.contains_key("target")),
|
||||
"plugins list should not include target"
|
||||
);
|
||||
// #703: structured summary replaces prose message
|
||||
@@ -1712,13 +1707,13 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
|
||||
assert!(
|
||||
!plugins
|
||||
.as_object()
|
||||
.is_some_and(|o| o.contains_key("reload_runtime")),
|
||||
.map_or(false, |o| o.contains_key("reload_runtime")),
|
||||
"plugins list should not include reload_runtime"
|
||||
);
|
||||
assert!(
|
||||
!plugins
|
||||
.as_object()
|
||||
.is_some_and(|o| o.contains_key("target")),
|
||||
.map_or(false, |o| o.contains_key("target")),
|
||||
"plugins list should not include target"
|
||||
);
|
||||
assert!(
|
||||
@@ -2951,7 +2946,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().is_some_and(|h| !h.is_empty()),
|
||||
parsed["hint"].as_str().map_or(false, |h| !h.is_empty()),
|
||||
"claw prompt empty arg missing_prompt hint must be non-empty (#823)"
|
||||
);
|
||||
}
|
||||
@@ -2989,9 +2984,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()
|
||||
.is_some_and(|h| h.contains("low") || h.contains("medium") || h.contains("high")),
|
||||
parsed["hint"].as_str().map_or(false, |h| h.contains("low")
|
||||
|| h.contains("medium")
|
||||
|| h.contains("high")),
|
||||
"hint must mention valid values (#756): {parsed}"
|
||||
);
|
||||
|
||||
@@ -3017,7 +3012,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().is_some_and(|h| !h.is_empty()),
|
||||
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()),
|
||||
"missing --model hint must be non-empty (#756): {parsed2}"
|
||||
);
|
||||
}
|
||||
@@ -3261,7 +3256,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().is_some_and(|h| !h.is_empty()),
|
||||
parsed2["hint"].as_str().map_or(false, |h| !h.is_empty()),
|
||||
"missing_prompt hint must be non-empty (#755)"
|
||||
);
|
||||
}
|
||||
@@ -3403,7 +3398,7 @@ fn config_unsupported_section_json_hint_741() {
|
||||
assert!(
|
||||
parsed["supported_sections"]
|
||||
.as_array()
|
||||
.is_some_and(|a| !a.is_empty()),
|
||||
.map_or(false, |a| !a.is_empty()),
|
||||
"config {section} JSON must include supported_sections (#741)"
|
||||
);
|
||||
}
|
||||
@@ -3460,7 +3455,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<_>>()
|
||||
@@ -3552,8 +3547,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()
|
||||
@@ -3587,8 +3582,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()
|
||||
@@ -3727,8 +3722,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()
|
||||
@@ -3767,8 +3762,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()
|
||||
@@ -3821,7 +3816,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
|
||||
@@ -3863,8 +3858,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()
|
||||
@@ -3887,8 +3882,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()
|
||||
@@ -3907,7 +3902,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('{') {
|
||||
@@ -4022,7 +4017,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
|
||||
@@ -4086,7 +4081,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
|
||||
@@ -4143,7 +4138,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
|
||||
@@ -4184,8 +4179,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()
|
||||
@@ -4222,7 +4217,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('{') {
|
||||
@@ -4291,7 +4286,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('{') {
|
||||
@@ -4372,7 +4367,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()
|
||||
@@ -4399,7 +4394,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()
|
||||
@@ -4436,7 +4431,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()
|
||||
@@ -4484,7 +4479,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()
|
||||
@@ -4516,7 +4511,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()
|
||||
@@ -4566,7 +4561,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()
|
||||
@@ -4616,7 +4611,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);
|
||||
|
||||
@@ -4778,7 +4773,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()
|
||||
@@ -4805,7 +4800,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()
|
||||
@@ -4843,7 +4838,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()
|
||||
@@ -4877,7 +4872,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()
|
||||
@@ -5063,7 +5058,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()
|
||||
@@ -5108,7 +5103,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()
|
||||
@@ -5372,7 +5367,7 @@ fn agents_create_scaffolds_toml_and_lists_locally_431() {
|
||||
.iter()
|
||||
.any(|agent| {
|
||||
agent["name"] == "my-agent"
|
||||
&& Path::new(agent["path"].as_str().expect("listed agent path"))
|
||||
&& PathBuf::from(agent["path"].as_str().expect("listed agent path"))
|
||||
== fs::canonicalize(&agent_path).expect("canonical listed agent path")
|
||||
}));
|
||||
}
|
||||
@@ -5497,7 +5492,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()
|
||||
@@ -5563,7 +5558,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`: --"),
|
||||
@@ -5588,7 +5583,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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
+1862
-63
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user