Compare commits

...
Author SHA1 Message Date
clawdbot dfa1e4f71e fix: resolve merge conflicts and test compatibility
- Resolve .gitignore conflict: include both nix entries and .claude/
- Update AnthropicRuntimeClient::new() test calls to match new signature
- Fix explicit_model_provider_hint test to unset OPENAI_BASE_URL env var
2026-10-13 00:12:36 +00:00
clawdbot 8a19ecd7cc fix: use PermissionMode.covers() instead of comparison operators
The PermissionMode enum intentionally does not implement PartialOrd
to prevent silent re-ranking of the privilege ladder. Use the explicit
covers() method for privilege comparisons instead of < and >=.
2026-10-13 00:12:35 +00:00
clawdbot 8938f53790 fix(clippy): resolve new lint violations in trident, compact, and claw-rag-service
- runtime/trident.rs: derive Default, fix for_kv_map, if_same_then_else, extend_with_drain, unnecessary_cast, needless_borrows
- runtime/compact.rs: fix useless_borrows_in_formatting
- claw-rag-service/ingest.rs: remove unnecessary into_iter()
- claw-rag-service/db.rs: use as_chunks instead of chunks_exact, remove unused import

All tests pass (1014 Rust + 47 Python tests verified).
2026-10-13 00:12:35 +00:00
TheArchitectit 5433dac4a2 fix(cli): order parallel tool results by call index and cap fan-out
execute_batch already keyed results to original call slots; this makes
that airtight: panicking worker threads resolve to their own index via
a fallback tuple instead of colliding with slot 0, and results are
asserted to follow call order with a regression test that mixes a
workspace-escape read among valid reads at cap=2.

Adds the fan-out bound: parallel-safe calls run in waves of at most
maxParallelToolCalls (default 8, CLAWD_PARALLEL_TOOL_CALLS env, or the
new setting applied via with_max_parallel_tool_calls at runtime build).
Sequential behavior is preserved: allowed_tools checks still run
before dispatch, and ToolSearch stays off the parallel path.
2026-10-13 00:12:35 +00:00
TheArchitectit f3a23bf39f feat(teams): bound per-team sub-agent concurrency with AgentGate
TeamCreate resolves maxConcurrentAgents from the per-team input, then
the maxConcurrentAgents setting, then unlimited (pre-fix behavior).
The cap is persisted in the manifest and registered on a process-wide
gate; agent threads still spawn immediately but block on an RAII slot
before entering their run loop, so manifest/task-claim semantics are
unchanged. TeamDelete forgets the gate so queued agents drain and no
stale team entries accumulate. Includes a gate regression test.
2026-10-13 00:12:35 +00:00
TheArchitectit 66006cca34 feat(config): parse maxParallelToolCalls and maxConcurrentAgents settings
Adds optional positive-integer parsing for the two new fan-out caps
next to subagentModel, in both camelCase and snake_case forms. Zero
parses as unset; non-numeric values are a named config error. Covered
by three regression tests mirroring the subagentModel test style.
2026-10-13 00:12:35 +00:00
TheArchitectitandClaw 08cce855b5 fix: restore workspace unsafe_code=forbid, fix TaskClaim TOCTOU, remove TUI deps
Review fixes for PR #3250:

1. Restore workspace-level unsafe_code = "forbid" (was downgraded to
   "warn" for TUI support). The per-crate [lints.rust] unsafe_code =
   "allow" on rusty-claude-cli already overrides it where needed.

2. Fix TaskClaim TOCTOU race in claim_task(). Replace exists()-then-write
   with OpenOptions::create_new(true) which atomically fails if the lock
   file already exists, eliminating the race window between the check and
   the write.

3. Remove TUI scaffolding deps (ratatui, tui-textarea, gag, crossbeam-channel,
   libc, unicode-width) and the /tui slash command spec — these belong in
   a future TUI PR and are unused in the current source.

Also updates the provider chain precedence test to reflect the new behavior
where the caller's resolved model is the chain primary.

Co-Authored-By: Claw <noreply@openclaw.ai>
2026-10-13 00:12:35 +00:00
TheArchitectit fe71ead55f test: add subagentModel accessor chain tests
Adds 3 unit tests covering the original silent-drop bug:
- camelCase 'subagentModel' key reads end-to-end
- snake_case 'subagent_model' key reads end-to-end
- blank value returns None (falls back to default model)

Requested by @1716775457damn in PR review.
2026-10-13 00:12:35 +00:00
TheArchitectit 334768249f fix: model_token_limit catch-all returns None for unknown models
Restores the fix from @Offwhite-Del that was lost during rebase.
Unknown models now return None so preflight skips the context-window
check and lets the API enforce its own limits.
2026-10-13 00:12:35 +00:00
TheArchitectit aab02464b2 cargo fmt 2026-10-13 00:12:35 +00:00
TheArchitectitandClaude Fable 5 7aa00a2798 feat(team): team enhancements, subagentModel, parallel tool exec
Port the agent-team enhancement layer onto upstream/main so the model
can spin up coordinated sub-agent teams for parallel work.

subagentModel config wiring (fix):
- Add subagent_model field to RuntimeFeatureConfig + RuntimeConfig::subagent_model()
  accessor so the subagentModel setting (already validated by config_validate.rs)
  is now actually read and stored.
- Agent tool's resolve_agent_model falls back to subagentModel from config
  when no explicit model is passed.

Provider namespace separation:
- New 'custom-openai' provider kind with dedicated env vars
  (CLAWCUSTOMOPENAI_API_KEY / CLAWCUSTOMOPENAI_BASE_URL)
- /setup wizard saves kind: 'custom-openai' for option 5
- Bare model name normalized to 'custom/' prefix to avoid proxy 404s
- Sub-agents inherit /setup-saved provider config via inject_config_as_env_fallbacks

Parallel tool execution:
- Override execute_batch to classify read-only tools as parallel-safe
  and run them concurrently via std::thread::scope
- Results return in original model order

Team coordination layer:
- AgentMessage, TaskClaim, TeamStatus tools + shared mailbox directory
- Mode presets (tiny/1x ... mega/6x) + enriched TeamCreate/Agent descriptions
- Background team watcher
- /team slash command: on/off/status/toggle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-13 00:12:35 +00:00
bellmanandOmX 76fe2e9f46 Document provider compatibility diagnostics and passthrough
Explain the provider compatibility behavior that now has executable coverage so future changes preserve routing, custom gateway slugs, diagnostics, and extra-body passthrough semantics.

Constraint: Task 2 requires docs/status evidence while leader owns Ultragoal checkpoints and .omx/ultragoal remains untouched.
Rejected: Add a new CLI status command | broader than the assigned provider compatibility documentation/status surface.
Confidence: high
Scope-risk: narrow
Directive: Keep docs aligned with provider_diagnostics_for_model, MessageRequest::extra_body, and OpenAI-compatible slash-model tests.
Tested: cargo test --manifest-path rust/Cargo.toml -p api custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params; cargo test --manifest-path rust/Cargo.toml -p api provider_diagnostics_explain_openai_compatible_capabilities; cargo check --manifest-path rust/Cargo.toml -p api
Not-tested: Live provider calls requiring external credentials.
Co-authored-by: OmX <omx@local>
2026-10-13 00:12:35 +00:00
bellmanandOmX 49e796051e Keep G007 mock parity references executable
The follow-up docs gate needed the parity manifest reference string that the mock parity diff checker requires, so this records the verified map-only repair after leader integration.

Constraint: Task 11 scope allowed only PARITY.md, rust/MOCK_PARITY_HARNESS.md, and docs/g007-plugin-mcp-verification-map.md; only PARITY.md needed a change.

Rejected: Editing harness code or .omx/ultragoal | checks proved the remaining gap was docs-map text only and Ultragoal state is leader-owned.

Confidence: high

Scope-risk: narrow

Directive: Keep auto_compact_triggered parity_refs synchronized with PARITY.md when mock parity scenarios change.

Tested: cd rust && python3 scripts/run_mock_parity_diff.py; python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json; rg -n auto_compact_triggered token_cost_reporting auto_compaction threshold from env G007 Plugin/MCP G007-plugin-mcp 12 scripted scenarios 21 captured PARITY.md rust/MOCK_PARITY_HARNESS.md docs/g007-plugin-mcp-verification-map.md; git diff --check

Not-tested: Full workspace suite; Task 11 was a docs/parity-map follow-up with focused executable map validation.

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-10-13 00:12:35 +00:00
bellmanandOmX d2f2b23dc4 Document G007 mock parity verification boundaries
Record the W4 verification map and parity-doc alignment so plugin/MCP lifecycle mock coverage can be audited without scraping stale harness prose.

Constraint: Task 9 owns mock integration and regression verification/docs mapping, not parser fixes or leader-owned Ultragoal state.

Rejected: Editing .omx/ultragoal or duplicating Worker-4 plugin command fallthrough fixes | leader checkpointing and parser implementation are separate lanes.

Confidence: high

Scope-risk: narrow

Directive: Keep rust/mock_parity_scenarios.json, rust/MOCK_PARITY_HARNESS.md, PARITY.md, and mock_parity_harness.rs aligned when adding scenarios.

Tested: cd rust && python3 scripts/run_mock_parity_diff.py --no-run; cd rust && ./scripts/run_mock_parity_harness.sh; cd rust && python3 scripts/run_mock_parity_diff.py; cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli build_runtime_plugin_state -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli build_runtime_runs_plugin_lifecycle_init_and_shutdown -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p plugins plugin_registry_runs_initialize_and_shutdown_for_enabled_plugins -- --nocapture; cargo fmt --manifest-path rust/Cargo.toml --all -- --check; cargo check --manifest-path rust/Cargo.toml -p rusty-claude-cli -p mock-anthropic-service; git diff --check

Not-tested: Full workspace test suite; W4 scope is docs/mock verification map and focused plugin/MCP lifecycle regression evidence.

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-10-13 00:12:35 +00:00
Bellman 18c0bead90 Merge pull request #3336 from ultraworkers/lane/gjc-s32-r4
revert: duplicate /status /cost /version handlers (#3333)
2026-10-13 09:09:51 +09:00
Bellman 6f1819a8ee Merge pull request #3328 from ultraworkers/fix/issue-3259-comprehensive
fix(cli): comprehensive fix for issue #3259 - markdown streaming block spacing
2026-10-13 09:06:25 +09:00
Bellman 122cf3ab0c Merge pull request #3337 from ultraworkers/release/v0.1.4
docs: add CHANGELOG for v0.1.4 release
2026-10-13 09:05:47 +09:00
clawdbot eb1537ca92 docs: add CHANGELOG for v0.1.4 release 2026-10-13 00:03:23 +00:00
clawdbot c13e71851b Revert "Merge pull request #3333 from ultraworkers/implement-slash-commands"
This reverts commit cf18dac3ee, reversing
changes made to a2eb74c00e.
2026-10-13 00:02:35 +00:00
Bellman 0508c2aa05 Merge pull request #3334 from ultraworkers/lane/gjc-s21-conflicts
Implement :conflicts selector for read_file tool (#3323)
2026-10-13 09:02:11 +09:00
Bellman cf18dac3ee Merge pull request #3333 from ultraworkers/implement-slash-commands
feat: implement /status, /cost, and /version slash commands
2026-10-13 08:59:43 +09:00
clawdbot 2b64546b30 Apply rustfmt formatting 2026-10-12 23:59:04 +00:00
clawdbot 30f607e03a Implement :conflicts selector for read_file tool
- Add real git merge conflict extraction matching GJC behavior
- Extract unresolved conflict blocks (<<<<<<, =======, >>>>>>>)
- Support 3-way conflicts with diff3 markers (|||||||)
- Format conflicts as indexed summary with line ranges and labels
- Return clear 'No unresolved git merge conflicts' when clean
- Add comprehensive behavior tests for 2-way, 3-way, and no-conflicts cases
- Update documentation to mark :conflicts as implemented
- Closes #3323

Tests:
- read_with_selector_conflicts_no_conflicts: verifies clean file message
- read_with_selector_conflicts_two_way: verifies multiple conflict detection
- read_with_selector_conflicts_three_way: verifies diff3 marker support
2026-10-12 23:57:50 +00:00
clawdbot 4bd5a6381e feat: implement /status, /cost, and /version slash commands
- Add real implementations for /status, /cost, and /version slash commands
- /status shows session metadata including message count and token usage
- /cost displays cumulative token usage including cache metrics
- /version shows the claw-code version
- All three commands are fully backed by existing Session infrastructure
- Add comprehensive tests for all three commands
- Remove expectations that these commands return None

These commands fulfill the requirement to implement 2-3 missing GJC slash
commands that the existing claw-code runtime can fully support.
2026-10-12 23:57:07 +00:00
Bellman a2eb74c00e Merge pull request #3332 from ultraworkers/lane/gjc-s21
feat(tools): GJC-compatible read selectors (#3323)
2026-10-13 08:52:18 +09:00
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
Bellman 20578124c4 Merge pull request #3330 from ultraworkers/lane/gjc-s11
feat(sdk): implement GJC SDK wire transport with WebSocket support
2026-10-13 08:46:25 +09:00
clawdbot 46eba21620 cleanup: remove phase 1a documentation (owned by PR #3329) 2026-10-12 23:44:44 +00:00
clawdbot 3b1f7220c5 feat(sdk): implement GJC SDK wire transport with WebSocket support
- Add error types and error code enumerations
- Implement frame types for SDK wire protocol (FrameType enum)
- Create Frame struct with JSON serialization/deserialization
- Implement SdkTransport with async WebSocket connection handling
- Support request/response correlation via request IDs
- Handle frame parsing and routing with background task
- Add comprehensive unit tests for frame creation and correlation
- Implement proper async handling with tokio and futures

Implements protocol version 3 with:
- Token-based authentication via query parameter
- Frame size limits (4 MiB max payload)
- Request timeout handling
- Proper error propagation and correlation
2026-10-12 23:43:10 +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 0d052f831b Merge pull request #3313 from ultraworkers/lane/cc-issue-3247
build: add Nix flake dev shell and claw package
2026-10-13 08:37:46 +09:00
clawdbot 3859627013 build: add Nix flake dev shell and claw package (#3247) 2026-10-12 23:35:06 +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
48 changed files with 6753 additions and 3108 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
use flake
+4
View File
@@ -25,6 +25,8 @@ on:
- tests/test_roadmap_helpers.py
- docs/**
- rust/**
- flake.nix
- .envrc
pull_request:
branches:
- main
@@ -47,6 +49,8 @@ on:
- tests/test_roadmap_helpers.py
- docs/**
- rust/**
- flake.nix
- .envrc
workflow_dispatch:
concurrency:
+5
View File
@@ -15,3 +15,8 @@ status-help.txt
.port_sessions/
# Local agent session state
.gjc/
.claude/
# Nix build outputs and direnv
.envrc.local
result
result-*
@@ -0,0 +1 @@
{"schema_version":1,"session_id":"24aa7e35-e6af-4d2d-ab41-a38b9115a031","state":"running","ready_for_input":false,"updated_at":"2026-10-12T22:10:00.508Z","current_turn_id":null,"last_turn_id":null,"live":true,"reason":null,"source":"agent_session_event","event":"turn_start","cwd":"/home/bellman/Data/Workspace/gc-task-cc-pr-3250-lane","workdir":"/home/bellman/Data/Workspace/gc-task-cc-pr-3250-lane","branch":null,"session_file":"/home/bellman/.gajaeway/broker/agent/sessions/v2-famocqbiduwkxmc2g2nvg6jfq2ay6qtc5aihbhbdoqopsmduj4sa/2026-10-12T22-02-16-124Z_24aa7e35-e6af-4d2d-ab41-a38b9115a031.jsonl","activity":{"seq":149,"last_activity_at":"2026-10-12T22:10:01.196Z","tool":"bash","phase":"started","outcome":null,"elapsed_ms":null,"active_tool_count":1,"active_tools":[{"tool":"bash","started_at":"2026-10-12T22:10:01.196Z"}],"in_flight":[{"digest":"fa5f186357264e6b1be2a7a4a7162d0fff04f721630413a25d05b40bcd933836","tool":"bash","started_at":"2026-10-12T22:10:01.196Z"}]}}
@@ -0,0 +1 @@
{"pid":1,"start_time":"unknown","token":"8389ee99-c41c-47dd-9615-e76a5268967c","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
@@ -0,0 +1 @@
{"pid":1,"start_time":"unknown","token":"6e35ded8-fda1-421e-a727-42906ef57646","owner_host_id":"7af2fb0e8e37bd543e3d0bda6f205b56f30077b5a9b77e83208e94ae441315ac","released":true}
Whitespace-only changes.
@@ -0,0 +1,12 @@
{
"version": 1,
"sessionId": "24aa7e35-e6af-4d2d-ab41-a38b9115a031",
"pid": 177576,
"host": "127.0.0.1",
"port": 34539,
"url": "ws://127.0.0.1:34539",
"token": "8FBuSDC3564yKKk5Fpgl6TEknzktQX8W",
"startedAt": 1791842537691,
"updatedAt": 1791842537691,
"stale": false
}
@@ -0,0 +1 @@
{"effectMarker":"7b92c148-d616-4eb5-95e8-4e95084bebe9","incarnation":"linux:159085489","pid":177576}
@@ -0,0 +1 @@
{"effectMarker":"7b92c148-d616-4eb5-95e8-4e95084bebe9","incarnation":"linux:159085489","pid":177576}
+38
View File
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.4] - 2026-10-13
### Added
- GJC-compatible line range selectors for read_file tool (`:50-200`, `:50+150` syntax)
- GJC-compatible `:conflicts` selector for git merge conflict detection
- SDK wire transport with WebSocket support
- Nix flake development environment
- `/status`, `/cost`, and `/version` slash commands
- Persistent provider configuration loading
- Pagination support for large read_file results
- Comprehensive GJC compatibility documentation and analysis
### Fixed
- Windows path containment checks for security boundary enforcement
- Git bash detection on Windows with proper probe caching
- Unresolvable path denial to prevent escape attempts
- Auto-compaction context window measurement
- Tool result preservation on failed turns
- Model stall recovery without tool calls
- Streaming markdown formatting with preserved block separators
- Local model validation bypass generalization beyond Ollama
- Release artifact checksums on macOS and Linux
- Duplicate assistant text rendering
- Workspace slash command wire-up
### Changed
- Enhanced path scope validation for multiple Windows path spellings
- Improved error handling and configuration defaults
- Upgraded GitHub Actions to immutable commit SHAs for CI security
### Known Limitations
- ACP / Zed integration is not yet implemented (status stub available via `claw acp`)
- Python porting workspace is for reference only, not used in production
## [0.1.3] - 2026-10-13
This is the first public binary release of Claw Code.
+338
View File
@@ -0,0 +1,338 @@
# 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` mode
- ✓ Implement `:conflicts` mode
- 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`
+203
View File
@@ -0,0 +1,203 @@
# Nix flake development environment
This repository includes a **`flake.nix`** for reproducible development environments on NixOS and systems with Nix installed.
## Prerequisites
- **Nix** (version 2.4 or later with flakes support enabled)
- **direnv** (optional, for automatic environment loading via `.envrc`)
To verify Nix is installed and flakes are enabled:
```bash
nix --version
nix flake --help
```
If flakes are not enabled, add this to your `~/.config/nix/nix.conf`:
```
experimental-features = nix-command flakes
```
## Quick start
### Enter the development environment
**Option 1: Using `nix flake`** (without direnv)
```bash
nix flake update # Update inputs (optional, pins nixpkgs)
nix develop # Enter default dev shell
nix develop .#full # Enter full dev shell with extra tools
exit # Leave the shell
```
**Option 2: Using direnv** (automatic on directory entry)
```bash
direnv allow # Trust the .envrc file (one-time setup)
cd . # Direnv reloads the flake automatically
```
When you leave the repository directory, the environment is automatically unloaded.
## Development shells
The flake provides two development shells:
### `default` (minimal)
```bash
nix develop
```
Includes:
- Rust (stable, latest)
- Rust source code (`rust-src`)
- `cargo-edit` (dependency management helpers)
- `cargo-expand` (macro expansion tool)
- `cargo-watch` (file watcher for incremental builds)
- Build tools: `pkg-config`, `openssl`, `git`, `ca-certificates`
### `full` (with extra tooling)
```bash
nix develop .#full
```
Includes everything from `default` plus:
- `clippy` (linter)
- `rustfmt` (code formatter)
- `mold` (fast linker)
- `lldb` (debugger)
## Building the claw binary
### With the dev environment
```bash
nix develop
cd rust
cargo build -p rusty-claude-cli
```
### Directly with `nix build`
```bash
nix build .#claw # Build the claw binary
nix build # Same as .#claw (default package)
```
The compiled binary is symlinked to `./result/bin/claw`.
## Running common tasks
Once in the dev shell:
```bash
# Format code
cd rust
cargo fmt
# Run clippy linter
cargo clippy --workspace --all-targets -- -D warnings
# Run all tests
cargo test --workspace
# Run tests for a specific crate
cargo test -p rusty-claude-cli
# Build with optimizations
cargo build --release -p rusty-claude-cli
```
## Environment variables
The flake automatically sets:
- `RUSTUP_TOOLCHAIN=stable` — uses stable Rust from nixpkgs
- Standard Rust build flags are inherited from the environment
For development, the minimal dev shell includes a helpful message on entry:
```
Claw Code development environment
Run 'cargo build -p rusty-claude-cli' to build the claw binary
Run 'cargo test --workspace' to run tests
Run 'scripts/fmt.sh' to format code
```
## Troubleshooting
### `command not found: nix`
Nix is not installed. Install it from [nixos.org/download.html](https://nixos.org/download.html).
### `error: experimental Nix feature 'flakes' is disabled`
Enable flakes in your Nix configuration (`~/.config/nix/nix.conf` or `/etc/nix/nix.conf`):
```
experimental-features = nix-command flakes
```
### `error: ... does not provide attribute 'claw'`
The flake structure may have changed. Try updating:
```bash
nix flake update
nix flake check
```
### Rebuilds are slow after updating dependencies
Cargo will recompile dependencies if `Cargo.lock` changes. This is expected:
```bash
nix build --rebuild
```
### `cache lock file needs to be updated`
The Cargo.lock in `rust/` needs to be committed. Run:
```bash
cd rust
cargo generate-lockfile
git add Cargo.lock
git commit -m "update: Cargo.lock"
```
## Integration with other tools
### IDE integration (VS Code, IntelliJ, etc.)
Many IDEs can detect and use Nix environments. Direnv integration is recommended:
1. Install the direnv extension
2. Enable `direnv` shell integration in your IDE
3. Run `direnv allow` in the repository
Your IDE will automatically use the correct Rust toolchain and dependencies from the flake.
### CI/CD
For GitHub Actions, consider using [cachix/install-nix-action](https://github.com/cachix/install-nix-action) to set up Nix on the runner:
```yaml
- uses: cachix/install-nix-action@v18
with:
nix_path: nixpkgs=channel:nixos-unstable
extra_nix_config: "experimental-features = nix-command flakes"
- run: nix develop -c cargo test --workspace
```
## More information
- [NixOS flakes documentation](https://nixos.wiki/wiki/Flakes)
- [Direnv documentation](https://direnv.net/)
- [Rust on Nix](https://github.com/oxalica/rust-overlay)
+115
View File
@@ -0,0 +1,115 @@
{
description = "claw-code: Claude Code CLI agent harness in Rust";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs {
inherit system overlays;
};
# Build inputs needed for Rust and the project
buildInputs = with pkgs; [
pkg-config
openssl
ca-certificates
git
];
# Runtime inputs for the dev environment
nativeBuildInputs = with pkgs; [
(rust-bin.stable.latest.default.override {
extensions = [ "rust-src" ];
targets = [ ];
})
cargo-edit
cargo-expand
cargo-watch
];
# Derivation to build the claw binary
clawBinary = pkgs.rustPlatform.buildRustPackage {
pname = "claw";
version = (builtins.fromTOML (builtins.readFile ./rust/Cargo.toml)).workspace.package.version;
src = ./rust;
# Hash of all the dependencies in Cargo.lock
# This needs to be updated when dependencies change
# Run: nix flake update
cargoLock = {
lockFile = ./rust/Cargo.lock;
};
cargoBuildFlags = [ "-p" "rusty-claude-cli" ];
buildInputs = buildInputs ++ (with pkgs; [
openssl
]);
nativeBuildInputs = with pkgs; [
pkg-config
];
meta = with pkgs.lib; {
description = "Claude Code CLI: agent harness with Rust implementation";
homepage = "https://github.com/ultraworkers/claw-code";
license = licenses.mit;
platforms = platforms.unix;
};
};
in
{
# Provide the claw binary as a package
packages.claw = clawBinary;
packages.default = clawBinary;
# Development shell for contributors
devShells.default = pkgs.mkShell {
name = "claw-dev";
inherit buildInputs nativeBuildInputs;
shellHook = ''
echo "Claw Code development environment"
echo "Run 'cargo build -p rusty-claude-cli' to build the claw binary"
echo "Run 'cargo test --workspace' to run tests"
echo "Run 'scripts/fmt.sh' to format code"
'';
# Make sure we have the right Rust toolchain
RUSTUP_TOOLCHAIN = "stable";
};
# Alternative shell with additional development tools
devShells.full = pkgs.mkShell {
name = "claw-dev-full";
inherit buildInputs nativeBuildInputs;
nativeBuildInputs = nativeBuildInputs ++ (with pkgs; [
clippy
rustfmt
mold
lldb
]);
shellHook = ''
echo "Claw Code full development environment (with extra tools)"
echo "Available tools: clippy, rustfmt, mold (linker), lldb (debugger)"
'';
RUSTUP_TOOLCHAIN = "stable";
};
}
);
}
+1 -1
View File
@@ -41,7 +41,7 @@ TUI rule: formatting fns take `&mut impl Write`, never stdout directly. Never mi
Library crates don't carry the `claw-` prefix. Binary crates do (except legacy `rusty-claude-cli`).
Workspace version is `0.1.3`, `publish = false`, MIT license.
Workspace version is `0.1.4`, `publish = false`, MIT license.
No rust-toolchain file, no MSRV. CI pins `dtolnay/rust-toolchain@stable`.
+755 -659
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.1.3"
version = "0.1.4"
edition = "2021"
license = "MIT"
publish = false
+100 -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 { .. }
@@ -205,6 +180,49 @@ impl ApiError {
}
}
/// HTTP status code for an `Api` error, if any. Used by callers (e.g. the
/// sub-agent provider chain) to decide whether to advance to the next
/// configured model — a 404 "model not found" on the primary should fall
/// through to the next fallback rather than killing the whole chain.
#[must_use]
pub fn status_code(&self) -> Option<reqwest::StatusCode> {
match self {
Self::Api { status, .. } => Some(*status),
Self::RetriesExhausted { last_error, .. } => last_error.status_code(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. }
| Self::RequestBodySizeExceeded { .. } => None,
}
}
/// Response body (best-effort) for an `Api` error, if any.
#[must_use]
pub fn response_body(&self) -> Option<&str> {
match self {
Self::Api { body, .. } => Some(body.as_str()),
Self::RetriesExhausted { last_error, .. } => last_error.response_body(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. }
| Self::RequestBodySizeExceeded { .. } => None,
}
}
#[must_use]
pub fn safe_failure_class(&self) -> &'static str {
match self {
@@ -216,12 +234,12 @@ impl ApiError {
Self::MissingCredentials { .. } | Self::ExpiredOAuthToken | Self::Auth(_) => {
"provider_auth"
}
Self::Api(details) if matches!(details.status.as_u16(), 401 | 403) => "provider_auth",
Self::Api { status, .. } if matches!(status.as_u16(), 401 | 403) => "provider_auth",
Self::ContextWindowExceeded { .. } => "context_window",
Self::Api(_) if self.is_context_window_failure() => "context_window",
Self::Api(details) if details.status.as_u16() == 429 => "provider_rate_limit",
Self::Api(_) if self.is_generic_fatal_wrapper() => "provider_internal",
Self::Api(_) => "provider_error",
Self::Api { .. } if self.is_context_window_failure() => "context_window",
Self::Api { status, .. } if status.as_u16() == 429 => "provider_rate_limit",
Self::Api { .. } if self.is_generic_fatal_wrapper() => "provider_internal",
Self::Api { .. } => "provider_error",
Self::Http(_) | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => {
"provider_transport"
}
@@ -233,12 +251,11 @@ impl ApiError {
#[must_use]
pub fn is_generic_fatal_wrapper(&self) -> bool {
match self {
Self::Api(details) => {
details
.message
Self::Api { message, body, .. } => {
message
.as_deref()
.is_some_and(looks_like_generic_fatal_wrapper)
|| looks_like_generic_fatal_wrapper(&details.body)
|| looks_like_generic_fatal_wrapper(body)
}
Self::RetriesExhausted { last_error, .. } => last_error.is_generic_fatal_wrapper(),
Self::MissingCredentials { .. }
@@ -255,36 +272,21 @@ impl ApiError {
}
}
/// True for failures whose own text cannot say what went wrong: transport, decode, and frame
/// errors.
///
/// These matter because a server that answers an oversized request with a non-SSE HTTP 500
/// body lands here rather than in [`Self::is_context_window_failure`] — reqwest reports
/// "error decoding response body" and the real cause is lost. Callers that know how large the
/// request was can combine the two signals; callers that do not must treat this as unknown,
/// never as an overflow.
#[must_use]
pub fn is_ambiguous_transport_failure(&self) -> bool {
match self {
Self::Http(_) | Self::Json { .. } | Self::InvalidSseFrame(_) => true,
Self::RetriesExhausted { last_error, .. } => {
last_error.is_ambiguous_transport_failure()
}
_ => false,
}
}
#[must_use]
pub fn is_context_window_failure(&self) -> bool {
match self {
Self::ContextWindowExceeded { .. } => true,
Self::Api(details) => {
matches!(details.status.as_u16(), 400 | 413 | 422)
&& (details
.message
Self::Api {
status,
message,
body,
..
} => {
matches!(status.as_u16(), 400 | 413 | 422)
&& (message
.as_deref()
.is_some_and(looks_like_context_window_error)
|| looks_like_context_window_error(&details.body))
|| looks_like_context_window_error(body))
}
Self::RetriesExhausted { last_error, .. } => last_error.is_context_window_failure(),
Self::MissingCredentials { .. }
@@ -368,14 +370,14 @@ impl Display for ApiError {
"failed to parse {provider} response for model {model}: {source}; first 200 chars of body: {body_snippet}"
),
// #28: enhance 401/403 errors with actionable auth guidance
Self::Api(details) if matches!(details.status.as_u16(), 401 | 403) => {
let (status, error_type, message, request_id, body) = (
&details.status,
&details.error_type,
&details.message,
&details.request_id,
&details.body,
);
Self::Api {
status,
error_type,
message,
request_id,
body,
..
} if matches!(status.as_u16(), 401 | 403) => {
if let (Some(error_type), Some(message)) = (error_type, message) {
write!(f, "api returned {status} ({error_type})")?;
if let Some(request_id) = request_id {
@@ -397,14 +399,14 @@ impl Display for ApiError {
Run `claw doctor` to verify your credential configuration."
)
}
Self::Api(details) => {
let (status, error_type, message, request_id, body) = (
&details.status,
&details.error_type,
&details.message,
&details.request_id,
&details.body,
);
Self::Api {
status,
error_type,
message,
request_id,
body,
..
} => {
if let (Some(error_type), Some(message)) = (error_type, message) {
write!(f, "api returned {status} ({error_type})")?;
if let Some(request_id) = request_id {
@@ -510,7 +512,6 @@ fn truncate_body_snippet(body: &str, max_chars: usize) -> String {
#[cfg(test)]
mod tests {
use super::ApiErrorDetails;
use super::{truncate_body_snippet, ApiError};
#[test]
@@ -575,7 +576,7 @@ mod tests {
#[test]
fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() {
let error = ApiError::Api(Box::new(ApiErrorDetails {
let error = ApiError::Api {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
error_type: Some("api_error".to_string()),
message: Some(
@@ -587,7 +588,7 @@ mod tests {
retryable: true,
suggested_action: None,
retry_after: None,
}));
};
assert!(error.is_generic_fatal_wrapper());
assert_eq!(error.safe_failure_class(), "provider_internal");
@@ -599,7 +600,7 @@ mod tests {
fn retries_exhausted_preserves_nested_request_id_and_failure_class() {
let error = ApiError::RetriesExhausted {
attempts: 3,
last_error: Box::new(ApiError::Api(Box::new(ApiErrorDetails {
last_error: Box::new(ApiError::Api {
status: reqwest::StatusCode::BAD_GATEWAY,
error_type: Some("api_error".to_string()),
message: Some(
@@ -611,7 +612,7 @@ mod tests {
retryable: true,
suggested_action: None,
retry_after: None,
}))),
}),
};
assert!(error.is_generic_fatal_wrapper());
@@ -621,7 +622,7 @@ mod tests {
#[test]
fn classifies_provider_context_window_errors() {
let error = ApiError::Api(Box::new(ApiErrorDetails {
let error = ApiError::Api {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_request_error".to_string()),
message: Some(
@@ -633,7 +634,7 @@ mod tests {
retryable: false,
suggested_action: None,
retry_after: None,
}));
};
assert!(error.is_context_window_failure());
assert_eq!(error.safe_failure_class(), "context_window");
@@ -642,7 +643,7 @@ mod tests {
#[test]
fn classifies_openai_configured_limit_errors_as_context_window_failures() {
let error = ApiError::Api(Box::new(ApiErrorDetails {
let error = ApiError::Api {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_request_error".to_string()),
message: Some(
@@ -654,7 +655,7 @@ mod tests {
retryable: false,
suggested_action: None,
retry_after: None,
}));
};
assert!(error.is_context_window_failure());
assert_eq!(error.safe_failure_class(), "context_window");
+5 -5
View File
@@ -12,7 +12,7 @@ pub use client::{
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,
@@ -29,10 +29,10 @@ pub use providers::openai_compat::{
OpenAiCompatClient, OpenAiCompatConfig,
};
pub use providers::{
context_window_override, detect_provider_kind, estimate_message_request_input_tokens,
max_tokens_for_model, max_tokens_for_model_with_override, model_family_identity_for,
model_family_identity_for_kind, model_token_limit, provider_diagnostics_for_model,
resolve_model_alias, ModelTokenLimit, ProviderDiagnostics, ProviderKind,
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
model_family_identity_for, model_family_identity_for_kind, model_token_limit,
provider_diagnostics_for_model, resolve_model_alias, ModelTokenLimit, ProviderDiagnostics,
ProviderKind,
};
pub use sse::{parse_frame, SseParser};
pub use types::{
+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);
+18 -68
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 {
@@ -737,13 +683,15 @@ fn model_token_limit_by_name(model: &str) -> Option<ModelTokenLimit> {
max_output_tokens: 8_192,
context_window_tokens: 131_072,
}),
"qwen-max" => Some(ModelTokenLimit {
max_output_tokens: 8_192,
// Qwen models via DashScope / OpenAI-compat
"qwen3.6-35b-fast" | "qwen3-235b-a22b" | "qwen-max" | "qwen-plus" | "qwen-turbo"
| "qwen-qwq" => Some(ModelTokenLimit {
max_output_tokens: 16_384,
context_window_tokens: 131_072,
}),
"qwen-plus" => Some(ModelTokenLimit {
max_output_tokens: 8_192,
context_window_tokens: 131_072,
"glm-5.1-fast" => Some(ModelTokenLimit {
max_output_tokens: 16_384,
context_window_tokens: 200_000,
}),
// Groq compound systems are exposed through an OpenAI-compatible API
// but cap completions at 8,192 output tokens. Without this metadata
@@ -753,6 +701,8 @@ fn model_token_limit_by_name(model: &str) -> Option<ModelTokenLimit> {
max_output_tokens: 8_192,
context_window_tokens: 131_072,
}),
// Unknown models return None so preflight skips the context-window
// check and lets the API enforce its own limits.
_ => None,
}
}
@@ -777,7 +727,7 @@ pub fn preflight_message_request(request: &MessageRequest) -> Result<(), ApiErro
Ok(())
}
pub fn estimate_message_request_input_tokens(request: &MessageRequest) -> u32 {
fn estimate_message_request_input_tokens(request: &MessageRequest) -> u32 {
let mut estimate = estimate_serialized_tokens(&request.messages);
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.system));
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.tools));
+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(
+9 -6
View File
@@ -82,7 +82,7 @@ async fn send_message_posts_json_and_parses_response() {
);
assert_eq!(
request.headers.get("user-agent").map(String::as_str),
Some("claude-code/0.1.3")
Some("claude-code/0.1.4")
);
assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str),
@@ -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:?}"),
}
+2 -2
View File
@@ -166,8 +166,8 @@ pub fn blob_to_f32_vec(blob: &[u8], dim: usize) -> Option<Vec<f32>> {
return None;
}
let mut v = Vec::with_capacity(dim);
for chunk in blob.chunks_exact(4) {
v.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
for chunk in blob.as_chunks::<4>().0 {
v.push(f32::from_le_bytes(*chunk));
}
Some(v)
}
+20 -2
View File
@@ -808,7 +808,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
name: "team",
aliases: &[],
summary: "Manage agent teams",
argument_hint: Some("[list|create|delete]"),
argument_hint: Some("[on|off|status]"),
resume_supported: true,
},
SlashCommandSpec {
@@ -1191,10 +1191,14 @@ pub enum SlashCommand {
History {
count: Option<String>,
},
Unknown(String),
Lsp {
action: Option<String>,
target: Option<String>,
},
Team {
action: Option<String>,
},
Unknown(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -1516,6 +1520,9 @@ pub fn validate_slash_command_input(
"history" => SlashCommand::History {
count: optional_single_arg(command, &args, "[count]")?,
},
"team" => SlashCommand::Team {
action: optional_single_arg(command, &args, "[list|create|delete]")?,
},
other => SlashCommand::Unknown(other.to_string()),
}))
}
@@ -5401,6 +5408,7 @@ pub fn handle_slash_command(
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. }
| SlashCommand::History { .. }
| SlashCommand::Lsp { .. }
| SlashCommand::Team { .. }
| SlashCommand::Setup
| SlashCommand::Unknown(_) => None,
@@ -5533,6 +5541,16 @@ mod tests {
#[test]
fn parses_supported_slash_commands() {
assert_eq!(SlashCommand::parse("/help"), Ok(Some(SlashCommand::Help)));
assert_eq!(
SlashCommand::parse("/team"),
Ok(Some(SlashCommand::Team { action: None }))
);
assert_eq!(
SlashCommand::parse("/team on"),
Ok(Some(SlashCommand::Team {
action: Some("on".to_string())
}))
);
assert_eq!(
SlashCommand::parse(" /status "),
Ok(Some(SlashCommand::Status))
+4 -273
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};
@@ -1095,7 +826,7 @@ mod tests {
"message[{}] is a ToolResult but message[{}] has no ToolUse: {:?}",
i,
i - 1,
&messages[i - 1].blocks
messages[i - 1].blocks
);
}
}
+295
View File
@@ -164,6 +164,20 @@ pub struct RuntimeFeatureConfig {
rules_import: RulesImportConfig,
provider: RuntimeProviderConfig,
auto_memory_enabled: bool,
/// Model override used when spawning sub-agents via the Agent tool.
/// Read from `subagentModel` (or `subagent_model`) in settings; falls
/// back to the default model when unset.
subagent_model: Option<String>,
/// Cap on concurrently executing parallel-safe tool calls in one batch.
/// Read from `maxParallelToolCalls` (or `max_parallel_tool_calls`) in
/// settings; the CLI falls back to `CLAWD_PARALLEL_TOOL_CALLS` and then
/// to its built-in default when unset.
max_parallel_tool_calls: Option<usize>,
/// Default cap on concurrently *running* sub-agents per team. Read from
/// `maxConcurrentAgents` (or `max_concurrent_agents`) in settings; a
/// per-team `maxConcurrentAgents` on TeamCreate overrides it. `None`
/// means no gate (current pre-fix behavior).
max_concurrent_agents: Option<usize>,
}
/// Controls which external AI coding framework rules are imported into the system prompt.
@@ -837,6 +851,15 @@ fn build_runtime_config(
rules_import: parse_optional_rules_import(&merged_value)?,
provider: parse_optional_provider_config(&merged_value)?,
auto_memory_enabled: parse_auto_memory_enabled(&merged_value),
subagent_model: parse_optional_subagent_model(&merged_value),
max_parallel_tool_calls: parse_optional_positive_usize(
&merged_value,
&["maxParallelToolCalls", "max_parallel_tool_calls"],
)?,
max_concurrent_agents: parse_optional_positive_usize(
&merged_value,
&["maxConcurrentAgents", "max_concurrent_agents"],
)?,
};
Ok(RuntimeConfig {
@@ -919,6 +942,29 @@ impl RuntimeConfig {
self.feature_config.model.as_deref()
}
/// Model override used when spawning sub-agents via the Agent tool.
/// Read from `subagentModel` in settings; `None` means "use default model".
#[must_use]
pub fn subagent_model(&self) -> Option<&str> {
self.feature_config.subagent_model.as_deref()
}
/// Cap on concurrently executing parallel-safe tool calls in one batch.
/// Read from `maxParallelToolCalls` in settings; `None` means the CLI
/// falls back to `CLAWD_PARALLEL_TOOL_CALLS` and then its built-in default.
#[must_use]
pub fn max_parallel_tool_calls(&self) -> Option<usize> {
self.feature_config.max_parallel_tool_calls
}
/// Default cap on concurrently running sub-agents per team. Read from
/// `maxConcurrentAgents` in settings; `None` means the built-in default
/// applies. A per-team `maxConcurrentAgents` on TeamCreate overrides it.
#[must_use]
pub fn max_concurrent_agents(&self) -> Option<usize> {
self.feature_config.max_concurrent_agents
}
#[must_use]
pub fn aliases(&self) -> &BTreeMap<String, String> {
&self.feature_config.aliases
@@ -979,6 +1025,20 @@ impl RuntimeFeatureConfig {
&self.provider
}
/// Cap on concurrently executing parallel-safe tool calls in one batch.
/// `None` means fall back to env/default.
#[must_use]
pub fn max_parallel_tool_calls(&self) -> Option<usize> {
self.max_parallel_tool_calls
}
/// Default per-team cap on concurrently running sub-agents; `None`
/// means unlimited (pre-fix behavior).
#[must_use]
pub fn max_concurrent_agents(&self) -> Option<usize> {
self.max_concurrent_agents
}
#[must_use]
pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
self.hooks = hooks;
@@ -1761,6 +1821,51 @@ fn parse_optional_model(root: &JsonValue) -> Option<String> {
.map(ToOwned::to_owned)
}
/// Reads `subagentModel` (or the snake_case `subagent_model` alias) from
/// merged settings. Returns `None` when absent or blank so the Agent tool
/// falls back to the default model.
fn parse_optional_subagent_model(root: &JsonValue) -> Option<String> {
root.as_object()
.and_then(|object| {
object
.get("subagentModel")
.or_else(|| object.get("subagent_model"))
})
.and_then(JsonValue::as_str)
.filter(|s| !s.trim().is_empty())
.map(|s| s.trim().to_string())
}
/// Reads the first present key from `keys` as a positive integer.
/// Absent, null, or non-positive values parse to `None` so callers fall
/// back to their own defaults rather than silently disabling a cap via a
/// `0` typo. Accepts JSON numbers or numeric strings.
fn parse_optional_positive_usize(
root: &JsonValue,
keys: &[&str],
) -> Result<Option<usize>, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(None);
};
for key in keys {
let Some(value) = object.get(*key) else {
continue;
};
if matches!(value, JsonValue::Null) {
continue;
}
let parsed = value
.as_i64()
.or_else(|| value.as_str().and_then(|s| s.trim().parse::<i64>().ok()))
.ok_or_else(|| ConfigError::Parse(format!("{key} must be a positive integer")))?;
if parsed <= 0 {
return Ok(None);
}
return Ok(usize::try_from(parsed).ok());
}
Ok(None)
}
fn parse_optional_aliases(root: &JsonValue) -> Result<BTreeMap<String, String>, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(BTreeMap::new());
@@ -2632,6 +2737,49 @@ fn deep_merge_objects(
}
}
/// Read the provider config saved by `/setup` and inject its credentials
/// into the environment so `ProviderClient::from_model()` can find them via
/// the env-var-based provider dispatch. Only sets vars that aren't already
/// present (preserves explicit env). Idempotent.
///
/// Called by the main CLI at startup and by sub-agent threads in the tools
/// crate so that custom/ exotic providers work for spawned agents too.
pub fn inject_config_as_env_fallbacks() {
let cwd = std::env::current_dir().unwrap_or_default();
let Ok(config) = ConfigLoader::default_for(&cwd).load() else {
return;
};
let provider = config.provider();
// Map provider kind to the expected env var names
let (api_key_env, base_url_env) = match provider.kind().unwrap_or("anthropic") {
"anthropic" => ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"),
"xai" => ("XAI_API_KEY", "XAI_BASE_URL"),
"openai" => ("OPENAI_API_KEY", "OPENAI_BASE_URL"),
"dashscope" => ("DASHSCOPE_API_KEY", "DASHSCOPE_BASE_URL"),
"custom-openai" => ("CLAWCUSTOMOPENAI_API_KEY", "CLAWCUSTOMOPENAI_BASE_URL"),
_ => return, // unknown provider kind — don't inject
};
// Only set env vars that aren't already set (preserve user's explicit env)
if let Some(api_key) = provider.api_key() {
if std::env::var(api_key_env).is_err() {
std::env::set_var(api_key_env, api_key);
}
}
if let Some(base_url) = provider.base_url() {
if std::env::var(base_url_env).is_err() {
std::env::set_var(base_url_env, base_url);
}
}
// Also inject the saved model so resolve_model_alias sees it
if let Some(model) = provider.model() {
if std::env::var("CLAWD_PROVIDER_MODEL").is_err() {
std::env::set_var("CLAWD_PROVIDER_MODEL", model);
}
}
}
#[cfg(test)]
mod tests {
use super::{
@@ -4029,4 +4177,151 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn subagent_model_reads_camel_case_config_key() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
"{\n \"subagentModel\": \"custom/openclaw\"\n}\n",
)
.expect("write settings");
let config = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
config.subagent_model(),
Some("custom/openclaw"),
"subagentModel camelCase key should be read end-to-end"
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn subagent_model_reads_snake_case_config_key() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
"{\n \"subagent_model\": \"qwen3.6-35b-fast\"\n}\n",
)
.expect("write settings");
let config = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
config.subagent_model(),
Some("qwen3.6-35b-fast"),
"subagent_model snake_case key should be read end-to-end"
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn subagent_model_blank_value_returns_none() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
"{\n \"subagentModel\": \" \"\n}\n",
)
.expect("write settings");
let config = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
config.subagent_model(),
None,
"blank subagentModel should fall back to None"
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn parallel_and_agent_caps_read_camel_and_snake_case_keys() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
"{\n \"maxParallelToolCalls\": 4,\n \"max_concurrent_agents\": \"6\"\n}\n",
)
.expect("write settings");
let config = ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
assert_eq!(
config.max_parallel_tool_calls(),
Some(4),
"maxParallelToolCalls camelCase key should parse"
);
assert_eq!(
config.max_concurrent_agents(),
Some(6),
"max_concurrent_agents snake_case key should parse numeric strings"
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn parallel_and_agent_caps_reject_nonpositive_and_malformed() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
"{\n \"maxParallelToolCalls\": 0\n}\n",
)
.expect("write settings");
let config = ConfigLoader::new(&cwd, &home)
.load()
.expect("zero cap should parse as unset");
assert_eq!(config.max_parallel_tool_calls(), None);
assert_eq!(config.max_concurrent_agents(), None);
fs::remove_dir_all(&root).expect("cleanup temp dir");
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
"{\n \"maxParallelToolCalls\": \"lots\"\n}\n",
)
.expect("write settings");
let error = ConfigLoader::new(&cwd, &home)
.load()
.expect_err("non-numeric cap should be a config error");
assert!(
error.to_string().contains("maxParallelToolCalls"),
"error should name the offending key: {error}"
);
fs::remove_dir_all(root).expect("cleanup temp dir");
}
}
File diff suppressed because it is too large. Load diff
+510 -210
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,56 @@ 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;
// Special handling for conflicts selector
if selector == Some("conflicts") {
let conflicts = extract_merge_conflicts(&lines);
let (selected, num_lines) = if conflicts.is_empty() {
(format!("No unresolved git merge conflicts in {}.", path), 1)
} else {
let display_path = absolute_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path);
let summary = format_conflict_summary(&conflicts, display_path);
let summary_lines = summary.lines().count();
(summary, summary_lines)
};
return Ok(ReadFileOutput {
kind: String::from("text"),
file: TextFilePayload {
file_path: absolute_path.to_string_lossy().into_owned(),
content: selected,
num_lines,
start_line: 1,
total_lines,
},
truncation_footer: None,
});
}
let selected = lines[start_index..end_index].join("\n");
let truncated = end_index < lines.len();
// 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 = if start_index < end_index {
lines[start_index..end_index].join("\n")
} else {
String::new()
};
Ok(ReadFileOutput {
kind: String::from("text"),
@@ -309,13 +303,255 @@ 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,
})
}
/// Represents a merge conflict block in a file.
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConflictBlock {
start_line: usize, // 1-based line number where <<<<<<< starts
separator_line: usize, // 1-based line number where ======= is
end_line: usize, // 1-based line number where >>>>>>> is
base_line: Option<usize>, // 1-based line number where ||||||| is (for 3-way)
ours_label: Option<String>,
base_label: Option<String>,
theirs_label: Option<String>,
}
/// Scans lines for unresolved git merge conflict blocks.
fn extract_merge_conflicts(lines: &[&str]) -> Vec<ConflictBlock> {
let mut blocks = Vec::new();
let mut i = 0;
const OURS_PREFIX: &str = "<<<<<<<";
const BASE_PREFIX: &str = "|||||||";
const SEPARATOR: &str = "=======";
const THEIRS_PREFIX: &str = ">>>>>>>";
while i < lines.len() {
let line = lines[i];
// Check for start of conflict block
if let Some(label) = extract_marker_label(line, OURS_PREFIX) {
let ours_label = if label.is_empty() {
None
} else {
Some(label.to_string())
};
let start_line = i + 1; // Convert to 1-based
let mut separator_line = None;
let mut base_line = None;
let mut base_label = None;
let mut end_line = None;
let mut theirs_label = None;
let mut j = i + 1;
// Look for separator, optional base marker, and end marker
while j < lines.len() {
let current = lines[j];
// Check for base marker (3-way)
if let Some(label) = extract_marker_label(current, BASE_PREFIX) {
if base_line.is_none() {
base_line = Some(j + 1);
base_label = if label.is_empty() {
None
} else {
Some(label.to_string())
};
j += 1;
continue;
}
}
// Check for separator
if current.trim() == SEPARATOR {
if separator_line.is_none() {
separator_line = Some(j + 1);
j += 1;
continue;
}
}
// Check for end marker
if let Some(label) = extract_marker_label(current, THEIRS_PREFIX) {
if separator_line.is_some() {
end_line = Some(j + 1);
theirs_label = if label.is_empty() {
None
} else {
Some(label.to_string())
};
j += 1;
break;
}
}
j += 1;
}
// Only add if we found both separator and end marker
if let (Some(sep_line), Some(end)) = (separator_line, end_line) {
blocks.push(ConflictBlock {
start_line,
separator_line: sep_line,
end_line: end,
base_line,
ours_label,
base_label,
theirs_label,
});
i = j;
continue;
}
}
i += 1;
}
blocks
}
/// Extract the optional label from a marker line.
/// Returns the label text (stripped of leading/trailing spaces) if the line matches the prefix,
/// or None otherwise.
fn extract_marker_label<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
if line.starts_with(prefix) {
let rest = &line[prefix.len()..];
// Accept either just the prefix or prefix + single space + label
if rest.is_empty() {
Some("")
} else if rest.starts_with(' ') {
Some(rest[1..].trim())
} else {
None
}
} else {
None
}
}
/// Format conflict blocks as a summary index, like GJC's implementation.
fn format_conflict_summary(conflicts: &[ConflictBlock], display_path: &str) -> String {
let mut output = Vec::new();
let total = conflicts.len();
let word = if total == 1 { "conflict" } else { "conflicts" };
output.push(format!(
"⚠ {} unresolved {} in {}",
total, word, display_path
));
// Extract labels for header
let ours_label = conflicts.iter().find_map(|c| c.ours_label.as_ref());
let theirs_label = conflicts.iter().find_map(|c| c.theirs_label.as_ref());
let base_label = conflicts.iter().find_map(|c| c.base_label.as_ref());
let any_base = conflicts.iter().any(|c| c.base_line.is_some());
if let Some(label) = ours_label {
output.push(format!("- ours = {}", label));
}
if let Some(label) = theirs_label {
output.push(format!("- theirs = {}", label));
}
if any_base {
let base_name = base_label
.map(|l| l.to_string())
.unwrap_or_else(|| "(no label)".to_string());
output.push(format!("- base = {}", base_name));
}
output.push(
"NOTICE: Bulk-resolve with `write({ path: \"conflict://*\", content })`, or address a single block with `write({ path: \"conflict://<N>\", content })`. Inspect a block by reading `conflict://<N>` (add `/ours` / `/theirs` / `/base` for a single side).".to_string()
);
output.push(
"`content` shorthand: `@ours` / `@theirs` / `@base` / `@both` lines expand to the recorded sections; `@both` = ours-then-theirs. Non-token lines pass through verbatim.".to_string()
);
output.push(String::new());
// Format each conflict as a one-liner index
let id_width = total.to_string().len();
for (idx, conflict) in conflicts.iter().enumerate() {
let id = idx + 1;
let range = if conflict.start_line == conflict.end_line {
format!("L{}", conflict.start_line)
} else {
format!("L{}-{}", conflict.start_line, conflict.end_line)
};
let kind = if conflict.base_line.is_some() {
" (3-way)"
} else {
""
};
output.push(format!(
"#{:>width$} {}{}",
id,
range,
kind,
width = id_width
));
}
output.join("\n")
}
/// 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 => {
// Conflicts are handled specially in read_file_with_selector, so this shouldn't be reached
(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 +1022,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 +1131,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 +1156,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 +1502,189 @@ 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());
}
#[test]
fn read_with_selector_conflicts_no_conflicts() {
let path = temp_path("conflicts-none.txt");
write_file(
path.to_string_lossy().as_ref(),
"const a = 1;\nconst b = 2;\n",
)
.expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("conflicts"),
)
.expect("read with conflicts selector should succeed");
assert!(output
.file
.content
.contains("No unresolved git merge conflicts"));
}
#[test]
fn read_with_selector_conflicts_two_way() {
let path = temp_path("conflicts-two-way.txt");
let content = "<<<<<<< A\na-ours\n=======\na-theirs\n>>>>>>> A\nmiddle\n<<<<<<< B\nb-ours\n=======\nb-theirs\n>>>>>>> B\ntail";
write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("conflicts"),
)
.expect("read with conflicts selector should succeed");
let text = &output.file.content;
// Should report 2 conflicts
assert!(text.contains("2 unresolved conflicts"), "text: {}", text);
// Should have indices for both blocks
assert!(text.contains("#1"), "missing #1: {}", text);
assert!(text.contains("#2"), "missing #2: {}", text);
// Should have line ranges
assert!(text.contains("L1-5"), "missing L1-5: {}", text);
assert!(text.contains("L7-11"), "missing L7-11: {}", text);
}
#[test]
fn read_with_selector_conflicts_three_way() {
let path = temp_path("conflicts-three-way.txt");
let content = "head\n<<<<<<< HEAD\nours body\n||||||| common ancestor\nbase body\n=======\ntheirs body\n>>>>>>> feat\ntail";
write_file(path.to_string_lossy().as_ref(), content).expect("write should succeed");
let output = super::read_file_with_selector(
path.to_string_lossy().as_ref(),
None,
None,
Some("conflicts"),
)
.expect("read with conflicts selector should succeed");
let text = &output.file.content;
// Should report 1 conflict
assert!(text.contains("1 unresolved conflict"), "text: {}", text);
// Should mark as 3-way
assert!(text.contains("3-way"), "missing 3-way marker: {}", text);
// Should show line range
assert!(text.contains("L2-8"), "missing L2-8: {}", text);
}
}
+15 -12
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;
@@ -66,15 +67,16 @@ pub use compact::{
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
};
pub use config::{
clear_user_provider_settings, default_config_home, save_user_provider_settings,
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig,
McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig, McpStdioServerConfig, McpTransport,
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
clear_user_provider_settings, default_config_home, inject_config_as_env_fallbacks,
save_user_provider_settings, suppress_config_warnings_for_json_mode, ApiTimeoutConfig,
ConfigEntry, ConfigError, ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader,
ConfigSource, McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig,
McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpServerConfig,
McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
ProviderFallbackConfig, ResolvedPermissionMode, RulesImportConfig, RuntimeConfig,
RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig, RuntimeInvalidHookConfig,
RuntimePermissionRuleConfig, RuntimePluginConfig, RuntimeProviderConfig, ScopedMcpServerConfig,
CLAW_SETTINGS_SCHEMA_NAME,
};
pub use config_validate::{
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
@@ -82,12 +84,13 @@ pub use config_validate::{
};
pub use conversation::{
auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent,
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError,
ToolExecutor, TurnSummary,
ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolCall, ToolError,
ToolExecutor, ToolResult, TurnProgressReporter, TurnSummary,
};
pub use file_ops::{
edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search,
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 {
+4 -2
View File
@@ -190,8 +190,10 @@ fn stage1_supersede(messages: &[ConversationMessage]) -> (Vec<ConversationMessag
if let Some(last_write) = last_write_idx {
for op in ops {
if op.index < last_write
&& matches!(op.op_type, FileOp::Read | FileOp::Write | FileOp::Edit)
if (op.op_type == FileOp::Read
|| op.op_type == FileOp::Write
|| op.op_type == FileOp::Edit)
&& op.index < last_write
{
obsolete_indices.insert(op.index);
}
-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 {
+10 -2
View File
@@ -14,6 +14,7 @@ api = { path = "../api" }
commands = { path = "../commands" }
crossterm = "0.28"
pulldown-cmark = "0.13"
once_cell = "1"
rustyline = "15"
runtime = { path = "../runtime" }
plugins = { path = "../plugins" }
@@ -25,8 +26,15 @@ tools = { path = "../tools" }
log = "0.4"
[lints]
workspace = true
[lints.rust]
unsafe_code = "allow"
[lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "allow", priority = -1 }
module_name_repetitions = "allow"
missing_panics_doc = "allow"
missing_errors_doc = "allow"
[dev-dependencies]
mock-anthropic-service = { path = "../mock-anthropic-service" }
+35 -3
View File
@@ -18,6 +18,8 @@ pub enum ReadOutcome {
Submit(String),
Cancel,
Exit,
ProviderSwap,
TeamToggle,
}
struct SlashCommandHelper {
@@ -86,12 +88,19 @@ impl Hinter for SlashCommandHelper {
impl Highlighter for SlashCommandHelper {
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
self.set_current_line(line);
Cow::Borrowed(line)
// When sentinel is present, show visible prompt instead of invisible char
if line.contains('\x01') {
let display = line.replace('\x01', "\x1b[36m[Provider Swap]\x1b[0m ");
Cow::Owned(display)
} else {
Cow::Borrowed(line)
}
}
fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool {
self.set_current_line(line);
false
// Re-highlight when sentinel is present to show the prompt
line.contains('\x01')
}
}
@@ -115,6 +124,18 @@ impl LineEditor {
editor.set_helper(Some(SlashCommandHelper::new(completions)));
editor.bind_sequence(KeyEvent(KeyCode::Char('J'), Modifiers::CTRL), Cmd::Newline);
editor.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::SHIFT), Cmd::Newline);
// Ctrl+P inserts a sentinel character that triggers provider swap.
// The sentinel is invisible but the highlighter shows "[Provider Swap]" prompt.
// User must press Enter to confirm (rustyline cannot chain commands).
editor.bind_sequence(
KeyEvent(KeyCode::Char('P'), Modifiers::CTRL),
Cmd::SelfInsert(1, '\x01'),
);
// Ctrl+T inserts a sentinel character that toggles agent teams mode.
editor.bind_sequence(
KeyEvent(KeyCode::Char('T'), Modifiers::CTRL),
Cmd::SelfInsert(1, '\x02'),
);
Self {
prompt: prompt.into(),
@@ -147,7 +168,18 @@ impl LineEditor {
}
match self.editor.readline(&self.prompt) {
Ok(line) => Ok(ReadOutcome::Submit(line)),
Ok(line) => {
// Ctrl+P inserts \x01 sentinel — triggers provider swap wizard.
// The sentinel is stripped and we return ProviderSwap to the REPL loop.
if line.contains('\x01') {
return Ok(ReadOutcome::ProviderSwap);
}
// Ctrl+T inserts \x02 sentinel — toggles team mode.
if line.contains('\x02') {
return Ok(ReadOutcome::TeamToggle);
}
Ok(ReadOutcome::Submit(line))
}
Err(ReadlineError::Interrupted) => {
let has_input = !self.current_line().is_empty();
self.finish_interrupted_read()?;
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()
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "sdk"
version = "0.1.4"
edition = "2021"
publish = false
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "0.23"
futures-util = "0.3"
serde_json = "1"
serde = { version = "1", features = ["derive"] }
uuid = { version = "1", features = ["v4", "serde"] }
thiserror = "1"
tracing = "0.1"
[dev-dependencies]
tokio-test = "0.4"
+130
View File
@@ -0,0 +1,130 @@
//! SDK error types and result type.
use std::fmt;
/// SDK wire protocol error codes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCode {
/// Invalid JSON or malformed frame
InvalidInput,
/// Unknown broker operation
UnknownOperation,
/// Payload too large (>4 MiB)
PayloadTooLarge,
/// Authentication token mismatch
Unauthorized,
/// Broker or session unavailable
Unavailable,
/// WebSocket connection error
ConnectionError,
/// Request timeout
Timeout,
/// Frame parsing or validation error
FrameError,
/// Request/response correlation failure
CorrelationError,
/// Other unclassified error
Internal,
}
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidInput => write!(f, "invalid_input"),
Self::UnknownOperation => write!(f, "unknown_operation"),
Self::PayloadTooLarge => write!(f, "payload_too_large"),
Self::Unauthorized => write!(f, "unauthorized"),
Self::Unavailable => write!(f, "unavailable"),
Self::ConnectionError => write!(f, "connection_error"),
Self::Timeout => write!(f, "timeout"),
Self::FrameError => write!(f, "frame_error"),
Self::CorrelationError => write!(f, "correlation_error"),
Self::Internal => write!(f, "internal_error"),
}
}
}
/// SDK wire protocol error.
#[derive(Debug)]
pub struct SdkError {
pub code: ErrorCode,
pub message: String,
}
impl SdkError {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
pub fn invalid_input(message: impl Into<String>) -> Self {
Self::new(ErrorCode::InvalidInput, message)
}
pub fn unknown_operation(message: impl Into<String>) -> Self {
Self::new(ErrorCode::UnknownOperation, message)
}
pub fn payload_too_large(message: impl Into<String>) -> Self {
Self::new(ErrorCode::PayloadTooLarge, message)
}
pub fn unauthorized(message: impl Into<String>) -> Self {
Self::new(ErrorCode::Unauthorized, message)
}
pub fn unavailable(message: impl Into<String>) -> Self {
Self::new(ErrorCode::Unavailable, message)
}
pub fn connection_error(message: impl Into<String>) -> Self {
Self::new(ErrorCode::ConnectionError, message)
}
pub fn timeout(message: impl Into<String>) -> Self {
Self::new(ErrorCode::Timeout, message)
}
pub fn frame_error(message: impl Into<String>) -> Self {
Self::new(ErrorCode::FrameError, message)
}
pub fn correlation_error(message: impl Into<String>) -> Self {
Self::new(ErrorCode::CorrelationError, message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(ErrorCode::Internal, message)
}
}
impl fmt::Display for SdkError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for SdkError {}
/// Result type for SDK operations.
pub type SdkResult<T> = Result<T, SdkError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_display_formats_code_and_message() {
let err = SdkError::invalid_input("test message");
assert_eq!(err.to_string(), "invalid_input: test message");
}
#[test]
fn error_code_display_works() {
assert_eq!(ErrorCode::InvalidInput.to_string(), "invalid_input");
assert_eq!(ErrorCode::PayloadTooLarge.to_string(), "payload_too_large");
assert_eq!(ErrorCode::Unauthorized.to_string(), "unauthorized");
}
}
+258
View File
@@ -0,0 +1,258 @@
//! Frame types and serialization for the SDK wire protocol.
use serde_json::{Map, Value};
use crate::error::{SdkError, SdkResult};
/// Protocol version implemented.
pub const PROTOCOL_VERSION: u32 = 3;
/// Maximum UTF-8 byte length for a single broker JSON frame.
pub const MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
/// Frame type identifier.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameType {
/// Hello frame sent by broker on connection
BrokerHello,
/// Request frame from client to broker
BrokerRequest,
/// Response frame from broker to client
BrokerResponse,
/// Query request
QueryRequest,
/// Query response
QueryResponse,
/// Control request
ControlRequest,
/// Control response
ControlResponse,
/// Event replay request
EventReplay,
/// Event replay result
EventReplayResult,
/// Session activation
SessionActivate,
/// Session activation result
SessionActivateResult,
}
impl FrameType {
pub fn as_str(&self) -> &'static str {
match self {
Self::BrokerHello => "broker_hello",
Self::BrokerRequest => "broker_request",
Self::BrokerResponse => "broker_response",
Self::QueryRequest => "query_request",
Self::QueryResponse => "query_response",
Self::ControlRequest => "control_request",
Self::ControlResponse => "control_response",
Self::EventReplay => "event_replay",
Self::EventReplayResult => "event_replay_result",
Self::SessionActivate => "session_activate",
Self::SessionActivateResult => "session_activate_result",
}
}
pub fn parse(s: &str) -> SdkResult<Self> {
match s {
"broker_hello" => Ok(Self::BrokerHello),
"broker_request" => Ok(Self::BrokerRequest),
"broker_response" => Ok(Self::BrokerResponse),
"query_request" => Ok(Self::QueryRequest),
"query_response" => Ok(Self::QueryResponse),
"control_request" => Ok(Self::ControlRequest),
"control_response" => Ok(Self::ControlResponse),
"event_replay" => Ok(Self::EventReplay),
"event_replay_result" => Ok(Self::EventReplayResult),
"session_activate" => Ok(Self::SessionActivate),
"session_activate_result" => Ok(Self::SessionActivateResult),
_ => Err(SdkError::frame_error(format!("unknown frame type: {}", s))),
}
}
}
/// A frame in the SDK wire protocol.
///
/// Frames are JSON objects with a `type` field and optional `id` field for correlation.
#[derive(Debug, Clone)]
pub struct Frame {
/// Underlying JSON value
pub data: Map<String, Value>,
}
impl Frame {
/// Create a new frame from a JSON object.
pub fn new(data: Map<String, Value>) -> Self {
Self { data }
}
/// Create an empty frame.
pub fn empty() -> Self {
Self { data: Map::new() }
}
/// Get the frame type.
pub fn frame_type(&self) -> SdkResult<FrameType> {
let frame_type_str = self
.data
.get("type")
.and_then(Value::as_str)
.ok_or_else(|| SdkError::frame_error("missing 'type' field"))?;
FrameType::parse(frame_type_str)
}
/// Get the optional request/response ID for correlation.
pub fn id(&self) -> Option<String> {
self.data
.get("id")
.and_then(Value::as_str)
.map(String::from)
}
/// Get the `ok` boolean field from a response frame.
pub fn ok(&self) -> SdkResult<bool> {
self.data
.get("ok")
.and_then(Value::as_bool)
.ok_or_else(|| SdkError::frame_error("missing or invalid 'ok' field"))
}
/// Get the error object from a response frame.
pub fn error(&self) -> Option<SdkResult<(String, String)>> {
let error_obj = self.data.get("error")?.as_object()?;
let code = error_obj.get("code")?.as_str()?.to_string();
let message = error_obj.get("message")?.as_str()?.to_string();
Some(Ok((code, message)))
}
/// Get the result field from a response frame.
pub fn result(&self) -> Option<Value> {
self.data.get("result").cloned()
}
/// Convert to JSON string.
pub fn to_json(&self) -> SdkResult<String> {
serde_json::to_string(&self.data)
.map_err(|e| SdkError::frame_error(format!("JSON serialization error: {}", e)))
}
/// Parse from JSON string.
pub fn from_json(json_str: &str) -> SdkResult<Self> {
let data: Map<String, Value> = serde_json::from_str(json_str)
.map_err(|e| SdkError::frame_error(format!("JSON parsing error: {}", e)))?;
Ok(Self::new(data))
}
/// Create a broker hello frame.
pub fn broker_hello() -> Self {
let mut data = Map::new();
data.insert(
"type".to_string(),
Value::String("broker_hello".to_string()),
);
data.insert(
"protocolVersion".to_string(),
Value::Number(PROTOCOL_VERSION.into()),
);
Self::new(data)
}
/// Create a broker request frame.
pub fn broker_request(id: String, operation: String, input: Value) -> Self {
let mut data = Map::new();
data.insert(
"type".to_string(),
Value::String("broker_request".to_string()),
);
data.insert("id".to_string(), Value::String(id));
data.insert("operation".to_string(), Value::String(operation));
data.insert("input".to_string(), input);
Self::new(data)
}
/// Create a broker response frame.
pub fn broker_response_ok(id: String, result: Value) -> Self {
let mut data = Map::new();
data.insert(
"type".to_string(),
Value::String("broker_response".to_string()),
);
data.insert("id".to_string(), Value::String(id));
data.insert("ok".to_string(), Value::Bool(true));
data.insert("result".to_string(), result);
Self::new(data)
}
/// Create a broker error response frame.
pub fn broker_response_error(id: String, code: String, message: String) -> Self {
let mut error_data = Map::new();
error_data.insert("code".to_string(), Value::String(code));
error_data.insert("message".to_string(), Value::String(message));
let mut data = Map::new();
data.insert(
"type".to_string(),
Value::String("broker_response".to_string()),
);
data.insert("id".to_string(), Value::String(id));
data.insert("ok".to_string(), Value::Bool(false));
data.insert("error".to_string(), Value::Object(error_data));
Self::new(data)
}
/// Get the payload size in bytes.
pub fn byte_size(&self) -> usize {
self.to_json().map(|s| s.len()).unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_type_round_trip() {
assert_eq!(FrameType::BrokerHello.as_str(), "broker_hello");
assert_eq!(
FrameType::parse("broker_hello").unwrap(),
FrameType::BrokerHello
);
}
#[test]
fn frame_parsing_extracts_type() {
let frame = Frame::broker_hello();
assert_eq!(frame.frame_type().unwrap(), FrameType::BrokerHello);
}
#[test]
fn frame_with_id_can_be_recovered() {
let frame = Frame::broker_request(
"test-id".to_string(),
"test.operation".to_string(),
Value::Object(Map::new()),
);
assert_eq!(frame.id(), Some("test-id".to_string()));
}
#[test]
fn frame_json_round_trip() {
let frame = Frame::broker_hello();
let json_str = frame.to_json().unwrap();
let parsed = Frame::from_json(&json_str).unwrap();
assert_eq!(parsed.frame_type().unwrap(), FrameType::BrokerHello);
}
#[test]
fn broker_response_error_contains_code_and_message() {
let frame = Frame::broker_response_error(
"id1".to_string(),
"invalid_input".to_string(),
"test error".to_string(),
);
let (code, message) = frame.error().unwrap().unwrap();
assert_eq!(code, "invalid_input");
assert_eq!(message, "test error");
}
}
+20
View File
@@ -0,0 +1,20 @@
//! GJC SDK wire transport implementation.
//!
//! This crate provides a Rust implementation of the GJC SDK wire protocol,
//! compatible with the gajae-code SDK broker framing, request/response correlation,
//! and event stream semantics.
#![forbid(unsafe_code)]
#![allow(
clippy::missing_errors_doc,
clippy::missing_panics_doc,
clippy::module_name_repetitions
)]
pub mod error;
pub mod frame;
pub mod transport;
pub use error::{SdkError, SdkResult};
pub use frame::{Frame, FrameType};
pub use transport::{SdkTransport, SdkTransportConfig};
+251
View File
@@ -0,0 +1,251 @@
//! WebSocket transport implementation for the SDK wire protocol.
use std::collections::HashMap;
use std::sync::Arc;
use futures_util::sink::SinkExt;
use futures_util::stream::StreamExt;
use serde_json::Value;
use tokio::sync::{mpsc, oneshot, Mutex, RwLock};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use uuid::Uuid;
use crate::error::{SdkError, SdkResult};
use crate::frame::{Frame, FrameType, MAX_FRAME_BYTES};
/// Configuration for SDK transport.
#[derive(Debug, Clone)]
pub struct SdkTransportConfig {
/// Server URL (e.g., "ws://127.0.0.1:8080")
pub url: String,
/// Authentication token
pub token: String,
/// Maximum payload size in bytes (default: 4 MiB)
pub max_payload_bytes: usize,
/// Request timeout in seconds (default: 30)
pub timeout_secs: u64,
}
impl Default for SdkTransportConfig {
fn default() -> Self {
Self {
url: "ws://127.0.0.1:8080".to_string(),
token: String::new(),
max_payload_bytes: MAX_FRAME_BYTES,
timeout_secs: 30,
}
}
}
/// Pending request tracking for correlation.
struct PendingRequest {
response_tx: oneshot::Sender<SdkResult<Frame>>,
}
/// SDK WebSocket transport.
pub struct SdkTransport {
/// Channel for sending messages to the writer task
tx: mpsc::UnboundedSender<Message>,
/// Pending requests by ID
pending: Arc<RwLock<HashMap<String, PendingRequest>>>,
/// Configuration
config: SdkTransportConfig,
/// Background task handle
task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
}
impl SdkTransport {
/// Create a new SDK transport and connect to the broker.
pub async fn connect(config: SdkTransportConfig) -> SdkResult<Self> {
// Add token to query params
let url_with_token = if config.url.contains('?') {
format!("{}&token={}", config.url, config.token)
} else {
format!("{}?token={}", config.url, config.token)
};
// Create WebSocket connection
let (ws, _) = connect_async(&url_with_token).await.map_err(|e| {
SdkError::connection_error(format!("WebSocket connection failed: {}", e))
})?;
let (ws_tx, ws_rx) = tokio::sync::mpsc::unbounded_channel();
let pending = Arc::new(RwLock::new(HashMap::new()));
let pending_clone = pending.clone();
// Spawn background task for handling the WebSocket
let task_handle = tokio::spawn(async move {
Self::background_task(ws, ws_rx, pending_clone).await;
});
let transport = Self {
tx: ws_tx,
pending,
config,
task_handle: Arc::new(Mutex::new(Some(task_handle))),
};
Ok(transport)
}
/// Send a request and wait for response.
pub async fn request(&self, operation: String, input: Value) -> SdkResult<Frame> {
// Generate correlation ID
let id = Uuid::new_v4().to_string();
// Create request frame
let request = Frame::broker_request(id.clone(), operation, input);
// Check payload size
if request.byte_size() > self.config.max_payload_bytes {
return Err(SdkError::payload_too_large(format!(
"frame size {} exceeds maximum {}",
request.byte_size(),
self.config.max_payload_bytes
)));
}
// Create response channel
let (response_tx, response_rx) = oneshot::channel();
self.pending
.write()
.await
.insert(id.clone(), PendingRequest { response_tx });
// Send request
let json = request.to_json()?;
self.tx
.send(Message::Text(json))
.map_err(|e| SdkError::connection_error(format!("send failed: {}", e)))?;
// Wait for response with timeout
let timeout_duration = std::time::Duration::from_secs(self.config.timeout_secs);
match tokio::time::timeout(timeout_duration, response_rx).await {
Ok(Ok(frame)) => frame,
Ok(Err(_)) => Err(SdkError::correlation_error("response channel closed")),
Err(_) => Err(SdkError::timeout("response not received within timeout")),
}
}
/// Background task for handling WebSocket communication.
#[allow(unused_mut)]
async fn background_task(
ws: tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
mut ws_rx: mpsc::UnboundedReceiver<Message>,
pending: Arc<RwLock<HashMap<String, PendingRequest>>>,
) {
let (mut ws_sink, mut ws_stream) = ws.split();
loop {
tokio::select! {
// Handle incoming WebSocket messages
msg = ws_stream.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
// Parse frame
match Frame::from_json(&text) {
Ok(frame) => {
// Handle frame type
match frame.frame_type() {
Ok(FrameType::BrokerHello) => {
// Hello frame received, continue
}
Ok(FrameType::BrokerResponse) => {
// Response frame, correlate with request
if let Some(id) = frame.id() {
if let Some(PendingRequest { response_tx }) =
pending.write().await.remove(&id)
{
let _ = response_tx.send(Ok(frame));
}
}
}
_ => {
// Other frame types
tracing::debug!("Unhandled frame type: {:?}", frame.frame_type());
}
}
}
Err(e) => {
tracing::warn!("Failed to parse frame: {}", e);
}
}
}
Some(Ok(Message::Close(_))) => {
break;
}
Some(Ok(_)) => {
// Other message types (binary, etc.), ignore
}
Some(Err(e)) => {
tracing::warn!("WebSocket error: {}", e);
break;
}
None => {
break;
}
}
}
// Handle outgoing messages
Some(msg) = ws_rx.recv() => {
if let Err(e) = ws_sink.send(msg).await {
tracing::warn!("Failed to send message: {}", e);
break;
}
}
else => break,
}
}
// Fail all pending requests
let mut pending = pending.write().await;
for (_, PendingRequest { response_tx }) in pending.drain() {
let _ = response_tx.send(Err(SdkError::connection_error("connection closed")));
}
}
/// Close the transport.
pub async fn close(self) -> SdkResult<()> {
// Send close message
let _ = self.tx.send(Message::Close(None));
// Wait for background task to finish
if let Some(handle) = self.task_handle.lock().await.take() {
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_default_values() {
let config = SdkTransportConfig::default();
assert_eq!(config.url, "ws://127.0.0.1:8080");
assert_eq!(config.max_payload_bytes, MAX_FRAME_BYTES);
assert_eq!(config.timeout_secs, 30);
}
#[tokio::test]
async fn transport_can_be_created() {
// This test doesn't actually connect, just checks that the struct can be created
let config = SdkTransportConfig::default();
assert_eq!(config.url, "ws://127.0.0.1:8080");
}
#[test]
fn request_frame_has_correlation_id() {
let frame = Frame::broker_request(
"test-id".to_string(),
"test.operation".to_string(),
Value::Object(serde_json::Map::new()),
);
assert_eq!(frame.id(), Some("test-id".to_string()));
}
}
+118
View File
@@ -0,0 +1,118 @@
# Multi-Tool Execution & Sub-Agent Delegation
Two complementary features that dramatically reduce latency and token usage when the model needs to perform multiple operations or gather context.
## Feature 1: Parallel Tool Execution
When the model returns multiple tool_use blocks in a single response, read-only tools now execute concurrently instead of sequentially.
### How it works
The `run_turn` loop is refactored into 3 phases:
1. **Pre-hooks + permission checks** (sequential — hooks may mutate state)
2. **Tool execution** (batch — parallel for read-only tools via `std::thread::scope`)
3. **Post-hooks + session updates** (sequential — preserves original ordering)
### Parallel-safe tools
These tools are safe to run concurrently because they only read state and dispatch through the stateless tool registry:
- `read_file`, `glob_search`, `grep_search`
- `WebFetch`, `WebSearch`
- `ToolSearch`, `Skill`
- `LSP`
- `GitStatus`, `GitDiff`, `GitLog`, `GitShow`, `GitBlame`
### Sequential-only tools
Tools that require `&mut self` or have side effects continue to run one at a time:
- `bash`, `write_file`, `edit_file` (side effects)
- `MCP`, `McpAuth`, `RemoteTrigger` (network state)
- `Agent`, `TaskCreate`, `WorkerCreate` (stateful)
- `NotebookEdit`, `REPL`, `PowerShell` (side effects)
### Safety guarantees
- Pre/post hooks always run sequentially
- Permission checks complete before any tool executes
- Tool results are pushed to the session in the original model order
- Falls back to sequential for single-tool batches
- Thread scopes ensure all parallel work completes before `execute_batch` returns
### Impact
For a response with 5 `read_file` calls: **~5x faster** execution. The main model still sees all results in order.
---
## Feature 2: SubAgent Delegation
A `SubAgent` tool that lets the main model delegate multi-step tasks to a fast sub-agent. The sub-agent runs autonomously with its own `ConversationRuntime`, making multiple tool calls without round-tripping through the main model.
### Tool parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `prompt` | string | yes | Task description for the sub-agent |
| `task_type` | string | no | `Explore` (default), `Plan`, or `Verify` |
| `model` | string | no | Override the sub-agent model |
### Task types
| Type | Available tools | Use for |
|------|----------------|---------|
| `Explore` | read_file, glob_search, grep_search, WebFetch, WebSearch, ToolSearch, StructuredOutput | Searching code, reading files, gathering context |
| `Plan` | Explore + TodoWrite | Planning approaches with structured todo output |
| `Verify` | Plan + bash | Running tests, checking builds, verifying changes |
### Configuration
Set the sub-agent model in `~/.claw/settings.json`:
```json
{
"model": "openai/glm-5.1-fast",
"subagentModel": "openai/qwen3.6-35b-fast"
}
```
If `subagentModel` is not set, the sub-agent uses the same model as the main session (or the `model` override parameter on the tool call).
### Example
Main model prompt: "Find all Rust files that import `ConversationRuntime` and list their paths"
Without SubAgent: 5–10 sequential tool calls (grep → read each file → summarize)
With SubAgent: 1 SubAgent call → sub-agent does all the work autonomously → returns summary
**Result**: ~10x fewer tokens consumed by the main model, faster overall completion.
### Architecture
The sub-agent reuses the same building blocks as the existing `Agent` tool:
- `ProviderRuntimeClient` — API client with fallback chain
- `SubagentToolExecutor` — filtered tool access with permission enforcement
- `ConversationRuntime` — full conversation loop with hooks and compaction
- `agent_permission_policy()` — auto-approve read-only, deny write tools
Key differences from the `Agent` tool:
- **Synchronous** — blocks until complete, returns result directly
- **Lighter** — fewer default tools, focused on the task type
- **Configurable model** — uses `subagentModel` or the tool's `model` param
- **Structured output** — returns `result`, `tool_calls`, and `iterations`
---
## Changed files
| File | Changes |
|------|---------|
| `rust/crates/runtime/src/conversation.rs` | `ToolCall`, `ToolResult` types; `execute_batch` on `ToolExecutor`; 3-phase `run_turn` |
| `rust/crates/runtime/src/lib.rs` | Exports for `ToolCall`, `ToolResult` |
| `rust/crates/runtime/src/config.rs` | `subagent_model` field, `parse_optional_subagent_model()`, accessor |
| `rust/crates/runtime/src/config_validate.rs` | `subagentModel` field spec |
| `rust/crates/rusty-claude-cli/src/main.rs` | `CliToolExecutor::execute_batch` with parallel-safe classification |
| `rust/crates/tools/src/lib.rs` | `SubAgent` tool spec, `SubAgentInput`, `run_sub_agent()`, `load_subagent_model_from_config()`, `build_sub_agent_system_prompt()` |
+2
View File
@@ -116,6 +116,8 @@ mod tests {
lane_events: vec![],
derived_state: "working".to_string(),
current_blocker: None,
team_id: None,
task_id: None,
error: None,
}
}
File diff suppressed because it is too large. Load diff