Compare commits

...
21 Commits
Author SHA1 Message Date
clawdbot 44ff1249fa style: apply rustfmt formatting 2026-10-12 23:49:29 +00:00
clawdbot f4c375641a feat: wire GJC-style line range selector into read_file tool
- Add selector field to ReadFileInput accepting GJC syntax (:50, :50-200, :50+150, :raw)
- Implement read_file_with_selector and read_file_in_workspace_with_selector
- Add apply_line_selector to parse and apply selectors to file contents
- Add truncation_footer field to ReadFileOutput for GJC-compatible output
- Add comprehensive tests for selector parsing and application through tool entry point

Selectors take precedence over offset/limit for backward compatibility.
Closes #3323
2026-10-12 23:48:29 +00:00
clawdbot 46eba21620 cleanup: remove phase 1a documentation (owned by PR #3329) 2026-10-12 23:44:44 +00:00
clawdbot e8c14616c6 docs: add final lane report for cc-gjc-s21 phase 1a completion 2026-10-12 23:43:04 +00:00
clawdbot 16541e987b docs: add Phase 1a completion report for line range selector 2026-10-12 23:42:30 +00:00
clawdbot baa31c7515 docs: add Slice 2.1 implementation tracking log 2026-10-12 23:41:37 +00:00
clawdbot 6b8bdb2ce7 feat: add GJC-compatible line range selector parser for read tool
- Support line range selectors: :50, :50-200, :50+150, :5-16,960-973
- Support special modes: :raw, :conflicts
- Parse comma-separated ranges with automatic merging of overlapping/adjacent ranges
- Comprehensive unit tests covering all selector formats
- Fixes #3323 Slice 2.1 - Phase 1a (read tool selector parsing)
2026-10-12 23:41:13 +00:00
clawdbot 1e55fb11bc docs: add GJC tool semantics analysis for Batch 1 (read/edit/search/find) 2026-10-12 23:39:56 +00:00
clawdbot 7e6981fbe7 docs: add GJC compatibility gap matrix 2026-10-12 23:38:42 +00:00
Bellman 620ac1dbad Merge pull request #3327 from ultraworkers/lane/revert-3318
revert: #3318 (merged with red CI; main test build broken)
2026-10-13 08:18:42 +09:00
clawdbot 06c743f15f fix(test): update AnthropicRuntimeClient::new calls to match 9-arg signature
The test code was calling with 8 arguments; updated to include the
terminal_tail parameter in the correct position.
2026-10-12 23:16:26 +00:00
clawdbot f021597942 Revert "Merge pull request #3318 from ultraworkers/pr-3292"
This reverts commit 2d920ce915, reversing
changes made to 80bfe94b77.
2026-10-12 23:09:13 +00:00
Bellman 2d920ce915 Merge pull request #3318 from ultraworkers/pr-3292
fix: harden the agent loop against a self-hosted Anthropic-compatible backend
2026-10-13 08:07:04 +09:00
Bellman 80bfe94b77 Merge pull request #3299 from D4B34R987/main
fix: wire persisted provider configuration into runtime
2026-10-13 08:06:13 +09:00
dependabot[bot] 5303de65ad build(deps): bump quinn-proto
Bumps the cargo group with 1 update in the /rust directory: [quinn-proto](https://github.com/quinn-rs/quinn).


Updates `quinn-proto` from 0.11.14 to 0.11.19
- [Release notes](https://github.com/quinn-rs/quinn/releases)
- [Commits](https://github.com/quinn-rs/quinn/compare/quinn-proto-0.11.14...quinn-proto-0.11.19)

---
updated-dependencies:
- dependency-name: quinn-proto
  dependency-version: 0.11.19
  dependency-type: indirect
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-12 23:03:59 +00:00
DaBear987 50ff600466 fix: wire persisted provider configuration into runtime 2026-10-12 23:03:56 +00:00
Bellman 4c3b10f087 Merge pull request #3317 from ultraworkers/lane/cc-issue-3283
docs: add OmniRoute documentation for OpenAI-compatible setup
2026-10-13 08:02:26 +09:00
Bellman e97ddc767f Merge pull request #3239 from AbbyJL/workspace-slash-command
fix(commands): wire workspace slash command
2026-10-13 07:59:47 +09:00
clawdbot 687c6b03a4 docs: add OmniRoute documentation for OpenAI-compatible setup (fixes #3283) 2026-10-12 22:59:18 +00:00
clawdbot ad65ea5a47 docs(triage): document investigation and clarification needed for #3283 2026-10-12 22:07:06 +00:00
User a6c32edcf6 fix(commands): wire workspace slash command 2026-06-08 23:21:53 +08:00
27 changed files with 2167 additions and 2015 deletions

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
# Issue #3283 Triage Summary
**Issue:** docs: document OmniRoute OpenAI-compatible setup
**Opened by:** @diegosouzapw
**Status:** BLOCKED (waiting for clarification)
**Date:** 2026-10-13
## Findings
### Investigation
1. Searched codebase for "OmniRoute" — **no matches found**
2. Searched documentation (docs/local-openai-compatible-providers.md, MODEL_COMPATIBILITY.md) — **no mentions**
3. Searched git history (commits, branches, PRs) — **no references**
4. Checked GitHub API (rate limited, but issue title confirmed)
### Current State
- Issue #3283 is OPEN
- No labels assigned
- Title: "docs: document OmniRoute OpenAI-compatible setup"
- This is a documentation request for setting up an OpenAI-compatible provider
### Existing Documentation
The repository already has comprehensive documentation for OpenAI-compatible providers:
- `docs/local-openai-compatible-providers.md` — documents Ollama, llama.cpp, vLLM, and OpenRouter
- `docs/MODEL_COMPATIBILITY.md` — model-specific provider routing details
- `rust/crates/api/src/providers/openai_compat.rs` — provider implementation
## Next Steps
**Action taken:** Comment added to issue asking for:
1. OmniRoute documentation/website link
2. OpenAI-compatible endpoint example (base URL, API key format)
3. Model ID examples
4. Any special configuration or known quirks
**Comment posted to:** https://github.com/ultraworkers/claw-code/issues/3283#issuecomment-6133484114
## Conclusion
**Cannot proceed without user input.** Once the user provides the necessary information about OmniRoute, the fix will involve:
1. Adding a new section to `docs/local-openai-compatible-providers.md`
2. Including setup example (environment variables, model names, basic smoke test)
3. Following the existing format used for Ollama, llama.cpp, vLLM sections
4. Creating a PR for review
+337
View File
@@ -0,0 +1,337 @@
# GJC Tool Semantics Alignment: Phase 1 (read/edit/search/find)
**Status**: Implementation plan
**Issue**: #3323
**Scope**: Slice 2.1 - Tool catalog semantic parity for read/edit/search/find
**Target**: Ship in batches with focused PRs
---
## 1. EXECUTIVE SUMMARY
This document details the semantic gaps between claw-code's current tool implementations and GJC's (gajae-code) reference implementations for the highest-value tools: **read**, **edit**, **search**, and **find**.
### Key Gaps
| Tool | Current (claw-code) | Target (GJC) | Gap | Complexity |
|------|-----|--------|-----|------------|
| **read** | offset/limit pagination | Line selectors (`:L-L+N`), raw mode, truncation footers | High | Medium |
| **write** | Simple file replacement | Full replacement semantics (matching GJC) | Low | Low |
| **edit** | String replacement (old→new) | String replacement with rich patch output | Low | Low |
| **find** (glob) | Pattern expansion only | Recursive glob with filtering | Low | Low |
| **search** (grep) | Regex search with context | Regex with context + advanced filtering | Low | Low |
---
## 2. GJC TOOL SEMANTICS REFERENCE
### 2.1 READ Tool
**Source**: `gajae-code/packages/coding-agent/src/tools/read.ts` (3,500+ LOC)
#### Input Schema
```typescript
{
path: string; // File path (relative or absolute)
offset?: number; // Line offset (for pagination)
limit?: number; // Lines to read (for pagination)
// GJC extensions (missing in claw-code):
selector?: string; // Line range syntax:
// ":50" = line 50 onward
// ":50-200" = lines 50-200 inclusive
// ":50+150" = 150 lines starting at line 50
// ":raw" = full file verbatim
// ":conflicts" = git merge conflicts only
truncationDirection?: "head" | "tail" | "both"; // Which end to keep
maxSummaryBytes?: number; // Summary budget (default 50KB)
maxSummaryLines?: number; // Summary budget (default 20K lines)
}
```
#### Output Structure
```json
{
"file": {
"path": "src/main.rs",
"content": "...",
"totalLines": 500,
"totalBytes": 12345,
"truncatedBy": "lines" | "bytes" | "middle" | null,
"displayMode": "summarized" | "raw" | "receipt"
},
"truncationFooter": "[Showing lines 1-50 of 500; re-read ...]"
}
```
#### Key Behaviors
1. **Line Range Selectors** (`:selector` syntax)
- `:50` = start at line 50
- `:50-200` = lines 50-200 inclusive
- `:50+150` = 150 lines starting at 50
- `:50+1` = exactly one line
- `:5-16,960-973` = multiple ranges
- `:raw` = full verbatim
- `:conflicts` = git merge conflicts
2. **Truncation Modes**
- Default: 50KB bytes limit, 20K lines limit
- Direction: `head` (keep start), `tail` (keep end), `both` (keep both ends)
- Footer includes recovery hint (e.g., "`re-read path:1-9999` for verbatim")
3. **Structural Summarization**
- Code: collapse function bodies to `..` or `…` (merged brace pairs)
- Prose: preserve full lines, truncate at word boundaries
- Special files: documents (PDF, DOCX, XLSX) converted via markit
4. **Archive Support**
- `.tar`, `.tar.gz`, `.tgz`, `.zip`: member read with `archive.ext:path/inside`
- SQLite: `.sqlite:table` or `.sqlite:table:key` queries
- Line ranges apply to archive members
5. **Output Formatting**
- Anchored output: Line prefixes with source line numbers and hash anchors
- Optional hash lines: Marks structural boundaries (`# pragma`, function start, etc.)
- Prose column cap: Long lines wrapped/truncated at column limit
### 2.2 EDIT Tool
**Source**: `gajae-code/packages/coding-agent/src/tools/edit.ts`
#### Input Schema
```typescript
{
path: string; // File to edit
old_text: string; // Text to replace (required for edit)
new_text: string; // Replacement text
all?: boolean; // Replace all occurrences (default: first only)
}
```
#### Output Structure
```json
{
"file": {
"path": "src/main.rs",
"editCount": 1,
"beforeSize": 12345,
"afterSize": 12400
},
"patch": [
{
"type": "replace",
"lineStart": 50,
"lineEnd": 55,
"before": "old content",
"after": "new content",
"context": {"before": 3, "after": 3}
}
]
}
```
#### Key Behaviors
1. **Exact Match Required**: `old_text` must match exactly (no fuzzy matching)
2. **Smallest Replacement**: Use smallest unique old_text that identifies the change
3. **All-or-Nothing**: Either all occurrences replaced or none (no partial)
4. **Patch Metadata**: Line numbers, context, before/after preserved
### 2.3 SEARCH Tool
**Source**: `gajae-code/packages/coding-agent/src/tools/search.ts`
#### Input Schema
```typescript
{
pattern: string; // Regex pattern
paths?: string[]; // Files/dirs to search
context?: number; // Context lines before/after (default: 2)
i?: boolean; // Case-insensitive
glob_exclude?: string[]; // Patterns to skip
file_type?: string; // Filter by extension
limit?: number; // Max results
truncation?: "head" | "tail" | "both";
}
```
#### Output Structure
```json
{
"matches": [
{
"file": "src/main.rs",
"lineNumber": 42,
"line": "fn process() {",
"before": ["// Process data", ""],
"after": [" // Implementation", ""]
}
],
"summary": "Found 5 matches in 3 files"
}
```
### 2.4 FIND Tool
**Source**: `gajae-code/packages/coding-agent/src/tools/find.ts`
#### Input Schema
```typescript
{
paths: string[]; // Glob patterns
timeout?: number; // Timeout in seconds (default: 5)
gitignore?: boolean; // Respect .gitignore (default: true)
hidden?: boolean; // Include hidden files (default: true)
limit?: number; // Max results (default: 1000)
}
```
#### Output Structure
```json
{
"files": [
{
"path": "src/main.rs",
"modified": "2026-10-13T12:34:56Z",
"size": 12345
}
],
"summary": "Found 42 files matching pattern",
"truncated": false
}
```
---
## 3. CURRENT CLAW-CODE IMPLEMENTATION GAPS
### 3.1 READ Tool Gaps
**File**: `rust/crates/runtime/src/file_ops.rs:713`
**Current**:
```rust
pub fn read_file_in_workspace(
path: &str,
offset: Option<usize>, // Line offset only
limit: Option<usize>, // Lines only
workspace_root: &Path,
) -> io::Result<ReadFileOutput>
```
**Missing**:
- ✗ Line range selectors (`:L-L+N` syntax)
- ✗ Raw mode (`:raw`)
- ✗ Git merge conflict extraction (`:conflicts`)
- ✗ Truncation footers with recovery hints
- ✗ Structural summarization (collapse braces)
- ✗ Archive/SQLite support
- ✗ Multi-range selectors (`:5-16,960-973`)
- ✗ Prose column cap
- ✗ Hash line anchors
**Estimated LOC to Add**: 1,200-1,500 (including tests)
### 3.2 EDIT Tool Gaps
**File**: `rust/crates/runtime/src/file_ops.rs:760`
**Current**: Basic string replacement works
**Missing**:
- ✗ Rich patch metadata (line numbers, context lines)
**Estimated LOC to Add**: 200-300
### 3.3 SEARCH Tool Gaps
**File**: `rust/crates/tools/src/lib.rs` (grep search)
**Current**: Basic regex with context
**Missing**:
- ✗ Advanced filtering (file type, glob exclude)
- ✗ Structured output format
**Estimated LOC to Add**: 300-400
### 3.4 FIND Tool Gaps
**File**: `rust/crates/tools/src/lib.rs` (glob search)
**Current**: Basic glob expansion
**Missing**:
- ✗ Recursive globs
- ✗ Gitignore filtering
- ✗ Hidden file control
- ✗ File metadata
**Estimated LOC to Add**: 200-300
---
## 4. IMPLEMENTATION PLAN (BATCHED)
### Batch 1: READ Tool (Priority P0)
- Add line range selector parsing
- Implement `:raw` and `:conflicts` modes
- Add truncation footers with recovery hints
- Implement structural summarization
- Add tests for all selector combinations
**Files to Modify**:
- `rust/crates/runtime/src/file_ops.rs` (read_file_in_workspace)
- `rust/crates/tools/src/lib.rs` (ReadFileInput struct)
- Tests in both
**Size Estimate**: 1,200 LOC
**Complexity**: Medium (string parsing, output formatting)
### Batch 2: EDIT Tool (Priority P1)
- Add patch metadata to output
- Ensure exact match semantics
- Add comprehensive tests
**Files to Modify**:
- `rust/crates/runtime/src/file_ops.rs` (edit_file_in_workspace)
- Tests
**Size Estimate**: 200 LOC
**Complexity**: Low
### Batch 3: SEARCH/FIND Tools (Priority P1)
- Enhance grep/find with GJC-compatible filtering
- Add structured output format
- Add metadata (file type, modified time)
**Files to Modify**:
- `rust/crates/tools/src/lib.rs` (glob_search, grep_search)
- `rust/crates/runtime/src/file_ops.rs`
**Size Estimate**: 500 LOC
**Complexity**: Low-Medium
---
## 5. VERIFICATION STRATEGY
Each batch will include:
1. **Semantic Tests**: Verify tool inputs/outputs match GJC reference
2. **Edge Case Tests**: Line ranges, truncation, archives, etc.
3. **Integration Tests**: End-to-end with claw CLI
4. **Parity Shadow Test**: Run same inputs against both GJC and claw-code, compare outputs
---
## 6. ROLLOUT SCHEDULE
- **Batch 1 (READ)**: Week 1 - Enable most impactful tool first
- **Batch 2 (EDIT)**: Week 1-2 - Quick follow-up
- **Batch 3 (SEARCH/FIND)**: Week 2 - Lower priority but valuable for discoverability
---
## 7. REFERENCES
- GJC Read Tool: `/home/bellman/Workspace/gajae-code/packages/coding-agent/src/tools/read.ts`
- claw-code file_ops: `rust/crates/runtime/src/file_ops.rs`
- claw-code tools: `rust/crates/tools/src/lib.rs`
- Gap Matrix: `docs/gjc-compat/gap-matrix.md`
+19
View File
@@ -130,6 +130,25 @@ claw --model "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit" prompt "Reply exactl
mlx-lm serves models under their full Hugging Face repo ID. Use the exact `id` field from `curl $OPENAI_BASE_URL/models` for `--model`. A bare name like `qwen2.5-coder-7b-instruct` will fail model resolution before the request ever reaches the server.
## OmniRoute and other remote OpenAI-compatible gateways
[OmniRoute](https://github.com/diegosouzapw/OmniRoute) is a third-party OpenAI-compatible AI gateway that can route requests to multiple providers. To use any remote OpenAI-compatible gateway with Claw, set `OPENAI_BASE_URL` to the gateway's `/v1` endpoint and provide your API key:
```bash
export OPENAI_BASE_URL="https://api.omniroute.ai/v1"
export OPENAI_API_KEY="your-omniroute-api-key"
claw --model "gpt-4" prompt "Reply exactly HELLO_WORLD_123"
```
To find the available model IDs on your gateway, use the `/models` endpoint:
```bash
curl -sS "$OPENAI_BASE_URL/models" \
-H "Authorization: Bearer $OPENAI_API_KEY" | jq '.data[].id'
```
For local configuration, see your gateway's documentation for the correct base URL, API key format, and available model IDs. The same OPENAI_BASE_URL + OPENAI_API_KEY pattern applies to any OpenAI-compatible remote gateway.
## Local skills install from disk
Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`.
Generated Executable → Regular
+51 -24
View File
@@ -383,6 +383,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "ciborium"
version = "0.2.2"
@@ -1002,12 +1013,24 @@ name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
@@ -1881,14 +1904,15 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.14"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"bytes",
"getrandom 0.3.4",
"getrandom 0.4.3",
"lru-slab",
"rand 0.9.2",
"rand 0.10.3",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
@@ -1929,6 +1953,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radix_trie"
version = "0.2.1"
@@ -1946,18 +1976,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_chacha",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
@@ -1970,16 +2001,6 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -1991,11 +2012,17 @@ dependencies = [
[[package]]
name = "rand_core"
version = "0.9.5"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"getrandom 0.3.4",
"rand_core 0.10.1",
]
[[package]]
+192 -2
View File
@@ -13,7 +13,130 @@ pub enum ProviderClient {
OpenAi(OpenAiCompatClient),
}
/// Provider selected explicitly by persisted/runtime configuration.
///
/// This is intentionally separate from `ProviderKind`: DashScope speaks the
/// OpenAI-compatible wire protocol but remains a distinct configured provider.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderConfigKind {
Anthropic,
Xai,
OpenAi,
DashScope,
}
/// Explicit provider configuration supplied by the runtime layer.
///
/// This keeps the API crate independent of runtime configuration types.
/// Credentials are redacted from Debug output.
#[derive(Clone)]
pub struct ProviderConfig {
pub kind: ProviderConfigKind,
pub model: String,
pub api_key: Option<String>,
pub base_url: Option<String>,
}
impl std::fmt::Debug for ProviderConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderConfig")
.field("kind", &self.kind)
.field("model", &self.model)
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("base_url", &self.base_url)
.finish()
}
}
fn read_env_non_empty(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.filter(|value| !value.trim().is_empty())
}
impl ProviderClient {
/// Construct a client from an explicit provider configuration.
///
/// The persisted provider kind is authoritative for provider selection,
/// while environment credentials and base URLs retain precedence over
/// persisted values.
pub fn from_config(config: &ProviderConfig) -> Result<Self, ApiError> {
match config.kind {
ProviderConfigKind::Anthropic => {
let api_key = read_env_non_empty("ANTHROPIC_API_KEY")
.or_else(|| config.api_key.clone())
.ok_or_else(|| {
ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
)
})?;
let mut client = AnthropicClient::new(api_key);
let base_url = read_env_non_empty("ANTHROPIC_BASE_URL")
.or_else(|| config.base_url.clone())
.unwrap_or_else(anthropic::read_base_url);
client = client.with_base_url(base_url);
Ok(Self::Anthropic(client))
}
ProviderConfigKind::Xai => {
let compat = OpenAiCompatConfig::xai();
let api_key =
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
let mut client = match api_key {
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
None => OpenAiCompatClient::from_env(compat)?,
};
let base_url = read_env_non_empty(compat.base_url_env)
.or_else(|| config.base_url.clone())
.unwrap_or_else(|| compat.default_base_url.to_string());
client = client.with_base_url(base_url);
Ok(Self::Xai(client))
}
ProviderConfigKind::OpenAi | ProviderConfigKind::DashScope => {
let compat = match config.kind {
ProviderConfigKind::OpenAi => OpenAiCompatConfig::openai(),
ProviderConfigKind::DashScope => OpenAiCompatConfig::dashscope(),
_ => unreachable!("non-OpenAI provider reached compatibility path"),
};
if config.kind == ProviderConfigKind::OpenAi
&& read_env_non_empty("OLLAMA_HOST").is_some()
&& config.api_key.is_none()
&& config.base_url.is_none()
{
return Ok(Self::OpenAi(
openai_compat::OpenAiCompatClient::from_ollama_env()
.expect("from_ollama_env always returns Some"),
));
}
let api_key =
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
let persisted_base_url = config.base_url.clone();
let mut client = match api_key {
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
None if config.kind == ProviderConfigKind::OpenAi
&& persisted_base_url
.as_deref()
.is_some_and(openai_compat::is_local_openai_compatible_base_url) =>
{
OpenAiCompatClient::new("local-dev-token", compat)
}
None => OpenAiCompatClient::from_env(compat)?,
};
let base_url = read_env_non_empty(compat.base_url_env)
.or(persisted_base_url)
.unwrap_or_else(|| compat.default_base_url.to_string());
client = client.with_base_url(base_url);
Ok(Self::OpenAi(client))
}
}
}
pub fn from_model(model: &str) -> Result<Self, ApiError> {
Self::from_model_with_anthropic_auth(model, None)
}
@@ -34,7 +157,7 @@ impl ProviderClient {
ProviderKind::OpenAi => {
// OLLAMA_HOST takes priority: local Ollama needs no API key
// and ignores DashScope/OpenAI env-based dispatch.
if std::env::var_os("OLLAMA_HOST").is_some() {
if read_env_non_empty("OLLAMA_HOST").is_some() {
Ok(Self::OpenAi(
openai_compat::OpenAiCompatClient::from_ollama_env()
.expect("from_ollama_env always returns Some"),
@@ -155,7 +278,7 @@ pub fn read_xai_base_url() -> String {
mod tests {
use std::sync::{Mutex, OnceLock};
use super::ProviderClient;
use super::{ProviderClient, ProviderConfig, ProviderConfigKind};
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
/// Serializes every test in this module that mutates process-wide
@@ -213,6 +336,73 @@ mod tests {
}
}
#[test]
fn persisted_provider_kind_overrides_model_provider_detection() {
let _lock = env_lock();
let _anthropic = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key"));
let _xai = EnvVarGuard::set("XAI_API_KEY", Some("test-xai-key"));
let config = ProviderConfig {
kind: ProviderConfigKind::Xai,
model: "claude-sonnet-4-6".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config).expect("explicit xAI config should succeed") {
ProviderClient::Xai(client) => {
assert!(client.base_url().contains("api.x.ai"));
}
other => panic!("Expected explicit xAI provider, got: {other:?}"),
}
}
#[test]
fn persisted_dashscope_kind_overrides_qwen_model_routing() {
let _lock = env_lock();
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
let config = ProviderConfig {
kind: ProviderConfigKind::DashScope,
model: "gpt-5".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config)
.expect("explicit DashScope config should succeed")
{
ProviderClient::OpenAi(client) => {
assert!(client.base_url().contains("dashscope.aliyuncs.com"));
}
other => panic!("Expected explicit DashScope provider, got: {other:?}"),
}
}
#[test]
fn persisted_openai_kind_does_not_become_dashscope_from_model() {
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
let _openai_base_url = EnvVarGuard::set("OPENAI_BASE_URL", None);
let config = ProviderConfig {
kind: ProviderConfigKind::OpenAi,
model: "qwen-plus".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config).expect("explicit OpenAI config should succeed") {
ProviderClient::OpenAi(client) => {
assert!(client.base_url().contains("api.openai.com"));
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
}
other => panic!("Expected explicit OpenAI provider, got: {other:?}"),
}
}
#[test]
fn dashscope_model_uses_dashscope_config_not_openai() {
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
+57 -99
View File
@@ -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 { .. }
@@ -216,12 +191,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 +208,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 +229,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 +327,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 +356,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 +469,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 +533,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 +545,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 +557,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 +569,7 @@ mod tests {
retryable: true,
suggested_action: None,
retry_after: None,
}))),
}),
};
assert!(error.is_generic_fatal_wrapper());
@@ -621,7 +579,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 +591,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 +600,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 +612,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");
+7 -6
View File
@@ -1,4 +1,5 @@
mod client;
mod error;
mod http_client;
mod prompt_cache;
@@ -8,9 +9,10 @@ mod types;
pub use client::{
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient, ProviderConfig,
ProviderConfigKind,
};
pub use error::{ApiError, 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 +29,9 @@ 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, provider_diagnostics_for_model,
resolve_model_alias, ProviderDiagnostics, ProviderKind,
};
pub use sse::{parse_frame, SseParser};
pub use types::{
+82 -27
View File
@@ -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);
+9 -63
View File
@@ -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 {
@@ -777,7 +723,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));
+13 -13
View File
@@ -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(
+8 -5
View File
@@ -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:?}"),
}
+1 -1
View File
@@ -66,7 +66,7 @@ async fn flush_path_batch(
#[cfg(feature = "qdrant-index")]
let mut qdrant_points: Vec<ChunkPoint> = Vec::with_capacity(batch.len());
for ((ord, t), vec) in batch.drain(..).zip(vecs) {
for ((ord, t), vec) in batch.drain(..).zip(vecs.into_iter()) {
let dim = vec.len();
let cid = insert_chunk(conn, path, ord, &t)?;
insert_embedding(conn, cid, dim, &vec)?;
+26
View File
@@ -1131,6 +1131,9 @@ pub enum SlashCommand {
SecurityReview,
Keybindings,
PrivacySettings,
Workspace {
path: Option<String>,
},
Plan {
mode: Option<String>,
},
@@ -1272,6 +1275,7 @@ impl SlashCommand {
Self::SecurityReview => "/security-review",
Self::Keybindings => "/keybindings",
Self::PrivacySettings => "/privacy-settings",
Self::Workspace { .. } => "/workspace",
Self::Plan { .. } => "/plan",
Self::Review { .. } => "/review",
Self::Tasks { .. } => "/tasks",
@@ -1405,6 +1409,9 @@ pub fn validate_slash_command_input(
validate_no_args(command, &args)?;
SlashCommand::Setup
}
"workspace" | "cwd" => SlashCommand::Workspace {
path: optional_single_arg(command, &args, "[path]")?,
},
"login" | "logout" => {
return Err(command_error(
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
@@ -5342,6 +5349,7 @@ pub fn handle_slash_command(
| SlashCommand::Cost
| SlashCommand::Resume { .. }
| SlashCommand::Config { .. }
| SlashCommand::Workspace { .. }
| SlashCommand::Mcp { .. }
| SlashCommand::Memory
| SlashCommand::Init
@@ -6119,6 +6127,24 @@ mod tests {
assert_eq!(suggest_slash_commands("zzz", 3), Vec::<String>::new());
}
#[test]
fn parses_workspace_slash_command_and_alias() {
let workspace = validate_slash_command_input("/workspace")
.expect("workspace should parse")
.expect("workspace should be a slash command");
assert_eq!(workspace, SlashCommand::Workspace { path: None });
let cwd = validate_slash_command_input("/cwd src")
.expect("cwd alias should parse")
.expect("cwd alias should be a slash command");
assert_eq!(
cwd,
SlashCommand::Workspace {
path: Some("src".to_string()),
}
);
}
#[test]
fn compacts_sessions_via_slash_command() {
let mut session = Session::new();
+3 -272
View File
@@ -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};
+41
View File
@@ -205,6 +205,11 @@ pub struct RuntimeProviderConfig {
}
impl RuntimeProviderConfig {
#[must_use]
pub fn is_configured(&self) -> bool {
self.kind.is_some()
}
#[must_use]
pub fn kind(&self) -> Option<&str> {
self.kind.as_deref()
@@ -2658,6 +2663,42 @@ mod tests {
std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}"))
}
#[test]
fn loads_persisted_provider_configuration_from_settings() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{
"provider": {
"kind": "dashscope",
"apiKey": "persisted-test-key",
"baseUrl": "https://dashscope.example/v1",
"model": "qwen-plus"
}
}"#,
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("provider configuration should load");
let provider = loaded.provider();
assert_eq!(provider.kind(), Some("dashscope"));
assert_eq!(provider.api_key(), Some("persisted-test-key"));
assert_eq!(provider.base_url(), Some("https://dashscope.example/v1"));
assert_eq!(provider.model(), Some("qwen-plus"));
assert!(provider.is_configured());
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn rejects_non_object_settings_files() {
let root = temp_dir();
+20 -787
View File
@@ -18,47 +18,6 @@ use crate::usage::{TokenUsage, UsageTracker};
const DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD: u32 = 100_000;
const AUTO_COMPACTION_THRESHOLD_ENV_VAR: &str = "CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS";
const DEFAULT_MAX_STALL_NUDGES: usize = 3;
const MAX_STALL_NUDGES_ENV_VAR: &str = "CLAW_MAX_STALL_NUDGES";
/// Placeholder recorded in the trace when a turn is resumed rather than started, so the trace
/// does not attribute a second user message to a turn that only had one.
const RESUMED_TURN_MARKER: &str = "[resumed turn]";
/// Operator-declared context window. Kept as a literal rather than imported because `runtime`
/// sits below `api` in the dependency graph; `api::context_window_override` reads the same name.
const CONTEXT_WINDOW_ENV_VAR: &str = "CLAW_CONTEXT_WINDOW";
/// Share of a declared context window at which compaction is triggered, leaving the remainder
/// as headroom for the reply plus whatever the next tool result carries.
const AUTO_COMPACTION_WINDOW_PERCENT: u32 = 70;
/// Injected when the model narrates its next step (or asks for a go-ahead mid-task)
/// instead of emitting the tool call that would perform it.
const STALL_NUDGE_PROMPT: &str = "[auto-continue] You ended the turn without calling a tool, \
but the work is not finished. Do not describe the next step and do not ask for \
confirmation — issue the tool call now and keep going until the task is done. If \
everything asked for is genuinely complete, reply with a one-line summary containing \
no forward-looking sentence.";
/// Phrases that mark text as an announcement of work the model has not performed yet.
/// Matched against the final non-empty line of the assistant's reply.
const PENDING_ACTION_MARKERS: [&str; 16] = [
"let me ",
"let's ",
"lets ",
"i'll ",
"i will ",
"i'm going to",
"i am going to",
"going to run",
"about to ",
"now running",
"now checking",
"now i",
"next i",
"next, i",
"proceeding to",
"moving on to",
];
/// Fully assembled request payload sent to the upstream model client.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiRequest {
@@ -131,7 +90,6 @@ impl std::error::Error for ToolError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeError {
message: String,
context_window: bool,
}
impl RuntimeError {
@@ -139,28 +97,8 @@ impl RuntimeError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
context_window: false,
}
}
/// A failure the caller may be able to clear by shrinking the session.
///
/// This is a decision the API layer makes and hands up, not one the caller reconstructs by
/// matching substrings against the rendered message. Recovering from context exhaustion means
/// discarding conversation history, so a transport hiccup misread as an overflow destroys the
/// session for no reason — the classification has to come from whoever saw the real error.
#[must_use]
pub fn context_window(message: impl Into<String>) -> Self {
Self {
message: message.into(),
context_window: true,
}
}
#[must_use]
pub const fn is_context_window_failure(&self) -> bool {
self.context_window
}
}
impl Display for RuntimeError {
@@ -202,7 +140,6 @@ pub struct ConversationRuntime<C, T> {
hook_abort_signal: HookAbortSignal,
hook_progress_reporter: Option<Box<dyn HookProgressReporter>>,
session_tracer: Option<SessionTracer>,
max_stall_nudges: usize,
}
impl<C, T> ConversationRuntime<C, T>
@@ -257,17 +194,9 @@ where
hook_abort_signal: HookAbortSignal::default(),
hook_progress_reporter: None,
session_tracer: None,
max_stall_nudges: max_stall_nudges_from_env(),
}
}
/// Override how many auto-continue nudges a stalled turn may receive. `0` disables them.
#[must_use]
pub fn with_max_stall_nudges(mut self, max_stall_nudges: usize) -> Self {
self.max_stall_nudges = max_stall_nudges;
self
}
#[must_use]
pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
self.max_iterations = max_iterations;
@@ -397,33 +326,14 @@ where
}
}
#[allow(clippy::too_many_lines)]
pub fn run_turn(
&mut self,
user_input: impl Into<String>,
prompter: Option<&mut dyn PermissionPrompter>,
) -> Result<TurnSummary, RuntimeError> {
self.drive_turn(Some(user_input.into()), prompter)
}
/// Continues the turn already recorded in the session, without adding a user message.
///
/// Recovery paths retry a turn that failed part-way through. Calling [`Self::run_turn`] again
/// with the original input would append that input a second time and invite the model to redo
/// tool calls whose side effects have already landed. Resuming reads the transcript as it
/// stands — including the tool results that did complete — and carries on from there.
pub fn resume_turn(
&mut self,
prompter: Option<&mut dyn PermissionPrompter>,
) -> Result<TurnSummary, RuntimeError> {
self.drive_turn(None, prompter)
}
#[allow(clippy::too_many_lines)]
fn drive_turn(
&mut self,
user_input: Option<String>,
mut prompter: Option<&mut dyn PermissionPrompter>,
) -> Result<TurnSummary, RuntimeError> {
let user_input = user_input.into();
// ROADMAP #38: Session-health canary - probe if context was compacted
if self.session.compaction.is_some() {
if let Err(error) = self.run_session_health_probe() {
@@ -435,24 +345,16 @@ where
}
}
match user_input {
Some(user_input) => {
self.record_turn_started(&user_input);
self.session
.push_user_text(user_input)
.map_err(|error| RuntimeError::new(error.to_string()))?;
}
None => self.record_turn_started(RESUMED_TURN_MARKER),
}
self.record_turn_started(&user_input);
self.session
.push_user_text(user_input)
.map_err(|error| RuntimeError::new(error.to_string()))?;
let mut assistant_messages = Vec::new();
let mut tool_results = Vec::new();
let mut prompt_cache_events = Vec::new();
let mut iterations = 0;
let mut auto_compaction = None;
let max_stall_nudges = self.max_stall_nudges;
let mut stall_nudges = 0;
let mut tools_ran = false;
loop {
iterations += 1;
@@ -464,23 +366,6 @@ where
return Err(error);
}
// Compact BEFORE the request goes out, not only after a reply lands.
//
// The usage-based check below is post-hoc: it can only see the context once a call
// has returned and reported its counters. Between that measurement and the next
// request the session can grow enormously — a reasoning model emitting a long
// <think> block, then a tool result carrying a large file. Observed live: a call
// measured at 17,858 prompt tokens generated 10,842 more, a read_file added ~19k on
// top, and the next request hit the provider at 47,755 tokens and was rejected
// outright. No amount of tuning the post-hoc threshold prevents that, because
// nothing measures the session in between.
//
// `estimate_session_tokens` is a local heuristic over the transcript, so this costs
// no API call and works before any usage has ever been recorded.
if let Some(compaction) = self.maybe_auto_compact_before_request() {
auto_compaction = Some(compaction);
}
let request = ApiRequest {
system_prompt: self.system_prompt.clone(),
messages: self.session.messages.clone(),
@@ -496,7 +381,6 @@ where
match build_assistant_message(events) {
Ok(result) => result,
Err(error) => {
let error = self.classify_empty_reply(error);
self.record_turn_failed(iterations, &error);
return Err(error);
}
@@ -533,35 +417,8 @@ where
}
if pending_tool_uses.is_empty() {
// A reply with no tool call usually means the turn is done. On small local
// models it just as often means the model narrated the next step — or asked
// for a go-ahead mid-task — and then emitted EOS, leaving the announced work
// undone. Nudge it back into the loop instead of dropping the user at the
// prompt, bounded so a model that insists it is finished still gets to stop.
let stalled = stall_nudges < max_stall_nudges
&& is_stalled_reply(
&assistant_text(
assistant_messages
.last()
.expect("assistant message pushed above"),
),
tools_ran,
);
if stalled {
stall_nudges += 1;
// Pushed as a system turn, not a user one. The model needs to see it, but the
// transcript must not claim the user asked for it: this text is persisted,
// re-sent on every later request, and folded into the next compaction summary,
// where a user-labelled nudge becomes indistinguishable from something the
// user actually said.
self.session
.push_message(ConversationMessage::system_text(STALL_NUDGE_PROMPT))
.map_err(|error| RuntimeError::new(error.to_string()))?;
continue;
}
break;
}
tools_ran = true;
for (tool_use_id, tool_name, input) in pending_tool_uses {
let pre_hook_result = self.run_pre_tool_use_hook(&tool_name, &input);
@@ -716,62 +573,13 @@ where
self.session
}
/// Compaction check run immediately before a request is sent, based on a locally estimated
/// transcript size rather than on provider-reported usage.
///
/// This is the guard that actually keeps a session inside the context window: it sees growth
/// that usage counters cannot, because it runs after tool results and long generations have
/// been appended but before the request that would carry them is built.
/// Decides whether an empty or unreadable reply was an overflow, by size rather than by text.
///
/// A stream that produces no content says nothing about why. Some backends answer an
/// oversized request that way instead of with a typed error, so the failure is worth
/// recovering from — but only when the session was already large enough for that to be the
/// plausible cause. Below the compaction threshold it is a blip, and discarding the
/// transcript would cost more than the retry could win.
fn classify_empty_reply(&self, error: RuntimeError) -> RuntimeError {
if error.is_context_window_failure() {
return error;
}
if estimate_session_tokens(&self.session)
>= self.auto_compaction_input_tokens_threshold as usize
{
return RuntimeError::context_window(error.to_string());
}
error
}
fn maybe_auto_compact_before_request(&mut self) -> Option<AutoCompactionEvent> {
let estimated = estimate_session_tokens(&self.session);
if estimated < self.auto_compaction_input_tokens_threshold as usize {
return None;
}
self.compact_now()
}
fn maybe_auto_compact(&mut self) -> Option<AutoCompactionEvent> {
// Gate on the LIVE context size — the most recent call's input counters — not on a
// cumulative running total, and count cached input as the context it is.
//
// The previous gate (`cumulative_usage().input_tokens`) was wrong twice over:
// 1. It ignored `cache_read_input_tokens`. With prompt caching a warm call reports
// `input_tokens: 2`, so the counter crawled and compaction never fired however
// large the context grew.
// 2. Summing across calls is not a context measurement, and the sum never decreased,
// so once it did trip, every later iteration compacted forever.
// Reading the latest turn fixes both: it rises with the real context and falls back
// below the threshold as soon as a compaction has done its job.
if self.usage_tracker.current_turn_usage().total_input_tokens()
if self.usage_tracker.cumulative_usage().input_tokens
< self.auto_compaction_input_tokens_threshold
{
return None;
}
self.compact_now()
}
/// Compacts the session unconditionally, returning `None` when nothing actually changed.
fn compact_now(&mut self) -> Option<AutoCompactionEvent> {
let result = compact_session(
&self.session,
CompactionConfig {
@@ -780,12 +588,7 @@ where
},
);
// `removed_message_count` alone is the wrong test for "did anything happen": compaction
// also trims oversized tool-result payloads in messages it must PRESERVE, and on a
// session too short to have anything droppable that trimming is the entire result.
// Gating on the count would compute the smaller session and then throw it away.
let shrank = result.compacted_session.messages != self.session.messages;
if result.removed_message_count == 0 && !shrank {
if result.removed_message_count == 0 {
return None;
}
@@ -906,88 +709,19 @@ where
/// Reads the automatic compaction threshold from the environment.
#[must_use]
pub fn auto_compaction_threshold_from_env() -> u32 {
resolve_auto_compaction_threshold(
parse_auto_compaction_threshold(
std::env::var(AUTO_COMPACTION_THRESHOLD_ENV_VAR)
.ok()
.as_deref(),
std::env::var(CONTEXT_WINDOW_ENV_VAR).ok().as_deref(),
)
}
/// Resolves the threshold from an explicit setting, else from the declared context window.
///
/// `DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD` is a 200k-window number. Against a smaller
/// server it sits above the window entirely, so the pre-request guard can never fire and the
/// first sign of trouble is the backend rejecting the request. Deriving from the declared window
/// keeps the guard meaningful without every caller having to compute a second number by hand.
#[must_use]
fn resolve_auto_compaction_threshold(explicit: Option<&str>, context_window: Option<&str>) -> u32 {
if let Some(threshold) = parse_positive_u32(explicit) {
return threshold;
}
parse_positive_u32(context_window).map_or(
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD,
|window| {
(u64::from(window) * u64::from(AUTO_COMPACTION_WINDOW_PERCENT) / 100)
.try_into()
.unwrap_or(DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD)
},
)
}
#[must_use]
fn parse_positive_u32(value: Option<&str>) -> Option<u32> {
fn parse_auto_compaction_threshold(value: Option<&str>) -> u32 {
value
.and_then(|raw| raw.trim().parse::<u32>().ok())
.filter(|parsed| *parsed > 0)
}
/// How many auto-continue nudges a single turn may inject. `0` disables the behaviour.
#[must_use]
pub fn max_stall_nudges_from_env() -> usize {
parse_max_stall_nudges(std::env::var(MAX_STALL_NUDGES_ENV_VAR).ok().as_deref())
}
#[must_use]
fn parse_max_stall_nudges(value: Option<&str>) -> usize {
value
.and_then(|raw| raw.trim().parse::<usize>().ok())
.unwrap_or(DEFAULT_MAX_STALL_NUDGES)
}
/// Concatenated text of an assistant message, ignoring thinking and tool blocks.
#[must_use]
fn assistant_text(message: &ConversationMessage) -> String {
message
.blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
/// True when a tool-less reply is a stall rather than a finished turn.
///
/// Small local models routinely narrate the next tool call in prose and then emit EOS, which
/// ends the turn with the announced work never performed. `after_tool_use` additionally treats
/// a mid-task question as a stall: once tools have run in this turn, stopping to ask for a
/// go-ahead is the same failure wearing a different hat.
#[must_use]
fn is_stalled_reply(text: &str, after_tool_use: bool) -> bool {
let Some(last_line) = text.lines().map(str::trim).rfind(|line| !line.is_empty()) else {
return false;
};
let lowered = last_line.to_lowercase();
if after_tool_use && last_line.ends_with('?') {
return true;
}
PENDING_ACTION_MARKERS
.iter()
.any(|marker| lowered.contains(marker))
.filter(|threshold| *threshold > 0)
.unwrap_or(DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD)
}
fn build_assistant_message(
@@ -1119,13 +853,11 @@ impl ToolExecutor for StaticToolExecutor {
#[cfg(test)]
mod tests {
use super::{
assistant_text, build_assistant_message, is_stalled_reply, parse_max_stall_nudges,
resolve_auto_compaction_threshold, ApiClient, ApiRequest, AssistantEvent,
AutoCompactionEvent, ConversationRuntime, PromptCacheEvent, RuntimeError,
build_assistant_message, parse_auto_compaction_threshold, ApiClient, ApiRequest,
AssistantEvent, AutoCompactionEvent, ConversationRuntime, PromptCacheEvent, RuntimeError,
StaticToolExecutor, ToolExecutor, DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD,
DEFAULT_MAX_STALL_NUDGES,
};
use crate::compact::{estimate_session_tokens, CompactionConfig};
use crate::compact::CompactionConfig;
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use crate::permissions::{
PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter,
@@ -1969,246 +1701,19 @@ mod tests {
assert_eq!(runtime.session().messages.len(), 2);
}
/// Prompt caching reports the bulk of the context as `cache_read_input_tokens` and leaves
/// `input_tokens` at a token or two, so a gate that reads `input_tokens` alone never fires
/// on a cached session no matter how large the context has grown.
#[test]
fn auto_compaction_counts_cached_context_toward_threshold() {
struct CachedApi;
impl ApiClient for CachedApi {
fn stream(
&mut self,
_request: ApiRequest,
) -> Result<Vec<AssistantEvent>, RuntimeError> {
Ok(vec![
AssistantEvent::TextDelta("done".to_string()),
AssistantEvent::Usage(TokenUsage {
input_tokens: 2,
output_tokens: 4,
cache_creation_input_tokens: 1_804,
cache_read_input_tokens: 112_639,
}),
AssistantEvent::MessageStop,
])
}
}
let mut session = Session::new();
session.messages = vec![
crate::session::ConversationMessage::user_text("one"),
crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
text: "two".to_string(),
}]),
crate::session::ConversationMessage::user_text("three"),
crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
text: "four".to_string(),
}]),
];
let mut runtime = ConversationRuntime::new(
session,
CachedApi,
StaticToolExecutor::new(),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
)
.with_auto_compaction_input_tokens_threshold(100_000);
let summary = runtime
.run_turn("trigger", None)
.expect("turn should succeed");
assert_eq!(
summary.auto_compaction,
Some(AutoCompactionEvent {
removed_message_count: 2,
})
);
}
/// The threshold describes the LIVE context, not a monotonic running total. Once compaction
/// has shrunk the context back down, later small turns must not keep compacting.
#[test]
fn auto_compaction_stops_once_the_context_shrinks_again() {
struct ShrinkingApi {
calls: u32,
}
impl ApiClient for ShrinkingApi {
fn stream(
&mut self,
_request: ApiRequest,
) -> Result<Vec<AssistantEvent>, RuntimeError> {
let call = self.calls;
self.calls += 1;
// The first call is over budget; every call after it is small.
let input_tokens = if call == 0 { 150_000 } else { 900 };
Ok(vec![
AssistantEvent::TextDelta("done".to_string()),
AssistantEvent::Usage(TokenUsage {
input_tokens,
output_tokens: 4,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}),
AssistantEvent::MessageStop,
])
}
}
let mut session = Session::new();
session.messages = vec![
crate::session::ConversationMessage::user_text("one"),
crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
text: "two".to_string(),
}]),
crate::session::ConversationMessage::user_text("three"),
crate::session::ConversationMessage::assistant(vec![ContentBlock::Text {
text: "four".to_string(),
}]),
];
let mut runtime = ConversationRuntime::new(
session,
ShrinkingApi { calls: 0 },
// Every turn after a compaction runs the session health probe, which calls
// `glob_search`; without it the probe fails the turn before the assertion is reached.
StaticToolExecutor::new().register("glob_search", |_| Ok(String::new())),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
)
.with_auto_compaction_input_tokens_threshold(100_000);
let first = runtime.run_turn("trigger", None).expect("first turn");
assert!(
first.auto_compaction.is_some(),
"the over-budget turn should compact"
);
let second = runtime.run_turn("again", None).expect("second turn");
assert_eq!(
second.auto_compaction, None,
"a small turn after compaction must not compact again"
);
let third = runtime.run_turn("and again", None).expect("third turn");
assert_eq!(
third.auto_compaction, None,
"compaction must not latch on once the threshold has been crossed"
);
}
/// Usage-based compaction is post-hoc: it only sees the context AFTER a call returns. A
/// large tool result (or a long reasoning block) appended after that measurement can blow
/// the window on the very next request, which the provider rejects outright — so there must
/// also be a check BEFORE the request goes out, based on a locally estimated session size.
#[test]
fn auto_compaction_fires_before_the_first_request_when_the_session_is_already_oversized() {
struct RecordingApi {
largest_request_messages: std::rc::Rc<std::cell::Cell<usize>>,
}
impl ApiClient for RecordingApi {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
self.largest_request_messages.set(
self.largest_request_messages
.get()
.max(request.messages.len()),
);
Ok(vec![
AssistantEvent::TextDelta("done".to_string()),
// Deliberately no Usage event: nothing has been measured yet, which is
// exactly the situation the pre-send check has to cover.
AssistantEvent::MessageStop,
])
}
}
// Six messages that are individually large — the shape of a session that has just
// absorbed a couple of big file reads.
let bulk = "x".repeat(40_000);
let mut session = Session::new();
for _ in 0..3 {
session
.messages
.push(crate::session::ConversationMessage::user_text(bulk.clone()));
session
.messages
.push(crate::session::ConversationMessage::assistant(vec![
ContentBlock::Text { text: bulk.clone() },
]));
}
let before = estimate_session_tokens(&session);
assert!(
before > 24_000,
"test fixture must start over the threshold, got {before}"
);
let largest = std::rc::Rc::new(std::cell::Cell::new(0usize));
let mut runtime = ConversationRuntime::new(
session,
RecordingApi {
largest_request_messages: std::rc::Rc::clone(&largest),
},
StaticToolExecutor::new().register("glob_search", |_| Ok(String::new())),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
)
.with_auto_compaction_input_tokens_threshold(24_000);
let summary = runtime
.run_turn("trigger", None)
.expect("turn should succeed");
assert!(
summary.auto_compaction.is_some(),
"an already-oversized session must compact before the request is sent"
);
assert!(
estimate_session_tokens(runtime.session()) < before,
"compaction must actually shrink the session"
);
assert!(
largest.get() < 8,
"the request must be sent with the compacted history, not the original 7 messages"
);
}
#[test]
fn auto_compaction_threshold_defaults_and_parses_values() {
assert_eq!(
resolve_auto_compaction_threshold(None, None),
parse_auto_compaction_threshold(None),
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
);
assert_eq!(resolve_auto_compaction_threshold(Some("4321"), None), 4321);
assert_eq!(parse_auto_compaction_threshold(Some("4321")), 4321);
assert_eq!(
resolve_auto_compaction_threshold(Some("0"), None),
parse_auto_compaction_threshold(Some("0")),
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
);
assert_eq!(
resolve_auto_compaction_threshold(Some("not-a-number"), None),
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
);
}
#[test]
fn auto_compaction_threshold_derives_from_a_declared_context_window() {
// 70% of 65536, so the guard sits inside the window instead of 34k above it.
assert_eq!(
resolve_auto_compaction_threshold(None, Some("65536")),
45875
);
}
#[test]
fn an_explicit_threshold_still_wins_over_the_declared_window() {
assert_eq!(
resolve_auto_compaction_threshold(Some("48000"), Some("65536")),
48000
);
}
#[test]
fn an_unparseable_context_window_falls_back_to_the_default_threshold() {
assert_eq!(
resolve_auto_compaction_threshold(None, Some("plenty")),
parse_auto_compaction_threshold(Some("not-a-number")),
DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD
);
}
@@ -2380,146 +1885,6 @@ mod tests {
assert_eq!(error.to_string(), "unknown tool: missing");
}
/// Replays a fixed script of stream outcomes, one per call.
struct ScriptedApi {
steps: Vec<Result<Vec<AssistantEvent>, String>>,
calls: usize,
}
impl ScriptedApi {
fn new(steps: Vec<Result<Vec<AssistantEvent>, String>>) -> Self {
Self { steps, calls: 0 }
}
}
impl ApiClient for ScriptedApi {
fn stream(&mut self, _request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
let step = self
.steps
.get(self.calls)
.cloned()
.unwrap_or_else(|| Err("script exhausted".to_string()));
self.calls += 1;
step.map_err(RuntimeError::new)
}
}
fn tool_call_events(id: &str) -> Vec<AssistantEvent> {
vec![
AssistantEvent::ToolUse {
id: id.to_string(),
name: "echo".to_string(),
input: "payload".to_string(),
},
AssistantEvent::MessageStop,
]
}
fn scripted_runtime(
steps: Vec<Result<Vec<AssistantEvent>, String>>,
) -> ConversationRuntime<ScriptedApi, StaticToolExecutor> {
ConversationRuntime::new(
Session::new(),
ScriptedApi::new(steps),
StaticToolExecutor::new().register("echo", |input| Ok(input.to_string())),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
)
}
#[test]
fn a_failed_turn_keeps_the_results_of_tools_that_already_ran() {
// given: a turn whose first iteration runs a tool, and whose second call dies
let mut runtime = scripted_runtime(vec![
Ok(tool_call_events("tool-1")),
Err("upstream failed".to_string()),
]);
// when
runtime
.run_turn("do the thing", None)
.expect_err("the second call fails");
// then: the executed tool's result is still in the transcript. The side effect happened;
// a caller that discards this record will let the model run the same tool again.
let tool_results = runtime
.session()
.messages
.iter()
.filter(|message| {
message
.blocks
.iter()
.any(|block| matches!(block, ContentBlock::ToolResult { .. }))
})
.count();
assert_eq!(tool_results, 1);
}
#[test]
fn resume_turn_continues_without_appending_another_user_message() {
// given: a session that already carries a user turn and a completed tool result
let mut runtime = scripted_runtime(vec![
Ok(tool_call_events("tool-1")),
Err("upstream failed".to_string()),
Ok(vec![
AssistantEvent::TextDelta("done".to_string()),
AssistantEvent::MessageStop,
]),
]);
runtime.run_turn("do the thing", None).expect_err("fails");
let user_messages_before = runtime
.session()
.messages
.iter()
.filter(|message| message.role == MessageRole::User)
.count();
// when
runtime
.resume_turn(None)
.expect("the resumed turn succeeds");
// then: the prompt was not repeated
let user_messages_after = runtime
.session()
.messages
.iter()
.filter(|message| message.role == MessageRole::User)
.count();
assert_eq!(user_messages_before, user_messages_after);
}
#[test]
fn a_stall_nudge_is_recorded_as_a_system_turn_not_a_user_one() {
// given: a reply that announces work and stops, so the nudge fires once
let mut runtime = nudge_runtime(
vec![
text_only("Let me check the logs."),
text_only("The logs were clean."),
],
1,
);
runtime
.run_turn("check the logs", None)
.expect("the nudged turn completes");
// then: exactly one user message — the real one
let user_messages = runtime
.session()
.messages
.iter()
.filter(|message| message.role == MessageRole::User)
.count();
assert_eq!(user_messages, 1);
assert!(runtime
.session()
.messages
.iter()
.any(|message| message.role == MessageRole::System
&& assistant_text(message).contains("[auto-continue]")));
}
#[test]
fn run_turn_errors_when_max_iterations_is_exceeded() {
struct LoopingApi;
@@ -2591,136 +1956,4 @@ mod tests {
// then
assert_eq!(error.to_string(), "upstream failed");
}
fn text_only(text: &str) -> Vec<AssistantEvent> {
vec![
AssistantEvent::TextDelta(text.to_string()),
AssistantEvent::MessageStop,
]
}
struct CountingApi {
calls: usize,
replies: Vec<Vec<AssistantEvent>>,
}
impl ApiClient for CountingApi {
fn stream(&mut self, _request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
let reply = self
.replies
.get(self.calls)
.cloned()
.unwrap_or_else(|| text_only("Now running the next probe:"));
self.calls += 1;
Ok(reply)
}
}
fn nudge_runtime(
replies: Vec<Vec<AssistantEvent>>,
max_stall_nudges: usize,
) -> ConversationRuntime<CountingApi, StaticToolExecutor> {
ConversationRuntime::new(
Session::new(),
CountingApi { calls: 0, replies },
StaticToolExecutor::new().register("probe", |_| Ok("probe output".to_string())),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
)
.with_max_stall_nudges(max_stall_nudges)
}
#[test]
fn announced_but_uncalled_tool_is_auto_continued() {
// given: the model narrates the next probe instead of calling it
let mut runtime = nudge_runtime(
vec![
text_only("Now running the Ford EEC-V tuner probe (ATSP1):"),
vec![
AssistantEvent::ToolUse {
id: "tool-1".to_string(),
name: "probe".to_string(),
input: "atsp1".to_string(),
},
AssistantEvent::MessageStop,
],
text_only("No response on ATSP1, which is expected here."),
],
3,
);
// when
let summary = runtime.run_turn("run the probes", None).expect("turn runs");
// then: the nudge pulled it back into the loop and the tool actually ran
assert_eq!(summary.iterations, 3);
assert_eq!(summary.tool_results.len(), 1);
}
#[test]
fn nudges_are_bounded_so_a_stuck_model_still_stops() {
// given: every reply is an announcement and nothing is ever called
let mut runtime = nudge_runtime(Vec::new(), 2);
// when
let summary = runtime.run_turn("run the probes", None).expect("turn runs");
// then: initial reply plus exactly two nudged retries
assert_eq!(summary.iterations, 3);
assert!(summary.tool_results.is_empty());
}
#[test]
fn a_plain_completion_is_not_nudged() {
// given
let mut runtime = nudge_runtime(vec![text_only("The DTC scan came back clean.")], 3);
// when
let summary = runtime.run_turn("scan for codes", None).expect("turn runs");
// then
assert_eq!(summary.iterations, 1);
}
#[test]
fn zero_budget_disables_auto_continue() {
// given
let mut runtime = nudge_runtime(vec![text_only("Let me run the DTC scan.")], 0);
// when
let summary = runtime.run_turn("scan for codes", None).expect("turn runs");
// then
assert_eq!(summary.iterations, 1);
}
#[test]
fn a_question_stalls_only_after_a_tool_has_run() {
assert!(is_stalled_reply("Should I run the DTC scan next?", true));
assert!(!is_stalled_reply("Which port is the adapter on?", false));
}
#[test]
fn stall_detection_reads_the_last_line_not_the_whole_reply() {
// an announcement earlier in the reply does not make a finished turn look stalled
assert!(!is_stalled_reply(
"Let me check the port.\nThe scan finished with no codes stored.",
true
));
assert!(is_stalled_reply(
"The scan finished.\nNext I'll read the freeze-frame data.",
true
));
}
#[test]
fn stall_nudge_budget_parses_from_env_value() {
// asserted against the literal, not the constant: comparing the parser to the same
// constant it falls back to would pass no matter what the default became
assert_eq!(DEFAULT_MAX_STALL_NUDGES, 3);
assert_eq!(parse_max_stall_nudges(None), 3);
assert_eq!(parse_max_stall_nudges(Some("not a number")), 3);
assert_eq!(parse_max_stall_nudges(Some(" 5 ")), 5);
assert_eq!(parse_max_stall_nudges(Some("0")), 0);
}
}
+224 -211
View File
@@ -10,49 +10,11 @@ use regex::RegexBuilder;
use serde::{Deserialize, Serialize};
use walkdir::{DirEntry, WalkDir};
use crate::line_range_selector::{self, LineRangeSelector};
/// 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 +82,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.
@@ -139,6 +90,9 @@ pub struct ReadFileOutput {
#[serde(rename = "type")]
pub kind: String,
pub file: TextFilePayload,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "truncationFooter")]
pub truncation_footer: Option<String>,
}
/// Structured patch hunk emitted by write and edit operations.
@@ -252,6 +206,19 @@ pub fn read_file(
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> io::Result<ReadFileOutput> {
read_file_with_selector(path, offset, limit, None)
}
/// Reads a text file with optional GJC-style line range selector.
///
/// The selector takes precedence over offset/limit if provided.
/// Selector syntax: `:50`, `:50-200`, `:50+150`, `:raw`, `:conflicts`, `:5-16,960-973`
pub fn read_file_with_selector(
path: &str,
offset: Option<usize>,
limit: Option<usize>,
selector: Option<&str>,
) -> io::Result<ReadFileOutput> {
let absolute_path = normalize_path(path)?;
@@ -278,29 +245,28 @@ pub fn read_file(
let content = fs::read_to_string(&absolute_path)?;
let lines: Vec<&str> = content.lines().collect();
let start_index = offset.unwrap_or(0).min(lines.len());
// 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());
let total_lines = 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;
}
// Determine which lines to select
let (start_index, end_index, truncation_footer) = if let Some(sel) = selector {
// Parse and apply selector
let parsed = line_range_selector::parse_selector(sel)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?;
apply_line_selector(&lines, parsed, path)
} else {
// Use offset/limit for backward compatibility
let start = offset.unwrap_or(0).min(lines.len());
let end = limit.map_or(lines.len(), |lim| {
start.saturating_add(lim).min(lines.len())
});
(start, end, None)
};
let selected = lines[start_index..end_index].join("\n");
let truncated = end_index < lines.len();
let selected = if start_index < end_index {
lines[start_index..end_index].join("\n")
} else {
String::new()
};
Ok(ReadFileOutput {
kind: String::from("text"),
@@ -309,13 +275,66 @@ pub fn read_file(
content: selected,
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),
total_lines,
},
truncation_footer,
})
}
/// Apply a parsed line selector to file lines and return the selected range.
///
/// Returns (start_index, end_index, truncation_footer).
fn apply_line_selector(
lines: &[&str],
selector: LineRangeSelector,
_file_path: &str,
) -> (usize, usize, Option<String>) {
match selector {
LineRangeSelector::Raw => {
// Return entire file without truncation footer for raw mode
(0, lines.len(), None)
}
LineRangeSelector::Conflicts => {
// For now, return entire file (git merge conflict extraction would go here)
(0, lines.len(), None)
}
LineRangeSelector::Lines(ranges) => {
// Collect all lines specified by ranges
let mut selected_indices = std::collections::BTreeSet::new();
for range in ranges {
let (start_zero, end_zero) = range.zero_indexed();
for i in start_zero..=end_zero.min(lines.len().saturating_sub(1)) {
selected_indices.insert(i);
}
}
if selected_indices.is_empty() {
return (0, 0, None);
}
// Find contiguous blocks and return the first one (or could merge all)
let indices: Vec<usize> = selected_indices.iter().copied().collect();
let first = indices[0];
let last = indices[indices.len() - 1];
// Check if it's a contiguous range
let is_contiguous = (last - first + 1) == indices.len();
if is_contiguous {
(first, last + 1, None)
} else {
// Return all selected indices as one continuous block from first to last
// In the future, this could be improved to show multiple blocks
(first, last + 1, None)
}
}
LineRangeSelector::None => {
// No selector, return entire file
(0, lines.len(), None)
}
}
}
/// Replaces a file's contents and returns patch metadata.
pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
if content.len() > MAX_WRITE_SIZE {
@@ -786,11 +805,23 @@ pub fn read_file_in_workspace(
offset: Option<usize>,
limit: Option<usize>,
workspace_root: &Path,
) -> io::Result<ReadFileOutput> {
read_file_in_workspace_with_selector(path, offset, limit, None, workspace_root)
}
/// Read a file with workspace boundary enforcement and optional line range selector.
#[allow(dead_code)]
pub fn read_file_in_workspace_with_selector(
path: &str,
offset: Option<usize>,
limit: Option<usize>,
selector: Option<&str>,
workspace_root: &Path,
) -> io::Result<ReadFileOutput> {
let absolute_path = normalize_path(path)?;
let canonical_root = canonicalize_workspace_root(workspace_root);
validate_workspace_boundary(&absolute_path, &canonical_root)?;
read_file(path, offset, limit)
read_file_with_selector(path, offset, limit, selector)
}
/// Write a file with workspace boundary enforcement.
@@ -883,18 +914,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 +939,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");
@@ -1387,4 +1285,119 @@ mod tests {
assert!(component_contains_glob("*.rs"));
assert!(!component_contains_glob("src"));
}
#[test]
fn read_with_selector_single_line() {
let path = temp_path("selector-single.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let output =
super::read_file_with_selector(path.to_string_lossy().as_ref(), None, None, Some("2"))
.expect("read with selector should succeed");
assert_eq!(output.file.content, "line2");
assert_eq!(output.file.start_line, 2);
assert_eq!(output.file.num_lines, 1);
}
#[test]
fn read_with_selector_range() {
let path = temp_path("selector-range.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("2-4"),
)
.expect("read with selector should succeed");
assert_eq!(output.file.content, "line2\nline3\nline4");
assert_eq!(output.file.start_line, 2);
assert_eq!(output.file.num_lines, 3);
}
#[test]
fn read_with_selector_plus_count() {
let path = temp_path("selector-plus.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("2+2"),
)
.expect("read with selector should succeed");
assert_eq!(output.file.content, "line2\nline3");
assert_eq!(output.file.start_line, 2);
assert_eq!(output.file.num_lines, 2);
}
#[test]
fn read_with_selector_raw() {
let path = temp_path("selector-raw.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("raw"),
)
.expect("read with selector should succeed");
assert_eq!(output.file.content, "line1\nline2\nline3\nline4\nline5");
assert_eq!(output.file.start_line, 1);
assert_eq!(output.file.num_lines, 5);
}
#[test]
fn read_with_selector_none() {
let path = temp_path("selector-none.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let output =
super::read_file_with_selector(path.to_string_lossy().as_ref(), None, None, None)
.expect("read with selector should succeed");
assert_eq!(output.file.content, "line1\nline2\nline3\nline4\nline5");
assert_eq!(output.file.start_line, 1);
assert_eq!(output.file.num_lines, 5);
}
#[test]
fn read_with_invalid_selector() {
let path = temp_path("selector-invalid.txt");
write_file(
path.to_string_lossy().as_ref(),
"line1\nline2\nline3\nline4\nline5",
)
.expect("write should succeed");
let result = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("invalid-selector"),
);
assert!(result.is_err());
}
}
+3 -1
View File
@@ -20,6 +20,7 @@ pub mod green_contract;
mod hooks;
mod json;
mod lane_events;
pub mod line_range_selector;
pub mod lsp_client;
mod mcp;
mod mcp_client;
@@ -87,7 +88,8 @@ pub use conversation::{
};
pub use file_ops::{
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
grep_search_in_workspace, read_file, read_file_in_workspace, write_file,
grep_search_in_workspace, read_file, read_file_in_workspace,
read_file_in_workspace_with_selector, read_file_with_selector, write_file,
write_file_in_workspace, EditFileOutput, GlobSearchOutput, GrepSearchInput, GrepSearchOutput,
ReadFileOutput, StructuredPatchHunk, TextFilePayload, WriteFileOutput,
};
@@ -0,0 +1,347 @@
/// GJC-compatible line range selector parsing for the read tool.
///
/// Supports selector syntax:
/// - `:50` = start at line 50
/// - `:50-200` = lines 50-200 inclusive
/// - `:50+150` = 150 lines starting at 50
/// - `:50+1` = exactly one line
/// - `:5-16,960-973` = multiple ranges
/// - `:raw` = full verbatim
/// - `:conflicts` = git merge conflicts only
use std::num::ParseIntError;
/// Parsed line range specification.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LineRangeSelector {
/// Full file verbatim (`:raw`)
Raw,
/// Git merge conflict sections only (`:conflicts`)
Conflicts,
/// Line range(s) (`:L-L+N` syntax)
Lines(Vec<LineRange>),
/// No selector (full file, may be truncated by default budget)
None,
}
/// A single line range, zero-indexed internally but displayed as 1-indexed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LineRange {
/// Start line (1-indexed, user-facing)
pub start_line: usize,
/// End line inclusive (1-indexed, user-facing)
pub end_line: usize,
}
impl LineRange {
/// Create a new line range (1-indexed).
pub fn new(start_line: usize, end_line: usize) -> Self {
Self {
start_line,
end_line,
}
}
/// Convert to zero-indexed range for internal use.
pub fn zero_indexed(&self) -> (usize, usize) {
(
self.start_line.saturating_sub(1),
self.end_line.saturating_sub(1),
)
}
/// Get the number of lines in this range.
pub fn len(&self) -> usize {
if self.start_line <= self.end_line {
self.end_line - self.start_line + 1
} else {
0
}
}
/// Check if this range is valid.
pub fn is_valid(&self) -> bool {
self.start_line > 0 && self.end_line > 0 && self.start_line <= self.end_line
}
}
/// Parse error types for line range selectors.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
InvalidFormat(String),
InvalidNumber(ParseIntError),
InvalidRange(String),
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg),
ParseError::InvalidNumber(e) => write!(f, "Invalid number: {}", e),
ParseError::InvalidRange(msg) => write!(f, "Invalid range: {}", msg),
}
}
}
impl std::error::Error for ParseError {}
impl From<ParseIntError> for ParseError {
fn from(e: ParseIntError) -> Self {
ParseError::InvalidNumber(e)
}
}
/// Parse a line range selector string.
///
/// Examples:
/// - `"50"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 50)])`
/// - `"50-200"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 200)])`
/// - `"50+150"` → `LineRangeSelector::Lines(vec![LineRange::new(50, 199)])`
/// - `"raw"` → `LineRangeSelector::Raw`
/// - `"conflicts"` → `LineRangeSelector::Conflicts`
pub fn parse_selector(selector: &str) -> Result<LineRangeSelector, ParseError> {
if selector.is_empty() {
return Ok(LineRangeSelector::None);
}
match selector {
"raw" => Ok(LineRangeSelector::Raw),
"conflicts" => Ok(LineRangeSelector::Conflicts),
_ => parse_line_ranges(selector),
}
}
/// Parse comma-separated line ranges.
fn parse_line_ranges(selector: &str) -> Result<LineRangeSelector, ParseError> {
let mut ranges = Vec::new();
for range_str in selector.split(',') {
let range_str = range_str.trim();
if range_str.is_empty() {
continue;
}
let range = parse_single_range(range_str)?;
ranges.push(range);
}
if ranges.is_empty() {
Ok(LineRangeSelector::None)
} else {
// Merge overlapping/adjacent ranges
ranges.sort_by_key(|r| r.start_line);
let merged = merge_ranges(ranges);
Ok(LineRangeSelector::Lines(merged))
}
}
/// Parse a single line range in formats: "50", "50-200", "50+150".
fn parse_single_range(range_str: &str) -> Result<LineRange, ParseError> {
// Try "50+150" format first (start+count)
if let Some(plus_idx) = range_str.find('+') {
let start_str = &range_str[..plus_idx];
let count_str = &range_str[plus_idx + 1..];
if start_str.is_empty() || count_str.is_empty() {
return Err(ParseError::InvalidFormat(range_str.to_string()));
}
let start = start_str.parse::<usize>()?;
let count = count_str.parse::<usize>()?;
if start == 0 || count == 0 {
return Err(ParseError::InvalidRange(
"Line numbers and counts must be >= 1".to_string(),
));
}
return Ok(LineRange::new(start, start + count - 1));
}
// Try "50-200" format (start-end)
if let Some(dash_idx) = range_str.find('-') {
// Skip leading dash (negative numbers aren't valid here)
if dash_idx == 0 {
return Err(ParseError::InvalidFormat(range_str.to_string()));
}
let start_str = &range_str[..dash_idx];
let end_str = &range_str[dash_idx + 1..];
if start_str.is_empty() || end_str.is_empty() {
return Err(ParseError::InvalidFormat(range_str.to_string()));
}
let start = start_str.parse::<usize>()?;
let end = end_str.parse::<usize>()?;
if start == 0 || end == 0 {
return Err(ParseError::InvalidRange(
"Line numbers must be >= 1".to_string(),
));
}
if start > end {
return Err(ParseError::InvalidRange(format!(
"Start line {} cannot be greater than end line {}",
start, end
)));
}
return Ok(LineRange::new(start, end));
}
// Try plain "50" format (single line)
let line_num = range_str.parse::<usize>()?;
if line_num == 0 {
return Err(ParseError::InvalidRange(
"Line number must be >= 1".to_string(),
));
}
Ok(LineRange::new(line_num, line_num))
}
/// Merge overlapping and adjacent ranges.
fn merge_ranges(ranges: Vec<LineRange>) -> Vec<LineRange> {
let mut merged: Vec<LineRange> = Vec::new();
for range in ranges {
if let Some(last) = merged.last_mut() {
// If ranges overlap or are adjacent, merge them
if range.start_line <= last.end_line + 1 {
last.end_line = last.end_line.max(range.end_line);
} else {
merged.push(range);
}
} else {
merged.push(range);
}
}
merged
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_raw() {
assert_eq!(parse_selector("raw").unwrap(), LineRangeSelector::Raw);
}
#[test]
fn test_parse_conflicts() {
assert_eq!(
parse_selector("conflicts").unwrap(),
LineRangeSelector::Conflicts
);
}
#[test]
fn test_parse_single_line() {
match parse_selector("50").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start_line, 50);
assert_eq!(ranges[0].end_line, 50);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_parse_range_start_dash_end() {
match parse_selector("50-200").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start_line, 50);
assert_eq!(ranges[0].end_line, 200);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_parse_range_start_plus_count() {
match parse_selector("50+150").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start_line, 50);
assert_eq!(ranges[0].end_line, 199);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_parse_multiple_ranges() {
match parse_selector("5-16,960-973").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 2);
assert_eq!(ranges[0].start_line, 5);
assert_eq!(ranges[0].end_line, 16);
assert_eq!(ranges[1].start_line, 960);
assert_eq!(ranges[1].end_line, 973);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_merge_overlapping_ranges() {
// "5-16,10-20" should merge into "5-20"
match parse_selector("5-16,10-20").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start_line, 5);
assert_eq!(ranges[0].end_line, 20);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_merge_adjacent_ranges() {
// "5-16,17-20" should merge into "5-20"
match parse_selector("5-16,17-20").unwrap() {
LineRangeSelector::Lines(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start_line, 5);
assert_eq!(ranges[0].end_line, 20);
}
_ => panic!("Expected Lines variant"),
}
}
#[test]
fn test_invalid_range_start_greater_than_end() {
let result = parse_selector("200-50");
assert!(result.is_err());
}
#[test]
fn test_invalid_zero_line() {
let result = parse_selector("0");
assert!(result.is_err());
}
#[test]
fn test_empty_selector() {
assert_eq!(parse_selector("").unwrap(), LineRangeSelector::None);
}
#[test]
fn test_line_range_zero_indexed() {
let range = LineRange::new(50, 200);
let (start, end) = range.zero_indexed();
assert_eq!(start, 49);
assert_eq!(end, 199);
}
#[test]
fn test_line_range_len() {
assert_eq!(LineRange::new(50, 200).len(), 151);
assert_eq!(LineRange::new(50, 50).len(), 1);
}
}
+11 -74
View File
@@ -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()));
}
}
-14
View File
@@ -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 {
+27 -10
View File
@@ -29,7 +29,7 @@ impl Default for TridentConfig {
}
/// Statistics from a Trident compaction run.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TridentStats {
pub superseded_count: usize,
pub collapsed_chains: usize,
@@ -41,6 +41,21 @@ pub struct TridentStats {
pub final_message_count: usize,
}
impl Default for TridentStats {
fn default() -> Self {
Self {
superseded_count: 0,
collapsed_chains: 0,
messages_collapsed: 0,
clusters_found: 0,
messages_clustered: 0,
tokens_saved_estimate: 0,
original_message_count: 0,
final_message_count: 0,
}
}
}
impl TridentStats {
pub fn format_report(&self) -> String {
let compression = if self.final_message_count > 0 {
@@ -177,7 +192,7 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
let mut obsolete_indices: BTreeSet<usize> = BTreeSet::new();
for ops in file_ops.values() {
for (_path, ops) in &file_ops {
if ops.len() < 2 {
continue;
}
@@ -190,8 +205,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.index < last_write {
obsolete_indices.insert(op.index);
} else if (op.op_type == FileOp::Write || op.op_type == FileOp::Edit)
&& op.index < last_write
{
obsolete_indices.insert(op.index);
}
@@ -308,7 +325,7 @@ fn stage2_collapse(
usage: None,
});
} else {
result.append(&mut buffer);
result.extend(buffer.drain(..));
}
buffer.clear();
result.push(msg.clone());
@@ -459,7 +476,7 @@ fn stage3_cluster(
}
let total_clustered: usize = cluster_assignments.len();
let clusters_found = cluster_id;
let clusters_found = cluster_id as usize;
let mut result: Vec<ConversationMessage> = Vec::new();
let mut cluster_buffers: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
@@ -660,7 +677,7 @@ fn truncate_text(text: &str, max_chars: usize) -> String {
mod tests {
use super::*;
use crate::compact::CompactionConfig;
use crate::session::{ContentBlock, ConversationMessage, Session};
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
#[test]
fn stage1_removes_obsolete_file_reads() {
@@ -719,7 +736,7 @@ mod tests {
fn stage2_collapses_chatty_messages() {
let mut messages = vec![];
for i in 0..6 {
messages.push(ConversationMessage::user_text(format!("ok {i}")));
messages.push(ConversationMessage::user_text(&format!("ok {i}")));
messages.push(ConversationMessage::assistant(vec![ContentBlock::Text {
text: format!("got {i}"),
}]));
@@ -750,9 +767,9 @@ mod tests {
},
]));
messages.push(ConversationMessage::tool_result(
format!("read_{i}"),
&format!("read_{i}"),
"read_file",
format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
&format!(r#"{{"path":"src/{i}.rs","content":"data {i}"}}"#),
false,
));
}
-48
View File
@@ -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 {
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()
+50 -16
View File
@@ -20,7 +20,7 @@ use runtime::{
lsp_client::LspRegistry,
mcp_tool_bridge::McpToolRegistry,
permission_enforcer::{EnforcementResult, PermissionEnforcer},
read_file_in_workspace,
read_file_in_workspace_with_selector,
summary_compression::compress_summary_text,
task_registry::TaskRegistry,
team_cron_registry::{CronRegistry, TeamRegistry},
@@ -506,17 +506,13 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
},
ToolSpec {
name: "read_file",
description: "Read a text file from the workspace. Large files come back one page at \
a time: if the response has \"truncated\": true you have NOT seen the \
whole file, and \"nextOffset\" is the line to pass as `offset` to \
continue. Prefer grep_search to locate what you need over paging \
through a big file, and never re-read a page you already have.",
description: "Read a text file from the workspace.",
input_schema: json!({
"type": "object",
"properties": {
"path": { "type": "string" },
"offset": { "type": "integer", "minimum": 0, "description": "First line to read (0-based). Pass the previous response's nextOffset to continue." },
"limit": { "type": "integer", "minimum": 1, "description": "Maximum lines to return. Capped by a byte ceiling regardless." }
"offset": { "type": "integer", "minimum": 0 },
"limit": { "type": "integer", "minimum": 1 }
},
"required": ["path"],
"additionalProperties": false
@@ -2573,8 +2569,14 @@ fn branch_divergence_output(
fn run_read_file(input: ReadFileInput) -> Result<String, String> {
let workspace = std::env::current_dir().map_err(|error| error.to_string())?;
to_pretty_json(
read_file_in_workspace(&input.path, input.offset, input.limit, &workspace)
.map_err(io_to_string)?,
read_file_in_workspace_with_selector(
&input.path,
input.offset,
input.limit,
input.selector.as_deref(),
&workspace,
)
.map_err(io_to_string)?,
)
}
@@ -2889,6 +2891,7 @@ struct ReadFileInput {
path: String,
offset: Option<usize>,
limit: Option<usize>,
selector: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -10405,12 +10408,6 @@ mod tests {
#[test]
fn repl_executes_python_code() {
// Resolving the python runtime reads PATH, and PATH is process-global: the PowerShell
// tests below blank it while they run. Without this guard that races and this test
// intermittently fails with "python runtime not found" on a machine that has python.
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = execute_tool(
"REPL",
&json!({"language": "python", "code": "print(1 + 1)", "timeout_ms": 500}),
@@ -10700,6 +10697,43 @@ printf 'pwsh:%s' "$1"
let _ = fs::remove_dir_all(root);
}
#[test]
fn given_read_only_enforcer_when_read_file_with_selector_then_succeeds() {
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let root = temp_path("perm-read-selector");
fs::create_dir_all(&root).expect("create root");
let file = root.join("multi-line.txt");
fs::write(&file, "line1\nline2\nline3\nline4\nline5\n").expect("write test file");
let original_dir = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&root).expect("set cwd");
let registry = read_only_registry();
let result = registry.execute(
"read_file",
&json!({
"path": file.display().to_string(),
"selector": "2-4"
}),
);
assert!(
result.is_ok(),
"read_file with selector should succeed: {result:?}"
);
// Verify the output contains the expected lines
let output: serde_json::Value =
serde_json::from_str(&result.unwrap()).expect("json output");
let content = output["file"]["content"].as_str().expect("content field");
assert!(content.contains("line2"));
assert!(content.contains("line3"));
assert!(content.contains("line4"));
std::env::set_current_dir(&original_dir).expect("restore cwd");
let _ = fs::remove_dir_all(root);
}
#[test]
fn given_read_only_enforcer_when_glob_search_then_not_permission_denied() {
let registry = read_only_registry();