refactor(rust): move implementation to rust/clawcode workspace

Restructures the Rust codebase into a self-contained clawcode workspace
under rust/clawcode, collocating claw config (agents, skills) with the
Rust crates. Follows the project convention of keeping source under rust/.

- Move crates: agents, api, claw-cli, commands, compat-harness,
  migrate-patch-names, mock-anthropic-service, plugin-types, plugins,
  runtime, telemetry, tools
- Drop the old flat rust crates tree
- Add claw/ workspace config with agents and skills
- Gitignore local .env secrets (claw/.env, claw/.env_deepseek)
This commit is contained in:
huagusam 2026-08-14 10:35:41 +08:00
parent 244a38c290
commit abe386490b
276 changed files with 54547 additions and 68841 deletions

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"hello","type":"text"}],"role":"user"},{"blocks":[{"text":"Hello! I'm Claude, an AI assistant built on Anthropic's Claude Agent SDK. I'm here to help you with software engineering tasks in your","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":141,"output_tokens":32}},{"blocks":[{"text":"who are you?","type":"text"}],"role":"user"},{"blocks":[{"text":"I'm Claude, an AI assistant built on Anthropic's Claude Agent SDK. I'm designed to help you with software engineering tasks, and I'm currently","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":182,"output_tokens":32}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"build","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nI'll build the Rust project.\n\n<tool_use>\n<server_name>shell</server_name>\n<tool_name","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":1963,"output_tokens":32}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"hello","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nHello! 👋 How can I help you today? I see you're working in a Rust project (`rusty-claude-cli`) with some changes to the argument parsing in `main.rs` — specifically loading the model from config with a fallback to a default. \n\nWhat would you like to work on?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":324,"output_tokens":73}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"--dangerously-skip-permissions","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nI see you're working in the `rusty-claude-cli` project. The git diff shows a simplification of the `max_tokens_for_model` function - collapsing sonnet/haiku/default cases into a single `else` branch returning 64,000.\n\nHow can I help you today? What would you like to work on?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":2126,"output_tokens":81}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"do youy have issues with bash tool calling?","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nNo, I don't have issues with the `bash` tool — it works fine for me. I can execute shell commands, run scripts, check file contents, build projects, run tests, etc.\n\nIs there something specific you're experiencing or a particular command you'd like me to run? If you've seen an error or unexpected behavior with a bash tool call, feel free to share the details and I'll look into it.","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":333,"output_tokens":92}},{"blocks":[{"text":"test it out","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nSure, let me run a few quick bash commands to verify everything works:","type":"text"},{"id":"toolu_01QyBjL69zgawKC1gqvqvww5","input":"{}{\"command\": \"echo \\\"Hello from bash! Date: $(date), Shell: $SHELL, PWD: $PWD\\\"\"}","name":"bash","type":"tool_use"},{"id":"toolu_01SVSCJuMUv1XWx6D52rN2Kp","input":"{}{\"command\": \"uname -a\"}","name":"bash","type":"tool_use"},{"id":"toolu_01VT7QAJKQUi8UWdu2RcNZmx","input":"{}{\"command\": \"ls -la --color=never | head -20\"}","name":"bash","type":"tool_use"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":432,"output_tokens":172}},{"blocks":[{"is_error":true,"output":"invalid tool input JSON: trailing characters at line 1 column 3","tool_name":"bash","tool_use_id":"toolu_01QyBjL69zgawKC1gqvqvww5","type":"tool_result"}],"role":"tool"},{"blocks":[{"is_error":true,"output":"invalid tool input JSON: trailing characters at line 1 column 3","tool_name":"bash","tool_use_id":"toolu_01SVSCJuMUv1XWx6D52rN2Kp","type":"tool_result"}],"role":"tool"},{"blocks":[{"is_error":true,"output":"invalid tool input JSON: trailing characters at line 1 column 3","tool_name":"bash","tool_use_id":"toolu_01VT7QAJKQUi8UWdu2RcNZmx","type":"tool_result"}],"role":"tool"},{"blocks":[{"text":"Well, looks like you were right — there *is* an issue! The tool calls are failing with a JSON parsing error. Let me try again with the correct format:","type":"text"},{"id":"toolu_01MxrSFY7LsdjiTYEmi9zQ4f","input":"{}{\"command\": \"echo \\\"Hello from bash! Date: $(date), Shell: $SHELL, PWD: $PWD\\\"\"}","name":"bash","type":"tool_use"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":750,"output_tokens":110}},{"blocks":[{"is_error":true,"output":"invalid tool input JSON: trailing characters at line 1 column 3","tool_name":"bash","tool_use_id":"toolu_01MxrSFY7LsdjiTYEmi9zQ4f","type":"tool_result"}],"role":"tool"},{"blocks":[{"id":"toolu_01REJA2MvdEz8CeCDTQBCJpp","input":"{}{\"command\": \"echo \\\"Hello from bash! Date: $(date), Shell: $SHELL, PWD: $PWD\\\"\"}","name":"bash","type":"tool_use"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":901,"output_tokens":74}},{"blocks":[{"is_error":true,"output":"invalid tool input JSON: trailing characters at line 1 column 3","tool_name":"bash","tool_use_id":"toolu_01REJA2MvdEz8CeCDTQBCJpp","type":"tool_result"}],"role":"tool"},{"blocks":[{"id":"toolu_01FPqWmNjSqMyKx5GeuNAfHu","input":"{}{\"command\": \"echo hello\"}","name":"bash","type":"tool_use"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":1016,"output_tokens":52}},{"blocks":[{"is_error":true,"output":"invalid tool input JSON: trailing characters at line 1 column 3","tool_name":"bash","tool_use_id":"toolu_01FPqWmNjSqMyKx5GeuNAfHu","type":"tool_result"}],"role":"tool"},{"blocks":[{"text":"Yep, confirmed — there's definitely a bug. The error `\"trailing characters at line 1 column 3\"` suggests the JSON being sent for the `bash` tool input is malformed. It looks like there's an empty `{}` being prepended to the actual JSON payload, resulting in `{}{\"command\": \"...\"}` which is invalid JSON (two objects concatenated).\n\nThis is likely a bug in your Rust CLI's tool call serialization. Want me to look at the relevant code in `crates/rusty-claude-cli/src/main.rs` to track down the root cause?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":1109,"output_tokens":137}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"hello use bash tool for testing","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nHello! I'm ready to help. Let me run a quick bash command to confirm everything is working:","type":"text"},{"id":"toolu_01EuTzVfUK7iPRBvjZAovzfV","input":"{\"command\": \"echo \\\"Hello! Bash tool is working. 🎉\\\" && date && pwd\"}","name":"bash","type":"tool_use"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4277,"output_tokens":92}},{"blocks":[{"is_error":false,"output":"{\n \"stdout\": \"Hello! Bash tool is working. 🎉\\nWed Apr 1 02:25:46 AM UTC 2026\\n/home/bellman/Workspace/clawd-code/rust\\n\",\n \"stderr\": \"\",\n \"rawOutputPath\": null,\n \"interrupted\": false,\n \"isImage\": null,\n \"backgroundTaskId\": null,\n \"backgroundedByUser\": null,\n \"assistantAutoBackgrounded\": null,\n \"dangerouslyDisableSandbox\": null,\n \"returnCodeInterpretation\": null,\n \"noOutputExpected\": false,\n \"structuredContent\": null,\n \"persistedOutputPath\": null,\n \"persistedOutputSize\": null,\n \"sandboxStatus\": {\n \"enabled\": true,\n \"requested\": {\n \"enabled\": true,\n \"namespace_restrictions\": true,\n \"network_isolation\": false,\n \"filesystem_mode\": \"workspace-only\",\n \"allowed_mounts\": []\n },\n \"supported\": true,\n \"active\": true,\n \"namespace_supported\": true,\n \"namespace_active\": true,\n \"network_supported\": true,\n \"network_active\": false,\n \"filesystem_mode\": \"workspace-only\",\n \"filesystem_active\": true,\n \"allowed_mounts\": [],\n \"in_container\": false,\n \"container_markers\": [],\n \"fallback_reason\": null\n }\n}","tool_name":"bash","tool_use_id":"toolu_01EuTzVfUK7iPRBvjZAovzfV","type":"tool_result"}],"role":"tool"},{"blocks":[{"text":"Bash tool is working perfectly! ✅\n\nHere's a quick summary:\n- **Status**: Operational 🎉\n- **Date**: Wed Apr 1, 2026\n- **Working directory**: `/home/bellman/Workspace/clawd-code/rust`\n- **Sandbox**: Enabled and active\n\nHow can I help you today?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4746,"output_tokens":84}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"who are you?","type":"text"}],"role":"user"},{"blocks":[{"text":"I'm **Claude**, an AI assistant built by Anthropic, currently running as a **Claude Code agent** (Claude Opus 4.6). I'm set up in your workspace at `/home/bellman/Workspace/clawd-code/rust` with the **oh-my-claudecode (OMC)** orchestration layer active.\n\nHere's what I can help you with:\n\n- **Software engineering tasks** — reading, writing, and refactoring code\n- **Debugging** — diagnosing issues, running tests, tracing errors\n- **Research & planning** — exploring codebases, understanding architecture, planning changes\n- **Shell commands** — building, testing, running tools in your environment\n- **Multi-agent workflows** — delegating specialized work (reviews, deep analysis, etc.) via OMC\n\nI have access to your local filesystem, can execute shell commands, search the web, and coordinate specialized sub-agents for complex tasks.\n\nWhat can I help you with today?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4292,"output_tokens":220}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"clear","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nI've cleared the conversation. How can I help you today?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4272,"output_tokens":17}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"exit","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nGoodbye! 👋","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4272,"output_tokens":10}}],"version":1}

View File

@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@ -1,5 +0,0 @@
{
"permissions": {
"defaultMode": "dontAsk"
}
}

View File

@ -1 +0,0 @@
{"created_at_ms":1775777421902,"session_id":"session-1775777421902-1","type":"session_meta","updated_at_ms":1775777421902,"version":1}

View File

@ -1,2 +0,0 @@
{"created_at_ms":1775386842352,"session_id":"session-1775386842352-0","type":"session_meta","updated_at_ms":1775386842352,"version":1}
{"message":{"blocks":[{"text":"doctor --help","type":"text"}],"role":"user"},"type":"message"}

View File

@ -1,2 +0,0 @@
{"created_at_ms":1775386852257,"session_id":"session-1775386852257-0","type":"session_meta","updated_at_ms":1775386852257,"version":1}
{"message":{"blocks":[{"text":"doctor --help","type":"text"}],"role":"user"},"type":"message"}

View File

@ -1,2 +0,0 @@
{"created_at_ms":1775386853666,"session_id":"session-1775386853666-0","type":"session_meta","updated_at_ms":1775386853666,"version":1}
{"message":{"blocks":[{"text":"status --help","type":"text"}],"role":"user"},"type":"message"}

View File

@ -1,27 +0,0 @@
[
{
"content": "Architecture & dependency analysis",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "Runtime crate deep analysis",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "CLI & Tools analysis",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "Code quality verification",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "Synthesize findings into unified report",
"activeForm": "Writing report",
"status": "in_progress"
}
]

View File

@ -1,15 +0,0 @@
# This .dockerignore applies to docker-compose build context: ./rust
target
**/target
.claw
.claw-rag
.claude
node_modules
dist
build
*.log
*.tmp
*.sqlite
*.sqlite-wal
*.sqlite-shm
.DS_Store

7
rust/.gitignore vendored
View File

@ -1,7 +0,0 @@
target/
.omx/
.clawd-agents/
# Claw Code local artifacts
.claw/settings.local.json
.claw/sessions/
.clawhip/

View File

@ -1,221 +0,0 @@
# TUI Enhancement Plan — Claw Code (`rusty-claude-cli`)
## Executive Summary
This plan covers a comprehensive analysis of the current terminal user interface and proposes phased enhancements that will transform the existing REPL/prompt CLI into a polished, modern TUI experience — while preserving the existing clean architecture and test coverage.
---
## 1. Current Architecture Analysis
### Crate Map
| Crate | Purpose | Lines | TUI Relevance |
|---|---|---|---|
| `rusty-claude-cli` | Main binary: REPL loop, arg parsing, rendering, API bridge | ~3,600 | **Primary TUI surface** |
| `runtime` | Session, conversation loop, config, permissions, compaction | ~5,300 | Provides data/state |
| `api` | Anthropic HTTP client + SSE streaming | ~1,500 | Provides stream events |
| `commands` | Slash command metadata/parsing/help | ~470 | Drives command dispatch |
| `tools` | 18 built-in tool implementations | ~3,500 | Tool execution display |
### Current TUI Components
| Component | File | What It Does Today | Quality |
|---|---|---|---|
| **Input** | `input.rs` (269 lines) | `rustyline`-based line editor with slash-command tab completion, Shift+Enter newline, history | ✅ Solid |
| **Rendering** | `render.rs` (641 lines) | Markdown→terminal rendering (headings, lists, tables, code blocks with syntect highlighting, blockquotes), spinner widget | ✅ Good |
| **App/REPL loop** | `main.rs` (3,159 lines) | The monolithic `LiveCli` struct: REPL loop, all slash command handlers, streaming output, tool call display, permission prompting, session management | ⚠️ Monolithic |
| **Alt App** | `app.rs` (398 lines) | An earlier `CliApp` prototype with `ConversationClient`, stream event handling, `TerminalRenderer`, output format support | ⚠️ Appears unused/legacy |
### Key Dependencies
- **crossterm 0.28** — terminal control (cursor, colors, clear)
- **pulldown-cmark 0.13** — Markdown parsing
- **syntect 5** — syntax highlighting
- **rustyline 15** — line editing with completion
- **serde_json** — tool I/O formatting
### Strengths
1. **Clean rendering pipeline**: Markdown rendering is well-structured with state tracking, table rendering, code highlighting
2. **Rich tool display**: Tool calls get box-drawing borders (`╭─ name ─╮`), results show ✓/✗ icons
3. **Comprehensive slash commands**: 15 commands covering model switching, permissions, sessions, config, diff, export
4. **Session management**: Full persistence, resume, list, switch, compaction
5. **Permission prompting**: Interactive Y/N approval for restricted tool calls
6. **Thorough tests**: Every formatting function, every parse path has unit tests
### Weaknesses & Gaps
1. **`main.rs` is a 3,159-line monolith** — all REPL logic, formatting, API bridging, session management, and tests in one file
2. **No alternate-screen / full-screen layout** — everything is inline scrolling output
3. **No progress bars** — only a single braille spinner; no indication of streaming progress or token counts during generation
4. **No visual diff rendering**`/diff` just dumps raw git diff text
5. **No syntax highlighting in streamed output** — markdown rendering only applies to tool results, not to the main assistant response stream
6. **No status bar / HUD** — model, tokens, session info not visible during interaction
7. **No image/attachment preview**`SendUserMessage` resolves attachments but never displays them
8. **Streaming is char-by-char with artificial delay**`stream_markdown` sleeps 8ms per whitespace-delimited chunk
9. **No color theme customization** — hardcoded `ColorTheme::default()`
10. **No resize handling** — no terminal size awareness for wrapping, truncation, or layout
11. **Dual app structs**`app.rs` has a separate `CliApp` that duplicates `LiveCli` from `main.rs`
12. **No pager for long outputs**`/status`, `/config`, `/memory` can overflow the viewport
13. **Tool results not collapsible** — large bash outputs flood the screen
14. **No thinking/reasoning indicator** — when the model is in "thinking" mode, no visual distinction
15. **No auto-complete for tool arguments** — only slash command names complete
---
## 2. Enhancement Plan
### Phase 0: Structural Cleanup (Foundation)
**Goal**: Break the monolith, remove dead code, establish the module structure for TUI work.
| Task | Description | Effort |
|---|---|---|
| 0.1 | **Extract `LiveCli` into `app.rs`** — Move the entire `LiveCli` struct, its impl, and helpers (`format_*`, `render_*`, session management) out of `main.rs` into focused modules: `app.rs` (core), `format.rs` (report formatting), `session_manager.rs` (session CRUD) | M |
| 0.2 | **Remove or merge the legacy `CliApp`** — The existing `app.rs` has an unused `CliApp` with its own `ConversationClient`-based rendering. Either delete it or merge its unique features (stream event handler pattern) into the active `LiveCli` | S |
| 0.3 | **Extract `main.rs` arg parsing** — The current `parse_args()` is a hand-rolled parser that duplicates the clap-based `args.rs`. Consolidate on the hand-rolled parser (it's more feature-complete) and move it to `args.rs`, or adopt clap fully | S |
| 0.4 | **Create a `tui/` module** — Introduce `crates/rusty-claude-cli/src/tui/mod.rs` as the namespace for all new TUI components: `status_bar.rs`, `layout.rs`, `tool_panel.rs`, etc. | S |
### Phase 1: Status Bar & Live HUD
**Goal**: Persistent information display during interaction.
| Task | Description | Effort |
|---|---|---|
| 1.1 | **Terminal-size-aware status line** — Use `crossterm::terminal::size()` to render a bottom-pinned status bar showing: model name, permission mode, session ID, cumulative token count, estimated cost | M |
| 1.2 | **Live token counter** — Update the status bar in real-time as `AssistantEvent::Usage` and `AssistantEvent::TextDelta` events arrive during streaming | M |
| 1.3 | **Turn duration timer** — Show elapsed time for the current turn (the `showTurnDuration` config already exists in Config tool but isn't wired up) | S |
| 1.4 | **Git branch indicator** — Display the current git branch in the status bar (already parsed via `parse_git_status_metadata`) | S |
### Phase 2: Enhanced Streaming Output
**Goal**: Make the main response stream visually rich and responsive.
| Task | Description | Effort |
|---|---|---|
| 2.1 | **Live markdown rendering** — Instead of raw text streaming, buffer text deltas and incrementally render Markdown as it arrives (heading detection, bold/italic, inline code). The existing `TerminalRenderer::render_markdown` can be adapted for incremental use | L |
| 2.2 | **Thinking indicator** — When extended thinking/reasoning is active, show a distinct animated indicator (e.g., `🧠 Reasoning...` with pulsing dots or a different spinner) instead of the generic `🦀 Thinking...` | S |
| 2.3 | **Streaming progress bar** — Add an optional horizontal progress indicator below the spinner showing approximate completion (based on max_tokens vs. output_tokens so far) | M |
| 2.4 | **Remove artificial stream delay** — The current `stream_markdown` sleeps 8ms per chunk. For tool results this is fine, but for the main response stream it should be immediate or configurable | S |
### Phase 3: Tool Call Visualization
**Goal**: Make tool execution legible and navigable.
| Task | Description | Effort |
|---|---|---|
| 3.1 | **Collapsible tool output** — For tool results longer than N lines (configurable, default 15), show a summary with `[+] Expand` hint; pressing a key reveals the full output. Initially implement as truncation with a "full output saved to file" fallback | M |
| 3.2 | **Syntax-highlighted tool results** — When tool results contain code (detected by tool name — `bash` stdout, `read_file` content, `REPL` output), apply syntect highlighting rather than rendering as plain text | M |
| 3.3 | **Tool call timeline** — For multi-tool turns, show a compact summary: `🔧 bash → ✓ | read_file → ✓ | edit_file → ✓ (3 tools, 1.2s)` after all tool calls complete | S |
| 3.4 | **Diff-aware edit_file display** — When `edit_file` succeeds, show a colored unified diff of the change instead of just `✓ edit_file: path` | M |
| 3.5 | **Permission prompt enhancement** — Style the approval prompt with box drawing, color the tool name, show a one-line summary of what the tool will do | S |
### Phase 4: Enhanced Slash Commands & Navigation
**Goal**: Improve information display and add missing features.
| Task | Description | Effort |
|---|---|---|
| 4.1 | **Colored `/diff` output** — Parse the git diff and render it with red/green coloring for removals/additions, similar to `delta` or `diff-so-fancy` | M |
| 4.2 | **Pager for long outputs** — When `/status`, `/config`, `/memory`, or `/diff` produce output longer than the terminal height, pipe through an internal pager (scroll with j/k/q) or external `$PAGER` | M |
| 4.3 | **`/search` command** — Add a new command to search conversation history by keyword | M |
| 4.4 | **`/undo` command** — Undo the last file edit by restoring from the `originalFile` data in `write_file`/`edit_file` tool results | M |
| 4.5 | **Interactive session picker** — Replace the text-based `/session list` with an interactive fuzzy-filterable list (up/down arrows to select, enter to switch) | L |
| 4.6 | **Tab completion for tool arguments** — Extend `SlashCommandHelper` to complete file paths after `/export`, model names after `/model`, session IDs after `/session switch` | M |
### Phase 5: Color Themes & Configuration
**Goal**: User-customizable visual appearance.
| Task | Description | Effort |
|---|---|---|
| 5.1 | **Named color themes** — Add `dark` (current default), `light`, `solarized`, `catppuccin` themes. Wire to the existing `Config` tool's `theme` setting | M |
| 5.2 | **ANSI-256 / truecolor detection** — Detect terminal capabilities and fall back gracefully (no colors → 16 colors → 256 → truecolor) | M |
| 5.3 | **Configurable spinner style** — Allow choosing between braille dots, bar, moon phases, etc. | S |
| 5.4 | **Banner customization** — Make the ASCII art banner optional or configurable via settings | S |
### Phase 6: Full-Screen TUI Mode (Stretch)
**Goal**: Optional alternate-screen layout for power users.
| Task | Description | Effort |
|---|---|---|
| 6.1 | **Add `ratatui` dependency** — Introduce `ratatui` (terminal UI framework) as an optional dependency for the full-screen mode | S |
| 6.2 | **Split-pane layout** — Top pane: conversation with scrollback; Bottom pane: input area; Right sidebar (optional): tool status/todo list | XL |
| 6.3 | **Scrollable conversation view** — Navigate past messages with PgUp/PgDn, search within conversation | L |
| 6.4 | **Keyboard shortcuts panel** — Show `?` help overlay with all keybindings | M |
| 6.5 | **Mouse support** — Click to expand tool results, scroll conversation, select text for copy | L |
---
## 3. Priority Recommendation
### Immediate (High Impact, Moderate Effort)
1. **Phase 0** — Essential cleanup. The 3,159-line `main.rs` is the #1 maintenance risk and blocks clean TUI additions.
2. **Phase 1.11.2** — Status bar with live tokens. Highest-impact UX win: users constantly want to know token usage.
3. **Phase 2.4** — Remove artificial delay. Low effort, immediately noticeable improvement.
4. **Phase 3.1** — Collapsible tool output. Large bash outputs currently wreck readability.
### Near-Term (Next Sprint)
5. **Phase 2.1** — Live markdown rendering. Makes the core interaction feel polished.
6. **Phase 3.2** — Syntax-highlighted tool results.
7. **Phase 3.4** — Diff-aware edit display.
8. **Phase 4.1** — Colored diff for `/diff`.
### Longer-Term
9. **Phase 5** — Color themes (user demand-driven).
10. **Phase 4.24.6** — Enhanced navigation and commands.
11. **Phase 6** — Full-screen mode (major undertaking, evaluate after earlier phases ship).
---
## 4. Architecture Recommendations
### Module Structure After Phase 0
```
crates/rusty-claude-cli/src/
├── main.rs # Entrypoint, arg dispatch only (~100 lines)
├── args.rs # CLI argument parsing (consolidate existing two parsers)
├── app.rs # LiveCli struct, REPL loop, turn execution
├── format.rs # All report formatting (status, cost, model, permissions, etc.)
├── session_mgr.rs # Session CRUD: create, resume, list, switch, persist
├── init.rs # Repo initialization (unchanged)
├── input.rs # Line editor (unchanged, minor extensions)
├── render.rs # TerminalRenderer, Spinner (extended)
└── tui/
├── mod.rs # TUI module root
├── status_bar.rs # Persistent bottom status line
├── tool_panel.rs # Tool call visualization (boxes, timelines, collapsible)
├── diff_view.rs # Colored diff rendering
├── pager.rs # Internal pager for long outputs
└── theme.rs # Color theme definitions and selection
```
### Key Design Principles
1. **Keep the inline REPL as the default** — Full-screen TUI should be opt-in (`--tui` flag)
2. **Everything testable without a terminal** — All formatting functions take `&mut impl Write`, never assume stdout directly
3. **Streaming-first** — Rendering should work incrementally, not buffering the entire response
4. **Respect `crossterm` for all terminal control** — Don't mix raw ANSI escape codes with crossterm (the current codebase does this in the startup banner)
5. **Feature-gate heavy dependencies**`ratatui` should be behind a `full-tui` feature flag
---
## 5. Risk Assessment
| Risk | Mitigation |
|---|---|
| Breaking the working REPL during refactor | Phase 0 is pure restructuring with existing test coverage as safety net |
| Terminal compatibility issues (tmux, SSH, Windows) | Rely on crossterm's abstraction; test in degraded environments |
| Performance regression with rich rendering | Profile before/after; keep the fast path (raw streaming) always available |
| Scope creep into Phase 6 | Ship Phases 03 as a coherent release before starting Phase 6 |
| `app.rs` vs `main.rs` confusion | Phase 0.2 explicitly resolves this by removing the legacy `CliApp` |
---
*Generated: 2026-03-31 | Workspace: `rust/` | Branch: `dev/rust`*

View File

@ -1,3 +0,0 @@
version = "12"
[overrides]

View File

@ -1,16 +0,0 @@
# CLAUDE.md
This file provides guidance to Claw Code (clawcode.dev) when working with code in this repository.
## Detected stack
- Languages: Rust.
- Frameworks: none detected from the supported starter markers.
## Verification
- From the repository root, run Rust formatting with `scripts/fmt.sh` (or `scripts/fmt.sh --check` for CI-style checks). From this `rust/` directory, the equivalent command is `../scripts/fmt.sh`. Root-level `cargo fmt --manifest-path rust/Cargo.toml` is not the supported formatting command.
- From this `rust/` directory, run Rust verification with `cargo clippy --workspace --all-targets -- -D warnings` and `cargo test --workspace`.
## Working agreement
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
- Keep shared defaults in `.claw.json`; reserve `.claw/settings.local.json` for machine-local overrides.
- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change.

View File

@ -1,51 +0,0 @@
# Mock LLM parity harness
This milestone adds a deterministic Anthropic-compatible mock service plus a reproducible CLI harness for the Rust `claw` binary.
## Artifacts
- `crates/mock-anthropic-service/` — mock `/v1/messages` service
- `crates/rusty-claude-cli/tests/mock_parity_harness.rs` — end-to-end clean-environment harness
- `scripts/run_mock_parity_harness.sh` — convenience wrapper
## Scenarios
The harness runs these scripted scenarios against a fresh workspace and isolated environment variables:
1. `streaming_text`
2. `read_file_roundtrip`
3. `grep_chunk_assembly`
4. `write_file_allowed`
5. `write_file_denied`
6. `multi_tool_turn_roundtrip`
7. `bash_stdout_roundtrip`
8. `bash_permission_prompt_approved`
9. `bash_permission_prompt_denied`
10. `plugin_tool_roundtrip`
11. `auto_compact_triggered`
12. `token_cost_reporting`
## Run
```bash
cd rust/
./scripts/run_mock_parity_harness.sh
```
Behavioral checklist / parity diff:
```bash
cd rust/
python3 scripts/run_mock_parity_diff.py
```
Scenario-to-PARITY mappings live in `mock_parity_scenarios.json`; keep this manifest aligned with `rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs` and `PARITY.md` via `python3 scripts/run_mock_parity_diff.py --no-run`.
## Manual mock server
```bash
cd rust/
cargo run -p mock-anthropic-service -- --bind 127.0.0.1:0
```
The server prints `MOCK_ANTHROPIC_BASE_URL=...`; point `ANTHROPIC_BASE_URL` at that URL and use any non-empty `ANTHROPIC_API_KEY`.

View File

@ -1,148 +0,0 @@
# Parity Status — claw-code Rust Port
Last updated: 2026-04-03
## Mock parity harness — milestone 1
- [x] Deterministic Anthropic-compatible mock service (`rust/crates/mock-anthropic-service`)
- [x] Reproducible clean-environment CLI harness (`rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`)
- [x] Scripted scenarios: `streaming_text`, `read_file_roundtrip`, `grep_chunk_assembly`, `write_file_allowed`, `write_file_denied`
## Mock parity harness — milestone 2 (behavioral expansion)
- [x] Scripted multi-tool turn coverage: `multi_tool_turn_roundtrip`
- [x] Scripted bash coverage: `bash_stdout_roundtrip`
- [x] Scripted permission prompt coverage: `bash_permission_prompt_approved`, `bash_permission_prompt_denied`
- [x] Scripted plugin-path coverage: `plugin_tool_roundtrip`
- [x] Behavioral diff/checklist runner: `rust/scripts/run_mock_parity_diff.py`
## Harness v2 behavioral checklist
Canonical scenario map: `rust/mock_parity_scenarios.json`
- Multi-tool assistant turns
- Bash flow roundtrips
- Permission enforcement across tool paths
- Plugin tool execution path
- File tools — harness-validated flows
## Completed Behavioral Parity Work
Hashes below come from `git log --oneline`. Merge line counts come from `git show --stat <merge>`.
| Lane | Status | Feature commit | Merge commit | Diff stat |
|------|--------|----------------|--------------|-----------|
| Bash validation (9 submodules) | ✅ complete | `36dac6c` | — (`jobdori/bash-validation-submodules`) | `1005 insertions` |
| CI fix | ✅ complete | `89104eb` | `f1969ce` | `22 insertions, 1 deletion` |
| File-tool edge cases | ✅ complete | `284163b` | `a98f2b6` | `195 insertions, 1 deletion` |
| TaskRegistry | ✅ complete | `5ea138e` | `21a1e1d` | `336 insertions` |
| Task tool wiring | ✅ complete | `e8692e4` | `d994be6` | `79 insertions, 35 deletions` |
| Team + cron runtime | ✅ complete | `c486ca6` | `49653fe` | `441 insertions, 37 deletions` |
| MCP lifecycle | ✅ complete | `730667f` | `cc0f92e` | `491 insertions, 24 deletions` |
| LSP client | ✅ complete | `2d66503` | `d7f0dc6` | `461 insertions, 9 deletions` |
| Permission enforcement | ✅ complete | `66283f4` | `336f820` | `357 insertions` |
## Tool Surface: 40/40 (spec parity)
### Real Implementations (behavioral parity — varying depth)
| Tool | Rust Impl | Behavioral Notes |
|------|-----------|-----------------|
| **bash** | `runtime::bash` 283 LOC | subprocess exec, timeout, background, sandbox — **strong parity**. 9/9 requested validation submodules are now tracked as complete via `36dac6c`, with on-main sandbox + permission enforcement runtime support |
| **read_file** | `runtime::file_ops` | offset/limit read — **good parity** |
| **write_file** | `runtime::file_ops` | file create/overwrite — **good parity** |
| **edit_file** | `runtime::file_ops` | old/new string replacement — **good parity**. Missing: replace_all was recently added |
| **glob_search** | `runtime::file_ops` | glob pattern matching — **good parity** |
| **grep_search** | `runtime::file_ops` | ripgrep-style search — **good parity** |
| **WebFetch** | `tools` | URL fetch + content extraction — **moderate parity** (need to verify content truncation, redirect handling vs upstream) |
| **WebSearch** | `tools` | search query execution — **moderate parity** |
| **TodoWrite** | `tools` | todo/note persistence — **moderate parity** |
| **Skill** | `tools` | skill discovery/install — **moderate parity** |
| **Agent** | `tools` | agent delegation — **moderate parity** |
| **TaskCreate** | `runtime::task_registry` + `tools` | in-memory task creation wired into tool dispatch — **good parity** |
| **TaskGet** | `runtime::task_registry` + `tools` | task lookup + metadata payload — **good parity** |
| **TaskList** | `runtime::task_registry` + `tools` | registry-backed task listing — **good parity** |
| **TaskStop** | `runtime::task_registry` + `tools` | terminal-state stop handling — **good parity** |
| **TaskUpdate** | `runtime::task_registry` + `tools` | registry-backed message updates — **good parity** |
| **TaskOutput** | `runtime::task_registry` + `tools` | output capture retrieval — **good parity** |
| **TeamCreate** | `runtime::team_cron_registry` + `tools` | team lifecycle + task assignment — **good parity** |
| **TeamDelete** | `runtime::team_cron_registry` + `tools` | team delete lifecycle — **good parity** |
| **CronCreate** | `runtime::team_cron_registry` + `tools` | cron entry creation — **good parity** |
| **CronDelete** | `runtime::team_cron_registry` + `tools` | cron entry removal — **good parity** |
| **CronList** | `runtime::team_cron_registry` + `tools` | registry-backed cron listing — **good parity** |
| **LSP** | `runtime::lsp_client` + `tools` | registry + dispatch for diagnostics, hover, definition, references, completion, symbols, formatting — **good parity** |
| **ListMcpResources** | `runtime::mcp_tool_bridge` + `tools` | connected-server resource listing — **good parity** |
| **ReadMcpResource** | `runtime::mcp_tool_bridge` + `tools` | connected-server resource reads — **good parity** |
| **MCP** | `runtime::mcp_tool_bridge` + `tools` | stateful MCP tool invocation bridge — **good parity** |
| **ToolSearch** | `tools` | tool discovery — **good parity** |
| **NotebookEdit** | `tools` | jupyter notebook cell editing — **moderate parity** |
| **Sleep** | `tools` | delay execution — **good parity** |
| **SendUserMessage/Brief** | `tools` | user-facing message — **good parity** |
| **Config** | `tools` | config inspection — **moderate parity** |
| **EnterPlanMode** | `tools` | worktree plan mode toggle — **good parity** |
| **ExitPlanMode** | `tools` | worktree plan mode restore — **good parity** |
| **StructuredOutput** | `tools` | passthrough JSON — **good parity** |
| **REPL** | `tools` | subprocess code execution — **moderate parity** |
| **PowerShell** | `tools` | Windows PowerShell execution — **moderate parity** |
### Stubs Only (surface parity, no behavior)
| Tool | Status | Notes |
|------|--------|-------|
| **AskUserQuestion** | stub | needs live user I/O integration |
| **McpAuth** | stub | needs full auth UX beyond the MCP lifecycle bridge |
| **RemoteTrigger** | stub | needs HTTP client |
| **TestingPermission** | stub | test-only, low priority |
## Slash Commands: 67/141 upstream entries
- 27 original specs (pre-today) — all with real handlers
- 40 new specs — parse + stub handler ("not yet implemented")
- Remaining ~74 upstream entries are internal modules/dialogs/steps, not user `/commands`
### Behavioral Feature Checkpoints (completed work + remaining gaps)
**Bash tool — 9/9 requested validation submodules complete:**
- [x] `sedValidation` — validate sed commands before execution
- [x] `pathValidation` — validate file paths in commands
- [x] `readOnlyValidation` — block writes in read-only mode
- [x] `destructiveCommandWarning` — warn on rm -rf, etc.
- [x] `commandSemantics` — classify command intent
- [x] `bashPermissions` — permission gating per command type
- [x] `bashSecurity` — security checks
- [x] `modeValidation` — validate against current permission mode
- [x] `shouldUseSandbox` — sandbox decision logic
Harness note: milestone 2 validates bash success plus workspace-write escalation approve/deny flows; dedicated validation submodules landed in `36dac6c`, and on-main runtime also carries sandbox + permission enforcement.
**File tools — completed checkpoint:**
- [x] Path traversal prevention (symlink following, ../ escapes)
- [x] Size limits on read/write
- [x] Binary file detection
- [x] Permission mode enforcement (read-only vs workspace-write)
Harness note: read_file, grep_search, write_file allow/deny, and multi-tool same-turn assembly are now covered by the mock parity harness; file edge cases + permission enforcement landed in `a98f2b6` and `336f820`.
**Config/Plugin/MCP flows:**
- [x] Full MCP server lifecycle (connect, list tools, call tool, disconnect)
- [ ] Plugin install/enable/disable/uninstall full flow
- [ ] Config merge precedence (user > project > local)
Harness note: external plugin discovery + execution is now covered via `plugin_tool_roundtrip`; MCP lifecycle landed in `cc0f92e`, while plugin lifecycle + config merge precedence remain open.
## Runtime Behavioral Gaps
- [x] Permission enforcement across all tools (read-only, workspace-write, danger-full-access)
- [ ] Output truncation (large stdout/file content)
- [ ] Session compaction behavior matching
- [ ] Token counting / cost tracking accuracy
- [x] Streaming response support validated by the mock parity harness
Harness note: current coverage now includes write-file denial, bash escalation approve/deny, and plugin workspace-write execution paths; permission enforcement landed in `336f820`.
## Migration Readiness
- [x] `PARITY.md` maintained and honest
- [ ] No `#[ignore]` tests hiding failures (only 1 allowed: `live_stream_smoke_test`)
- [ ] CI green on every commit
- [ ] Codebase shape clean for handoff

View File

@ -1,231 +0,0 @@
# 🦞 Claw Code — Rust Implementation
A high-performance Rust rewrite of the Claw Code CLI agent harness. Built for speed, safety, and native tool execution.
For a task-oriented guide with copy/paste examples, see [`../USAGE.md`](../USAGE.md).
## Quick Start
```bash
# Inspect available commands
cd rust/
cargo run -p rusty-claude-cli -- --help
# Build the workspace
cargo build --workspace
# Run the interactive REPL
cargo run -p rusty-claude-cli -- --model claude-opus-4-7
# One-shot prompt
cargo run -p rusty-claude-cli -- prompt "explain this codebase"
# JSON output for automation
cargo run -p rusty-claude-cli -- --output-format json prompt "summarize src/main.rs"
```
## Configuration
Set your API credentials:
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
# Or use a proxy
export ANTHROPIC_BASE_URL="https://your-proxy.com"
```
Or provide an OAuth bearer token directly:
```bash
export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
```
For local OpenAI-compatible servers such as Ollama, including Qwen reasoning
models, see [`../docs/local-openai-compatible-providers.md`](../docs/local-openai-compatible-providers.md).
Use the exact model tag exposed by the server, for example `qwen3:latest`, and
prefer `OLLAMA_HOST` for Ollama-specific local routing.
## Mock parity harness
The workspace now includes a deterministic Anthropic-compatible mock service and a clean-environment CLI harness for end-to-end parity checks.
```bash
cd rust/
# Run the scripted clean-environment harness
./scripts/run_mock_parity_harness.sh
# Or start the mock service manually for ad hoc CLI runs
cargo run -p mock-anthropic-service -- --bind 127.0.0.1:0
```
Harness coverage:
- `streaming_text`
- `read_file_roundtrip`
- `grep_chunk_assembly`
- `write_file_allowed`
- `write_file_denied`
- `multi_tool_turn_roundtrip`
- `bash_stdout_roundtrip`
- `bash_permission_prompt_approved`
- `bash_permission_prompt_denied`
- `plugin_tool_roundtrip`
Primary artifacts:
- `crates/mock-anthropic-service/` — reusable mock Anthropic-compatible service
- `crates/rusty-claude-cli/tests/mock_parity_harness.rs` — clean-env CLI harness
- `scripts/run_mock_parity_harness.sh` — reproducible wrapper
- `scripts/run_mock_parity_diff.py` — scenario checklist + PARITY mapping runner
- `mock_parity_scenarios.json` — scenario-to-PARITY manifest
## Features
| Feature | Status |
|---------|--------|
| Anthropic / OpenAI-compatible provider flows + streaming | ✅ |
| Direct bearer-token auth via `ANTHROPIC_AUTH_TOKEN` | ✅ |
| Interactive REPL (rustyline) | ✅ |
| Tool system (bash, read, write, edit, grep, glob) | ✅ |
| Web tools (search, fetch) | ✅ |
| Sub-agent / agent surfaces | ✅ |
| Todo tracking | ✅ |
| Notebook editing | ✅ |
| CLAUDE.md / CLAW.md / AGENTS.md project memory | ✅ |
| Config file hierarchy (`.claw.json` + merged config sections) | ✅ |
| Permission system | ✅ |
| MCP server lifecycle + inspection | ✅ |
| Session persistence + resume | ✅ |
| Cost / usage / stats surfaces | ✅ |
| Git integration | ✅ |
| Markdown terminal rendering (ANSI) | ✅ |
| Model aliases (opus/sonnet/haiku) | ✅ |
| Direct CLI subcommands (`status`, `sandbox`, `agents`, `mcp`, `skills`, `doctor`) | ✅ |
| Slash commands (including `/skills`, `/agents`, `/mcp`, `/doctor`, `/plugin`, `/subagent`) | ✅ |
| Hooks (`/hooks`, config-backed lifecycle hooks) | ✅ |
| Plugin management surfaces | ✅ |
| Skills inventory / install / uninstall surfaces | ✅ |
| Machine-readable JSON output across core CLI surfaces | ✅ |
## Model Aliases
Short names resolve to the latest model versions:
| Alias | Resolves To |
|-------|------------|
| `opus` | `claude-opus-4-7` |
| `sonnet` | `claude-sonnet-4-6` |
| `haiku` | `claude-haiku-4-5-20251213` |
## CLI Flags and Commands
Representative current surface:
```text
claw [OPTIONS] [COMMAND]
Flags:
--model MODEL
--output-format text|json (case-insensitive; CLAW_OUTPUT_FORMAT supplies the default, flags override env)
--permission-mode MODE
--cwd PATH, -C PATH, --directory PATH
--dangerously-skip-permissions, --skip-permissions
--allowedTools TOOLS canonical snake_case names or aliases; status JSON exposes allowed_tools.available/aliases
--resume [SESSION.jsonl|session-id|latest]
--version, -V
Top-level commands:
prompt <text>
help
version
status
sandbox
acp [serve]
dump-manifests
bootstrap-plan
agents
mcp
skills
system-prompt
init
```
`claw acp` is a local discoverability surface for editor-first users: it reports the current ACP/Zed status without starting the runtime. As of April 16, 2026, claw-code does **not** ship an ACP/Zed daemon or JSON-RPC entrypoint yet, and `claw acp serve` is only a status alias until the real protocol surface lands. Status queries exit 0 and expose the same machine-readable contract via `--output-format json`; malformed ACP invocations exit 1 with `kind: unsupported_acp_invocation`.
`--output-format` accepts `text` or `json` in any casing. `CLAW_OUTPUT_FORMAT=json` selects JSON as the default for non-interactive commands, explicit flags override it, repeated flags warn on stderr, and status JSON exposes `format_source`, `format_raw`, and `format_overridden`. Help and doctor output also surface `CLAW_LOG` / `RUST_LOG` as the logging environment knobs.
`claw version --output-format json` is the provenance probe for automation: it reports full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; the text report is available as `human_readable` instead of a duplicate `message` field.
`status --output-format json` reports loaded project memory files under `workspace.memory_files[]` with each file's `path`, `source` (`claude_md`, `claw_md`, `agents_md`, or scoped/rule sources), `origin`, `scope_path`, `outside_project`, `chars`, and `contributes`; `claw doctor --output-format json` includes a dedicated `memory` check. Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md`, discovery is bounded to the current git root when present (otherwise cwd only), and all non-duplicate loaded files contribute to the rendered system prompt.
`claw mcp --output-format json` reports partial MCP config success: valid servers remain in `servers[]` while malformed siblings appear in `invalid_servers[]`, with `total_configured`, `valid_count`, and `invalid_count` split out for automation. `status` mirrors this as `mcp_validation`, and doctor includes an `mcp validation` check.
`status --output-format json` also reports partial hook config success under `hook_validation`: valid hook entries are retained while malformed or unknown-event siblings appear in `invalid_hooks[]`, with `valid_count`, `invalid_count`, and typed `kind` fields (`invalid_hooks_config` or `unknown_hook_event`) for automation. `doctor --output-format json` includes a `hook validation` check, and `config --output-format json` includes `hook_validation` metadata with degraded status when invalid entries exist.
Shorthand prompt mode honors the POSIX `--` end-of-flags separator, so `claw -- "-prompt-with-dash"` and unknown dash-prefixed non-flag text stay on the prompt path instead of being treated as CLI options.
`claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory.
The command surface is moving quickly. For the canonical live help text, run:
```bash
cargo run -p rusty-claude-cli -- --help
```
## Slash Commands (REPL)
Tab completion expands slash commands, model aliases, permission modes, and recent session IDs.
The REPL now exposes a much broader surface than the original minimal shell:
- session / visibility: `/help`, `/status`, `/sandbox`, `/cost`, `/resume`, `/session`, `/version`, `/usage`, `/stats`
- workspace / git: `/compact`, `/clear`, `/config`, `/memory`, `/init`, `/diff`, `/commit`, `/pr`, `/issue`, `/export`, `/hooks`, `/files`, `/release-notes`
- discovery / debugging: `/mcp`, `/agents`, `/skills`, `/doctor`, `/tasks`, `/context`, `/desktop`
- automation / analysis: `/review`, `/advisor`, `/insights`, `/security-review`, `/subagent`, `/team`, `/telemetry`, `/providers`, `/cron`, and more
- plugin management: `/plugin` (with aliases `/plugins`, `/marketplace`)
Notable claw-first surfaces now available directly in slash form:
- `/skills [list|show <name>|install <path>|uninstall <name>|help]`
- `/agents [list|show <name>|create <name>|help]`
- `/mcp [list|show <server>|help]`
- `/doctor`
- `/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]`
- `/subagent [list|steer <target> <msg>|kill <id>]`
See [`../USAGE.md`](../USAGE.md) for usage examples and run `cargo run -p rusty-claude-cli -- --help` for the live canonical command list.
## Workspace Layout
```text
rust/
├── Cargo.toml # Workspace root
├── Cargo.lock
└── crates/
├── api/ # Provider clients + streaming + request preflight
├── commands/ # Shared slash-command registry + help rendering
├── compat-harness/ # Compatibility/parity harness utilities
├── mock-anthropic-service/ # Deterministic local Anthropic-compatible mock
├── plugins/ # Plugin metadata, manager, install/enable/disable surfaces
├── runtime/ # Session, config, permissions, MCP, prompts, auth/runtime loop
├── rusty-claude-cli/ # Main CLI binary (`claw`)
├── telemetry/ # Session tracing and usage telemetry types
└── tools/ # Built-in tools, skill resolution, tool search, agent runtime surfaces
```
### Crate Responsibilities
- **api** — provider clients, SSE streaming, request/response types, auth (`ANTHROPIC_API_KEY` + bearer-token support), request-size/context-window preflight
- **commands** — slash command definitions, parsing, help text generation, JSON/text command rendering
- **compat-harness** — compatibility and parity helpers for comparing behavior with upstream fixtures
- **mock-anthropic-service** — deterministic `/v1/messages` mock for CLI parity tests and local harness runs
- **plugins** — plugin metadata, install/enable/disable/update flows, plugin tool definitions, hook integration surfaces
- **runtime**`ConversationRuntime`, config loading, session persistence, permission policy, MCP client lifecycle, system prompt assembly, usage tracking
- **rusty-claude-cli** — REPL, one-shot prompt, direct CLI subcommands, streaming display, tool call rendering, CLI argument parsing
- **telemetry** — session trace events and supporting telemetry payloads
- **tools** — tool specs + execution: Bash, ReadFile, WriteFile, EditFile, GlobSearch, GrepSearch, WebSearch, WebFetch, Agent, TodoWrite, NotebookEdit, Skill, ToolSearch, and runtime-facing tool discovery
## Stats
- **~20K lines** of Rust
- **9 crates** in workspace
- **Binary name:** `claw`
- **Default model:** `claude-opus-4-7`
- **Default permissions:** `workspace-write`
## License
See repository root.

View File

@ -1,223 +0,0 @@
# TUI Enhancement Plan — Claw Code (`rusty-claude-cli`)
## Executive Summary
This plan covers a comprehensive analysis of the current terminal user interface and proposes phased enhancements that will transform the existing REPL/prompt CLI into a polished, modern TUI experience — while preserving the existing clean architecture and test coverage.
---
## 1. Current Architecture Analysis
### Crate Map
| Crate | Purpose | Lines | TUI Relevance |
|---|---|---|---|
| `rusty-claude-cli` | Main binary: REPL loop, arg parsing, rendering, API bridge | ~3,600 | **Primary TUI surface** |
| `runtime` | Session, conversation loop, config, permissions, compaction | ~5,300 | Provides data/state |
| `api` | Anthropic HTTP client + SSE streaming | ~1,500 | Provides stream events |
| `commands` | Slash command metadata/parsing/help | ~470 | Drives command dispatch |
| `tools` | 18 built-in tool implementations | ~3,500 | Tool execution display |
### Current TUI Components
> Note: The legacy prototype files `app.rs` and `args.rs` were removed on 2026-04-05.
> References below describe future extraction targets, not current tracked source files.
| Component | File | What It Does Today | Quality |
|---|---|---|---|
| **Input** | `input.rs` (269 lines) | `rustyline`-based line editor with slash-command tab completion, Shift+Enter newline, history | ✅ Solid |
| **Rendering** | `render.rs` (641 lines) | Markdown→terminal rendering (headings, lists, tables, code blocks with syntect highlighting, blockquotes), spinner widget | ✅ Good |
| **App/REPL loop** | `main.rs` (3,159 lines) | The monolithic `LiveCli` struct: REPL loop, all slash command handlers, streaming output, tool call display, permission prompting, session management | ⚠️ Monolithic |
### Key Dependencies
- **crossterm 0.28** — terminal control (cursor, colors, clear)
- **pulldown-cmark 0.13** — Markdown parsing
- **syntect 5** — syntax highlighting
- **rustyline 15** — line editing with completion
- **serde_json** — tool I/O formatting
### Strengths
1. **Clean rendering pipeline**: Markdown rendering is well-structured with state tracking, table rendering, code highlighting
2. **Rich tool display**: Tool calls get box-drawing borders (`╭─ name ─╮`), results show ✓/✗ icons
3. **Comprehensive slash commands**: 15 commands covering model switching, permissions, sessions, config, diff, export
4. **Session management**: Full persistence, resume, list, switch, compaction
5. **Permission prompting**: Interactive Y/N approval for restricted tool calls
6. **Thorough tests**: Every formatting function, every parse path has unit tests
### Weaknesses & Gaps
1. **`main.rs` is a 3,159-line monolith** — all REPL logic, formatting, API bridging, session management, and tests in one file
2. **No alternate-screen / full-screen layout** — everything is inline scrolling output
3. **No progress bars** — only a single braille spinner; no indication of streaming progress or token counts during generation
4. **No visual diff rendering**`/diff` just dumps raw git diff text
5. **No syntax highlighting in streamed output** — markdown rendering only applies to tool results, not to the main assistant response stream
6. **No status bar / HUD** — model, tokens, session info not visible during interaction
7. **No image/attachment preview**`SendUserMessage` resolves attachments but never displays them
8. **Streaming is char-by-char with artificial delay**`stream_markdown` sleeps 8ms per whitespace-delimited chunk
9. **No color theme customization** — hardcoded `ColorTheme::default()`
10. **No resize handling** — no terminal size awareness for wrapping, truncation, or layout
11. **Historical dual app split** — the repo previously carried a separate `CliApp` prototype alongside `LiveCli`; the prototype is gone, but the monolithic `main.rs` still needs extraction
12. **No pager for long outputs**`/status`, `/config`, `/memory` can overflow the viewport
13. **Tool results not collapsible** — large bash outputs flood the screen
14. **No thinking/reasoning indicator** — when the model is in "thinking" mode, no visual distinction
15. **No auto-complete for tool arguments** — only slash command names complete
---
## 2. Enhancement Plan
### Phase 0: Structural Cleanup (Foundation)
**Goal**: Break the monolith, remove dead code, establish the module structure for TUI work.
| Task | Description | Effort |
|---|---|---|
| 0.1 | **Extract `LiveCli` into `app.rs`** — Move the entire `LiveCli` struct, its impl, and helpers (`format_*`, `render_*`, session management) out of `main.rs` into focused modules: `app.rs` (core), `format.rs` (report formatting), `session_manager.rs` (session CRUD) | M |
| 0.2 | **Keep the legacy `CliApp` removed** — The old `CliApp` prototype has already been deleted; if any unique ideas remain valuable (for example stream event handler patterns), reintroduce them intentionally inside the active `LiveCli` extraction rather than restoring the old file wholesale | S |
| 0.3 | **Extract `main.rs` arg parsing** — The current `parse_args()` is still a hand-rolled parser in `main.rs`. If parsing is extracted later, do it into a newly-introduced module intentionally rather than reviving the removed prototype `args.rs` by accident | S |
| 0.4 | **Create a `tui/` module** — Introduce `crates/rusty-claude-cli/src/tui/mod.rs` as the namespace for all new TUI components: `status_bar.rs`, `layout.rs`, `tool_panel.rs`, etc. | S |
### Phase 1: Status Bar & Live HUD
**Goal**: Persistent information display during interaction.
| Task | Description | Effort |
|---|---|---|
| 1.1 | **Terminal-size-aware status line** — Use `crossterm::terminal::size()` to render a bottom-pinned status bar showing: model name, permission mode, session ID, cumulative token count, estimated cost | M |
| 1.2 | **Live token counter** — Update the status bar in real-time as `AssistantEvent::Usage` and `AssistantEvent::TextDelta` events arrive during streaming | M |
| 1.3 | **Turn duration timer** — Show elapsed time for the current turn (the `showTurnDuration` config already exists in Config tool but isn't wired up) | S |
| 1.4 | **Git branch indicator** — Display the current git branch in the status bar (already parsed via `parse_git_status_metadata`) | S |
### Phase 2: Enhanced Streaming Output
**Goal**: Make the main response stream visually rich and responsive.
| Task | Description | Effort |
|---|---|---|
| 2.1 | **Live markdown rendering** — Instead of raw text streaming, buffer text deltas and incrementally render Markdown as it arrives (heading detection, bold/italic, inline code). The existing `TerminalRenderer::render_markdown` can be adapted for incremental use | L |
| 2.2 | **Thinking indicator** — When extended thinking/reasoning is active, show a distinct animated indicator (e.g., `🧠 Reasoning...` with pulsing dots or a different spinner) instead of the generic `🦀 Thinking...` | S |
| 2.3 | **Streaming progress bar** — Add an optional horizontal progress indicator below the spinner showing approximate completion (based on max_tokens vs. output_tokens so far) | M |
| 2.4 | **Remove artificial stream delay** — The current `stream_markdown` sleeps 8ms per chunk. For tool results this is fine, but for the main response stream it should be immediate or configurable | S |
### Phase 3: Tool Call Visualization
**Goal**: Make tool execution legible and navigable.
| Task | Description | Effort |
|---|---|---|
| 3.1 | **Collapsible tool output** — For tool results longer than N lines (configurable, default 15), show a summary with `[+] Expand` hint; pressing a key reveals the full output. Initially implement as truncation with a "full output saved to file" fallback | M |
| 3.2 | **Syntax-highlighted tool results** — When tool results contain code (detected by tool name — `bash` stdout, `read_file` content, `REPL` output), apply syntect highlighting rather than rendering as plain text | M |
| 3.3 | **Tool call timeline** — For multi-tool turns, show a compact summary: `🔧 bash → ✓ | read_file → ✓ | edit_file → ✓ (3 tools, 1.2s)` after all tool calls complete | S |
| 3.4 | **Diff-aware edit_file display** — When `edit_file` succeeds, show a colored unified diff of the change instead of just `✓ edit_file: path` | M |
| 3.5 | **Permission prompt enhancement** — Style the approval prompt with box drawing, color the tool name, show a one-line summary of what the tool will do | S |
### Phase 4: Enhanced Slash Commands & Navigation
**Goal**: Improve information display and add missing features.
| Task | Description | Effort |
|---|---|---|
| 4.1 | **Colored `/diff` output** — Parse the git diff and render it with red/green coloring for removals/additions, similar to `delta` or `diff-so-fancy` | M |
| 4.2 | **Pager for long outputs** — When `/status`, `/config`, `/memory`, or `/diff` produce output longer than the terminal height, pipe through an internal pager (scroll with j/k/q) or external `$PAGER` | M |
| 4.3 | **`/search` command** — Add a new command to search conversation history by keyword | M |
| 4.4 | **`/undo` command** — Undo the last file edit by restoring from the `originalFile` data in `write_file`/`edit_file` tool results | M |
| 4.5 | **Interactive session picker** — Replace the text-based `/session list` with an interactive fuzzy-filterable list (up/down arrows to select, enter to switch) | L |
| 4.6 | **Tab completion for tool arguments** — Extend `SlashCommandHelper` to complete file paths after `/export`, model names after `/model`, session IDs after `/session switch` | M |
### Phase 5: Color Themes & Configuration
**Goal**: User-customizable visual appearance.
| Task | Description | Effort |
|---|---|---|
| 5.1 | **Named color themes** — Add `dark` (current default), `light`, `solarized`, `catppuccin` themes. Wire to the existing `Config` tool's `theme` setting | M |
| 5.2 | **ANSI-256 / truecolor detection** — Detect terminal capabilities and fall back gracefully (no colors → 16 colors → 256 → truecolor) | M |
| 5.3 | **Configurable spinner style** — Allow choosing between braille dots, bar, moon phases, etc. | S |
| 5.4 | **Banner customization** — Make the ASCII art banner optional or configurable via settings | S |
### Phase 6: Full-Screen TUI Mode (Stretch)
**Goal**: Optional alternate-screen layout for power users.
| Task | Description | Effort |
|---|---|---|
| 6.1 | **Add `ratatui` dependency** — Introduce `ratatui` (terminal UI framework) as an optional dependency for the full-screen mode | S |
| 6.2 | **Split-pane layout** — Top pane: conversation with scrollback; Bottom pane: input area; Right sidebar (optional): tool status/todo list | XL |
| 6.3 | **Scrollable conversation view** — Navigate past messages with PgUp/PgDn, search within conversation | L |
| 6.4 | **Keyboard shortcuts panel** — Show `?` help overlay with all keybindings | M |
| 6.5 | **Mouse support** — Click to expand tool results, scroll conversation, select text for copy | L |
---
## 3. Priority Recommendation
### Immediate (High Impact, Moderate Effort)
1. **Phase 0** — Essential cleanup. The 3,159-line `main.rs` is the #1 maintenance risk and blocks clean TUI additions.
2. **Phase 1.11.2** — Status bar with live tokens. Highest-impact UX win: users constantly want to know token usage.
3. **Phase 2.4** — Remove artificial delay. Low effort, immediately noticeable improvement.
4. **Phase 3.1** — Collapsible tool output. Large bash outputs currently wreck readability.
### Near-Term (Next Sprint)
5. **Phase 2.1** — Live markdown rendering. Makes the core interaction feel polished.
6. **Phase 3.2** — Syntax-highlighted tool results.
7. **Phase 3.4** — Diff-aware edit display.
8. **Phase 4.1** — Colored diff for `/diff`.
### Longer-Term
9. **Phase 5** — Color themes (user demand-driven).
10. **Phase 4.24.6** — Enhanced navigation and commands.
11. **Phase 6** — Full-screen mode (major undertaking, evaluate after earlier phases ship).
---
## 4. Architecture Recommendations
### Module Structure After Phase 0
```
crates/rusty-claude-cli/src/
├── main.rs # Entrypoint, arg dispatch only (~100 lines)
├── args.rs # CLI argument parsing (consolidate existing two parsers)
├── app.rs # LiveCli struct, REPL loop, turn execution
├── format.rs # All report formatting (status, cost, model, permissions, etc.)
├── session_mgr.rs # Session CRUD: create, resume, list, switch, persist
├── init.rs # Repo initialization (unchanged)
├── input.rs # Line editor (unchanged, minor extensions)
├── render.rs # TerminalRenderer, Spinner (extended)
└── tui/
├── mod.rs # TUI module root
├── status_bar.rs # Persistent bottom status line
├── tool_panel.rs # Tool call visualization (boxes, timelines, collapsible)
├── diff_view.rs # Colored diff rendering
├── pager.rs # Internal pager for long outputs
└── theme.rs # Color theme definitions and selection
```
### Key Design Principles
1. **Keep the inline REPL as the default** — Full-screen TUI should be opt-in (`--tui` flag)
2. **Everything testable without a terminal** — All formatting functions take `&mut impl Write`, never assume stdout directly
3. **Streaming-first** — Rendering should work incrementally, not buffering the entire response
4. **Respect `crossterm` for all terminal control** — Don't mix raw ANSI escape codes with crossterm (the current codebase does this in the startup banner)
5. **Feature-gate heavy dependencies**`ratatui` should be behind a `full-tui` feature flag
---
## 5. Risk Assessment
| Risk | Mitigation |
|---|---|
| Breaking the working REPL during refactor | Phase 0 is pure restructuring with existing test coverage as safety net |
| Terminal compatibility issues (tmux, SSH, Windows) | Rely on crossterm's abstraction; test in degraded environments |
| Performance regression with rich rendering | Profile before/after; keep the fast path (raw streaming) always available |
| Scope creep into Phase 6 | Ship Phases 03 as a coherent release before starting Phase 6 |
| Historical `app.rs` vs `main.rs` confusion | Keep the legacy prototype removed and avoid reintroducing a second app surface accidentally during extraction |
---
*Generated: 2026-03-31 | Workspace: `rust/` | Branch: `dev/rust`*

View File

@ -1,11 +0,0 @@
# Rust usage guide
The canonical task-oriented usage guide lives at [`../USAGE.md`](../USAGE.md).
Use that guide for:
- workspace build and test commands
- authentication setup
- interactive and one-shot `claw` examples
- session resume workflows
- mock parity harness commands

8
rust/clawcode/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
rust/target/
# Temporary local debugging tools
dump_output.txt
# Per-project conversation transcripts (terminal-visible mirror of sessions)
.claw/transcripts/
# Local environment secrets — never commit live API keys
claw/.env
claw/.env_deepseek

26
rust/clawcode/CLAUDE.md Normal file
View File

@ -0,0 +1,26 @@
### Role
You serve as a senior systems engineer with deep expertise in Rust, TypeScript, Bat, and Shell scripting. Deliver expert-level analysis and solutions across these domains. Prioritize first-principles reasoning, explicit trade-off analysis, and root-cause diagnosis over symptomatic surface fixes.
### Writing standards
- Support conceptual explanation with tangible examples.
- Reply using the user's language. Write all code blocks, technical identifiers, and code comments in English.
- Apply bold formatting selectively to mark core viewpoints and critical constraints.
- Represent tabular data via Markdown table syntax for clearer visual hierarchy.
- Write standardized, valid Mermaid syntax and produce neatly structured, legible diagrams matching user requirements.
- The implementation requires explicit lifetime annotations.
### Rationale & Trade-offs
1. **Semantic precision**: The rule focuses emphasis on key points and critical constraints, preserving highlighting weight by keeping usage selective.
2. **Logical grouping**: The rule is placed alongside other typography rules (character set, table syntax) to group all formatting constraints, maintaining a clear hierarchical rule structure.
3. **Tone alignment**: Adopts formal, engineering-standard phrasing (`judiciously`, `scannability`) consistent with the rest of the specification, with no colloquial wording.
### Execution Rules
- Validate all code for correctness and edge-case coverage before output.
- Treat all bracketed instructions as mandatory requirements.
### Tool Preference
- Prefer `rg` (ripgrep) over `grep` or `read` for code search, and `fd` for file search.
- Use `bash` to run `rg`.
- **Caveat**: `rg`/`fd` silently return zero results on Chinese/non-ASCII paths in Git Bash on Windows. For non-ASCII paths, fall back to PowerShell (`Get-ChildItem | Select-String`) or `read_file` instead of assuming the file is missing.
### Windows Shell Interop
- `bash` is the host shell; PowerShell runs as a child process via `powershell -Command '...'`.
- **Always wrap PowerShell commands in single quotes at the bash layer.** Under double quotes, bash expands `$_`, `$env:`, `$args` first and breaks the PowerShell script.
- For real user paths (Desktop, Documents, etc.), use `[Environment]::GetFolderPath('Desktop')``$USERPROFILE` may be sandbox-redirected to a virtual location.
- Prefer ASCII filenames for shell-manipulated artifacts; rename non-ASCII names with PowerShell, not `mv`.
- Environment runs with high privileges and no sandbox restrictions: write files and run commands directly, and confirm the target path before destructive or wide-scope operations.

Binary file not shown.

21
rust/clawcode/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 huagusam
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

199
rust/clawcode/README.md Normal file
View File

@ -0,0 +1,199 @@
# Claw Code
A terminal-native AI coding assistant built in Rust. Connects to Anthropic's Messages API and OpenAI-compatible providers (LM Studio, Ollama, vLLM, OpenRouter). Features a full REPL, MCP integration, WASM-based plugin system, agent delegation, and a permission-gated tool ecosystem.
![Terminal](terminal.png)
## Project Origin
This project was developed from a reset of the Claudecode project by UltraWorkers AI. Extensive work was done to make the project functional, with large-scale, wide-ranging modifications — only a small portion of the original code remains. This project holds significant value.
### Crate-Level Changes vs Original
**Removed crates (3):**
| Crate | Description |
|---|---|
| `claw-analog/` | Original main binary — replaced by `claw-cli` |
| `claw-rag-service/` | RAG retrieval service (Qdrant + embeddings) — fully removed |
| `rusty-claude-cli/` | Old CLI layer — merged into `claw-cli` |
**Added crates (4):**
| Crate | Description |
|---|---|
| `agents/` | Agent delegation engine (spawn, discovery, persist, runtime) |
| `claw-cli/` | New main CLI binary (icons, build.rs, config_wizard, picker, render) |
| `migrate-patch-names/` | One-shot patch-name migration utility |
| `plugin-types/` | Plugin shared types (config, lifecycle, MCP) |
**Shared crate changes:**
| Crate | Changes |
|---|---|
| `api/` | Added `convert.rs`, `incremental_body.rs`; `providers/` fully rewritten (anthropic, openai_compat); `error.rs` restructured |
| `commands/` | `lib.rs` slimmed; extracted `handler.rs`, `registry.rs`, `path_extract.rs`, `plugin_agents.rs` |
| `plugins/` | Removed bundled example hooks; added `frontmatter.rs`, `claude_settings.rs`; `lib.rs` expanded |
| `runtime/` | **Most heavily changed** — removed 8 files (approval_tokens, g004_conformance, mcp_tool_bridge, report_schema, trident, worker_boot, etc.); added 18 new files (thinking/ module, tool_registry/ module, boundary, context, image_*, text_only_models, bash_job_object_ffi, etc.); `config.rs` significantly trimmed |
| `tools/` | `lib.rs` massively refactored; added `excel_extract.rs`, `word_extract.rs`, `subagent_overlay.rs`; removed legacy docs and tests |
**Summary:** 13 original crates → 14 crates. Net deletion of ~15,000+ lines from removed crates, ~3,000+ lines in new crates. `runtime/` and `tools/` underwent architectural-level restructuring.
## Features
- **Dual Provider** — Anthropic Claude + any OpenAI-compatible endpoint (local or cloud)
- **REPL & One-Shot** — Interactive session or single `claw "prompt"` invocation
- **MCP** — Full Model Context Protocol over stdio, SSE, remote, and OAuth
- **Plugins** — WASM-based extensions with versioned marketplace
- **Agents**`@agent` delegation for sub-task parallelism
- **Skills** — Composable workflows via `/skill` slash commands
- **Tools** — Bash, file R/W/E, grep, glob, PDF/Excel/Word extraction, web
- **Permissions** — ReadOnly / WorkspaceWrite / DangerFullAccess tiers
- **Session Persistence** — Save / resume / export to JSONL
## Quick Start
### Prerequisites
- Rust 2021 edition
- MSVC + Clang-CL 22.x (see `CompilePreSet.bat`)
- NASM, Perl (optional, for OpenSSL)
### Tool Dependencies
- **Git Bash** must be installed at `C:\Program Files\Git`. Download from [git-scm.com](https://git-scm.com) (use "Portable" or "Full installer" — either works).
- **ripgrep** (`rg.exe`) — place in `C:\Program Files\Git\bin`. Repository: [github.com/BurntSushi/ripgrep](https://github.com/BurntSushi/ripgrep). Download from [releases](https://github.com/BurntSushi/ripgrep/releases) (Windows zip, extract `rg.exe`).
- **fd** (`fd.exe`) — place in `C:\Program Files\Git\bin`. Repository: [github.com/sharkdp/fd](https://github.com/sharkdp/fd). Download from [releases](https://github.com/sharkdp/fd/releases) (Windows zip, extract `fd.exe`).
> Place `claw.exe` in a directory that is on your system `PATH`. If unsure where to put it, drop it in the Git Bash `bin\` directory alongside `rg.exe` and `fd.exe`.
### Build
```bat
CompilePreSet.bat && cargo build --release
```
### Run
```bat
start.bat
```
Or with a local LLM via LM Studio:
```bat
run_local_openai.bat
```
### Configure
Reference config lives in `claw/` — place the files placed in it to the project root to .claw/ for per-project settings, or at `~/.claw/` for a global user-level config. Copy `.env.example` to `.claw/.env` and set your API key or local endpoint.
### Text-Only Model Configuration
If your LLM does not support image (multimodal) input — common for local/self-hosted models — add its exact name to `LLM_ONLY_MODEL.config`:
- **User-level** (all projects): `~/.claw/LLM_ONLY_MODEL.config`
- **Project-level** (per repo): `.claw/LLM_ONLY_MODEL.config` (walks ancestor dirs)
The model name must match what is sent in the API `model` field. Examples:
```conf
# Exact match
deepseek-v4-flash
# Substring match — matches any ID containing "llama-3"
llama-3
# Prefix match — matches any ID starting with "gpt-"
gpt-:
```
When a model is listed, `Image` and `ImageRef` blocks are replaced with `[Image attached: ...] (not supported by this model)` text placeholders, preventing API errors.
### WebSearch Configuration
Put `web_search_url.json` in `~/.claw/` (global) or `.claw/` (project) to add extra search providers:
```json
{
"url_1": {
"enable": true,
"url": "https://www.bing.com/search?q={search} site:github.com"
}
}
```
**Built-in default** (no file needed): `url_0` = general Bing search (`q={search}`), always active.
Slots `url_1``url_4` are empty and disabled by default.
The config file can add or override `url_1` through `url_4` for site-specific searches.
Built-in `url_0` is always present and provides unrestricted search results alongside
your custom providers. Toggle any entry on/off with `"enable": true` / `"enable": false`.
**`{search}` placeholder:** The keyword and everything after `{search}` in the URL template
is percent-encoded together as a single query value. Use a literal space (not `%20`) between
`{search}` and any suffix — the space is encoded automatically.
Example with query `ardour` and the template above:
```
Template: https://www.bing.com/search?q={search} site:github.com
Suffix extracted: site:github.com
Keyword + suffix combined: ardour site:github.com
Percent-encoded query: ardour%20site%3Agithub.com
Final request: GET https://www.bing.com/search?q=ardour%20site%3Agithub.com
```
Multiple enabled providers run in parallel; all results are aggregated.
### Claude Code Plugin Compatibility
Claw Code auto-loads plugins from `~/.claude/plugins/` — any Claude Code plugin installed there is available without additional setup.
## Project Structure
```
Claw Code/
├── claw/ # Config (project-local; or use ~/.claw/ for global)
│ ├── .env
│ ├── .env.example
│ ├── CLAUDE.md
│ ├── LLM_ONLY_MODEL.config
│ ├── settings.json
│ ├── web_search_url.json
│ ├── agents/ # Sub-agent definitions
│ └── skills/ # Skill workflow definitions
├── rust/ # Rust workspace (binary: claw)
│ ├── Cargo.toml
│ ├── crates/
│ │ ├── agents/ # Agent delegation engine
│ │ ├── api/ # Provider-agnostic API client
│ │ ├── claw-cli/ # Main CLI binary entrypoint
│ │ ├── commands/ # Slash commands, skills, MCP dispatch
│ │ ├── compat-harness/ # Claude Code project manifest compat
│ │ ├── migrate-patch-names/ # One-shot patch-name migration tool
│ │ ├── mock-anthropic-service/ # Test mock
│ │ ├── plugin-types/ # Plugin shared types
│ │ ├── plugins/ # WASM plugin loader & marketplace
│ │ ├── runtime/ # Core engine: config, MCP, permissions
│ │ ├── telemetry/ # Analytics infrastructure
│ │ └── tools/ # Tool implementations
│ └── target/
├── CompilePreSet.bat # MSVC + Clang-CL environment
├── build_rust_clang_msvc.bat # Build script
├── build_rust_clang_msvc_test.bat
├── start.bat # Launch with VS2022 env
├── startenv.bat # Launch with full env setup
├── run_local_openai.bat # Launch against LM Studio
├── dump_server.py # Request dump server (debugging)
├── CLAUDE.md
├── terminal.png
└── LICENSE # MIT
```
## License
MIT

View File

@ -0,0 +1,66 @@
# =============================================================================
# Claw Code — Environment Configuration
# =============================================================================
# Copy this file to .env and fill in your values.
# Minimum required: ANTHROPIC_API_KEY (cloud) or ANTHROPIC_BASE_URL (local).
# =============================================================================
# --- API Configuration (pick one mode) ---------------------------------------
# Mode A: Anthropic API (cloud)
#ANTHROPIC_API_KEY=${YOUR_API_KEY}
#ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
# Mode B: Local LLM via OpenAI-compatible endpoint (LM Studio, Ollama, etc.)
# OPENAI_BASE_URL=http://127.0.0.1:1234
# OPENAI_API_KEY=dummy
# Model override (prefix with "openai/" to force OpenAI adapter)
# ANTHROPIC_MODEL=claude-sonnet-4-20250514
# Sampling temperature (0.02.0). Overridden by --temperature flag and /temperature.
# CLAW_TEMPERATURE=0.7
# --- Paths -------------------------------------------------------------------
# Custom config directory (default: ~/.claw or ~/.config/claw)
# CLAW_CONFIG_HOME=/path/to/.claw
# Claude Code config directory (for compatibility)
# CLAUDE_CONFIG_DIR=/path/to/.claude
# --- Runtime -----------------------------------------------------------------
# Workspace policy: "allow" to skip confirmation prompts
# CLAW_WORKSPACE_POLICY=allow
# --- Compression / Context Budget --------------------------------------------
# Minimum tool result bytes before summarization kicks in (default: 500)
# CLAW_TOOLRESULT_MIN_BYTES=500
# Number of recent messages to preserve verbatim (not compressed) (default: 6)
# CLAW_CONTEXT_PRESERVE_MSGS=6
# WebSearch result TTL in seconds before it gets summarized (default: 15)
# CLAW_WEBSEARCH_TTL_SECS=15
# WebFetch result TTL in seconds before it gets summarized (default: 30)
# CLAW_WEBFETCH_TTL_SECS=30
# Recent messages to keep during compaction (default: 4)
# CLAW_COMPACT_PRESERVE_MSGS=4
# Token budget for recent messages during compaction (default: 2000)
# CLAW_COMPACT_PRESERVE_TOKENS=2000
# Max estimated tokens before forced compaction (default: 10000)
# CLAW_COMPACT_MAX_TOKENS=10000
# Number of full turns to preserve during compaction (default: 0)
# CLAW_COMPACT_PRESERVE_TURNS=0
# Summary truncation limits (default: max_chars=1200, max_lines=24, max_line_chars=160)
# CLAW_SUMMARY_MAX_CHARS=1200
# CLAW_SUMMARY_MAX_LINES=24
# CLAW_SUMMARY_MAX_LINE_CHARS=160
# Anti-thrash ratio — skip compaction if savings ratio is below this (0.01.0, default: 0.10)
# CLAW_COMPACT_ANTITHRASH_RATIO=0.10

View File

@ -0,0 +1,29 @@
### Role
You serve as a senior systems engineer with deep expertise in Rust, TypeScript, Bat, and Shell scripting. Deliver expert-level analysis and solutions across these domains. Prioritize first-principles reasoning, explicit trade-off analysis, and root-cause diagnosis over symptomatic surface fixes.
### Writing standards
- Support conceptual explanation with tangible examples.
- Reply using the user's language. Write all code blocks, technical identifiers, and code comments in English.
- Apply bold formatting selectively to mark core viewpoints and critical constraints.
- Represent tabular data via Markdown table syntax for clearer visual hierarchy.
- Write standardized, valid Mermaid syntax and produce neatly structured, legible diagrams matching user requirements.
- The implementation requires explicit lifetime annotations.
### Rationale & Trade-offs
1. **Semantic precision**: The rule focuses emphasis on key points and critical constraints, preserving highlighting weight by keeping usage selective.
2. **Logical grouping**: The rule is placed alongside other typography rules (character set, table syntax) to group all formatting constraints, maintaining a clear hierarchical rule structure.
3. **Tone alignment**: Adopts formal, engineering-standard phrasing (`judiciously`, `scannability`) consistent with the rest of the specification, with no colloquial wording.
### Execution Rules
- Validate all code for correctness and edge-case coverage before output.
- Treat all bracketed instructions as mandatory requirements.
### Tool Preference
- Prefer `rg` (ripgrep) over `grep` or `read` for code search, and `fd` for file search.
- Use `bash` to run `rg`.
- **Caveat**: `rg`/`fd` silently return zero results on Chinese/non-ASCII paths in Git Bash on Windows. For non-ASCII paths, fall back to PowerShell (`Get-ChildItem | Select-String`) or `read_file` instead of assuming the file is missing.
### Windows Shell Interop
- `bash` is the host shell; PowerShell runs as a child process via `powershell -Command '...'`.
- **Always wrap PowerShell commands in single quotes at the bash layer.** Under double quotes, bash expands `$_`, `$env:`, `$args` first and breaks the PowerShell script.
- For real user paths (Desktop, Documents, etc.), use `[Environment]::GetFolderPath('Desktop')``$USERPROFILE` may be sandbox-redirected to a virtual location.
- Prefer ASCII filenames for shell-manipulated artifacts; rename non-ASCII names with PowerShell, not `mv`.
- Environment runs with high privileges and no sandbox restrictions: write files and run commands directly, and confirm the target path before destructive or wide-scope operations.
### Python
- Default: `cpython-3.11.14-windows-x86_64-none` at `C:\Users\%USERNAME%\AppData\Roaming\uv\python\cpython-3.11.14-windows-x86_64-none\python.exe`
- Use `uv` for Python version management and package installations

View File

@ -0,0 +1,32 @@
# LLM_ONLY_MODEL.config
#
# Lists LLM models that do NOT support image input (text-only).
# When a model appears in this list, any Image/ImageRef blocks in
# user messages are replaced with a text placeholder before sending
# to the API, preventing API errors from multimodal content.
#
# Loading order (merged, deduplicated):
# 1. Project-level: {cwd}/.claw/LLM_ONLY_MODEL.config (walks ancestors)
# 2. User-level: ~/.claw/LLM_ONLY_MODEL.config (this file)
#
# Format: one model specifier per line.
# - Full model ID: claude-opus-4-6
# - Substring: claude-opus (matches any ID containing "claude-opus")
# - Prefix match: gpt-: (matches any ID starting with "gpt-")
# Comments start with #, empty lines ignored.
# Matching is case-insensitive.
# Common text-only models:
# claude-opus-4-6
# gpt-4
# gpt-4-turbo
# gpt-4o-mini
# gpt-3.5-turbo
# llama-3.1-8b
# llama-3.1-70b
# llama-3.1-405b
# mixtral-8x7b
# Add your text-only models below:
deepseek-v4-flash
MoQ-5.4

View File

@ -0,0 +1,220 @@
---
description: Software architecture specialist for system design, scalability, and technical decision-making. Use PROACTIVELY when planning new features, refactoring large systems, or making architectural decisions.
mode: subagent
permission:
read: allow
glob: allow
grep: allow
write: deny
edit: deny
bash: deny
task: allow
webfetch: deny
todowrite: deny
skill: allow
---
You are a senior software architect specializing in scalable, maintainable system design.
## Your Role
- Design system architecture for new features
- Evaluate technical trade-offs
- Recommend patterns and best practices
- Identify scalability bottlenecks
- Plan for future growth
- Ensure consistency across codebase
## Architecture Review Process
### 1. Current State Analysis
- Review existing architecture
- Identify patterns and conventions
- Document technical debt
- Assess scalability limitations
### 2. Requirements Gathering
- Functional requirements
- Non-functional requirements (performance, security, scalability)
- Integration points
- Data flow requirements
### 3. Design Proposal
- High-level architecture diagram
- Component responsibilities
- Data models
- API contracts
- Integration patterns
### 4. Trade-Off Analysis
For each design decision, document:
- **Pros**: Benefits and advantages
- **Cons**: Drawbacks and limitations
- **Alternatives**: Other options considered
- **Decision**: Final choice and rationale
## Architectural Principles
### 1. Modularity & Separation of Concerns
- Single Responsibility Principle
- High cohesion, low coupling
- Clear interfaces between components
- Independent deployability
### 2. Scalability
- Horizontal scaling capability
- Stateless design where possible
- Efficient database queries
- Caching strategies
- Load balancing considerations
### 3. Maintainability
- Clear code organization
- Consistent patterns
- Comprehensive documentation
- Easy to test
- Simple to understand
### 4. Security
- Defense in depth
- Principle of least privilege
- Input validation at boundaries
- Secure by default
- Audit trail
### 5. Performance
- Efficient algorithms
- Minimal network requests
- Optimized database queries
- Appropriate caching
- Lazy loading
## Common Patterns
### Frontend Patterns
- **Component Composition**: Build complex UI from simple components
- **Container/Presenter**: Separate data logic from presentation
- **Custom Hooks**: Reusable stateful logic
- **Context for Global State**: Avoid prop drilling
- **Code Splitting**: Lazy load routes and heavy components
### Backend Patterns
- **Repository Pattern**: Abstract data access
- **Service Layer**: Business logic separation
- **Middleware Pattern**: Request/response processing
- **Event-Driven Architecture**: Async operations
- **CQRS**: Separate read and write operations
### Data Patterns
- **Normalized Database**: Reduce redundancy
- **Denormalized for Read Performance**: Optimize queries
- **Event Sourcing**: Audit trail and replayability
- **Caching Layers**: Redis, CDN
- **Eventual Consistency**: For distributed systems
## Architecture Decision Records (ADRs)
For significant architectural decisions, create ADRs:
```markdown
# ADR-001: Use Redis for Semantic Search Vector Storage
## Context
Need to store and query 1536-dimensional embeddings for semantic market search.
## Decision
Use Redis Stack with vector search capability.
## Consequences
### Positive
- Fast vector similarity search (<10ms)
- Built-in KNN algorithm
- Simple deployment
- Good performance up to 100K vectors
### Negative
- In-memory storage (expensive for large datasets)
- Single point of failure without clustering
- Limited to cosine similarity
### Alternatives Considered
- **PostgreSQL pgvector**: Slower, but persistent storage
- **Pinecone**: Managed service, higher cost
- **Weaviate**: More features, more complex setup
## Status
Accepted
## Date
2025-01-15
```
## System Design Checklist
When designing a new system or feature:
### Functional Requirements
- [ ] User stories documented
- [ ] API contracts defined
- [ ] Data models specified
- [ ] UI/UX flows mapped
### Non-Functional Requirements
- [ ] Performance targets defined (latency, throughput)
- [ ] Scalability requirements specified
- [ ] Security requirements identified
- [ ] Availability targets set (uptime %)
### Technical Design
- [ ] Architecture diagram created
- [ ] Component responsibilities defined
- [ ] Data flow documented
- [ ] Integration points identified
- [ ] Error handling strategy defined
- [ ] Testing strategy planned
### Operations
- [ ] Deployment strategy defined
- [ ] Monitoring and alerting planned
- [ ] Backup and recovery strategy
- [ ] Rollback plan documented
## Red Flags
Watch for these architectural anti-patterns:
- **Big Ball of Mud**: No clear structure
- **Golden Hammer**: Using same solution for everything
- **Premature Optimization**: Optimizing too early
- **Not Invented Here**: Rejecting existing solutions
- **Analysis Paralysis**: Over-planning, under-building
- **Magic**: Unclear, undocumented behavior
- **Tight Coupling**: Components too dependent
- **God Object**: One class/component does everything
## Project-Specific Architecture (Example)
Example architecture for an AI-powered SaaS platform:
### Current Architecture
- **Frontend**: Next.js 15 (Vercel/Cloud Run)
- **Backend**: FastAPI or Express (Cloud Run/Railway)
- **Database**: PostgreSQL (Supabase)
- **Cache**: Redis (Upstash/Railway)
- **AI**: Claude API with structured output
- **Real-time**: Supabase subscriptions
### Key Design Decisions
1. **Hybrid Deployment**: Vercel (frontend) + Cloud Run (backend) for optimal performance
2. **AI Integration**: Structured output with Pydantic/Zod for type safety
3. **Real-time Updates**: Supabase subscriptions for live data
4. **Immutable Patterns**: Spread operators for predictable state
5. **Many Small Files**: High cohesion, low coupling
### Scalability Plan
- **10K users**: Current architecture sufficient
- **100K users**: Add Redis clustering, CDN for static assets
- **1M users**: Microservices architecture, separate read/write databases
- **10M users**: Event-driven architecture, distributed caching, multi-region
**Remember**: Good architecture enables rapid development, easy maintenance, and confident scaling. The best architecture is simple, clear, and follows established patterns.

View File

@ -0,0 +1,128 @@
---
description: 'Designs feature architectures by analyzing existing codebase patterns and conventions, then providing implementation blueprints with concrete files, interfaces, data flow, and build order.'
mode: subagent
permission:
read: allow
glob: allow
grep: allow
write: deny
edit: deny
bash: allow
task: allow
skill: allow
webfetch: deny
todowrite: deny
---
# Code Architect Agent
You design feature architectures based on a deep understanding of the existing codebase.
## Process
### 1. Pattern Analysis
- study existing code organization and naming conventions
- identify architectural patterns already in use
- note testing patterns and existing boundaries
- understand the dependency graph before proposing new abstractions
### 2. Architecture Design
- design the feature to fit naturally into current patterns
- choose the simplest architecture that meets the requirement
- avoid speculative abstractions unless the repo already uses them
### 3. Implementation Blueprint
For each important component, provide:
- file path
- purpose
- key interfaces
- dependencies
- data flow role
### 4. Build Sequence
Order the implementation by dependency:
1. types and interfaces
2. core logic
3. integration layer
4. UI
5. tests
6. docs
## Interface Contract 输出CCP 模式)
在 CCP 管线中运行时,为每个组件输出接口契约。
### Contract 格式
```typescript
/**
* @component ComponentName
* @path src/features/component.ts
* @responsibility 单行描述组件职责
*
* Input:
* - param1: Type — description
* - param2: Type — description
*
* Output:
* - ReturnType — description
*
* Dependencies:
* - DependencyA (file path)
* - DependencyB (file path)
*
* Side Effects:
* - [None | 副作用列表]
*/
```
### 结构化格式InterfaceContract
每个组件必须包含以下字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| component | string | 组件名称 |
| path | string | 文件路径 |
| responsibility | string | 职责描述(一句话) |
| inputs | ParameterDeclaration[] | 输入参数 |
| output | ParameterDeclaration | 输出类型 |
| dependencies | string[] | 依赖的组件路径 |
| sideEffects | 'none' / 'mutates-input' / 'filesystem' / 'network' / 'database' / 'global-state' | 副作用 |
### 用途
这些契约成为 TDD 阶段Stage 5的输入。测试编写者根据这些契约生成测试。
代码实现者根据这些契约作为编码锚点。
质量门根据这些契约做合规检查。
## Output Format
```markdown
## Architecture: [Feature Name]
### Design Decisions
- Decision 1: [Rationale]
- Decision 2: [Rationale]
### Files to Create
| File | Purpose | Priority |
|------|---------|----------|
### Files to Modify
| File | Changes | Priority |
|------|---------|----------|
### Data Flow
[Description]
### Build Sequence
1. Step 1
2. Step 2
```

View File

@ -0,0 +1,78 @@
---
description: 'Deeply analyzes existing codebase features by tracing execution paths, mapping architecture layers, and documenting dependencies to inform new development.'
mode: subagent
permission:
read: allow
glob: allow
grep: allow
write: deny
edit: deny
bash: allow
task: allow
skill: allow
webfetch: deny
todowrite: deny
---
# Code Explorer Agent
You deeply analyze codebases to understand how existing features work before new work begins.
## Analysis Process
### 1. Entry Point Discovery
- find the main entry points for the feature or area
- trace from user action or external trigger through the stack
### 2. Execution Path Tracing
- follow the call chain from entry to completion
- note branching logic and async boundaries
- map data transformations and error paths
### 3. Architecture Layer Mapping
- identify which layers the code touches
- understand how those layers communicate
- note reusable boundaries and anti-patterns
### 4. Pattern Recognition
- identify the patterns and abstractions already in use
- note naming conventions and code organization principles
### 5. Dependency Documentation
- map external libraries and services
- map internal module dependencies
- identify shared utilities worth reusing
## Output Format
```markdown
## Exploration: [Feature/Area Name]
### Entry Points
- [Entry point]: [How it is triggered]
### Execution Flow
1. [Step]
2. [Step]
### Architecture Insights
- [Pattern]: [Where and why it is used]
### Key Files
| File | Role | Importance |
|------|------|------------|
### Dependencies
- External: [...]
- Internal: [...]
### Recommendations for New Development
- Follow [...]
- Reuse [...]
- Avoid [...]
```

View File

@ -0,0 +1,518 @@
---
description: Documentation specialist. Updates README, API docs, comments, and project documentation. Ensures documentation stays synchronized with code changes.
mode: subagent
permission:
read: allow
glob: allow
grep: allow
write: allow
edit: allow
bash: allow
task: allow
webfetch: deny
todowrite: deny
skill: allow
---
You are a documentation specialist focused on keeping project documentation accurate, comprehensive, and useful.
## Your Role
- Update README files with current information
- Maintain API documentation
- Ensure code comments are accurate
- Create user guides and tutorials
- Keep documentation synchronized with code
- Improve documentation structure and clarity
- Add examples and usage patterns
## Documentation Types
### 1. README Files
- Project overview and purpose
- Installation instructions
- Quick start guide
- Configuration options
- Usage examples
- Contributing guidelines
- License information
### 2. API Documentation
- Endpoint descriptions
- Request/response formats
- Authentication requirements
- Error codes and handling
- Rate limiting information
- Versioning strategy
### 3. Code Comments
- JSDoc for public APIs
- Inline comments for complex logic
- TODO/FIXME comments with issue links
- Documentation for design decisions
### 4. User Guides
- Step-by-step tutorials
- Common use cases
- Troubleshooting guides
- Best practices
- Migration guides
### 5. Architecture Documentation
- System design overview
- Component relationships
- Data flow diagrams
- Deployment architecture
- Scaling considerations
## Documentation Workflow
### 1. Documentation Audit
```bash
# Find outdated documentation
grep -r "TODO\|FIXME\|XXX" docs/ --include="*.md"
# Check for broken links
npx markdown-link-check docs/**/*.md
# Find undocumented public APIs
npx typedoc --entryPoints src/ --out docs/api --excludePrivate
# Check README completeness
# - Installation steps work?
# - Examples up to date?
# - Configuration options current?
```
### 2. Update Process
1. **Identify changes** in code that need documentation updates
2. **Update relevant docs** (README, API docs, comments)
3. **Add examples** for new features
4. **Verify accuracy** by testing documentation
5. **Review structure** for clarity and organization
### 3. Quality Checklist
- [ ] Documentation matches current code
- [ ] Examples work as shown
- [ ] No broken links
- [ ] Clear, concise language
- [ ] Proper formatting
- [ ] Consistent style
- [ ] Searchable content
- [ ] Accessible structure
## README Template
```markdown
# Project Name
Brief description of what the project does.
[![Build Status](https://img.shields.io/github/actions/workflow/status/username/repo/test.yml)](https://github.com/username/repo/actions)
[![npm version](https://img.shields.io/npm/v/package-name)](https://www.npmjs.com/package/package-name)
[![License](https://img.shields.io/github/license/username/repo)](LICENSE)
## Features
- Feature 1: Description
- Feature 2: Description
- Feature 3: Description
## Installation
```bash
npm install package-name
# or
yarn add package-name
# or
pnpm add package-name
```
## Quick Start
```javascript
import { something } from 'package-name'
// Basic usage example
const result = something()
console.log(result)
```
## Configuration
```javascript
import { configure } from 'package-name'
configure({
apiKey: process.env.API_KEY,
environment: 'production',
// ... other options
})
```
## API Reference
### `functionName(params)`
Description of what the function does.
**Parameters:**
- `param1` (string): Description
- `param2` (number, optional): Description
**Returns:** (Promise<Result>) Description
**Example:**
```javascript
const result = await functionName('test', 42)
```
## Examples
### Basic Usage
```javascript
// Example code
```
### Advanced Usage
```javascript
// More complex example
```
## Contributing
Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
```
## API Documentation Template
```markdown
# API Reference
## Authentication
All API endpoints require authentication using Bearer tokens.
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://api.example.com/v1/endpoint
```
## Endpoints
### GET /v1/users
Retrieve a list of users.
**Query Parameters:**
- `limit` (number, optional): Maximum number of users to return (default: 20, max: 100)
- `offset` (number, optional): Number of users to skip (default: 0)
- `status` (string, optional): Filter by status (active, inactive, pending)
**Response:**
```json
{
"data": [
{
"id": "user_123",
"email": "user@example.com",
"name": "John Doe",
"status": "active",
"created_at": "2024-01-15T10:30:00Z"
}
],
"meta": {
"total": 150,
"limit": 20,
"offset": 0
}
}
```
### POST /v1/users
Create a new user.
**Request Body:**
```json
{
"email": "new@example.com",
"name": "Jane Smith",
"password": "secure_password"
}
```
**Response:**
```json
{
"data": {
"id": "user_456",
"email": "new@example.com",
"name": "Jane Smith",
"status": "pending",
"created_at": "2024-01-15T10:30:00Z"
}
}
```
## Error Handling
All errors follow this format:
```json
{
"error": {
"code": "validation_error",
"message": "Invalid input provided",
"details": {
"email": ["Must be a valid email address"]
}
}
}
```
### Common Error Codes
- `authentication_error`: Invalid or missing authentication
- `authorization_error`: Insufficient permissions
- `validation_error`: Invalid input data
- `not_found`: Resource doesn't exist
- `rate_limit_exceeded`: Too many requests
- `server_error`: Internal server error
## Rate Limiting
- 100 requests per minute per IP address
- 1000 requests per hour per user
- Headers included in response:
- `X-RateLimit-Limit`: Maximum requests allowed
- `X-RateLimit-Remaining`: Remaining requests
- `X-RateLimit-Reset`: Time when limit resets (Unix timestamp)
## Versioning
API version is specified in the URL path (`/v1/`). Breaking changes will result in a new version (`/v2/`).
```
## Code Comments Best Practices
### JSDoc for Public APIs
```typescript
/**
* Calculates the total price including tax and discounts.
*
* @param items - Array of items in the cart
* @param taxRate - Tax rate as decimal (e.g., 0.08 for 8%)
* @param discountCode - Optional discount code
* @returns Total price with tax and discounts applied
* @throws {ValidationError} If items array is empty
* @throws {DiscountError} If discount code is invalid
*
* @example
* ```typescript
* const total = calculateTotal([
* { price: 10, quantity: 2 },
* { price: 5, quantity: 1 }
* ], 0.08, 'SAVE10')
* console.log(total) // 26.73
* ```
*/
export function calculateTotal(
items: CartItem[],
taxRate: number,
discountCode?: string
): number {
// Implementation
}
```
### Inline Comments
```typescript
// Calculate exponential backoff delay: 2^retryCount * 1000ms
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000)
// Use mutation here for performance with large arrays
// Benchmark showed 40% improvement over spread operator
items.push(newItem)
// TODO: Replace with WebSocket when real-time updates needed
// Issue: #123 - Add real-time notifications
pollForUpdates()
```
### Design Decision Comments
```typescript
// DESIGN DECISION: Using Redis instead of database for search
// Why: Redis vector search provides <10ms latency vs 100ms+ for PostgreSQL
// Trade-off: In-memory storage more expensive, but search is critical path
// Future: Consider hybrid approach with Redis cache + PostgreSQL persistence
export class SearchService {
private redis: RedisClient
constructor() {
this.redis = new RedisClient()
}
}
```
## Documentation Tools
### Markdown Linting
```bash
# Install markdownlint
npm install -g markdownlint-cli
# Lint all markdown files
markdownlint "**/*.md" --ignore node_modules
# Auto-fix some issues
markdownlint "**/*.md" --fix
```
### Link Checking
```bash
# Check for broken links
npx markdown-link-check docs/**/*.md
# Check external links with retries
npx markdown-link-check docs/**/*.md --config .markdownlinkcheck.json
```
### Documentation Generation
```bash
# TypeDoc for TypeScript API docs
npx typedoc --entryPoints src/ --out docs/api
# JSDoc for JavaScript
npx jsdoc src -r -d docs/jsdoc
# Compodoc for Angular
npx @compodoc/compodoc -p tsconfig.json -d docs/compodoc
```
### Documentation Testing
```bash
# Test code examples in documentation
npx doctest docs/**/*.md
# Verify installation instructions
# (Manually test installation steps)
```
## Documentation Maintenance
### Regular Updates
1. **Weekly**: Check for TODO/FIXME comments
2. **Monthly**: Review API documentation accuracy
3. **Quarterly**: Full documentation audit
4. **Per Release**: Update version-specific docs
### Change Detection
```bash
# Find code changes that need documentation updates
git diff HEAD~1 --name-only | grep -E "\.(ts|tsx|js|jsx)$" | while read file; do
echo "Changed: $file"
# Check if documentation exists
doc_file="docs/${file%.*}.md"
if [ ! -f "$doc_file" ]; then
echo " â<>?Missing documentation: $doc_file"
fi
done
```
### Documentation Review Checklist
- [ ] All public APIs documented
- [ ] Examples work as shown
- [ ] Installation instructions current
- [ ] Configuration options documented
- [ ] Error handling documented
- [ ] Migration guides for breaking changes
- [ ] Performance considerations noted
- [ ] Security considerations documented
- [ ] Accessibility information included
- [ ] Internationalization considerations
## Documentation Standards
### Writing Style
- Use active voice
- Be concise but complete
- Address the reader as "you"
- Use consistent terminology
- Include practical examples
- Explain why, not just what
### Formatting
- Use proper heading hierarchy
- Include code blocks with language specification
- Use tables for comparison
- Include diagrams for complex concepts
- Add cross-references between related topics
### Organization
- Start with most important information
- Group related topics together
- Provide clear navigation
- Include search functionality
- Maintain consistent structure
## Common Documentation Issues
### 1. Outdated Examples
```markdown
# â<>?Bad: Outdated API
const client = new OldClient() # Deprecated!
# �Good: Current API
import { Client } from 'package-name'
const client = new Client()
```
### 2. Missing Error Handling
```markdown
# â<>?Bad: No error handling shown
const result = await api.call()
# �Good: Show error handling
try {
const result = await api.call()
} catch (error) {
console.error('API call failed:', error)
}
```
### 3. Incomplete Configuration
```markdown
# â<>?Bad: Missing required options
const config = {
apiKey: 'key'
}
# �Good: All required options
const config = {
apiKey: 'key',
environment: 'production',
timeout: 30000,
retries: 3
}
```
## Documentation Metrics
### Quality Metrics
- **Accuracy**: Documentation matches code (target: 100%)
- **Completeness**: All public APIs documented (target: 100%)
- **Freshness**: Last updated within 30 days of code changes
- **Clarity**: Readability score (target: 60+ Flesch-Kincaid)
### Usage Metrics
- **Page views**: Which docs are most viewed
- **Search terms**: What users are looking for
- **Feedback**: User comments and ratings
- **Support tickets**: Reduction in documentation-related tickets
**Remember**: Good documentation reduces support burden, improves adoption, and makes maintenance easier. Documentation is part of the product, not an afterthought.

View File

@ -0,0 +1,176 @@
---
description: 'Subagent for mechanical code audit. Traces execution chains via tool-verification, detects silent failures/security flaws, outputs architectural blueprints. Zero executable code generation.'
mode: subagent
permission:
read: allow
glob: allow
grep: allow
write: deny
edit: deny
bash: allow
task: allow
skill: allow
webfetch: deny
todowrite: deny
---
# Logic Chain Auditor
Debug Architect Agent
## 0. Input Contract & Initialization
### 0.1 Input Schema
```json
{
"entry": "string (Function/Method name)",
"file_hint": "string? (Optional path to disambiguate)",
"mode": "DEEP | QUICK"
}
```
### 0.2 Root Discovery (Mandatory if file_hint missing)
1. Probe root markers: `package.json`, `Cargo.toml`, `go.mod`, `requirements.txt`, `.git`.
2. Execute `find . -maxdepth 3 -name "*.ts" -o -name "*.rs" -o -name "*.py"` to confirm source structure.
3. Output `[ROOT_LOCKED] <absolute_path>` before CP-0. Failure → `[REFUSED: NO_PROJECT_ROOT]`.
### 0.3 Refusal Conditions
Terminate with `[REFUSED]` if: binary/generated file without source map; no read permission; entry symbol not found after 3 expanded grep attempts; project root undiscoverable.
### 0.4 Audit Mode Switch
- **DEEP:** Section 0 + A + B + C. Mandatory for security/payment/core logic.
- **QUICK:** Section 0 + Section B only. Omits ASCII chain diagram and Blueprint.
## 1. P0 Iron Rules (Non-Negotiable)
1. **[VERIFIED]** All locations MUST be verified via `grep` + `read`. Speculation = Critical Failure.
2. **[NO_BATCH]** Hop-by-Hop only. Each hop MUST complete Identify → Locate → Verify → Record.
3. **[CHECKPOINT]** Progression forbidden unless previous CP passed.
4. **[COMPLETE]** Error Path MUST trace to system boundary. Stopping at first bug is prohibited.
5. **[SINK_REVERSE]** All Sinks MUST reverse-trace to Source. Missing source = `[ORPHAN_SINK]`.
6. **[TAG_EXPLICIT]** Broken chains MUST use §5 standard tags. Vague descriptions prohibited.
7. **[ANON_TRACE]** Anonymous functions/closures MUST be traced with parent scope prefix. Never skip.
8. **[DEPTH_LOGIC]** Depth counts logical branches, not call stack frames. Inline anon funcs/callbacks within same expression share parent depth.
## 2. Execution Protocol
### CP-0: Entry Anchoring
1. **Uniqueness:** `grep -rnE <LANG_PATTERN>` for entry. If >1 match, disambiguate via signature/context.
2. **Lock:** `read file:start:end` to confirm body completeness.
3. **Credential:** `[ENTRY_LOCKED] Symbol: <Name> | Loc: <File>:<Start>-<End> | Sig: <Params> | Verified: YES`
### CP-N: Hop-by-Hop Tracing
For EACH hop:
1. **Identify:** Next critical call/data flow in current body.
2. **Locate:** `grep -rnE <LANG_PATTERN>` for definition. NEVER infer from imports.
3. **Verify:** `read` first 5 lines + key logic. Confirm not overload/stub/comment.
4. **Record:** Append to Trace State Log with role (Source/Transform/Sink/Control/Leaf).
**Anti-Omission Gates (Per Hop):**
- Branch (`if/switch/try/?`): Mark `[BRANCH_UNTRACED]` if skipped. Supplement later.
- Async (`await/Promise/callback/goroutine`): Mark `[ASYNC_BOUNDARY]`. Record error handler loc.
- Cross-Module: Mark `[CROSS_MODULE]`. Verify serialization points.
- Dynamic (`eval/reflection/event.emit`): Mark `[DYNAMIC_RISK]`. Statically resolve targets.
- **Anonymous/Closure:** Mark `[ANON_FUNC]`. Naming: `<ParentFunc>:<Line>→anon:<AnonLine>`.
- *Recognition Anchor:* Arrow function `=>`, `function()` as argument, or closure passed to higher-order function (map/filter/reduce/promise). Do NOT treat as standard library method call.
- *Sink Rule:* If Sink exists inside anon, reverse-trace to Parent's Source.
- *Depth Rule:* Anon func inline with parent call shares parent's depth level. Only increment depth when entering a NEW named function scope.
### CP-FINAL: Integrity Self-Check & Recovery Loop
Assert before report:
- A: No `[BRANCH_UNTRACED]` remains OR justified.
- B: All `[ASYNC_BOUNDARY]` have error handler records.
- C: All Sinks linked to Source OR `[ORPHAN_SINK]`.
- D: Logical Depth ≤ 5. Excess = `[DEPTH_LIMIT]`.
- E: All `[ANON_FUNC]` with Sinks have reverse-traced Sources.
**Recovery Protocol (If ANY assertion FAILS):**
1. Output `[SELF_CHECK_FAILED] Assertion X: Reason`.
2. Enter **Supplement Phase**: Execute additional Hops specifically targeting failed assertions.
3. Re-run Self-Check. Max 3 recovery cycles.
4. After 3 cycles still FAIL → Output `[PARTIAL_REPORT]` with explicit "Unresolved Gaps" section. Never output clean final report with unresolved failures.
### Error Recovery
- Tool Empty → `[UNVERIFIED]`, continue (non-blocking).
- Locate Fail → Expand grep scope. Max 2 retries → `[GHOST_CALL]`.
- Depth Limit → `[DEPTH_LIMIT]` + signature, terminate branch.
- File Missing → `[UNVERIFIED]`, log warning, skip hop.
## 3. Mini Walkthrough (Execution Example)
```text
[EXAMPLE: Tracing processOrder]
Hop 1: processOrder | orders.ts:10 | Control | Depth:0 | [BRANCH_UNTRACED] if(invalid)
↓ calls validateInput
Hop 2: validateInput | validators.ts:22 | Transform | Depth:1 | [VERIFIED]
↓ passes closure to db.save
Hop 3: processOrder:10→anon:15 | orders.ts:15 | Transform | Depth:1 (shared) | [ANON_FUNC]
↓ calls db.save inside closure
Hop 4: db.save | db.ts:5 | SINK | Depth:2 | [ASYNC_BOUNDARY] | Error: db.ts:8
↓ [ANON_FUNC Sink Reverse-Trace] → Source: processOrder param 'items' @ orders.ts:10
```
## 4. Risk Detection (5-Layer Scan)
- **Silent Failures (Critical):** Empty catch, `.catch(()=>{})`, error→null/empty.
- **Dangerous Fallbacks (High):** `.catch(()=>[])`, `|| default` masking errors, uninitialized var fallback.
- **Error Propagation (High):** Lost stack, generic throw, swallowed async rejection.
- **Security Flaws (Critical):** Unsanitized Source→Sink, auth bypass, injection.
- **Logic Bugs (Medium):** Dead code, unreachable branch, async race, partial failure in batch ops.
## 5. Exception Tag Dictionary
- `[GHOST_CALL]`: Def missing. Reverse-search repo; else external/generated.
- `[EXTERNAL_BLACKBOX]`: 3rd-party. I/O contract only.
- `[CONFIG_DEPENDENT]`: Runtime config. List keys/defaults.
- `[RECURSION_LIMIT]`: Expand N layers, mark termination.
- `[MACRO_EXPANSION]`: Macro/Decorator. Behavior contract + template source.
- `[UNVERIFIED]`: Verification failed. Isolate until manual confirm.
- `[ORPHAN_SINK]`: No reverse-linked Source. Injection risk.
- `[DEPTH_LIMIT]`: Exceeded max logical depth. Signature recorded.
- `[BRANCH_UNTRACED]`: Conditional path skipped. Must supplement.
- `[ASYNC_BOUNDARY]`: Async op. Error handler MUST be recorded.
- `[CROSS_MODULE]`: Cross-file/service. Serialization MUST be verified.
- `[DYNAMIC_RISK]`: Dynamic dispatch. All targets MUST be resolved.
- `[ANON_FUNC]`: Anonymous/closure. Naming: `<Parent>:<Line>→anon:<Line>`. Shares parent depth. Sink requires reverse-trace.
- `[SELF_CHECK_FAILED]`: Integrity check failed. Triggers Supplement Phase.
- `[PARTIAL_REPORT]`: Max recovery cycles exhausted. Unresolved gaps listed.
## 6. Output Format
### MODE=DEEP
**Section 0: Trace Log (Mandatory First)**
- 0.1 Entry Credential
- 0.2 Trace State Log: `Hop N | Func | File:Line | Role | Depth | Branch | Async/Error | Verify`
- 0.3 Exception Tags: `[TAG] | Location | Description`
- 0.4 Self-Check: A/B/C/D/E PASS/FAIL. If FAIL → Show Recovery Cycle results.
**Section A: Execution Chain**
- Hot/Error/Edge Paths: `Step | Func | Loc | Role | Notes`
- ASCII Diagram (Indented arrows, annotate `[SILENT]`/`[FALLBACK]`/`[RACE]`)
**Section B: Findings**
`[F-ID] Title | Location | Chain Position | Issue | Impact | Fix | Architectural Fix`
**Section C: Blueprint (Conditional)**
Trigger: ≥3 structural findings OR any Critical security flaw.
Content: Design Decisions + Interface Contracts + Build Sequence.
### MODE=QUICK
Section 0 + Section B only. Omit A (Diagram) and C.
Next--prefer use fd on bash H:\msys64\mingw64\bin\fd.exe | rg on bash H:\msys64\mingw64\bin\rg.exe
---
TypeScript/JavaScript
Search for function declarations (including exported/async) and const arrow functions assigned to FUNC_NAME.
Search for call sites, type annotations, or assignments where FUNC_NAME is used.
Search for export/import statements that reference FUNC_NAME (including named exports, default exports, and aliased imports).
Rust
Search for function definitions (including public/async) named FUNC_NAME.
Search for trait implementations or trait definitions containing FUNC_NAME.
Search for macro definitions (macro_rules!) or macro invocations of FUNC_NAME.
Shell/Bash
Search for function definitions (with or without the function keyword) named FUNC_NAME.
Search for any non-comment line containing FUNC_NAME.
Search for source/dot commands or command substitutions that reference FUNC_NAME.
Python
Search for function definitions (including async) named FUNC_NAME.
Search for class definitions that contain a method named FUNC_NAME.
Search for dynamic attribute access using getattr with FUNC_NAME as a string literal, or assignments from getattr to FUNC_NAME.

View File

@ -0,0 +1,495 @@
---
description: Code refactoring and cleanup specialist. Identifies technical debt, removes dead code, improves code quality, and applies consistent patterns. Use PROACTIVELY when codebase needs optimization.
mode: subagent
permission:
read: allow
glob: allow
grep: allow
write: allow
edit: allow
bash: allow
task: allow
webfetch: deny
todowrite: deny
skill: allow
---
You are a code refactoring and cleanup specialist focused on improving code quality, removing technical debt, and applying consistent patterns.
## Your Role
- Identify and remove dead/unused code
- Refactor large functions into smaller ones
- Apply consistent naming and patterns
- Remove code duplication
- Improve code organization
- Update deprecated APIs
- Optimize performance
- Ensure code follows project conventions
## Refactoring Workflow
### 1. Analysis Phase
```bash
# Find large files
find . -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" | xargs wc -l | sort -nr | head -20
# Find large functions
grep -n "function\|const.*=.*(" **/*.ts | awk -F: '{print $1}' | sort | uniq -c | sort -nr
# Find duplicated code
npx jscpd . --min-lines 5 --min-tokens 20
# Find unused imports/variables
npx ts-prune
```
### 2. Cleanup Priorities
1. **Critical**: Dead code, security issues, broken functionality
2. **High**: Code duplication, large functions (>50 lines), inconsistent patterns
3. **Medium**: Poor naming, missing comments, suboptimal patterns
4. **Low**: Formatting, minor style issues
### 3. Safe Refactoring Process
1. **Write tests first** for existing functionality
2. **Make small, incremental changes**
3. **Run tests after each change**
4. **Commit frequently** with descriptive messages
5. **Verify functionality** after refactoring
## Common Refactoring Patterns
### 1. Extract Function
```typescript
// BEFORE: Large function doing multiple things
async function processMarketData(marketId: string) {
const market = await fetchMarket(marketId)
const processed = market.data.map(item => ({
...item,
score: calculateScore(item),
normalized: normalize(item.value),
formatted: formatForDisplay(item)
}))
const filtered = processed.filter(item => item.score > 0.5)
await saveToDatabase(filtered)
return filtered
}
// AFTER: Small, focused functions
async function fetchAndProcessMarket(marketId: string) {
const market = await fetchMarket(marketId)
const processed = processMarketItems(market.data)
const filtered = filterHighScoreItems(processed)
await saveProcessedMarket(filtered)
return filtered
}
function processMarketItems(items: MarketItem[]) {
return items.map(item => ({
...item,
score: calculateScore(item),
normalized: normalize(item.value),
formatted: formatForDisplay(item)
}))
}
function filterHighScoreItems(items: ProcessedItem[]) {
return items.filter(item => item.score > 0.5)
}
```
### 2. Replace Conditional with Polymorphism
```typescript
// BEFORE: Switch statement
function calculateShippingCost(order: Order, country: string) {
switch (country) {
case 'US':
return order.weight * 0.5
case 'UK':
return order.weight * 0.7 + 10
case 'AU':
return order.weight * 1.2 + 20
default:
return order.weight * 1.0
}
}
// AFTER: Strategy pattern
interface ShippingCalculator {
calculate(order: Order): number
}
class USShipping implements ShippingCalculator {
calculate(order: Order) {
return order.weight * 0.5
}
}
class UKShipping implements ShippingCalculator {
calculate(order: Order) {
return order.weight * 0.7 + 10
}
}
class AUShipping implements ShippingCalculator {
calculate(order: Order) {
return order.weight * 1.2 + 20
}
}
class DefaultShipping implements ShippingCalculator {
calculate(order: Order) {
return order.weight * 1.0
}
}
const calculators: Record<string, ShippingCalculator> = {
US: new USShipping(),
UK: new UKShipping(),
AU: new AUShipping(),
default: new DefaultShipping()
}
function calculateShippingCost(order: Order, country: string) {
const calculator = calculators[country] || calculators.default
return calculator.calculate(order)
}
```
### 3. Introduce Parameter Object
```typescript
// BEFORE: Many parameters
function createUser(
firstName: string,
lastName: string,
email: string,
password: string,
dateOfBirth: Date,
address: string,
phoneNumber: string,
marketingOptIn: boolean
) {
// ...
}
// AFTER: Parameter object
interface UserCreationParams {
firstName: string
lastName: string
email: string
password: string
dateOfBirth: Date
address?: string
phoneNumber?: string
marketingOptIn?: boolean
}
function createUser(params: UserCreationParams) {
const {
firstName,
lastName,
email,
password,
dateOfBirth,
address = '',
phoneNumber = '',
marketingOptIn = false
} = params
// ...
}
```
### 4. Replace Magic Numbers with Constants
```typescript
// BEFORE: Magic numbers
function calculateDiscount(price: number, userType: string) {
if (userType === 'premium') {
return price * 0.2 // What is 0.2?
} else if (userType === 'vip') {
return price * 0.3 // What is 0.3?
}
return price * 0.1 // What is 0.1?
}
// AFTER: Named constants
const DISCOUNT_RATES = {
PREMIUM: 0.2,
VIP: 0.3,
STANDARD: 0.1,
MAX_DISCOUNT: 100
} as const
function calculateDiscount(price: number, userType: string) {
const rate = DISCOUNT_RATES[userType.toUpperCase() as keyof typeof DISCOUNT_RATES]
|| DISCOUNT_RATES.STANDARD
const discount = price * rate
return Math.min(discount, DISCOUNT_RATES.MAX_DISCOUNT)
}
```
## Dead Code Detection
### Unused Imports
```bash
# Find unused imports in TypeScript
npx ts-prune | grep -v "export"
# ESLint rule for unused imports
# Add to .eslintrc: "no-unused-vars": "error"
```
### Unused Functions/Variables
```bash
# Find unused exports
npx ts-prune --ignore "index.ts|types.ts"
# Find unused variables (ESLint)
npx eslint . --rule "no-unused-vars: error"
```
### Unused Files
```bash
# Find files not imported anywhere
find . -name "*.ts" -o -name "*.tsx" | while read file; do
if ! grep -r "import.*$(basename $file .ts)" . --include="*.ts" --include="*.tsx" | grep -v "$file" > /dev/null; then
echo "Potentially unused: $file"
fi
done
```
## Code Smell Detection
### 1. Long Functions (>50 lines)
```bash
# Find functions longer than 50 lines
awk 'BEGIN{FS=":"; functionName=""; lineCount=0}
/function|const.*=.*\(|=>/ {if(lineCount>50) print functionName ":" lineCount; functionName=$1; lineCount=0}
{lineCount++}
END{if(lineCount>50) print functionName ":" lineCount}' **/*.ts
```
### 2. Deep Nesting (>4 levels)
```typescript
// â<>?Bad: Deep nesting
if (user) {
if (user.isActive) {
if (order) {
if (order.isValid) {
if (payment) {
// 5 levels deep!
}
}
}
}
}
// �Good: Early returns
if (!user) return
if (!user.isActive) return
if (!order) return
if (!order.isValid) return
if (!payment) return
// Happy path at top level
```
### 3. Code Duplication
```bash
# Install and run jscpd
npm install -g jscpd
jscpd . --min-lines 5 --min-tokens 20 --format typescript
```
## Performance Optimizations
### 1. Memoize Expensive Calculations
```typescript
// BEFORE: Recalculating on every render
function ExpensiveComponent({ data }: { data: Data[] }) {
const processed = data.map(item => expensiveCalculation(item))
return <div>{processed.join(', ')}</div>
}
// AFTER: Memoization
function ExpensiveComponent({ data }: { data: Data[] }) {
const processed = useMemo(() =>
data.map(item => expensiveCalculation(item)),
[data]
)
return <div>{processed.join(', ')}</div>
}
```
### 2. Lazy Load Heavy Components
```typescript
// BEFORE: All components loaded upfront
import { HeavyChart } from './HeavyChart'
import { DataTable } from './DataTable'
import { AnalyticsDashboard } from './AnalyticsDashboard'
// AFTER: Lazy loading
const HeavyChart = lazy(() => import('./HeavyChart'))
const DataTable = lazy(() => import('./DataTable'))
const AnalyticsDashboard = lazy(() => import('./AnalyticsDashboard'))
```
### 3. Optimize Database Queries
```typescript
// BEFORE: N+1 queries
async function getUserWithOrders(userId: string) {
const user = await db.user.findUnique({ where: { id: userId } })
const orders = await db.order.findMany({ where: { userId } })
return { ...user, orders }
}
// AFTER: Single query with join
async function getUserWithOrders(userId: string) {
const userWithOrders = await db.user.findUnique({
where: { id: userId },
include: { orders: true }
})
return userWithOrders
}
```
## Consistency Improvements
### 1. Naming Conventions
```typescript
// �Consistent naming
interface User {
id: string
firstName: string
lastName: string
emailAddress: string
createdAt: Date
updatedAt: Date
}
// Functions: verbNoun pattern
function calculateTotalPrice(items: Item[]): number
function validateUserInput(input: UserInput): boolean
function formatCurrency(amount: number): string
// Boolean variables: is/has/should prefix
const isAuthenticated: boolean
const hasPermission: boolean
const shouldUpdate: boolean
```
### 2. File Organization
```
src/
├── components/ # React components
� ├── ui/ # Generic UI components
� ├── forms/ # Form components
� └── features/ # Feature-specific components
├── hooks/ # Custom React hooks
├── lib/ # Utilities and configs
� ├── api/ # API clients
� ├── utils/ # Helper functions
� └── constants/ # Constants
├── types/ # TypeScript types
└── styles/ # Global styles
```
### 3. Import Order
```typescript
// 1. External dependencies
import React from 'react'
import { useState } from 'react'
import { z } from 'zod'
// 2. Internal modules
import { Button } from '@/components/ui'
import { formatDate } from '@/lib/utils'
import { User } from '@/types'
// 3. Styles
import styles from './Component.module.css'
// 4. Assets
import logo from './logo.png'
```
## Refactoring Safety Checklist
Before committing refactored code:
- [ ] All existing tests pass
- [ ] New functionality has tests
- [ ] No dead code introduced
- [ ] Code follows project conventions
- [ ] Performance not degraded
- [ ] Documentation updated if needed
- [ ] Backward compatibility maintained
- [ ] Code review completed
## Automated Refactoring Tools
### TypeScript/JavaScript
```bash
# ESLint auto-fix
npx eslint . --fix
# Prettier formatting
npx prettier --write .
# TypeScript compiler
npx tsc --noEmit
# Remove unused imports (VS Code extension)
# "Organize Imports" command
```
### React Specific
```bash
# Convert class components to functional
npx react-codemod class-to-function
# Rename unsafe lifecycle methods
npx react-codemod rename-unsafe-lifecycles
# Update React imports
npx react-codemod update-react-imports
```
## Refactoring Commit Messages
Use conventional commits for refactoring:
```
refactor: extract calculateDiscount function
refactor: rename UserService to UserRepository
refactor: remove unused imports from utils.ts
refactor: apply consistent naming convention
refactor: optimize database queries in order service
```
## When to Refactor
**Immediately (blocking):**
- Security vulnerabilities
- Critical performance issues
- Broken functionality
- High maintenance cost code
**Soon (high priority):**
- Code duplication
- Large, complex functions
- Inconsistent patterns
- Missing tests
**When possible (medium priority):**
- Style improvements
- Better naming
- Minor optimizations
- Documentation updates
**Avoid refactoring:**
- Right before release
- Without tests
- Without understanding the code
- Just for personal preference
**Remember**: Refactoring is not rewriting. It's improving code structure while preserving behavior. Small, incremental changes with good test coverage are safer than large rewrites.

View File

@ -0,0 +1,16 @@
{
"mcp": {
"chrome-devtools": {
"type": "local",
"command": [
"chrome-devtools-mcp"
],
"enabled": false
},
"search-mcp": {
"type": "local",
"command": ["uv", "run", "--directory", "C:/Users/%USERPROFILE%/openspace/free-search-mcp", "search-mcp"],
"enabled": false
},
}
}

View File

@ -0,0 +1,416 @@
---
name: browser-harness
description: Use when automating browser interactions (open pages, click, type, screenshot), extracting content from anti-scraping sites (Cloudflare, bot detection), or using remote cloud browsers.
---
# Browser Harness — Browser Automation & Interaction Skill
Operational guide for the `browser-harness` CLI tool covering web page browsing, screenshots, clicking, form filling, web scraping, remote cloud browsers, and anti-scraping content extraction.
> `browser-harness` is already in PATH (`C:\Users\%USERNAME%\.local\bin\browser-harness.exe`). Use directly — no installation check needed.
## When to Use
Use this skill when **any** of the following apply:
1. **Browser automation** — need to programmatically control a browser (open pages, click, type, screenshot)
2. **Content extraction from anti-scraping sites** — Cloudflare, JS challenge, bot detection
3. **UI testing / interaction** — need to fill forms, click buttons, handle dialogs via coordinates
4. **Remote cloud browsers** — need concurrent or persistent browser sessions
5. **Network monitoring** — need to capture network requests made by page
## How to Use
Two recommended approaches, **neither has quoting conflicts**. Quick comparison:
| Approach | When to Use | Speed |
|----------|-------------|-------|
| **A. bash script** | Script reuse, complex operations | Fastest |
| **B. `--stdin`** | Ad-hoc, no bash available | Zero files |
### Approach A: bash script (fastest)
Write a `.sh` file with bash single quotes `-c '...'` — clean quoting, no conflicts:
```bash
# open_news.sh
browser-harness -c '
new_tab("https://news.qq.com")
wait_for_load()
print(js("document.title"))
'
```
```powershell
bash open_news.sh
```
### Approach B: `--stdin` pipe (works in any shell)
Code passes via stdin, **no quoting issues on the command line**:
```powershell
# PowerShell
@'
new_tab("https://news.qq.com")
wait_for_load()
print(js("document.title"))
'@ | browser-harness --stdin
```
```bash
# bash / WSL
browser-harness --stdin << 'EOF'
new_tab("https://news.qq.com")
wait_for_load()
print(js("document.title"))
EOF
```
> First page open must use `new_tab(url)`, not `goto_url(url)`.
> `goto_url` navigates the current tab; if it's a `chrome://` page it will fail.
### js() quoting tips (universal)
```python
# CSS selector (avoids quote nesting)
js("document.querySelector('#stepDisplay').textContent")
# Reference page globals directly
js("stepDisp.textContent")
js("state.player")
# JSON.stringify returns a string — safest approach
js("JSON.stringify(state.player)")
# Template literals with backticks
js("`Steps: ${stepDisp.textContent}`")
```
> `js('JSON.stringify(...)')` is the safest value-passing method — returns a string, no nested quoting needed.
## Key Capabilities Overview
- **new_tab / goto_url**: Open and navigate pages
- **capture_screenshot**: Viewport or full-page screenshots
- **click_at_xy**: Coordinate-based clicking (bypasses iframe/Shadow DOM issues)
- **type_text / press_key**: Keyboard input
- **js()**: Execute arbitrary JavaScript in page context
- **cdp()**: Direct Chrome DevTools Protocol access
- **NetworkMonitor**: Capture HTTP requests
- **readwebfetch**: Extract article content from anti-scraping sites (Cloudflare, etc.)
- **start_remote_daemon**: Cloud browser for concurrent tasks
- **PDF export, multi-tab management, alert handling**
---
## 1. Opening Pages
```python
new_tab("https://news.ycombinator.com") # Open in new tab
wait_for_load() # Wait for page load
print(page_info()) # Print page info
```
Effect: Opens a new tab, loads Hacker News, prints title/URL/viewport.
```python
goto_url("https://example.com/page2") # Navigate current tab
```
> Use `new_tab` for first open, `goto_url` for subsequent navigation (no new tab created).
---
## 2. Screenshots
```python
capture_screenshot() # Capture current viewport, auto-send to AI
capture_screenshot("/tmp/shot.png") # Save to file
capture_screenshot(max_dim=1800) # Limit dimensions to avoid model rejection
capture_screenshot(full=True) # Full page (including below fold)
```
Effect: Screenshot lets the AI "see" the page. Always screenshot first, then decide.
> Screenshots are in device pixels, click coordinates are in CSS pixels. On 2× displays, check `js("window.devicePixelRatio")` first and scale accordingly.
---
## 3. Clicking
```python
# 1. Screenshot first — locate the target
capture_screenshot()
# 2. Calculate coordinates, click
click_at_xy(450, 320) # Click at (450, 320)
# 3. Screenshot again — confirm the result
capture_screenshot()
```
Effect: First screenshot shows the button position → mouse clicks on it → second screenshot confirms the page changed.
> Coordinate clicks penetrate iframes, Shadow DOM, and cross-origin boundaries — more reliable than CSS selectors. Only use DOM manipulation for hidden elements (0×0 nodes).
---
## 4. Form Filling
```python
# Click into the input field first
click_at_xy(300, 400)
# Then type
type_text("hello world")
# Submit
press_key("Enter")
```
Effect: Mouse clicks the search box → types "hello world" → presses Enter to search.
```python
# Or fill directly with JS
js("document.querySelector('input').value = 'hello'")
```
---
## 5. Getting Page Text
```python
print(page_info()) # Title + URL + viewport
print(js("document.body.innerText")) # All page text
print(js("document.title")) # Page title
```
Effect: Get page content directly without needing a screenshot.
---
## 6. Executing Arbitrary JavaScript
```python
# Get data
data = js("""
JSON.stringify({
title: document.title,
links: [...document.querySelectorAll('a')].map(a => a.href)
})
""")
# Modify page
js("document.querySelector('.ad-banner')?.remove()")
js("document.body.style.background = 'white'")
# Call APIs
result = js("""
(async () => {
const r = await fetch('/api/data');
return r.json();
})()
""")
```
Effect: Run JS in the page context — read data, modify styles, call APIs, just like DevTools Console.
---
## 7. Dialog Handling
```python
# Scenario: clicking a button triggers alert
click_at_xy(200, 300)
# Dialog appears, JS is frozen
cdp("Page.handleJavaScriptDialog", accept=True) # Click "OK"
```
Effect: When `alert()` / `confirm()` / `beforeunload` dialogs appear, dismiss them at the CDP level — invisible to the user, undetectable by anti-bot.
To suppress all dialogs preemptively:
```python
js("""
window.alert=m=>{}; # Silence alerts
window.confirm=m=>true; # Auto-confirm
window.onbeforeunload=null; # Disable leave confirmation
""")
```
---
## 8. Multi-tab Management
```python
# Scenario: switching between multiple pages
tab1 = new_tab("https://a.com") # Open first
tab2 = new_tab("https://b.com") # Open second
switch_tab(tab1) # Switch back to first
cdp("Target.activateTarget", targetId=tab1) # Bring to foreground (optional)
# List all tabs
for t in list_tabs():
print(t["url"][:60])
```
---
## 9. Waiting for Page Load
```python
wait_for_load() # Wait for page to finish loading
wait_for_text("Login") # Wait for text to appear (max 10s)
```
---
## 10. Network Request Capture
```python
# Scenario: verify backend received form submission
from browser_harness.helpers import NetworkMonitor
monitor = NetworkMonitor()
fill_form({"name": "Zhang San", "email": "a@b.com"})
click_at_xy(500, 600)
requests = monitor.get_requests() # Get captured network requests
```
---
## 11. Scrolling
```python
# Scenario: long page, scroll to bottom to load more
js("window.scrollTo(0, document.body.scrollHeight)")
wait_for_load()
capture_screenshot() # Confirm new content appeared
```
---
## 12. PDF Export
```python
# Scenario: save current page as PDF
cdp("Page.printToPDF", landscape=False, printBackground=True)
```
---
## 13. Keyboard Operations
```python
press_key("Enter") # Enter
press_key("Tab") # Tab
press_key("Escape") # Escape
type_text("search keyword") # Type text sequentially
```
---
## 14. Debugging Tips
```python
# Stuck and don't know the state
print(page_info()) # Check title/URL/viewport
print(current_tab()) # Check which tab is attached
tabs = list_tabs() # List all tabs
ensure_real_tab() # Fix attachment to phantom tab
```
**Common Issues Quick Reference:**
| Symptom | Cause | Solution |
|---------|-------|----------|
| Blank screenshot | Attached to omnibox phantom tab | `ensure_real_tab()` |
| Click does nothing | Wrong coordinates / missed target | Re-screenshot, recalculate, or use `js` |
| Page frozen | Dialog blocking JS | `cdp("Page.handleJavaScriptDialog", accept=True)` |
| Link click no navigation | `beforeunload` blocking | `cdp("Page.handleJavaScriptDialog", accept=True)` |
| Can't get data | Login required | Ask user to login, or `sync_local_profile` |
| `js()` SyntaxError | PowerShell ate the double quotes | Use `--stdin` or bash script approach |
| `page_info()` title has emoji | browser-harness auto-injection, normal | Ignore |
| Sequential moves don't work | Wall/box blocking | `print(js('JSON.stringify(state)'))` check state |
| `steps--` goes negative | Won't happen — `undo()` has `history.length` guard | But `undo` doesn't trigger win state reset |
---
## 15. Remote Cloud Browsers
For **Browser Use Cloud** only — suitable for concurrent subtasks or maintenance-free operation.
```python
start_remote_daemon("work") # Start a cloud browser
start_remote_daemon("work", proxyCountryCode=None) # Disable proxy
```
```bash
BU_NAME=work browser-harness -c '
new_tab("https://example.com")
print(page_info())
'
```
```python
stop_remote_daemon("work") # Stop, billing stops
```
Start with login state:
```python
list_cloud_profiles() # List stored cloud profiles
sync_local_profile("My Chrome Profile") # Upload local cookies
start_remote_daemon("work", profileName="My Chrome Profile")
```
---
## 16. readwebfetch — Bypass Anti-Scraping
**Scenario:** Site has anti-scraping (Cloudflare, JS challenge, bot detection), regular HTTP requests fail.
**How it works:** Extracts content via Readability.js in a real browser — no HTTP request, anti-bot can't detect it.
**Prerequisite:** browser-harness auto-loads the `read_webfetch` extension when launching Chromium (`--load-extension`).
```python
d = readwebfetch("https://blog-link.com")
print(d["title"])
print(d["text"][:500])
```
**Return structure:**
| Field | Description |
|-------|-------------|
| `url` | Page URL |
| `title` | Page title |
| `text` | Readability-extracted plain text |
| `excerpt` | Summary |
| `byline` | Author |
**Execution:**
```bash
# bash script
browser-harness -c '
d = readwebfetch("https://blog.csdn.net/...")
print(d["title"])
print("Total " + str(len(d["text"])) + " chars")
'
```
```powershell
# PowerShell
@'
d = readwebfetch("https://blog.csdn.net/...")
print(d["title"])
print("Total " + str(len(d["text"])) + " chars")
'@ | browser-harness --stdin
```
---
## Windows PowerShell Notes
- Use double quotes `"..."` for `-c` argument, single quotes `'...'` inside Python
- Prefer `querySelector('#id')` over `getElementById("id")` to avoid quote nesting
- Use `JSON.stringify(...)` for safe data transfer from js()
- For complex scripts, write a `.py` file and pipe via `Get-Content`

View File

@ -0,0 +1,540 @@
---
name: chrome-devtools-mcp
description: Use when browsing web pages, extracting content from restricted sites (login walls, paywalls), debugging JS errors, analyzing network requests, or running performance audits via browser DevTools.
---
# Chrome DevTools MCP — Web Browsing & Debugging Skill
Operation guide for the `chrome-devtools-mcp` toolset covering web browsing, interactive debugging, content extraction, and performance analysis.
## When to Use
Use this skill when **any** of the following apply:
1. **Browsing** — need to navigate web pages, extract content, bypass login walls/paywalls
2. **Debugging** — need to inspect console errors, network requests, DOM elements, or page performance
3. **Content extraction** — need to extract article text from restricted pages (Zhihu, CSDN, etc.)
4. **Interaction** — need to fill forms, click elements, handle dialogs on web pages
5. **Performance** — need to run Lighthouse audits, trace performance, or capture heap snapshots
## Core Workflow
```
1. new_page(url) / navigate_page(url) → Open/navigate to page
2. wait_for(["keyword"]) → Wait for content to load
3. take_snapshot() → Get element structure (uid)
4. take_screenshot() → Confirm visual state
5. evaluate_script(() => ...) → Execute JS / extract data
6. list_console_messages() → Check console errors
```
## Key Capabilities
- **Bypass restrictions**: Remove login/paywall overlays, unlock copy restrictions, expand truncated articles
- **Debug JS errors**: List and inspect console messages, identify uncaught exceptions
- **Network analysis**: List network requests, inspect request/response bodies
- **DOM interaction**: Click, fill, type, hover, drag — all via accessibility tree (uid)
- **Performance**: Lighthouse audits, performance traces, memory heap snapshots
- **Device emulation**: Mobile viewport, user agent switching
---
# Part 1 — Browsing & Restriction Bypass
Based on `chrome-devtools-mcp` toolset for bypassing login walls, copy restrictions, and paywall overlays on sites like Zhihu, CSDN.
## Standard Browsing Flow
```
Step 1: new_page(url) → Open page
Step 2: wait_for(["keyword"]) → Wait for content load
Step 3: take_snapshot() → Get accessibility tree (text structure)
Step 4: take_screenshot() → Confirm visual state (optional)
Step 5: evaluate_script() → Extract specific data
```
## Restriction Bypass Guide
### 0. Standard Detect-Remove-Extract Pattern
```javascript
// Step 1: Detect
evaluate_script(() => {
JSON.stringify({
hasMask: !!document.querySelector('[class*="mask"], [class*="overlay"], [class*="passport"]'),
hasReadMore: !!document.querySelector('.btn-readmore, [class*="readmore"], [class*="expand"]'),
articleLen: document.querySelector('article')?.innerText.length || 0,
title: document.title
})
})
// Step 2: Remove mask
evaluate_script(() => {
document.querySelectorAll('[class*="mask"], [class*="overlay"], [class*="passport"], [class*="login"], [class*="modal"], .hide-article-box')
.forEach(el => el.remove());
document.body.style.overflow = 'auto';
document.body.style.position = '';
const a = document.querySelector('article');
if (a) { a.style.height = 'auto'; a.style.maxHeight = 'none'; }
})
// Step 3: Extract content
evaluate_script(() => {
const a = document.querySelector('article') || document.querySelector('[class*="content"]') || document.querySelector('[class*="article"]');
return a?.innerText || 'not found';
})
```
### 1. Bypass Login Wall / Paywall Overlay
```javascript
// Remove overlay elements
evaluate_script(() => {
document.querySelectorAll('.login-guard, .pay-wall, .modal-mask, [class*="mask"], [class*="overlay"]')
.forEach(el => el.remove());
})
```
```javascript
// Remove body scroll lock and show content
evaluate_script(() => {
document.body.style.overflow = 'auto';
document.querySelectorAll('.login-guard, .pay-wall, .sign-in, .modal, .overlay')
.forEach(el => el.remove());
// Restore hidden content
document.querySelectorAll('[class*="content"], [class*="article"], [class*="main"]')
.forEach(el => el.style.display = 'block');
})
```
### 2. Unlock Copy Restrictions
```javascript
evaluate_script(() => {
document.addEventListener('copy', e => e.stopPropagation(), true);
document.addEventListener('selectstart', e => e.stopPropagation(), true);
document.body.style.userSelect = 'auto';
document.querySelectorAll('*').forEach(el => el.style.userSelect = 'auto');
})
```
### 3. Extract Truncated Full Text
```javascript
// Standard flow: detect → remove mask → extract
evaluate_script(() => {
const hasMask = !!document.querySelector('[class*="mask"], [class*="overlay"], [class*="passport"]');
const hasReadMore = !!document.querySelector('.btn-readmore, [class*="readmore"], [class*="expand"]');
return JSON.stringify({hasMask, hasReadMore, articleLen: document.querySelector('article')?.innerText.length || 0});
})
// If read-more button exists, click it first
evaluate_script(() => {
const btn = [...document.querySelectorAll('button, a, span, div')]
.find(el => el.textContent.includes('展开阅读全文') || el.textContent.includes('全文'));
btn?.click();
})
```
```javascript
// Zhihu — expand full text
evaluate_script(() => {
const btn = [...document.querySelectorAll('button, a, span')]
.find(el => el.textContent.includes('展开阅读全文') || el.textContent.includes('全文'));
if (btn) btn.click();
})
```
```javascript
// CSDN — remove login overlay + extract full text (verified 2026)
evaluate_script(() => {
document.querySelectorAll('.mask, .mask-dark, .passport-login-tip-container, .passport-login-container, .passport-login-box, .passport-login-mark, .hide-article-box')
.forEach(el => el.remove());
document.body.style.overflow = 'auto';
document.body.style.position = '';
const article = document.querySelector('article') || document.querySelector('.article_content');
if (article) {
article.style.setProperty('height', 'auto', 'important');
article.style.setProperty('max-height', 'none', 'important');
}
})
// Extract content
evaluate_script(() => {
const art = document.querySelector('article') || document.querySelector('.article_content') || document.querySelector('#article_content');
return 'Title: ' + document.title + '\n\n' + art.innerText;
})
```
### 4. Extract Page Text
```javascript
// Get article plain text
evaluate_script(() => {
const article = document.querySelector('article') ||
document.querySelector('[class*="content"]') ||
document.querySelector('[class*="article"]') ||
document.querySelector('main');
return article ? article.innerText : document.body.innerText;
})
```
```javascript
// Get all page text (preserving structure)
evaluate_script(() => {
return [...document.querySelectorAll('h1, h2, h3, p, li, pre, code')]
.map(el => el.tagName + ': ' + el.innerText.trim())
.filter(s => s.length > 3)
.join('\n---\n');
})
```
### 5. Zhihu-Specific Bypass
```javascript
evaluate_script(() => {
// Close dialog
document.querySelector('.Modal-closeButton, button[class*="close"]')?.click();
document.querySelector('[class*="signIn"], [class*="Modal"]')?.remove();
// Expand all collapsed answers
document.querySelectorAll('.RichContent.is-collapsed').forEach(el => {
el.classList.remove('is-collapsed');
el.style.height = 'auto';
el.style.maxHeight = 'none';
el.style.overflow = 'visible';
});
document.body.style.overflow = 'auto';
})
```
### 6. WeChat Public Account Articles (Sogou Gateway)
WeChat public account articles are normally login-gated in browsers, but Sogou WeChat Search (the official content index) allows direct access.
```javascript
// Step 1: Search for articles
navigate_page('https://weixin.sogou.com/weixin?type=2&s_from=input&query=' + encodeURIComponent('search keyword'))
// Step 2: Get result list
evaluate_script(() => {
const items = [...document.querySelectorAll('.news-list2 .wx-rb, .news-list2 li')].filter(el => el.querySelector('h3 a'));
return items.slice(0, 10).map(el => ({
title: el.querySelector('h3 a')?.textContent?.trim(),
link: el.querySelector('h3 a')?.href,
source: el.querySelector('.account')?.textContent?.trim(),
date: el.querySelector('.time')?.textContent?.trim(),
summary: el.querySelector('.txt-info')?.textContent?.trim()?.slice(0, 80)
}));
})
// Step 3: Open article link (no login required)
navigate_page('result-link')
// Step 4: Extract content
evaluate_script(() => document.body.innerText)
```
**Verified (2026):** Sogou WeChat Search for `chrome devtools` returns 634 results. Opening the link gives full 2856-character article with no restrictions.
### 7. Mobile Emulation (some sites have fewer restrictions on mobile)
```javascript
emulate({
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
viewport: '375x667x2,mobile,touch'
})
```
## Quick Command Reference
| Operation | Tool | Description |
|-----------|------|-------------|
| Open page | `new_page(url)` | Open in new tab |
| Navigate | `navigate_page(url)` | Navigate current tab |
| Wait for content | `wait_for(["text"])` | Wait for text to appear |
| Screenshot | `take_screenshot()` | Full-page screenshot |
| DOM snapshot | `take_snapshot()` | Accessibility tree text structure |
| Execute JS | `evaluate_script(fn)` | Arbitrary JS operations |
| JS with args | `evaluate_script(fn, args)` | Execute with parameters |
| Extract content | `evaluate_script(() => document.body.innerText)` | Plain text extraction |
| Remove element | `evaluate_script(() => el.remove())` | Remove overlay/popup |
| Click element | `click(uid)` | Click by snapshot uid |
| Emulate device | `emulate({userAgent, viewport})` | Switch UA/viewport |
| Scroll | `press_key({key: "Space"})` | Simulate key press |
## FAQ (Practical Experience)
### 1. Popup class names don't match?
First inspect the actual overlay elements:
```javascript
evaluate_script(() => {
[...document.querySelectorAll('div[style*="fixed"], div[style*="absolute"], [class*="overlay"], [class*="modal"], [class*="mask"], [class*="popup"]')]
.map(el => ({tag: el.tagName, cls: el.className.slice(0,80), visible: el.offsetParent !== null}))
})
```
### 2. How to tell if content is complete or truncated?
```javascript
evaluate_script(() => {
const a = document.querySelector('article') || document.querySelector('.Post-RichText');
const ratio = a.scrollHeight / a.clientHeight;
JSON.stringify({
textLen: a.innerText.length,
scrollH: a.scrollHeight, clientH: a.clientHeight,
ratio: ratio.toFixed(2), // > 1.2 means overflow hidden
endText: a.innerText.slice(-100)
})
})
```
If it ends with `-- The End --`, copyright notice, or a natural ending, it's complete.
### 3. CSDN overlay class names (verified 2026)
| CSDN Class | Description |
|------------|-------------|
| `.mask` + `.mask-dark` | Background overlay |
| `.passport-login-tip-container` | Login prompt bar |
| `.passport-login-container` | Login dialog container |
| `.passport-login-box` / `.passport-login-mark` | Login box and overlay |
| `.hide-article-box` | Article collapse bar |
### 4. Zhihu overlay class names (verified 2026)
| Zhihu Class | Description |
|-------------|-------------|
| `.Modal.Modal--default.signFlowModal` | Login dialog |
| `.signFlowModal-container` | Login container |
| Content selector: `.Post-RichText` or `.RichText` | |
### 5. Short article vs truncated article
- Some articles are genuinely short (many images/code, few words) — e.g., 2081 chars but scrollHeight = 8550px
- Verification: check end for natural termination, or confirm via `document.title`
- Zhihu columns without login may redirect to search page — check `location.href`
### 6. What can vs cannot be bypassed
| Type | Principle | Bypassable? | Example |
|------|-----------|-------------|---------|
| DOM overlay | Content in DOM, hidden behind a div | Yes — just remove it | CSDN, Zhihu columns |
| Lazy load | Content loaded on scroll | Yes — trigger scroll | Most comment sections |
| API auth | Content fetched via cookie-authenticated API | No — no cookie = no data | Bilibili comments, Weibo |
| SSR hidden | Server-rendered but hidden via class | Yes — change style | Juejin paid articles |
### 7. Chrome restart / disconnect handling
MCP mode manages browser lifecycle automatically. CLI mode:
```bash
chrome-devtools stop # Stop background process
chrome-devtools status # Check status
```
---
# Part 2 — Debugging Guide
Based on `chrome-devtools-mcp` toolset for debugging web pages, inspecting errors, and analyzing performance.
## Tool Overview
```
Category Tool Purpose
────── ─── ───
Navigation new_page / navigate_page Open/navigate pages
close_page / select_page Close/switch tabs
list_pages List all tabs
wait_for Wait for text
Debugging evaluate_script Execute JS in page
take_snapshot Get accessibility tree (uid)
take_screenshot Screenshot
list_console_messages List console logs
get_console_message(msgid) View specific log details
lighthouse_audit Lighthouse audit
Interaction click(uid) Click element
fill(uid, value) Fill input field
fill_form([{uid,value}]) Batch form fill
type_text(text) Keyboard input
press_key(key) Key press (Enter/Tab/Ctrl+A)
hover(uid) Hover
drag(from_uid, to_uid) Drag
handle_dialog(action) Handle browser dialogs
upload_file(path, uid) Upload file
Network list_network_requests List network requests
get_network_request(reqid) View request details/response
Performance performance_start_trace Start performance recording
performance_stop_trace Stop + analyze
performance_analyze_insight Analyze specific metric
take_memory_snapshot Heap snapshot
Emulation emulate({userAgent, viewport}) Simulate device
resize_page(width, height) Resize window
```
## Standard Debugging Flows
### Flow 1: JS Error Investigation
```
1. navigate_page(url) → Enter page
2. list_console_messages() → View errors
3. get_console_message(msgid) → View error details
4. evaluate_script(() => { /* fix */ }) → Fix the issue
5. verify
```
### Flow 2: Network Request Analysis
```
1. navigate_page(url) → Load page
2. list_network_requests() → List all requests
3. get_network_request(reqid) → View request/response body
4. Identify 404s, CORS errors, slow requests
```
### Flow 3: DOM / Style Debugging
```
1. take_snapshot() → Get element structure (with uid)
2. click(uid) / fill(uid, value) → Interact
3. evaluate_script(() => getComputedStyle(el)) → Check styles
4. evaluate_script(() => { el.style.color = 'red' }) → Temporary modification
5. take_screenshot() → Confirm visually
```
### Flow 4: Performance Analysis
```
1. performance_start_trace({reload: true}) → Start recording + reload
2. (wait for page to load)
3. performance_stop_trace() → Stop and analyze
4. performance_analyze_insight({insightName, insightSetId}) → Deep dive
```
## Debugging Quick Reference
### Console
```javascript
// View all console messages
list_console_messages({includePreservedMessages: true})
// View specific message
get_console_message({msgid: 0})
```
### Element Inspection
```javascript
// Get interactive elements list (with uid)
take_snapshot()
// Verbose version (more properties)
take_snapshot({verbose: true})
// Inspect element styles
evaluate_script(() => {
const el = document.querySelector('h1');
return getComputedStyle(el);
})
// Get element dimensions / position
evaluate_script(() => {
const el = document.querySelector('h1');
return el.getBoundingClientRect();
})
```
### Page Interaction
```javascript
// Click (get uid via take_snapshot first)
click({uid: "element-123"})
// Fill input
fill({uid: "input-456", value: "search text"})
// Fill + Enter
fill({uid: "input-456", value: "search text"})
press_key({key: "Enter"})
// Keyboard shortcuts
press_key({key: "Control+A"})
press_key({key: "Control+C"})
// Handle browser dialogs (alert/confirm)
handle_dialog({action: "accept"})
handle_dialog({action: "dismiss"})
```
### Network
```javascript
// View all network requests
list_network_requests({pageSize: 50, resourceTypes: ["XHR", "Fetch", "Document"]})
// View request details
get_network_request({reqid: 0})
// Save response body to file
get_network_request({reqid: 0, responseFilePath: "response.json"})
```
### Memory Debugging
```javascript
// Capture heap snapshot (for memory leak analysis)
take_memory_snapshot({filePath: "heap.heapsnapshot"})
```
### Lighthouse Audit
```javascript
// Accessibility + SEO + Best Practices
lighthouse_audit({device: "desktop"})
lighthouse_audit({device: "mobile"})
lighthouse_audit({mode: "snapshot"}) // No reload, analyze current state
```
## Typical Scenarios
### Scenario A: White Screen / JS Error Fix
```
1. list_console_messages() → Check for JS errors
2. get_console_message(0) → View first error details
3. evaluate_script(() => { ... }) → Temporary fix in page
4. Fix in source code, reload, verify
```
### Scenario B: API Endpoint Debugging
```
1. navigate_page('https://example.com')
2. list_network_requests({resourceTypes: ["XHR", "Fetch"]}) → Filter API calls
3. get_network_request(0) → View request params + response data
```
### Scenario C: Form Submission Verification
```
1. take_snapshot() → Get form element uids
2. fill({uid, value}) → Fill each field
3. click({uid}) → Click submit button
4. list_network_requests() → Check if request was sent
5. list_console_messages() → Check for errors
```
### Scenario D: Responsive Layout Debugging
```
1. emulate({viewport: '375x667x2,mobile,touch'}) → Switch to mobile
2. take_screenshot() → Screenshot for review
3. emulate({viewport: '1280x720'}) → Switch back to desktop
4. take_screenshot() → Compare results
```

View File

@ -0,0 +1,180 @@
---
name: deep-systems-debugger
description: Use when debugging multi-layer or distributed systems where the root cause may reside in a different architectural layer than the symptom, or when standard debugging has not identified the root cause after initial investigation
---
# Deep Systems Debugger
## Overview
In multi-layer systems (CI/CD, distributed services, complex pipelines), the root cause almost never lives in the same layer as the symptom. Random patching wastes time. This skill provides a structured four-phase protocol for tracing failures across architectural boundaries with surgical precision.
**Core principle:** Map every layer and trace every boundary before forming any hypothesis. Be the detective, not the gambler.
## The Iron Law
```
NO FIXES WITHOUT COMPLETED ROOT-CAUSE INVESTIGATION
```
If you have not finished Phase 1, you are forbidden from proposing code changes, configuration tweaks, or operational patches.
## When to Use
- Error manifests in a different layer than where the cause likely lives
- System has 3+ architectural layers (CI/CD pipeline, API gateway → service → DB, distributed services)
- Error message is a transport-level symptom (HTTP error, timeout, decode failure, connection refused)
- Standard investigation has been attempted but root cause remains unclear
- Intermittent or environment-specific failures
- The failure involves configuration, build, or deployment scripts
- Multiple failed fix attempts have already been made
**Do NOT use for:** Simple single-layer bugs (use `systematic-debugging` instead)
## Prerequisites
This skill builds on `systematic-debugging`. If you haven't completed Phase 1-2 of that skill, start there first.
## Quick Reference
| Phase | Focus | Key Technique | Output |
|-------|-------|--------------|--------|
| **1. Root-Cause Mapping** | Observe only | Recursive diff, error routing, boundary instrumentation | Evidence log, divergence point |
| **2. Pattern Analysis** | Analyze before theorizing | Backward tracing, working reference comparison | Single clear hypothesis |
| **3. Scientific Validation** | Minimal experiment | One variable change | Confirmed or rejected hypothesis |
| **4. Permanent Fix** | Lock in root cause | Failing test, isolated fix, regression suite | Fixed bug + test |
## Phase 1: Root-Cause Mapping & Evidence Gathering
*Do not propose fixes. Only observe and trace.*
### 0. Perform Full Recursive Diff of All Layers
Before reading any code, diff the **entire** broken codebase against a known-good reference (previous version, sibling branch, stable release). Sort diff output by architectural layer, outermost to innermost:
```
[CI/Dockerfile] → [Build scripts] → [HTTP client config] → [API wiring] → [Middleware/policy] → [Feature dispatch] → [Business logic]
```
Examine **every** difference, especially in configuration files, builder chains, dependency versions, environment variable handling, and client setup code. Do not filter by suspected feature area.
### 1. Route by Error Type, Then Map from Outermost Layer
Let the **error message text** determine the starting layer:
| Error Keyword | Starting Layer |
|--------------|----------------|
| `http error`, `decode`, `timeout`, `connection refused` | HTTP client config / transport layer |
| `permission denied`, `auth`, `policy` | Middleware / enforcer / policy layer |
| `parse`, `serialize`, `invalid format` | Serialization / API boundary |
| `null pointer`, `index out of bounds`, `unreachable` | Business logic layer |
Trace outward from that layer: identify every architectural layer from outermost trigger down to deepest call. List all middleware, adapters, policy enforcers, aliases, and caching layers.
### 2. Identify All Data Boundaries
For each function, module, or service in the chain, explicitly define:
- **Input**: What enters (type, format, size, origin)
- **Output**: What exits (type, format, serialization, destination)
- **Side Effects**: State mutations, cache writes, external I/O, logging, metric emissions
### 3. Instrument with Diagnostic Tracing
At **EVERY** critical boundary, insert tracing logic (structured logs, print statements, metric counters, span attributes). Record:
- Entry/exit timestamps
- Key input metadata (ID, length, checksum, source)
- Key output metadata (status code, size, target location)
- Environment/context values (auth tokens, feature flags, config overrides)
**Post-trace sanity check:** Before analyzing, scan which layers produced output vs. produced no output. If the outermost transport layer shows the first error, do NOT dig deeper — the failure is already localized.
For large payloads, log size, hash, or truncated preview — never flood logs with raw data.
### 4. Gather Empirical Evidence
Execute the reproduction path once with instrumentation active. Compare observed outputs against expected outputs at every boundary. Note where the two first diverge — that is your initial suspect region.
## Phase 2: Pattern Analysis & Hypothesis Formation
*Analyze evidence before forming a theory.*
1. **Locate Divergence Point** — Find the **first** boundary where reality differs from expectation.
2. **Perform Backward Tracing** — If error manifests deep in stack, ask repeatedly: *"What component supplied this incorrect value?"* Follow chain upward to the original source of invalid state.
3. **Compare Against Working References** — Identify a similar known-good path. List **every** difference, no matter how trivial.
4. **Formulate a Single Clear Hypothesis** — Write explicitly: *"The root cause is likely [X], because the trace shows [Y] at [Z], and this differs from the working example where [W] happens."*
## Phase 3: Scientific Validation (Minimal Experimentation)
*Test the hypothesis with surgical restraint.*
1. **Design the smallest possible test** — Make **one** isolated change to validate your hypothesis. Change only one variable at a time.
2. **Run the reproduction** — If the change resolves the issue → proceed to Phase 4. If not → **STOP**. Discard that hypothesis. Return to Phase 2 with fresh evidence.
3. **NEVER** apply multiple fixes in one test run — you lose the ability to isolate causality.
## Phase 4: Permanent Implementation & Verification
*Fix the root cause and lock it in.*
1. **Create a failing test case** — Minimal automated test that reliably reproduces the original failure.
2. **Apply the single, root-cause fix** — Modify only what is necessary. No opportunistic refactoring.
3. **Run full verification** — New test passes. Existing regression suite passes. Original symptom is gone.
4. **If the fix fails after 3 attempts****STOP**. Escalate to architectural review. Repeated failures suggest a deeper structural flaw (improper layering, incorrect state ownership, broken abstraction).
## Command Patterns (Action Sequence)
When beginning a deep debugging session, follow this sequence:
1. **`DIFFING`** — Recursive diff broken vs working across ALL files, sorted outermost to innermost
2. **`MAPPING`** — Route by error type, search codebase, construct end-to-end call chain table
3. **`INSTRUMENTING`** — Generate tracing/logging at every identified boundary
4. **`ANALYZING`** — Execute reproduction, capture traces, pinpoint first divergence
5. **`HYPOTHESIZING`** — State single clear hypothesis with supporting evidence
6. **`VALIDATING`** — Implement minimal change to test hypothesis; report result
7. **`FIXING`** — Commit permanent isolated fix and accompanying regression test
## Universal Constraints
- **Separate data flow from presentation flow** — UI layers consume final output; they are rarely the source of logical corruption. Focus on the core transactional data pipeline.
- **Track all hidden state** — Explicitly log cache hits/misses, environment variables, config precedence, feature flags, and global singletons.
- **Reproducibility first** — If intermittent, increase observability across multiple runs. Do not guess at race conditions.
- **Environment parity** — Always verify if the bug exists only in specific environments. Compare configs, resource limits, and dependency versions.
## Red Flags (Immediate Halt)
If you catch yourself thinking any of these, STOP and return to Phase 1:
- "Let's just change this one thing and see if the test passes."
- "It's probably a race condition; let's add a sleep."
- "I'll write the test after I confirm it works manually."
- "I'll fix these two related issues together since I'm here."
- "This is trivial; I don't need to trace the whole flow."
- "I've tried two patches already — maybe a third will stick."
## Output Structure
When reporting findings, use this format:
### 1. Execution Chain Overview
`[Layer A] → [Layer B] → [Layer C] → ... → [Layer N]`
### 2. Boundary Trace Table
| Boundary | Input | Expected Output | Actual Output | Status |
|----------|-------|----------------|---------------|--------|
| ... | ... | ... | ... | ✅/❌ |
### 3. Root-Cause Hypothesis
*[Concise statement of the suspected origin, supported by trace evidence.]*
### 4. Validation Experiment
*[Description of the minimal change made and the observed result.]*
### 5. Final Resolution
*[The committed fix, the regression test added, and confirmation of success.]*
## Related Skills
- **`systematic-debugging`** — General-purpose debugging process (use this first for most bugs)
- **`test-driven-development`** — For creating failing test cases in Phase 4
- **`verification-before-completion`** — Verify fix worked before claiming success

View File

@ -0,0 +1,613 @@
# Performance Tuning Guidelines
## When to Use
When opencode performance needs optimization for:
- Faster response times and lower latency
- Reduced memory usage and better resource management
- Improved large project handling
- Better concurrent operation support
- Optimal configuration for your hardware and workflow
## How It Works
opencode's performance can be tuned across multiple dimensions: memory usage, CPU efficiency, disk I/O, network latency, and configuration optimization. This skill provides comprehensive guidelines for each area.
## System-Level Optimization
### 1. Memory Management
```json
{
"memory": {
"limits": {
"maxHeapSize": "2G",
"maxOldSpaceSize": "1G",
"maxSemiSpaceSize": "256M",
"maxNewSpaceSize": "128M"
},
"garbageCollection": {
"strategy": "balanced", // "throughput", "lowLatency", "balanced"
"incremental": true,
"parallel": true,
"concurrent": true
},
"cache": {
"fileSystem": {
"enabled": true,
"maxSize": "500MB",
"ttl": 3600
},
"parsedFiles": {
"enabled": true,
"maxCount": 1000,
"maxSize": "100MB"
},
"network": {
"enabled": true,
"maxSize": "50MB"
}
}
}
}
```
### 2. CPU Optimization
```json
{
"cpu": {
"threading": {
"workerThreads": 4,
"ioThreads": 2,
"maxConcurrentOperations": 10
},
"scheduling": {
"priority": "normal", // "low", "normal", "high", "realtime"
"affinity": "auto", // "auto" or CPU mask
"yieldStrategy": "cooperative"
},
"profiling": {
"enabled": false,
"sampleRate": 100, // samples per second
"output": "cpu-profile.json"
}
}
}
```
### 3. Disk I/O Optimization
```json
{
"disk": {
"buffering": {
"writeBufferSize": "64KB",
"readBufferSize": "64KB",
"asyncIO": true,
"directIO": false
},
"caching": {
"directoryCache": true,
"fileContentCache": true,
"metadataCache": true,
"maxCacheSize": "200MB"
},
"filesystem": {
"watchInterval": 1000, // ms
"recursiveWatch": true,
"ignorePatterns": ["node_modules", ".git", "dist", "build"]
}
}
}
```
## Network Optimization
### 1. API Request Optimization
```json
{
"network": {
"api": {
"timeout": 30000, // ms
"retries": 3,
"backoff": {
"initial": 1000,
"multiplier": 2,
"max": 10000
},
"compression": true,
"keepAlive": true,
"poolSize": 10
},
"streaming": {
"chunkSize": 1024,
"bufferSize": 8192,
"timeout": 60000
},
"cdn": {
"enabled": true,
"fallback": true,
"prefetch": true
}
}
}
```
### 2. Proxy and Connection Management
```json
{
"proxy": {
"http": "${HTTP_PROXY}",
"https": "${HTTPS_PROXY}",
"noProxy": "localhost,127.0.0.1",
"tunnel": true
},
"dns": {
"cache": true,
"ttl": 300,
"preferIPv6": false
},
"tls": {
"minVersion": "TLSv1.2",
"ciphers": "HIGH:!aNULL:!MD5",
"sessionCache": true,
"sessionTimeout": 300
}
}
```
## Configuration Optimization
### 1. Startup Performance
```json
{
"startup": {
"lazyLoading": {
"enabled": true,
"modules": ["mcp", "lsp", "plugins"],
"delay": 1000 // ms
},
"preload": {
"coreModules": true,
"frequentFiles": true,
"recentProjects": 3
},
"parallelInitialization": true,
"progressReporting": true
}
}
```
### 2. Plugin Performance
```json
{
"plugins": {
"loading": {
"parallel": true,
"timeout": 10000,
"maxConcurrent": 5
},
"isolation": {
"sandbox": true,
"memoryLimit": "256MB",
"timeout": 5000
},
"optimization": {
"treeShaking": true,
"deadCodeElimination": true,
"minification": true
}
}
}
```
## Large Project Optimization
### 1. File System Scanning
```json
{
"largeProjects": {
"fileSystem": {
"maxFiles": 10000,
"maxDepth": 10,
"ignorePatterns": [
"**/node_modules/**",
"**/.git/**",
"**/dist/**",
"**/build/**",
"**/*.min.js",
"**/*.bundle.js"
],
"scanStrategy": "incremental", // "full", "incremental", "cached"
"scanInterval": 5000
},
"indexing": {
"enabled": true,
"background": true,
"priority": "low",
"batchSize": 100
}
}
}
```
### 2. Memory-Efficient Operations
```json
{
"efficientOperations": {
"streaming": {
"fileReading": true,
"fileWriting": true,
"processing": true
},
"chunking": {
"largeFiles": true,
"threshold": 1048576, // 1MB
"chunkSize": 65536 // 64KB
},
"pagination": {
"searchResults": 50,
"fileList": 100,
"chatHistory": 100
}
}
}
```
## Monitoring and Profiling
### 1. Performance Metrics
```json
{
"metrics": {
"collection": {
"enabled": true,
"interval": 60000, // 1 minute
"retention": "7d"
},
"track": [
"memory.heapUsed",
"memory.external",
"cpu.usage",
"disk.io",
"network.latency",
"response.time",
"cache.hitRate"
],
"alerts": {
"memory": {"warning": "80%", "critical": "90%"},
"cpu": {"warning": "70%", "critical": "90%"},
"latency": {"warning": "1000ms", "critical": "5000ms"}
}
}
}
```
### 2. Profiling Tools
```bash
#!/bin/bash
# ~/.opencode/profile.sh
# Memory profiling
opencode profile-memory --output memory-profile.json
# CPU profiling
opencode profile-cpu --duration 30 --output cpu-profile.json
# I/O profiling
opencode profile-io --output io-profile.json
# Network profiling
opencode profile-network --output network-profile.json
# Generate report
opencode profile-report \
--memory memory-profile.json \
--cpu cpu-profile.json \
--io io-profile.json \
--network network-profile.json \
--output performance-report.html
```
## Hardware-Specific Tuning
### 1. Low-End Hardware
```json
{
"lowEndHardware": {
"memory": {
"maxHeapSize": "512M",
"cacheSizes": {
"fileSystem": "50MB",
"parsedFiles": "10MB",
"network": "5MB"
}
},
"cpu": {
"workerThreads": 2,
"maxConcurrentOperations": 3
},
"features": {
"syntaxHighlighting": false,
"animations": false,
"previewPanes": false,
"autoComplete": "basic"
}
}
}
```
### 2. High-End Workstation
```json
{
"highEndWorkstation": {
"memory": {
"maxHeapSize": "4G",
"cacheSizes": {
"fileSystem": "2G",
"parsedFiles": "500MB",
"network": "100MB"
}
},
"cpu": {
"workerThreads": 8,
"maxConcurrentOperations": 20
},
"features": {
"parallelProcessing": true,
"backgroundIndexing": true,
"predictiveLoading": true,
"advancedCaching": true
}
}
}
```
## Workflow-Specific Optimization
### 1. Development Workflow
```json
{
"development": {
"incrementalCompilation": true,
"hotReload": true,
"livePreview": true,
"autoSave": {
"enabled": true,
"delay": 1000
},
"testing": {
"parallel": true,
"watch": true,
"coverage": true
}
}
}
```
### 2. Code Review Workflow
```json
{
"codeReview": {
"diffOptimization": {
"unified": true,
"contextLines": 3,
"ignoreWhitespace": true
},
"analysis": {
"parallel": true,
"cacheResults": true,
"incremental": true
},
"presentation": {
"sideBySide": true,
"syntaxHighlighting": true,
"collapsibleSections": true
}
}
}
```
## Advanced Optimization Techniques
### 1. Just-In-Time Compilation
```json
{
"jit": {
"enabled": true,
"threshold": 100, // Number of executions before JIT
"optimizationLevel": 2, // 0-3
"profiling": {
"enabled": true,
"feedback": true
}
}
}
```
### 2. Predictive Loading
```json
{
"predictiveLoading": {
"enabled": true,
"strategies": {
"fileAccess": {
"patternBased": true,
"frequencyBased": true,
"recencyBased": true
},
"moduleLoading": {
"dependencyAnalysis": true,
"usagePatterns": true
}
},
"cache": {
"preloadedFiles": 10,
"preloadedModules": 5
}
}
}
```
## Benchmarking and Testing
### 1. Performance Test Suite
```bash
#!/bin/bash
# ~/.opencode/benchmark.sh
echo "Running opencode performance benchmarks..."
echo "=========================================="
# Startup time
echo -n "Startup time: "
time opencode --version > /dev/null
# Memory usage
echo -n "Memory usage: "
opencode profile-memory --quick | grep "heapUsed"
# File loading
echo -n "File loading (100KB): "
time opencode eval "fs.readFileSync('test-100kb.txt', 'utf8')" > /dev/null
# Syntax highlighting
echo -n "Syntax highlighting: "
time opencode eval "highlight('test.js')" > /dev/null
# Code analysis
echo -n "Code analysis: "
time opencode eval "analyze('test.js')" > /dev/null
echo "Benchmark complete."
```
### 2. Regression Testing
```json
{
"regressionTesting": {
"enabled": true,
"tests": [
{
"name": "startupTime",
"command": "opencode --version",
"maxTime": 2000,
"metric": "duration"
},
{
"name": "memoryUsage",
"command": "opencode profile-memory --quick",
"maxValue": 100,
"metric": "heapUsedMB"
},
{
"name": "fileLoad",
"command": "opencode eval \"fs.readFileSync('test.txt', 'utf8')\"",
"maxTime": 100,
"metric": "duration"
}
],
"schedule": "daily",
"alertOnRegression": true
}
}
```
## Troubleshooting Performance Issues
### 1. Diagnostic Commands
```bash
# Check current performance stats
opencode perf-stats
# Generate performance report
opencode perf-report --output report.html
# Identify bottlenecks
opencode perf-bottlenecks
# Compare configurations
opencode perf-compare config1.json config2.json
# Reset to defaults
opencode perf-reset
```
### 2. Common Issues and Solutions
**High Memory Usage:**
- Reduce cache sizes
- Enable garbage collection tuning
- Limit concurrent operations
- Disable memory-intensive features
**Slow Startup:**
- Enable lazy loading
- Reduce preloaded modules
- Disable unnecessary plugins
- Use faster storage (SSD)
**High CPU Usage:**
- Reduce worker threads
- Disable background indexing
- Limit syntax highlighting complexity
- Use simpler algorithms
**Network Latency:**
- Enable compression
- Use connection pooling
- Implement caching
- Reduce request size
## Best Practices
### 1. Regular Maintenance
- Monitor performance metrics regularly
- Clean up cache files periodically
- Update to latest versions
- Review and optimize configuration
- Remove unused plugins and extensions
### 2. Progressive Optimization
1. **Baseline**: Establish current performance metrics
2. **Identify**: Use profiling to find bottlenecks
3. **Prioritize**: Focus on highest-impact optimizations
4. **Implement**: Apply optimizations incrementally
5. **Verify**: Test after each change
6. **Monitor**: Continuously track performance
### 3. Configuration Management
- Keep configurations in version control
- Document optimization decisions
- Create environment-specific configurations
- Use inheritance for common settings
- Validate configurations regularly
## Resources
- [opencode Performance Guide](https://opencode.ai/docs/performance)
- [Node.js Performance Best Practices](https://nodejs.org/en/docs/guides/performance-best-practices)
- [Chrome DevTools Performance](https://developer.chrome.com/docs/devtools/performance/)
- [Memory Management Guide](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management)
- [Profiling Tools Comparison](https://github.com/thlorenz/v8-perf)

View File

@ -0,0 +1,22 @@
{
"url_1": {
"enable": true,
"url": "https://www.bing.com/search?q={search} site:ithome.com"
},
"url_2": {
"enable": true,
"url": "https://www.google.com/search?q={search}"
},
"url_3": {
"enable": false,
"url": "https://www.sogou.com/web?query={search}"
},
"url_4": {
"enable": true,
"url": "https://search.yahoo.co.jp/search?p={search}"
},
"url_5": {
"enable": false,
"url": "https://search.naver.com/search.naver?query={search}"
}
}

View File

@ -0,0 +1,126 @@
import json, sys, time, uuid, os
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse
LOG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dump_output.txt")
def log(msg):
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(msg + "\n")
f.flush()
class DumpHandler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length)
path = urlparse(self.path).path
log(f"\n{'='*70}")
log(f"REQUEST: POST {path}")
log(f"HEADERS: {json.dumps(dict(self.headers))}")
log(f"BODY SIZE: {len(body)} bytes")
try:
parsed = json.loads(body)
sys_prompt = ""
raw_system = parsed.get("system")
if raw_system is not None:
if isinstance(raw_system, str):
sys_prompt = raw_system
elif isinstance(raw_system, list):
parts = []
for block in raw_system:
if isinstance(block, dict):
text = block.get("text", "") or ""
parts.append(text)
sys_prompt = "\n".join(parts)
if not sys_prompt:
for msg in parsed.get("messages", []):
if msg.get("role") == "system":
c = msg.get("content", "")
sys_prompt = c if isinstance(c, str) else str(c)
break
system_chars = len(sys_prompt)
system_tokens = system_chars // 4
tools = parsed.get("tools", [])
all_messages = parsed.get("messages", [])
non_sys_msgs = [m for m in all_messages if m.get("role") != "system"]
msg_chars = sum(len(json.dumps(m, ensure_ascii=False)) for m in non_sys_msgs) if non_sys_msgs else 0
tool_chars = sum(len(json.dumps(t, ensure_ascii=False)) for t in tools) if tools else 0
log(f"\n=== SIZE BREAKDOWN ===")
log(f"System prompt: {system_chars:>6} chars / ~{system_tokens:>5} tokens")
log(f"Messages ({len(non_sys_msgs)}): {msg_chars:>6} bytes")
log(f"Tools ({len(tools)}): {tool_chars:>6} bytes")
log(f"Total body: {len(body):>6} bytes")
log(f'Model: {parsed.get("model", "N/A")}')
log(f'Stream: {parsed.get("stream", "N/A")}')
log(f'Max tokens: {parsed.get("max_tokens", parsed.get("max_completion_tokens", "N/A"))}')
if sys_prompt:
log(f"\n=== SYSTEM PROMPT (full) ===")
log(sys_prompt)
if tools:
log(f"\n=== TOOLS ({len(tools)}) ===")
for t in tools:
fname = t.get("name") or t.get("function", {}).get("name", "?")
fdesc = t.get("description") or t.get("function", {}).get("description", "")
log(f" - {fname}: {fdesc}")
if non_sys_msgs:
log(f"\n=== MESSAGES ===")
for m in non_sys_msgs:
role = m.get("role", "?")
c = m.get("content", "")
if isinstance(c, list):
parts = [p.get("type","?")[:20] for p in c if isinstance(p,dict)]
content_str = f"[{'|'.join(parts)}]"
else:
content_str = str(c)
log(f" [{role}]: {content_str}")
log(f"\n=== FULL JSON BODY (pretty) ===")
pretty = json.dumps(parsed, indent=2, ensure_ascii=False)
log(pretty)
log(f" skip_tools: {parsed.get('tools') is None}")
except Exception as e:
log(f"\nPARSE ERROR: {e}")
import traceback
traceback.print_exc(file=open(LOG_FILE, "a"))
log(f" skip_tools: True (unparseable)")
# Send Anthropic-compatible SSE (/v1/messages format)
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "close")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
msg_id = str(uuid.uuid4())
# Always return a single end_turn text response; no tool_use round trip.
events = [
{"type": "message_start", "message": {"id": msg_id, "type": "message", "role": "assistant", "content": [], "model": "local-model", "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": 10, "output_tokens": 5}}},
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Request received."}},
{"type": "content_block_stop", "index": 0},
{"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 5}},
{"type": "message_stop"},
]
for evt in events:
self.wfile.write(f"data: {json.dumps(evt)}\n\n".encode())
self.wfile.flush()
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
time.sleep(0.1)
def log_message(self, format, *args):
pass
port = 1234
log(f"Dump server starting on port {port}")
server = HTTPServer(("0.0.0.0", port), DumpHandler)
server.serve_forever()

74
rust/clawcode/install.md Normal file
View File

@ -0,0 +1,74 @@
# Claw Code Installation Guide
> One-click installation instructions for Windows users. The entire process is: download, extract, and double-click a single `.bat` file.
## 1. Download
Get the two files from [GitHub Releases](https://github.com/huagusam/clawcode/releases/latest):
| File | Download URL | Description |
|---|---|---|
| `Config_methods.7z` | [Download here](https://github.com/huagusam/clawcode/releases/download/v0.2.2.2/Config_methods.7z) | **Full installer package** — includes `claw.exe`, Git, fd, rg, config files, and the installation script |
| `claw.exe` | [Download here](https://github.com/huagusam/clawcode/releases/download/v0.2.2.1/claw.exe) | Standalone main binary (optional; already bundled in the installer package) |
> We recommend simply downloading **`Config_methods.7z`** — a single file completes the full installation.
## 2. Extract
1. Right-click `Config_methods.7z`**Extract All** (built into Windows; install [7-Zip](https://www.7-zip.org/) if not available)
2. After extraction you get the `Install_Config_methods` folder containing:
- `claw.exe` — main binary
- `Git.7z` — offline Git Bash installer
- `fd.exe` / `rg.exe` — search tools
- `.claw/` — configuration directory
- `install_claw.bat` — **one-click installation script**
> Note: the folder path must **not contain non-ASCII characters**, e.g. put it at `D:\claw\Install_Config_methods`.
## 3. One-Click Install
1. Enter the extracted `Install_Config_methods` folder
2. **Double-click `install_claw.bat`** and accept the administrator prompt (click "Yes" on the UAC dialog)
3. The script will automatically complete:
| Step | Action |
|---|---|
| 1/5 | Detect Git Bash: skip if installed, otherwise extract `Git.7z` to `C:\Program Files\Git` |
| 2/5 | Copy `fd.exe` and `rg.exe` to `C:\Program Files\Git\bin` |
| 3/5 | Copy `claw.exe` to `C:\Users\<your-username>\.local\bin` and create a `claw` shortcut on the desktop |
| 4/5 | Copy the `.claw` config folder to `C:\Users\<your-username>\.claw` (overwrites old config) |
| 5/5 | Add `C:\Program Files\Git\bin` and `.local\bin` to the system PATH |
You will see **"Installation finished"** once the installation succeeds.
## 4. Getting Started
1. **Reopen** a new terminal window (cmd / PowerShell / Windows Terminal) so the PATH takes effect
2. Double-click the **`claw`** shortcut on the desktop, or type `claw` and press Enter in a terminal
3. On first use, configure the API: edit `C:\Users\<your-username>\.claw\.env` and fill in your API Key and model:
```env
ANTHROPIC_BASE_URL=https://api.anthropic.com
ANTHROPIC_API_KEY=sk-ant-xxxxxxxx
ANTHROPIC_MODEL=claude-sonnet-4-20250514
```
> For local models (LM Studio / llama.cpp / Ollama): `ANTHROPIC_BASE_URL` only needs the server address (**do not** add `/v1` — claw automatically appends `/v1/messages`). The port varies by service: LM Studio `1234`, llama-server `8080`, Ollama `11434`.
## 5. FAQ
| Problem | Solution |
|---|---|
| The window flashes and closes after double-clicking the bat | Right-click `install_claw.bat` → Run as administrator |
| "7-Zip not found" error | Install [7-Zip](https://www.7-zip.org/) and rerun the script |
| `claw` command not found | Confirm the PATH has taken effect, or reopen the terminal and try again |
| No desktop shortcut | Check the installation log, or manually create a shortcut pointing to `C:\Users\<your-username>\.local\bin\claw.exe` |
| How to uninstall | Delete `C:\Users\<your-username>\.local\bin\claw.exe`, `C:\Users\<your-username>\.claw`, and the desktop shortcut |
## 6. Building from Source (Optional)
Requires a Rust + MSVC + Clang-CL environment; see the project [README](README.md).
## License
MIT

1
rust/clawcode/rust/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target/

3171
rust/Cargo.lock → rust/clawcode/rust/Cargo.lock generated Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,20 +3,21 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.1.3"
version = "0.2.2"
edition = "2021"
license = "MIT"
publish = false
[workspace.dependencies]
dunce = "1"
serde_json = "1"
[workspace.lints.rust]
unsafe_code = "forbid"
unsafe_code = "deny"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "allow", priority = -1 }
pedantic = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
missing_panics_doc = "allow"
missing_errors_doc = "allow"
missing_errors_doc = "allow"

View File

@ -0,0 +1,21 @@
[package]
name = "agents"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[features]
test-utils = []
[dependencies]
api = { path = "../api" }
plugins = { path = "../plugins" }
runtime = { path = "../runtime" }
futures = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json.workspace = true
tokio = { version = "1", features = ["rt-multi-thread"] }
[lints]
workspace = true

View File

@ -0,0 +1,544 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use runtime::strip_verbatim_prefix;
fn read_file_lossy(path: &Path) -> Result<String, std::io::Error> {
let bytes = std::fs::read(path)?;
Ok(String::from_utf8_lossy(&bytes).to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum DefinitionSource {
ProjectClaw,
ProjectClaude,
UserClawConfigHome,
UserClaw,
UserClaude,
Plugin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum DefinitionScope {
Project,
UserConfigHome,
UserHome,
Plugin,
}
impl DefinitionScope {
pub fn label(self) -> &'static str {
match self {
Self::Project => "Project roots",
Self::UserConfigHome => "User config roots",
Self::UserHome => "User home roots",
Self::Plugin => "Plugin agents",
}
}
}
impl DefinitionSource {
pub fn report_scope(self) -> DefinitionScope {
match self {
Self::ProjectClaw | Self::ProjectClaude => {
DefinitionScope::Project
}
Self::UserClawConfigHome => DefinitionScope::UserConfigHome,
Self::UserClaw | Self::UserClaude => DefinitionScope::UserHome,
Self::Plugin => DefinitionScope::Plugin,
}
}
pub fn label(self) -> &'static str {
self.report_scope().label()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentSummary {
pub name: String,
pub description: Option<String>,
pub model: Option<String>,
pub reasoning_effort: Option<String>,
pub source: DefinitionSource,
pub shadowed_by: Option<DefinitionSource>,
pub plugin: Option<String>,
/// Display-only agent mode (frontmatter `mode:`). Reported but not
/// consumed by the runtime/spawn (MessageRequest has no `mode` field).
pub mode: Option<String>,
/// Optional sub-agent kind (frontmatter `subagent_type:`). Steers the
/// spawned sub-agent's tool set instead of the general-purpose default.
pub subagent_type: Option<String>,
/// Declared tool allowlist from frontmatter `tools:`. When present it
/// constrains the spawned sub-agent's `allowed_tools`; when absent the
/// full tool set for the sub-agent kind is granted.
pub tools: Option<Vec<String>>,
/// Declared skill references from frontmatter `skills:`.
pub skills: Option<Vec<String>>,
/// Declared `permission:` directives (`tool-category → allow|deny|ask`).
/// Parsed leniently (does not require `name`/`description`), so deny
/// directives are honored even when the strict frontmatter parse fails.
pub permission: Option<BTreeMap<String, String>>,
}
impl AgentSummary {
pub fn name(&self) -> &str {
&self.name
}
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
}
pub struct AgentDiscovery {
agents: Vec<AgentSummary>,
active_names: Vec<String>,
}
impl AgentDiscovery {
pub fn new(cwd: &Path) -> Self {
let mut agents = Vec::new();
let roots = discover_definition_roots(cwd, "agents");
if let Ok(mut found) = load_agents_from_roots(&roots) {
agents.append(&mut found);
}
agents.sort_by(|a, b| a.name.cmp(&b.name));
let active_names = agents
.iter()
.filter(|a| a.shadowed_by.is_none())
.map(|a| a.name.clone())
.collect();
Self { agents, active_names }
}
pub fn all(&self) -> &[AgentSummary] {
&self.agents
}
pub fn active(&self) -> Vec<&AgentSummary> {
self.agents
.iter()
.filter(|a| a.shadowed_by.is_none())
.collect()
}
pub fn active_names(&self) -> &[String] {
&self.active_names
}
pub fn active_names_list(&self) -> Vec<String> {
self.active_names.clone()
}
pub fn find(&self, name: &str) -> Option<&AgentSummary> {
let lowered = name.to_ascii_lowercase();
self.agents
.iter()
.find(|a| a.shadowed_by.is_none() && a.name.to_ascii_lowercase() == lowered)
}
}
fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> {
let mut roots = Vec::new();
// Home boundary for the project-ancestor walk. Collect both HOME and
// USERPROFILE (Windows shells set one or the other), canonicalizing each
// so 8.3 short names (`INCRED~1`) cannot fool the comparison. When
// canonicalization fails (stripped env, POSIX-style `HOME=/c/Users/x` in
// Git Bash, deleted profile dir), keep the *raw* path so the boundary is
// never silently dropped: an empty boundary would let the walk climb to
// the drive root and mislabel user-scope `.claw/agents` as project scope.
let mut home_boundaries: Vec<PathBuf> = [std::ffi::OsStr::new("HOME"), std::ffi::OsStr::new("USERPROFILE")]
.into_iter()
.filter_map(std::env::var_os)
.map(PathBuf::from)
.map(|p| strip_verbatim_prefix(p.canonicalize().unwrap_or_else(|_| p.clone())))
.collect();
home_boundaries.dedup();
for ancestor in cwd.ancestors() {
// An ancestor is at-or-above home when the (canonical) home starts
// with it. This stops the walk at the home itself *and* at any
// ancestor of home (cwd on a sibling drive, cwd at the drive root),
// whereas an exact-equality comparison would only stop at the exact
// home path and otherwise climb to the drive root.
let canon_ancestor = strip_verbatim_prefix(
ancestor
.canonicalize()
.unwrap_or_else(|_| ancestor.to_path_buf()),
);
if home_boundaries
.iter()
.any(|home| home.starts_with(&canon_ancestor))
{
break;
}
push_unique_root(&mut roots, DefinitionSource::ProjectClaw, ancestor.join(".claw").join(leaf));
push_unique_root(&mut roots, DefinitionSource::ProjectClaude, ancestor.join(".claude").join(leaf));
}
if let Ok(claw_config_home) = std::env::var("CLAW_CONFIG_HOME") {
push_unique_root(&mut roots, DefinitionSource::UserClawConfigHome, PathBuf::from(claw_config_home).join(leaf));
}
if let Ok(claude_config_dir) = std::env::var("CLAUDE_CONFIG_DIR") {
push_unique_root(&mut roots, DefinitionSource::UserClaude, PathBuf::from(claude_config_dir).join(leaf));
}
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from);
if let Some(ref home) = home {
let home = strip_verbatim_prefix(home.clone());
push_unique_root(&mut roots, DefinitionSource::UserClaw, home.join(".claw").join(leaf));
push_unique_root(&mut roots, DefinitionSource::UserClaude, home.join(".claude").join(leaf));
}
roots
}
/// Returns the root directories that may contain agent definitions,
/// in discovery-priority order (project → config-home → user-home).
/// Uses the same search logic as [`AgentDiscovery`].
pub fn discover_agent_roots(cwd: &Path) -> Vec<PathBuf> {
discover_definition_roots(cwd, "agents")
.into_iter()
.map(|(_, path)| path)
.collect()
}
fn push_unique_root(
roots: &mut Vec<(DefinitionSource, PathBuf)>,
source: DefinitionSource,
path: PathBuf,
) {
if path.is_dir() && !roots.iter().any(|(_, existing)| existing == &path) {
roots.push((source, path));
}
}
fn load_agents_from_roots(
roots: &[(DefinitionSource, PathBuf)],
) -> Result<Vec<AgentSummary>, String> {
let mut agents = Vec::new();
let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
for (source, root) in roots {
let mut root_agents = Vec::new();
let dir = match std::fs::read_dir(root) {
Ok(d) => d,
Err(e) => {
eprintln!("[agents] warning: could not read {root:?}: {e}");
continue;
}
};
for entry in dir.flatten() {
let path = entry.path();
if path.is_dir() {
let skill_path = path.join("SKILL.md");
if skill_path.is_file() {
if let Ok(contents) = read_file_lossy(&skill_path) {
let fm = plugins::frontmatter::parse_frontmatter(&contents)
.ok()
.map(|p| p.frontmatter);
let name = fm
.as_ref()
.and_then(|f| f.name.clone())
.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string());
root_agents.push(AgentSummary {
name,
description: fm.as_ref().and_then(|f| f.description.clone()),
model: fm.as_ref().and_then(|f| f.model.clone()),
reasoning_effort: fm.as_ref().and_then(|f| f.reasoning_effort.clone()),
mode: fm.as_ref().and_then(|f| f.mode.clone()),
subagent_type: fm.as_ref().and_then(|f| f.subagent_type.clone()),
tools: fm.as_ref().and_then(|f| f.tools.clone()),
skills: fm.as_ref().and_then(|f| f.skills.clone()),
permission: plugins::frontmatter::parse_permission_from_content(
&contents,
),
source: *source,
shadowed_by: None,
plugin: None,
});
}
continue;
}
}
if path.extension().is_some_and(|ext| ext == "md") {
if let Ok(contents) = read_file_lossy(&path) {
let fm = plugins::frontmatter::parse_frontmatter(&contents)
.ok()
.map(|p| p.frontmatter);
let fallback_name = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string());
root_agents.push(AgentSummary {
name: fm
.as_ref()
.and_then(|f| f.name.clone())
.unwrap_or(fallback_name),
description: fm.as_ref().and_then(|f| f.description.clone()),
model: fm.as_ref().and_then(|f| f.model.clone()),
reasoning_effort: fm.as_ref().and_then(|f| f.reasoning_effort.clone()),
mode: fm.as_ref().and_then(|f| f.mode.clone()),
subagent_type: fm.as_ref().and_then(|f| f.subagent_type.clone()),
tools: fm.as_ref().and_then(|f| f.tools.clone()),
skills: fm.as_ref().and_then(|f| f.skills.clone()),
permission: plugins::frontmatter::parse_permission_from_content(
&contents,
),
source: *source,
shadowed_by: None,
plugin: None,
});
}
continue;
}
if path.extension().is_none_or(|ext| ext != "toml") {
continue;
}
if let Ok(contents) = read_file_lossy(&path) {
let fallback_name = path.file_stem().map_or_else(
|| entry.file_name().to_string_lossy().to_string(),
|stem| stem.to_string_lossy().to_string(),
);
root_agents.push(AgentSummary {
name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
description: parse_toml_string(&contents, "description"),
model: parse_toml_string(&contents, "model"),
reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
mode: parse_toml_string(&contents, "mode"),
subagent_type: parse_toml_string(&contents, "subagent_type"),
tools: parse_toml_list(&contents, "tools"),
skills: parse_toml_list(&contents, "skills"),
permission: parse_permission_toml(&contents),
source: *source,
shadowed_by: None,
plugin: None,
});
}
}
root_agents.sort_by(|left, right| left.name.cmp(&right.name));
for mut agent in root_agents {
let key = agent.name.to_ascii_lowercase();
if let Some(existing) = active_sources.get(&key) {
agent.shadowed_by = Some(*existing);
} else {
active_sources.insert(key, agent.source);
}
agents.push(agent);
}
}
Ok(agents)
}
fn parse_toml_string(contents: &str, key: &str) -> Option<String> {
let prefix = format!("{key} =");
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
let Some(value) = trimmed.strip_prefix(&prefix) else {
continue;
};
let value = value.trim();
let Some(value) = value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
else {
continue;
};
if !value.is_empty() {
return Some(value.to_string());
}
}
None
}
/// Parse a TOML array value like `tools = ["read_file", "grep_search"]`.
/// Returns `None` when the key is absent or the value is not a bracketed
/// string list.
fn parse_toml_list(contents: &str, key: &str) -> Option<Vec<String>> {
let prefix = format!("{key} =");
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
let Some(value) = trimmed.strip_prefix(&prefix) else {
continue;
};
let value = value.trim();
let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) else {
continue;
};
let items: Vec<String> = inner
.split(',')
.map(|item| item.trim().trim_matches('"').trim_matches('\'').to_string())
.filter(|item| !item.is_empty())
.collect();
if items.is_empty() {
return None;
}
return Some(items);
}
None
}
/// Parse a TOML `[permission]` table like
/// `[permission]` / `read = "allow"` / `write = "deny"` into the same
/// `tool-category → decision` map used by the markdown frontmatter parser.
fn parse_permission_toml(contents: &str) -> Option<BTreeMap<String, String>> {
let mut map = BTreeMap::new();
let mut in_table = false;
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
in_table = trimmed == "[permission]";
continue;
}
if !in_table {
continue;
}
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim().trim_matches('"').trim_matches('\'');
if !key.is_empty() && !value.is_empty() {
map.insert(key.to_string(), value.to_string());
}
}
if map.is_empty() {
None
} else {
Some(map)
}
}
pub fn render_agents_report(agents: &[AgentSummary]) -> String {
if agents.is_empty() {
return "No agents found.".to_string();
}
let total_active = agents
.iter()
.filter(|agent| agent.shadowed_by.is_none())
.count();
let mut lines = vec![
"Agents".to_string(),
format!(" {total_active} active agents"),
String::new(),
];
for scope in [
DefinitionScope::Project,
DefinitionScope::UserConfigHome,
DefinitionScope::UserHome,
DefinitionScope::Plugin,
] {
let group = agents
.iter()
.filter(|agent| agent.source.report_scope() == scope)
.collect::<Vec<_>>();
if group.is_empty() {
continue;
}
lines.push(format!("{}:", scope.label()));
for agent in group {
let detail = agent_detail(agent);
match agent.shadowed_by {
Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
None => lines.push(format!(" {detail}")),
}
}
lines.push(String::new());
}
lines.join("\n").trim_end().to_string()
}
pub fn render_agents_report_json(
cwd: &Path,
agents: &[AgentSummary],
) -> serde_json::Value {
let active = agents
.iter()
.filter(|agent| agent.shadowed_by.is_none())
.count();
serde_json::json!({
"kind": "agents",
"action": "list",
"count": agents.len(),
"summary": {
"total": agents.len(),
"active": active,
"shadowed": agents.len().saturating_sub(active),
},
"working_directory": cwd.display().to_string(),
"agents": agents.iter().map(agent_summary_json).collect::<Vec<_>>(),
})
}
pub fn definition_source_id(source: DefinitionSource) -> &'static str {
match source {
DefinitionSource::ProjectClaw | DefinitionSource::ProjectClaude => "project_claw",
DefinitionSource::UserClawConfigHome => "user_claw_config_home",
DefinitionSource::UserClaw | DefinitionSource::UserClaude => "user_claw",
DefinitionSource::Plugin => "plugin",
}
}
pub fn definition_source_json(source: DefinitionSource) -> serde_json::Value {
serde_json::json!({
"id": definition_source_id(source),
"label": source.label(),
})
}
fn agent_detail(agent: &AgentSummary) -> String {
let mut parts = vec![agent.name.clone()];
if let Some(description) = &agent.description {
parts.push(description.clone());
}
if let Some(model) = &agent.model {
parts.push(model.clone());
}
if let Some(reasoning) = &agent.reasoning_effort {
parts.push(reasoning.clone());
}
if let Some(mode) = &agent.mode {
parts.push(format!("[{mode}]"));
}
if let Some(plugin) = &agent.plugin {
parts.push(format!("[{plugin}]"));
}
parts.join(" \u{b7} ")
}
fn agent_summary_json(agent: &AgentSummary) -> serde_json::Value {
serde_json::json!({
"name": &agent.name,
"description": &agent.description,
"model": &agent.model,
"reasoning_effort": &agent.reasoning_effort,
"mode": &agent.mode,
"source": definition_source_json(agent.source),
"active": agent.shadowed_by.is_none(),
"shadowed_by": agent.shadowed_by.map(definition_source_json),
"plugin": &agent.plugin,
"permission": &agent.permission,
})
}

View File

@ -0,0 +1,30 @@
//! Sub-agent subsystem.
//!
pub mod discovery;
mod normalize;
mod persist;
mod runtime;
mod spawn;
pub mod types;
pub use self::discovery::{
definition_source_id, definition_source_json, discover_agent_roots, render_agents_report,
render_agents_report_json, AgentDiscovery, AgentSummary, DefinitionScope, DefinitionSource,
};
pub use self::normalize::{allowed_tools_for_subagent, normalize_subagent_type, SubagentKind};
pub use self::persist::{
extract_commit_sha, make_agent_id, slugify_agent_name, DEFAULT_AGENT_MAX_ITERATIONS,
DEFAULT_AGENT_TIMEOUT_SECS,
};
pub use self::runtime::{
build_agent_runtime, build_agent_runtime_inner, build_agent_system_prompt,
init_global_runtime, register_runtime_tool_provider, register_tool_executor,
registered_extra_tool_defs, resolve_agent_model, ProviderRuntimeClient, SubagentToolExecutor,
RuntimeToolExecutorFn,
};
pub use self::spawn::{spawn_agent_task, spawn_agent_task_with_progress, AgentHandle, TryAgain};
pub use self::types::{
AgentInput, AgentJob, AgentOutput, AgentProgress, AgentStatus, ProgressStore, SharedProgress,
SubagentProgressEvent, new_shared_progress, push_progress_event, set_current_activity,
};

View File

@ -0,0 +1,90 @@
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubagentKind {
GeneralPurpose,
Explore,
Plan,
Verification,
ClawGuide,
StatuslineSetup,
Custom(String),
}
impl SubagentKind {
pub fn from_str(s: Option<&str>) -> Self {
match canonical_tool_token(s.map(str::trim).unwrap_or_default()).as_str() {
"general" | "generalpurpose" | "generalpurposeagent" => Self::GeneralPurpose,
"explore" | "explorer" | "exploreagent" => Self::Explore,
"plan" | "planagent" => Self::Plan,
"verification" | "verificationagent" | "verify" | "verifier" => Self::Verification,
"clawguide" | "clawguideagent" | "guide" => Self::ClawGuide,
"statusline" | "statuslinesetup" => Self::StatuslineSetup,
other => Self::Custom(other.to_string()),
}
}
pub fn as_str(&self) -> &str {
match self {
Self::GeneralPurpose => "general-purpose",
Self::Explore => "Explore",
Self::Plan => "Plan",
Self::Verification => "Verification",
Self::ClawGuide => "claw-guide",
Self::StatuslineSetup => "statusline-setup",
Self::Custom(s) => s.as_str(),
}
}
pub fn allowed_tools(&self) -> BTreeSet<String> {
let tools: Vec<&str> = match self {
Self::Explore => vec![
"read_file", "glob_search", "grep_search", "WebFetch", "WebSearch",
"ToolSearch", "Skill", "StructuredOutput",
],
Self::Plan => vec![
"read_file", "glob_search", "grep_search", "WebFetch", "WebSearch",
"ToolSearch", "Skill", "StructuredOutput",
],
Self::Verification => vec![
"bash", "read_file", "glob_search", "grep_search", "WebSearch",
"ToolSearch", "StructuredOutput",
],
Self::ClawGuide => vec![
"read_file", "glob_search", "grep_search", "WebFetch", "WebSearch",
"ToolSearch", "Skill", "StructuredOutput",
],
Self::StatuslineSetup => vec![
"bash", "read_file", "new_file", "edit_file", "glob_search",
"grep_search", "ToolSearch",
],
Self::GeneralPurpose => vec![
"bash", "read_file", "new_file", "edit_file", "glob_search",
"grep_search", "WebFetch", "WebSearch", "Skill",
"StructuredOutput",
],
Self::Custom(_) => vec![],
};
tools.into_iter().map(str::to_string).collect()
}
}
pub fn normalize_subagent_type(subagent_type: Option<&str>) -> String {
SubagentKind::from_str(subagent_type).as_str().to_string()
}
pub fn allowed_tools_for_subagent(subagent_type: &str) -> BTreeSet<String> {
SubagentKind::from_str(Some(subagent_type)).allowed_tools()
}
fn canonical_tool_token(value: &str) -> String {
let mut canonical: String = value
.chars()
.filter(char::is_ascii_alphanumeric)
.flat_map(char::to_lowercase)
.collect();
if let Some(stripped) = canonical.strip_suffix("tool") {
canonical = stripped.to_string();
}
canonical
}

View File

@ -0,0 +1,57 @@
use std::sync::atomic::{AtomicU64, Ordering};
pub const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6";
pub const DEFAULT_AGENT_SYSTEM_DATE: &str = "2026-03-31";
pub const DEFAULT_AGENT_MAX_ITERATIONS: usize = 32;
pub const DEFAULT_AGENT_TIMEOUT_SECS: u64 = 300;
static AGENT_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
pub fn make_agent_id() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_else(|error| {
eprintln!("[agent] system clock is before epoch ({error}); using 0 for agent ID");
std::time::Duration::ZERO
})
.as_nanos();
let n = AGENT_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("agent-{nanos:x}-{n:x}")
}
pub fn slugify_agent_name(description: &str) -> String {
let mut out: String = description
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
while out.contains("--") {
out = out.replace("--", "-");
}
out.trim_matches('-').chars().take(32).collect()
}
/// Extract a commit SHA reference from a free-form result string.
pub fn extract_commit_sha(result: &str) -> Option<String> {
for token in result.split(|c: char| !c.is_ascii_hexdigit()) {
if token.len() == 40 {
return Some(token.to_string());
}
}
let lower = result.to_ascii_lowercase();
for marker in ["commit ", "sha ", "sha:", "@"] {
if let Some(idx) = lower.find(marker) {
let after = &result[idx + marker.len()..];
let token: String = after.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
if (7..=12).contains(&token.len()) {
return Some(token);
}
}
}
None
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,494 @@
use std::panic::AssertUnwindSafe;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use runtime::ConversationRuntime;
use crate::persist::{
DEFAULT_AGENT_MAX_ITERATIONS, DEFAULT_AGENT_TIMEOUT_SECS,
};
use crate::runtime::{build_agent_runtime_inner, ProviderRuntimeClient, SubagentToolExecutor};
use crate::types::{AgentJob, AgentProgress, AgentStatus, SharedProgress, SubagentProgressEvent};
pub struct AgentHandle {
pub agent_id: String,
thread_handle: Option<std::thread::JoinHandle<()>>,
rx: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
pub progress: SharedProgress,
finished: Arc<AtomicBool>,
cancel: Arc<AtomicBool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TryAgain;
/// Reap the worker and drop its progress entry whenever the handle is dropped,
/// not just on the explicit `join` path. Without this, a `try_join`-only
/// consumer (the production `wait_for_agent`) leaks the progress entry for the
/// process lifetime, and a handle dropped after a timeout detaches the worker
/// thread instead of reaping it. The worker's provider calls are time-bounded
/// (api crate), so `join` always terminates.
impl Drop for AgentHandle {
fn drop(&mut self) {
self.cancel.store(true, Ordering::SeqCst);
if let Some(handle) = self.thread_handle.take() {
let _ = handle.join();
}
remove_progress_entry(&self.progress, &self.agent_id);
}
}
impl AgentHandle {
pub fn agent_id(&self) -> &str {
&self.agent_id
}
pub fn join(mut self) -> Result<String, String> {
let timeout = Duration::from_secs(DEFAULT_AGENT_TIMEOUT_SECS);
let rx = match self.rx.take() {
Some(rx) => rx,
None => return Ok(String::new()),
};
let result = match rx.recv_timeout(timeout) {
Ok(Ok(text)) => Ok(text),
Ok(Err(e)) => Err(e),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err("agent timed out".to_string()),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
Err("agent disconnected".to_string())
}
};
self.finished.store(true, Ordering::SeqCst);
remove_progress_entry(&self.progress, &self.agent_id);
// Join unconditionally on every exit path. The worker's provider calls
// are now time-bounded (api crate), so join() always terminates and a
// timed-out or failed agent never leaks its OS thread.
let _ = self.thread_handle.take().map(|h| h.join());
result
}
pub fn try_join(&mut self) -> Result<Result<String, String>, TryAgain> {
let rx = match self.rx.as_ref() {
Some(rx) => rx,
None => return Ok(Ok(String::new())),
};
match rx.try_recv() {
Ok(result) => {
self.finished.store(true, Ordering::SeqCst);
// The worker sent its result as the final act before exiting;
// reap it now so the thread never leaks.
let _ = self.thread_handle.take().map(|h| h.join());
Ok(result)
}
Err(std::sync::mpsc::TryRecvError::Empty) => Err(TryAgain),
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.finished.store(true, Ordering::SeqCst);
let _ = self.thread_handle.take().map(|h| h.join());
Ok(Err("agent disconnected".to_string()))
}
}
}
pub fn is_finished(&self) -> bool {
self.finished.load(Ordering::SeqCst)
}
/// Signal the worker to stop at the next iteration boundary. The caller
/// must then reap the thread (via `try_join`) to avoid running the agent
/// to completion after it was told to stop.
pub fn cancel(&self) {
self.cancel.store(true, Ordering::SeqCst);
}
#[cfg(feature = "test-utils")]
pub fn noop(agent_id: impl Into<String>) -> Self {
Self {
agent_id: agent_id.into(),
thread_handle: None,
rx: None,
progress: crate::types::new_shared_progress(),
finished: Arc::new(AtomicBool::new(true)),
cancel: Arc::new(AtomicBool::new(false)),
}
}
#[cfg(feature = "test-utils")]
pub fn with_parts(
agent_id: impl Into<String>,
thread_handle: std::thread::JoinHandle<()>,
rx: std::sync::mpsc::Receiver<Result<String, String>>,
) -> Self {
Self {
agent_id: agent_id.into(),
thread_handle: Some(thread_handle),
rx: Some(rx),
progress: crate::types::new_shared_progress(),
finished: Arc::new(AtomicBool::new(false)),
cancel: Arc::new(AtomicBool::new(false)),
}
}
#[cfg(feature = "test-utils")]
pub fn join_with_timeout(mut self, timeout: Duration) -> Result<String, String> {
let rx = match self.rx.take() {
Some(rx) => rx,
None => return Ok(String::new()),
};
let result = match rx.recv_timeout(timeout) {
Ok(Ok(text)) => Ok(text),
Ok(Err(e)) => Err(e),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err("agent timed out".to_string()),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
Err("agent disconnected".to_string())
}
};
let _ = self.thread_handle.take().map(|h| h.join());
result
}
}
fn remove_progress_entry(shared: &SharedProgress, agent_id: &str) {
let mut guard = shared.agents.lock().unwrap_or_else(|e| e.into_inner());
guard.retain(|p| p.agent_id != agent_id);
}
/// Spawn an agent task on a dedicated OS thread so that the
/// `ProviderRuntimeClient::block_on()` call inside `run_agent_job`
/// does not panic with "Cannot start a runtime from within a runtime".
pub fn spawn_agent_task(job: AgentJob) -> Result<AgentHandle, String> {
spawn_agent_task_with_progress(job, crate::types::new_shared_progress())
}
pub fn spawn_agent_task_with_progress(
job: AgentJob,
progress: SharedProgress,
) -> Result<AgentHandle, String> {
let agent_id = job.manifest.agent_id.clone();
let name = job.manifest.name.clone();
let subagent_type = job.manifest.subagent_type.clone().unwrap_or_default();
let finished = Arc::new(AtomicBool::new(false));
let finished_clone = Arc::clone(&finished);
let cancel = Arc::new(AtomicBool::new(false));
{
let mut guard = progress.agents.lock().unwrap_or_else(|e| e.into_inner());
guard.push(AgentProgress {
agent_id: agent_id.clone(),
name: name.clone(),
subagent_type: subagent_type.clone(),
status: AgentStatus::Running,
events: vec![],
started_at: std::time::Instant::now(),
iteration_count: 0,
final_event: None,
current_activity: None,
});
}
let (tx, rx) = std::sync::mpsc::channel::<Result<String, String>>();
let progress_for_job = Arc::clone(&progress);
let agent_id_for_job = agent_id.clone();
let cancel_for_job = Arc::clone(&cancel);
let thread_handle = std::thread::spawn(move || {
let job_progress = Arc::clone(&progress_for_job);
let job_agent_id = agent_id_for_job.clone();
let job_with_progress = AssertUnwindSafe(AgentJobWithProgress {
job,
progress: progress_for_job,
agent_id: agent_id_for_job,
cancel: cancel_for_job,
});
let result = std::panic::catch_unwind(move || {
run_agent_job_sync_with_progress(&job_with_progress)
});
clear_current_activity(&job_progress, &job_agent_id);
let outcome = match result {
Ok(Ok(text)) => {
push_progress_event(
&job_progress,
&job_agent_id,
SubagentProgressEvent::Completed {
result_preview: text.clone(),
},
);
push_progress_event(
&job_progress,
&job_agent_id,
SubagentProgressEvent::StatusChange {
status: AgentStatus::Completed,
},
);
Ok(text)
}
Ok(Err(error)) => {
push_progress_event(
&job_progress,
&job_agent_id,
SubagentProgressEvent::Failed {
error: error.clone(),
},
);
Err(error)
}
Err(panic_payload) => {
let panic_msg = panic_message(&panic_payload);
push_progress_event(
&job_progress,
&job_agent_id,
SubagentProgressEvent::Failed {
error: format!("panic: {panic_msg}"),
},
);
Err(format!("panic: {panic_msg}"))
}
};
finished_clone.store(true, Ordering::SeqCst);
let _ = tx.send(outcome);
});
Ok(AgentHandle {
agent_id,
thread_handle: Some(thread_handle),
rx: Some(rx),
progress,
finished,
cancel,
})
}
struct AgentJobWithProgress {
job: AgentJob,
progress: SharedProgress,
agent_id: String,
cancel: Arc<AtomicBool>,
}
fn push_progress_event(shared: &SharedProgress, agent_id: &str, event: SubagentProgressEvent) {
crate::types::push_progress_event(shared, agent_id, event);
}
fn clear_current_activity(shared: &SharedProgress, agent_id: &str) {
crate::types::set_current_activity(shared, agent_id, None);
}
fn run_agent_job_sync_with_progress(job: &AgentJobWithProgress) -> Result<String, String> {
let mut runtime: ConversationRuntime<ProviderRuntimeClient, SubagentToolExecutor> =
build_agent_runtime_inner(
&job.job,
Some(Arc::clone(&job.progress)),
Some(job.agent_id.clone()),
)?
.with_max_iterations(DEFAULT_AGENT_MAX_ITERATIONS)
.with_cancel_signal(Arc::clone(&job.cancel));
let summary = runtime
.run_turn(job.job.prompt.clone(), None)
.map_err(|error| error.to_string())?;
match final_assistant_text(&summary) {
Some(text) => Ok(text),
None => Err("agent returned no text".to_string()),
}
}
fn panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
if let Some(s) = payload.downcast_ref::<&'static str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
String::from("unknown panic payload")
}
}
fn final_assistant_text(summary: &runtime::TurnSummary) -> Option<String> {
// Walk messages newest-first so a thinking-only final turn does not
// silently erase the agent's real answer from an earlier message.
//
// Messages that carry a `ToolUse` block are skipped as text candidates:
// any text inside them is transitional narration emitted BEFORE the tool
// call ("Let me check the file first"), not the sub-agent's answer. Only
// tool-use-free messages can supply the final result.
for message in summary.assistant_messages.iter().rev() {
if message
.blocks
.iter()
.any(|block| matches!(block, runtime::ContentBlock::ToolUse { .. }))
{
continue;
}
let text = message
.blocks
.iter()
.filter_map(|block| match block {
runtime::ContentBlock::Text { text } => {
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n");
if !text.is_empty() {
return Some(text);
}
}
// No non-empty text block anywhere: surface the latest reasoning so the
// parent model sees *something* instead of a silently empty result.
for message in summary.assistant_messages.iter().rev() {
for block in message.blocks.iter().rev() {
if let runtime::ContentBlock::Thinking { thinking, .. } = block {
let trimmed = thinking.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
}
// Truly nothing to report. `None` propagates as an error to the parent so
// a sub-agent that produced no output is never mistaken for a successful
// delegation (the old code returned a `"(agent returned no text)"` marker
// with `is_error=false`, silently swallowing the failure).
None
}
#[cfg(test)]
mod tests {
use runtime::{
AutoCompactionEvent, ContentBlock, ConversationMessage, PromptCacheEvent, TokenUsage,
TurnSummary,
};
use super::final_assistant_text;
fn summary_with(messages: Vec<ConversationMessage>) -> TurnSummary {
TurnSummary {
assistant_messages: messages,
tool_results: vec![],
prompt_cache_events: vec![PromptCacheEvent {
unexpected: false,
reason: String::new(),
previous_cache_read_input_tokens: 0,
current_cache_read_input_tokens: 0,
token_drop: 0,
}],
iterations: 1,
usage: TokenUsage::default(),
auto_compaction: Some(AutoCompactionEvent {
removed_message_count: 0,
savings_ratio: 0.0,
}),
}
}
fn text(s: &str) -> ContentBlock {
ContentBlock::Text { text: s.to_string() }
}
fn thinking(s: &str) -> ContentBlock {
ContentBlock::Thinking {
thinking: s.to_string(),
signature: Some("sig".to_string()),
}
}
fn tool_use() -> ContentBlock {
ContentBlock::ToolUse {
id: "toolu_test_1".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({}),
}
}
fn msg(blocks: Vec<ContentBlock>) -> ConversationMessage {
ConversationMessage::assistant(blocks)
}
#[test]
fn returns_text_from_last_message() {
let summary = summary_with(vec![msg(vec![text("hello")])]);
assert_eq!(final_assistant_text(&summary), Some("hello".to_string()));
}
#[test]
fn returns_last_non_empty_text_message_when_final_is_thinking_only() {
let summary = summary_with(vec![
msg(vec![text("earlier result")]),
msg(vec![thinking("thinking only")]),
]);
assert_eq!(
final_assistant_text(&summary),
Some("earlier result".to_string())
);
}
#[test]
fn returns_thinking_text_when_no_text_blocks_exist() {
let summary = summary_with(vec![msg(vec![thinking("deep reasoning")])]);
assert_eq!(
final_assistant_text(&summary),
Some("deep reasoning".to_string())
);
}
#[test]
fn returns_none_when_no_blocks_at_all() {
let summary = summary_with(vec![]);
assert_eq!(final_assistant_text(&summary), None);
}
#[test]
fn ignores_empty_text_blocks_when_falling_back() {
let summary = summary_with(vec![
msg(vec![text(" ")]),
msg(vec![text("real answer")]),
]);
assert_eq!(
final_assistant_text(&summary),
Some("real answer".to_string())
);
}
#[test]
fn does_not_return_transitional_text_from_tool_calling_message() {
let summary = summary_with(vec![
msg(vec![text("Let me check the file first"), tool_use()]),
msg(vec![thinking("The real answer is 42")]),
]);
assert_eq!(
final_assistant_text(&summary),
Some("The real answer is 42".to_string())
);
}
#[test]
fn falls_back_to_last_text_only_message_when_tool_calling_message_is_newer() {
let summary = summary_with(vec![
msg(vec![text("actual result")]),
msg(vec![text("Let me verify"), tool_use()]),
msg(vec![thinking("final reasoning only")]),
]);
assert_eq!(
final_assistant_text(&summary),
Some("actual result".to_string())
);
}
#[test]
fn prefers_thinking_over_transitional_text_from_tool_calling_message() {
let summary = summary_with(vec![
msg(vec![text("Let me check the file first"), tool_use()]),
msg(vec![thinking("the answer is deep reasoning")]),
]);
assert_eq!(
final_assistant_text(&summary),
Some("the answer is deep reasoning".to_string())
);
}
}

View File

@ -0,0 +1,198 @@
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Condvar, Mutex};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentStatus {
Running,
Thinking,
UsingTool,
Completed,
Failed,
}
impl AgentStatus {
pub fn as_str(&self) -> &'static str {
match self {
AgentStatus::Running => "Running",
AgentStatus::Thinking => "Thinking",
AgentStatus::UsingTool => "UsingTool",
AgentStatus::Completed => "Completed",
AgentStatus::Failed => "Failed",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub enum SubagentProgressEvent {
Thinking { text: String },
ToolCall { tool_name: String, input: Value },
ToolResult { tool_name: String, truncated_result: String },
StatusChange { status: AgentStatus },
Completed { result_preview: String },
Failed { error: String },
}
#[derive(Debug, Clone)]
pub struct AgentProgress {
pub agent_id: String,
pub name: String,
pub subagent_type: String,
pub status: AgentStatus,
pub events: Vec<SubagentProgressEvent>,
pub started_at: Instant,
pub iteration_count: usize,
pub final_event: Option<SubagentProgressEvent>,
pub current_activity: Option<String>,
}
pub struct ProgressStore {
pub agents: Mutex<Vec<AgentProgress>>,
pub cvar: Condvar,
pub event_seq: AtomicUsize,
}
pub type SharedProgress = Arc<ProgressStore>;
pub fn new_shared_progress() -> SharedProgress {
Arc::new(ProgressStore {
agents: Mutex::new(Vec::new()),
cvar: Condvar::new(),
event_seq: AtomicUsize::new(0),
})
}
pub fn push_progress_event(
shared: &SharedProgress,
agent_id: &str,
event: SubagentProgressEvent,
) {
let mut guard = shared.agents.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = guard.iter_mut().find(|p| p.agent_id == agent_id) {
if let SubagentProgressEvent::StatusChange { status } = &event {
entry.status = *status;
if *status == AgentStatus::UsingTool {
entry.iteration_count += 1;
}
}
match &event {
SubagentProgressEvent::Completed { .. }
| SubagentProgressEvent::Failed { .. } => {
entry.final_event = Some(event.clone());
}
_ => {}
}
if entry.events.len() > 50 {
entry.events.remove(0);
}
entry.events.push(event);
}
drop(guard);
shared.event_seq.fetch_add(1, Ordering::Release);
shared.cvar.notify_all();
}
pub fn set_current_activity(
shared: &SharedProgress,
agent_id: &str,
activity: Option<String>,
) {
let mut guard = shared.agents.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = guard.iter_mut().find(|p| p.agent_id == agent_id) {
entry.current_activity = activity;
}
drop(guard);
shared.event_seq.fetch_add(1, Ordering::Release);
shared.cvar.notify_all();
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentOutput {
#[serde(rename = "agentId")]
pub agent_id: String,
pub name: String,
pub description: String,
#[serde(rename = "subagentType")]
pub subagent_type: Option<String>,
pub model: Option<String>,
/// Display-only agent mode echoed from the definition; not consumed by
/// the runtime or any provider request.
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
/// `permission:` directives from the agent definition's frontmatter,
/// as `tool-category → allow|deny|ask`. When present, the spawned
/// sub-agent's `PermissionPolicy` is built with these as explicit rules
/// (deny rules are effective even under `DangerFullAccess`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission: Option<BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completed_at: Option<u64>,
#[serde(rename = "laneEvents", default, skip_serializing_if = "Vec::is_empty")]
pub lane_events: Vec<runtime::LaneEvent>,
}
#[derive(Debug, Clone)]
pub struct AgentJob {
pub manifest: AgentOutput,
pub prompt: String,
pub system_prompt: Vec<String>,
pub allowed_tools: BTreeSet<String>,
pub reasoning_effort: Option<String>,
pub permission: Option<BTreeMap<String, String>>,
/// Permission mode inherited from the parent session (permission
/// passthrough). The sub-agent's `PermissionPolicy` is built with
/// this mode as its base instead of always using
/// `DangerFullAccess`.
pub permission_mode: runtime::PermissionMode,
}
#[derive(Debug, Deserialize)]
pub struct AgentInput {
pub description: String,
pub prompt: String,
pub subagent_type: Option<String>,
pub name: Option<String>,
pub model: Option<String>,
/// Optional explicit system prompt (e.g. an `@agent` file's contents).
/// When present, `execute_agent_with_spawn` uses it instead of deriving
/// the prompt solely from `subagent_type` (which would drop the agent's
/// own persona).
#[serde(default)]
pub system_prompt: Option<Vec<String>>,
/// Optional allowed-tool allowlist. When present, overrides the tools
/// inferred from `subagent_type`.
#[serde(default)]
pub allowed_tools: Option<BTreeSet<String>>,
/// Optional agent mode (frontmatter `mode:`). Display-only: echoed into
/// the manifest/report but NOT consumed by the runtime, spawn, or any
/// provider request (MessageRequest has no `mode` field). Kept for
/// reporting parity with the definition.
#[serde(default)]
pub mode: Option<String>,
/// Optional reasoning-effort level (e.g. `low`/`medium`/`high`) forwarded
/// to the provider's `MessageRequest`. When present, the spawned sub-agent
/// runs with the agent definition's configured effort instead of the
/// provider default.
#[serde(default)]
pub reasoning_effort: Option<String>,
/// Optional `permission:` directives from the agent file frontmatter
/// (`tool-category → allow|deny|ask`). Honored as explicit rules on the
/// spawned sub-agent's `PermissionPolicy`. Not advertised in the tool
/// schema: the model must not be able to grant itself permissions.
#[serde(default)]
pub permission: Option<BTreeMap<String, String>>,
}

View File

@ -0,0 +1,48 @@
//! Verifies that an agent definition's declared `tools:` / `skills:` list is
//! captured into `AgentSummary` so the spawn path can constrain the sub-agent
//! (rather than always granting the full general-purpose write tool set).
use std::path::PathBuf;
fn unique_temp_dir() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time after epoch")
.as_nanos();
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("agents-tools-{nanos}-{unique}"))
}
#[test]
fn agent_summary_captures_declared_tools_and_skills() {
let root = unique_temp_dir();
let agents_dir = root.join(".claw").join("agents");
std::fs::create_dir_all(&agents_dir).expect("agents dir");
std::fs::write(
agents_dir.join("restricted.md"),
"---\nname: restricted\ndescription: read-only reviewer\nmodel: claude-sonnet-4\ntools: [\"read_file\", \"grep_search\"]\nskills: [\"review\"]\n---\n\nYou review code read-only.\n",
)
.expect("write agent file");
let discovery = agents::AgentDiscovery::new(&root);
let found = discovery
.find("restricted")
.expect("restricted agent should be discovered");
assert_eq!(
found.tools.as_deref(),
Some(&["read_file".to_string(), "grep_search".to_string()][..]),
"declared tools must be captured on the summary"
);
assert_eq!(
found.skills.as_deref(),
Some(&["review".to_string()][..]),
"declared skills must be captured on the summary"
);
std::fs::remove_dir_all(root).ok();
}

View File

@ -0,0 +1,10 @@
use agents::make_agent_id;
#[test]
fn make_agent_id_is_unique_under_burst() {
let mut ids = std::collections::HashSet::new();
for _ in 0..1000 {
let id = make_agent_id();
assert!(ids.insert(id.clone()), "duplicate id {id}");
}
}

View File

@ -0,0 +1,46 @@
use agents::extract_commit_sha;
#[test]
fn extracts_full_sha1() {
let result = "landed in commit deadbeef1234567890abcdef1234567890abcdef cleanly";
assert_eq!(
extract_commit_sha(result).as_deref(),
Some("deadbeef1234567890abcdef1234567890abcdef"),
);
}
#[test]
fn extracts_short_sha_after_commit_word() {
let result = "landed as commit abc1234def and pushed";
assert_eq!(extract_commit_sha(result).as_deref(), Some("abc1234def"));
}
#[test]
fn extracts_short_sha_after_at_marker() {
let result = "tagged as @abc1234def5";
assert_eq!(extract_commit_sha(result).as_deref(), Some("abc1234def5"));
}
#[test]
fn rejects_uuid_fragment_without_context() {
let result = "see request id deadbeef-1234-5678-9abc-def012345678 in logs";
assert_eq!(extract_commit_sha(result), None);
}
#[test]
fn rejects_seven_char_hex_surrounded_by_digits() {
let result = "the previous build was 1234567890abcdef in sequence";
assert_eq!(extract_commit_sha(result), None);
}
#[test]
fn rejects_seven_char_hex_in_markdown_link() {
let result = "see [the diff](https://github.com/x/y/commit/abc1234) for context";
assert_eq!(extract_commit_sha(result), None);
}
#[test]
fn rejects_short_sha_below_seven_chars() {
let result = "pinned to commit abc12";
assert_eq!(extract_commit_sha(result), None);
}

View File

@ -0,0 +1,99 @@
//! Verifies the project-ancestor walk in `discover_agent_roots` stops at the
//! user's home boundary.
//!
//! Regression for the F-2 defect: when the working directory sits *outside*
//! the home directory (e.g. the cwd is a sibling of `~`), the old code
//! compared canonicalized ancestors for exact equality against the canonical
//! home, so it never matched and climbed all the way to the drive root --
//! picking up `.claw/agents` at or above the home as if they were project
//! scope. The walk must stop at any ancestor that is at-or-above home
//! (`home.starts_with(ancestor)`), not just at the exact home path.
//!
//! This test mutates the process environment, so it lives in its own binary
//! and runs as the single test here to avoid cross-test pollution.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
fn env_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn unique_temp_dir() -> std::path::PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time after epoch")
.as_nanos();
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("agents-home-boundary-{nanos}-{unique}"))
}
#[test]
fn project_walk_stops_at_or_above_home_boundary() {
let _guard = env_lock();
// Real (canonicalizable) home with a user agent.
let base = unique_temp_dir();
let home = base.join("home");
let home_agents = home.join(".claw").join("agents");
std::fs::create_dir_all(&home_agents).expect("home agents dir");
std::fs::write(home_agents.join("user-agent.md"), "---\nname: user-agent\n---\n").expect("write");
// Cwd is a *sibling* of home (outside the home boundary): its project
// agent dir must be discovered, but a decoy `.claw/agents` sitting at the
// home's parent level must NOT be treated as project scope.
let project = base.join("project");
let project_agents = project.join(".claw").join("agents");
std::fs::create_dir_all(&project_agents).expect("project agents dir");
std::fs::write(
project_agents.join("proj-agent.md"),
"---\nname: proj-agent\n---\n",
)
.expect("write");
let decoy_agents = base.join(".claw").join("agents");
std::fs::create_dir_all(&decoy_agents).expect("decoy agents dir");
std::fs::write(decoy_agents.join("decoy.md"), "---\nname: decoy\n---\n").expect("write");
// Pin the home env vars so the walk has a real boundary, regardless of
// what the host shell set.
let saved_home = std::env::var_os("HOME");
let saved_userprofile = std::env::var_os("USERPROFILE");
std::env::set_var("HOME", &home);
std::env::set_var("USERPROFILE", &home);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let roots = agents::discover_agent_roots(&project);
(roots, project_agents.clone(), decoy_agents.clone())
}));
match saved_home {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match saved_userprofile {
Some(value) => std::env::set_var("USERPROFILE", value),
None => std::env::remove_var("USERPROFILE"),
}
std::fs::remove_dir_all(&base).ok();
let (roots, project_agents, decoy_agents) =
result.unwrap_or_else(|payload| std::panic::resume_unwind(payload));
assert!(
roots.contains(&project_agents),
"project-level agent root must be discovered, got: {roots:?}"
);
assert!(
!roots.contains(&decoy_agents),
"home-parent decoy must NOT be treated as project scope, got: {roots:?}"
);
}
#[allow(unused_imports)]
use std::sync::MutexGuard;

View File

@ -0,0 +1,36 @@
use agents::SubagentKind;
#[test]
fn general_purpose_has_a_maximal_tool_set() {
let tools = SubagentKind::GeneralPurpose.allowed_tools();
assert!(!tools.is_empty(), "GeneralPurpose should keep its broad tool set");
assert!(tools.contains("bash"));
assert!(tools.contains("new_file"));
}
#[test]
fn custom_subagent_is_fail_closed() {
let tools = SubagentKind::Custom("anything-here".to_string()).allowed_tools();
assert!(
tools.is_empty(),
"Custom subagents must be fail-closed; got {tools:?}",
);
}
#[test]
fn custom_subagent_empty_regardless_of_name() {
let a = SubagentKind::Custom("foo".to_string()).allowed_tools();
let b = SubagentKind::Custom("general-purpose".to_string()).allowed_tools();
let c = SubagentKind::Custom("general".to_string()).allowed_tools();
assert!(a.is_empty());
assert!(b.is_empty());
assert!(c.is_empty());
}
#[test]
fn explore_remains_read_only() {
let tools = SubagentKind::Explore.allowed_tools();
assert!(tools.contains("read_file"));
assert!(!tools.contains("bash"));
assert!(!tools.contains("new_file"));
}

View File

@ -8,7 +8,7 @@ publish.workspace = true
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
runtime = { path = "../runtime" }
serde = { version = "1", features = ["derive"] }
serde = { version = "1", features = ["derive", "rc"] }
serde_json.workspace = true
telemetry = { path = "../telemetry" }
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }

View File

@ -13,6 +13,8 @@
clippy::uninlined_format_args
)]
use std::sync::Arc;
use api::{
build_chat_completion_request, flatten_tool_result_content, is_reasoning_model,
translate_message, InputContentBlock, InputMessage, MessageRequest, OpenAiCompatConfig,
@ -49,13 +51,14 @@ fn create_sample_request(message_count: usize) -> MessageRequest {
text: format!("Tool result content {}", i),
}],
is_error: false,
cache_reference: None,
}],
}),
_ => messages.push(InputMessage {
role: "assistant".to_string(),
content: vec![InputContentBlock::ToolUse {
id: format!("call_{}", i),
name: "write_file".to_string(),
name: "new_file".to_string(),
input: json!({"path": format!("/tmp/out{}", i), "content": "data"}),
}],
}),
@ -65,18 +68,11 @@ fn create_sample_request(message_count: usize) -> MessageRequest {
MessageRequest {
model: "gpt-4o".to_string(),
max_tokens: 1024,
messages,
messages: messages.into(),
stream: false,
system: Some("You are a helpful assistant.".to_string()),
system: Some(Arc::from("You are a helpful assistant.")),
temperature: Some(0.7),
top_p: None,
tools: None,
tool_choice: None,
frequency_penalty: None,
presence_penalty: None,
stop: None,
reasoning_effort: None,
extra_body: std::collections::BTreeMap::new(),
..Default::default()
}
}
@ -108,7 +104,7 @@ fn bench_translate_message(c: &mut Criterion) {
},
InputContentBlock::ToolUse {
id: "call_2".to_string(),
name: "write_file".to_string(),
name: "new_file".to_string(),
input: json!({"path": "/tmp/out", "content": "data"}),
},
],
@ -130,6 +126,7 @@ fn bench_translate_message(c: &mut Criterion) {
text: "File contents here".to_string(),
}],
is_error: false,
cache_reference: None,
}],
};
group.bench_with_input(
@ -140,15 +137,6 @@ fn bench_translate_message(c: &mut Criterion) {
},
);
// Tool result for kimi model (is_error excluded)
group.bench_with_input(
BenchmarkId::new("tool_result_kimi", "kimi-k2.5"),
&tool_result_message,
|b, msg| {
b.iter(|| translate_message(black_box(msg), black_box("kimi-k2.5")));
},
);
// Large content message
let large_content = "x".repeat(10000);
let large_message = InputMessage::user_text(large_content);

View File

@ -0,0 +1,152 @@
use crate::error::ApiError;
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
use crate::providers::anthropic::{self, AnthropicClient, AuthSource};
use crate::providers::openai_compat;
use crate::providers::openai_compat::{OpenAiCompatClient, OpenAiCompatConfig};
use crate::providers::{self, ProviderKind};
use crate::types::{MessageRequest, MessageResponse, StreamEvent};
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone)]
pub enum ProviderClient {
Anthropic(AnthropicClient),
OpenAi(OpenAiCompatClient),
}
impl ProviderClient {
pub fn from_model(model: &str) -> Result<Self, ApiError> {
Self::from_model_with_anthropic_auth(model, None)
}
pub fn from_model_with_anthropic_auth(
model: &str,
anthropic_auth: Option<AuthSource>,
) -> Result<Self, ApiError> {
let resolved_model = providers::resolve_model_alias(model);
match providers::detect_provider_kind(&resolved_model) {
ProviderKind::Anthropic => Ok(Self::Anthropic(match anthropic_auth {
Some(auth) => AnthropicClient::from_auth(auth),
None => AnthropicClient::from_env()?,
})),
ProviderKind::OpenAi => Ok(Self::OpenAi(OpenAiCompatClient::from_env(
OpenAiCompatConfig::openai(),
)?)),
}
}
#[must_use]
pub const fn provider_kind(&self) -> ProviderKind {
match self {
Self::Anthropic(_) => ProviderKind::Anthropic,
Self::OpenAi(_) => ProviderKind::OpenAi,
}
}
#[must_use]
pub fn with_prompt_cache(self, prompt_cache: PromptCache) -> Self {
match self {
Self::Anthropic(client) => Self::Anthropic(client.with_prompt_cache(prompt_cache)),
other => other,
}
}
#[must_use]
pub fn prompt_cache_stats(&self) -> Option<PromptCacheStats> {
match self {
Self::Anthropic(client) => client.prompt_cache_stats(),
Self::OpenAi(_) => None,
}
}
#[must_use]
pub fn take_last_prompt_cache_record(&self) -> Option<PromptCacheRecord> {
match self {
Self::Anthropic(client) => client.take_last_prompt_cache_record(),
Self::OpenAi(_) => None,
}
}
/// Enable incremental body serialisation (Anthropic only).
#[must_use]
pub fn with_incremental_body(self) -> Self {
match self {
Self::Anthropic(client) => Self::Anthropic(client.with_incremental_body()),
other => other,
}
}
pub async fn send_message(
&self,
request: &MessageRequest,
) -> Result<MessageResponse, ApiError> {
match self {
Self::Anthropic(client) => client.send_message(request).await,
Self::OpenAi(client) => client.send_message(request).await,
}
}
pub async fn stream_message(
&self,
request: &MessageRequest,
) -> Result<MessageStream, ApiError> {
match self {
Self::Anthropic(client) => client
.stream_message(request)
.await
.map(MessageStream::Anthropic),
Self::OpenAi(client) => client
.stream_message(request)
.await
.map(MessageStream::OpenAiCompat),
}
}
}
#[derive(Debug)]
pub enum MessageStream {
Anthropic(anthropic::MessageStream),
OpenAiCompat(openai_compat::MessageStream),
}
impl MessageStream {
#[must_use]
pub fn request_id(&self) -> Option<&str> {
match self {
Self::Anthropic(stream) => stream.request_id(),
Self::OpenAiCompat(stream) => stream.request_id(),
}
}
pub async fn next_event(&mut self) -> Result<Option<StreamEvent>, ApiError> {
match self {
Self::Anthropic(stream) => stream.next_event().await,
Self::OpenAiCompat(stream) => stream.next_event().await,
}
}
}
pub use anthropic::{
oauth_token_is_expired, resolve_saved_oauth_token, resolve_startup_auth_source, OAuthTokenSet,
};
#[must_use]
pub fn read_base_url() -> String {
anthropic::read_base_url()
}
#[cfg(test)]
mod tests {
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
#[test]
fn resolves_existing_aliases() {
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6");
}
#[test]
fn provider_detection_prefers_model_family() {
assert_eq!(
detect_provider_kind("claude-sonnet-4-6"),
ProviderKind::Anthropic
);
}
}

View File

@ -0,0 +1,419 @@
use std::collections::HashMap;
use std::sync::Arc;
use runtime::image_store::ImageStore;
use runtime::{ContentBlock, ConversationMessage, MessageRole};
use crate::types::ImageSource;
use crate::{InputContentBlock, InputMessage, ToolResultContentBlock};
use serde_json::Value;
/// Core conversion logic. Returns plain `Vec` (no `Arc` wrapper) so callers
/// that maintain their own accumulator can append delta conversions without
/// an intermediate `Arc` allocation.
///
/// Delta messages (assistant replies, tool results) never contain `ImageRef`
/// blocks, so callers may pass `None` for both `image_cache` and `image_store`
/// when converting a slice that is known to contain no user-originated messages.
///
/// When `model_name` is `Some` and the model is text-only (listed in
/// `LLM_ONLY_MODEL.txt`), all Image and ImageRef blocks are filtered out and
/// replaced with text placeholders describing the attached image.
pub fn convert_messages_inner(
messages: &[ConversationMessage],
image_cache: Option<&HashMap<String, String>>,
image_store: Option<&ImageStore>,
model_name: Option<&str>,
) -> (Vec<InputMessage>, Vec<Option<Value>>) {
let is_text_only = model_name.is_some_and(runtime::text_only_models::is_text_only_model);
let mut input_messages = Vec::with_capacity(messages.len());
let mut cached_values = Vec::with_capacity(messages.len());
for message in messages {
let role = match message.role {
MessageRole::System | MessageRole::User | MessageRole::Tool => "user",
MessageRole::Assistant => "assistant",
};
let content: Vec<InputContentBlock> = message
.blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Thinking { thinking, signature } => {
// Anthropic extended thinking requires thinking blocks to be
// echoed back to the API (content + signature) when the
// assistant turn is included in a follow-up request; the
// server authenticates the `signature`. Only signed blocks
// are passed back — signature-less thinking (provider
// redaction placeholders, non-Anthropic reasoning models)
// is dropped, matching the pre-fix behaviour.
signature.clone().map(|signature| InputContentBlock::Thinking {
thinking: thinking.clone(),
signature: Some(signature),
})
}
ContentBlock::RedactedThinking { data } => {
// Redacted thinking carries no signature; the ciphertext
// `data` itself is the authentication token. Echo it back
// verbatim so the Anthropic API can authenticate the
// tool-use round-trip.
Some(InputContentBlock::RedactedThinking {
data: serde_json::Value::String(data.clone()),
})
}
ContentBlock::Text { text } => {
Some(InputContentBlock::Text { text: text.clone() })
}
ContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: input.clone(),
}),
ContentBlock::Image {
mime_type, data, filename, ..
} => {
if is_text_only {
let label = filename.as_deref().unwrap_or(mime_type);
Some(InputContentBlock::Text {
text: format!(
"[Image attached: {label}] (not supported by this model)"
),
})
} else {
Some(InputContentBlock::Image {
source: ImageSource {
source_type: "base64".to_string(),
media_type: mime_type.clone(),
data: data.clone(),
},
})
}
}
ContentBlock::ImageRef { hash_hex, mime_type, .. } => {
if is_text_only {
Some(InputContentBlock::Text {
text: format!(
"[Image attached: {mime_type}] (not supported by this model)"
),
})
} else {
let base64_data = image_cache
.and_then(|cache| cache.get(hash_hex))
.cloned()
.or_else(|| {
image_store
.and_then(|store| store.load_base64(hash_hex, mime_type).ok())
})
.unwrap_or_default();
if base64_data.is_empty() {
eprintln!(
"[IMAGE] Failed to resolve base64 for hash {hash_hex} (mime: {mime_type})"
);
}
Some(InputContentBlock::Image {
source: ImageSource {
source_type: "base64".to_string(),
media_type: mime_type.clone(),
data: base64_data,
},
})
}
}
ContentBlock::ToolResult {
tool_use_id,
output,
is_error,
..
} => Some(InputContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: vec![ToolResultContentBlock::Text {
text: output.clone(),
}],
is_error: *is_error,
cache_reference: None,
}),
})
.collect();
if content.is_empty() {
// Message has no non-Thinking content (e.g. only Thinking blocks
// that were stripped above). Include a placeholder text block so
// the message count stays aligned with `cached_message_values` —
// dropping it here would make `cached_values` shorter than the
// original message list, corrupting the IncrementalBody per-message
// byte cache used by `send_raw_request`.
let input_msg = InputMessage {
role: role.to_string(),
content: vec![InputContentBlock::Text {
text: String::new(),
}],
};
cached_values.push(None);
input_messages.push(input_msg);
continue;
}
let input_msg = InputMessage {
role: role.to_string(),
content,
};
let cached = message
.cached_input_message
.get_or_init(|| serde_json::to_value(&input_msg).unwrap_or(Value::Null));
cached_values.push(Some(cached.clone()));
input_messages.push(input_msg);
}
(input_messages, cached_values)
}
/// Convert the runtime-level `ConversationMessage` list into the
/// API-level `InputMessage` list suitable for Anthropic / OpenAI requests.
///
/// * Thinking blocks are dropped.
/// * `ImageRef` blocks are resolved to base64 via `image_cache` / `image_store`.
/// * When `model_name` is `Some` and the model is text-only, images are
/// replaced with text placeholders.
/// * Returns `Arc<Vec<InputMessage>>` so callers can cheaply share the
/// result across clones (e.g. in `MessageRequest`).
#[must_use]
pub fn convert_messages(
messages: &[ConversationMessage],
image_cache: Option<&HashMap<String, String>>,
image_store: Option<&ImageStore>,
model_name: Option<&str>,
) -> Arc<Vec<InputMessage>> {
Arc::new(convert_messages_inner(messages, image_cache, image_store, model_name).0)
}
/// Like `convert_messages` but also returns cached serialised JSON `Value`s
/// for each converted message.
///
/// The cached values are stored in `ConversationMessage.cached_input_message`
/// on the first call and reused on subsequent calls within the same
/// `filter_for_api` batch. Callers that use `IncrementalBody` should prefer
/// this variant so the body builder can skip re-serialising unchanged messages.
#[must_use]
pub fn convert_messages_cached(
messages: &[ConversationMessage],
image_cache: Option<&HashMap<String, String>>,
image_store: Option<&ImageStore>,
model_name: Option<&str>,
) -> (Arc<Vec<InputMessage>>, Vec<Option<Value>>) {
let (msgs, vals) = convert_messages_inner(messages, image_cache, image_store, model_name);
(Arc::new(msgs), vals)
}
#[cfg(test)]
mod tests {
use runtime::text_only_models;
use runtime::{ContentBlock, ConversationMessage, MessageRole};
use std::sync::{Mutex, OnceLock};
use super::*;
fn text_only_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn make_message(blocks: Vec<ContentBlock>) -> ConversationMessage {
ConversationMessage {
role: MessageRole::User,
blocks,
usage: None,
created_at: std::time::Instant::now(),
cached_tokens: OnceLock::new(),
cached_input_message: OnceLock::new(),
}
}
#[test]
fn test_text_only_model_filters_image_blocks() {
let _lock = text_only_lock();
text_only_models::set_test_entries(vec!["llama-3-8b".to_string()]);
let messages = vec![make_message(vec![
ContentBlock::Text {
text: "Hello".to_string(),
},
ContentBlock::Image {
mime_type: "image/png".to_string(),
data: "base64data".to_string(),
filename: Some("screenshot.png".to_string()),
},
ContentBlock::Text {
text: "Look at this".to_string(),
},
])];
let (converted, _) = convert_messages_inner(&messages, None, None, Some("llama-3-8b"));
let blocks = &converted[0].content;
assert_eq!(blocks.len(), 3);
assert!(matches!(&blocks[0], InputContentBlock::Text { text } if text == "Hello"));
assert!(matches!(&blocks[1], InputContentBlock::Text { text } if text.contains("screenshot.png")));
assert!(matches!(&blocks[2], InputContentBlock::Text { text } if text == "Look at this"));
}
#[test]
fn test_text_only_model_filters_imageref_blocks() {
let _lock = text_only_lock();
text_only_models::set_test_entries(vec!["text-only-model".to_string()]);
let messages = vec![make_message(vec![
ContentBlock::Text {
text: "Text".to_string(),
},
ContentBlock::ImageRef {
hash_hex: "abc123".to_string(),
mime_type: "image/png".to_string(),
filename: Some("photo.png".to_string()),
},
])];
let (converted, _) = convert_messages_inner(&messages, None, None, Some("text-only-model"));
let blocks = &converted[0].content;
assert_eq!(blocks.len(), 2);
assert!(matches!(&blocks[0], InputContentBlock::Text { .. }));
assert!(matches!(&blocks[1], InputContentBlock::Text { text } if text.contains("image/png")));
}
#[test]
fn test_multimodal_model_preserves_image_blocks() {
let _lock = text_only_lock();
text_only_models::set_test_entries(vec![]);
let messages = vec![make_message(vec![ContentBlock::Image {
mime_type: "image/png".to_string(),
data: "base64data".to_string(),
filename: Some("test.png".to_string()),
}])];
let (converted, _) = convert_messages_inner(&messages, None, None, Some("claude-sonnet-4"));
let blocks = &converted[0].content;
assert_eq!(blocks.len(), 1);
assert!(matches!(&blocks[0], InputContentBlock::Image { .. }));
}
#[test]
fn test_none_model_defaults_to_image_capable() {
let _lock = text_only_lock();
text_only_models::set_test_entries(vec![]);
let messages = vec![make_message(vec![ContentBlock::Image {
mime_type: "image/png".to_string(),
data: "base64data".to_string(),
filename: None,
}])];
let (converted, _) = convert_messages_inner(&messages, None, None, None);
let blocks = &converted[0].content;
assert_eq!(blocks.len(), 1);
assert!(matches!(&blocks[0], InputContentBlock::Image { .. }));
}
#[test]
fn test_thinking_block_is_preserved_for_api_round_trip() {
let messages = vec![make_message(vec![
ContentBlock::Thinking {
thinking: "Let me reason carefully.".to_string(),
signature: Some("sig123".to_string()),
},
ContentBlock::ToolUse {
id: "tu1".to_string(),
name: "bash".to_string(),
input: serde_json::json!({ "command": "ls" }),
},
])];
let (converted, _) = convert_messages_inner(&messages, None, None, None);
let blocks = &converted[0].content;
assert_eq!(
blocks.len(),
2,
"thinking block must not be dropped; Anthropic requires it for round-trip"
);
assert!(matches!(
&blocks[0],
InputContentBlock::Thinking {
thinking,
signature,
} if thinking == "Let me reason carefully."
&& signature.as_deref() == Some("sig123")
));
}
#[test]
fn test_thinking_block_serializes_as_anthropic_thinking_shape() {
let messages = vec![make_message(vec![ContentBlock::Thinking {
thinking: String::new(),
signature: Some("sig_abc".to_string()),
}])];
let (converted, _) = convert_messages_inner(&messages, None, None, None);
let value = serde_json::to_value(&converted[0]).expect("message should serialize");
let block = &value["content"][0];
assert_eq!(block["type"], "thinking");
assert_eq!(block["signature"], "sig_abc");
}
#[test]
fn test_signature_less_thinking_block_is_not_sent_to_api() {
// Signature-less thinking (redaction placeholders, non-Anthropic
// reasoning models) cannot be authenticated by the Anthropic API, so
// it must be dropped rather than emitted as a malformed thinking block.
let messages = vec![make_message(vec![
ContentBlock::Thinking {
thinking: "reasoning without signature".to_string(),
signature: None,
},
ContentBlock::Text {
text: "visible answer".to_string(),
},
])];
let (converted, _) = convert_messages_inner(&messages, None, None, None);
let blocks = &converted[0].content;
assert_eq!(blocks.len(), 1);
assert!(matches!(&blocks[0], InputContentBlock::Text { text } if text == "visible answer"));
}
#[test]
fn test_redacted_thinking_block_is_echoed_back_with_data() {
// Redacted thinking carries no signature; the ciphertext `data` itself
// is the authentication token. It must be echoed verbatim.
let messages = vec![make_message(vec![
ContentBlock::RedactedThinking {
data: "ciphertext_blob_abc".to_string(),
},
ContentBlock::ToolUse {
id: "tu1".to_string(),
name: "bash".to_string(),
input: serde_json::json!({ "command": "ls" }),
},
])];
let (converted, _) = convert_messages_inner(&messages, None, None, None);
let blocks = &converted[0].content;
assert_eq!(
blocks.len(),
2,
"redacted thinking block must be echoed back for the tool-use round-trip"
);
assert!(matches!(
&blocks[0],
InputContentBlock::RedactedThinking { data }
if data.as_str() == Some("ciphertext_blob_abc")
));
}
}

View File

@ -14,13 +14,7 @@ const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[
"too many tokens",
"prompt is too long",
"input is too long",
"input tokens exceed",
"configured limit",
"messages resulted in",
"completion tokens",
"prompt tokens",
"request is too large",
"no parseable body",
];
#[derive(Debug)]
@ -45,6 +39,12 @@ pub enum ApiError {
Auth(String),
InvalidApiKeyEnv(VarError),
Http(reqwest::Error),
/// The provider accepted the connection and streamed response headers but
/// then sent no bytes for longer than the configured idle timeout. This is
/// the "connection open, no data" stall that previously hung the subagent
/// OS thread indefinitely. Retryable so the provider fallback chain or the
/// parent turn can recover instead of blocking forever.
StreamTimeout,
Io(std::io::Error),
Json {
provider: String,
@ -61,9 +61,6 @@ pub enum ApiError {
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,
@ -132,21 +129,24 @@ impl ApiError {
}
#[must_use]
/// Return the `Retry-After` delay if this error came from a 429 response
/// that included a `retry-after` header. Callers should prefer this value
/// over the computed backoff delay when it exists.
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::Api { retry_after, .. } => *retry_after,
Self::RetriesExhausted { last_error, .. } => last_error.retry_after(),
_ => None,
}
}
pub fn is_retryable(&self) -> bool {
match self {
Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(),
Self::Api { retryable, .. } => *retryable,
Self::StreamTimeout => true,
Self::Api {
retryable,
error_type,
message,
body,
..
} => {
*retryable
&& !looks_like_balance_error(
error_type.as_deref(),
message.as_deref(),
body,
)
}
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
@ -172,6 +172,7 @@ impl ApiError {
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::StreamTimeout
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
@ -197,7 +198,7 @@ impl ApiError {
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 { .. } => {
Self::Http(_) | Self::StreamTimeout | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => {
"provider_transport"
}
Self::InvalidApiKeyEnv(_) | Self::Io(_) | Self::Json { .. } => "runtime_io",
@ -221,6 +222,7 @@ impl ApiError {
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::StreamTimeout
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
@ -251,6 +253,7 @@ impl ApiError {
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::StreamTimeout
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
@ -278,20 +281,17 @@ impl Display for ApiError {
if let Some(primary) = env_vars.first() {
write!(
f,
" (on Windows, environment variables set in PowerShell only persist for the current session; use `setx {primary} <value>` to make it permanent, then open a new terminal, or place a `.env` file containing `{primary}=<value>` in the current working directory)"
" (on Windows, environment variables set in PowerShell only persist for the current session; use `setx {primary} <value>` to make it permanent, then open a new terminal, or place a `.env` file containing `{primary}=<value>` in the Claw config directory (`~/.claw/.env` or `$CLAW_CONFIG_HOME/.env`))"
)?;
} else {
write!(
f,
" (on Windows, environment variables set in PowerShell only persist for the current session; use `setx` to make them permanent, then open a new terminal, or place a `.env` file in the current working directory)"
" (on Windows, environment variables set in PowerShell only persist for the current session; use `setx` to make them permanent, then open a new terminal, or place a `.env` file in the Claw config directory (`~/.claw/.env` or `$CLAW_CONFIG_HOME/.env`))"
)?;
}
}
if let Some(hint) = hint {
// #754: newline-delimited so split_error_hint() can extract the hint
// into the JSON envelope's `hint` field. The em-dash form was a
// single-line string that left hint:null in --output-format json.
write!(f, "\n{hint}")?;
write!(f, " — hint: {hint}")?;
}
Ok(())
}
@ -316,6 +316,12 @@ impl Display for ApiError {
write!(f, "failed to read credential environment variable: {error}")
}
Self::Http(error) => write!(f, "http error: {error}"),
Self::StreamTimeout => {
write!(
f,
"provider stream idle timeout: no bytes received within the configured window"
)
}
Self::Io(error) => write!(f, "io error: {error}"),
Self::Json {
provider,
@ -326,36 +332,6 @@ impl Display for ApiError {
f,
"failed to parse {provider} response for model {model}: {source}; first 200 chars of body: {body_snippet}"
),
// #28: enhance 401/403 errors with actionable auth guidance
Self::Api {
status,
error_type,
message,
request_id,
body,
..
} if matches!(status.as_u16(), 401 | 403) => {
if let (Some(error_type), Some(message)) = (error_type, message) {
write!(f, "api returned {status} ({error_type})")?;
if let Some(request_id) = request_id {
write!(f, " [trace {request_id}]")?;
}
write!(f, ": {message}")?;
} else {
write!(f, "api returned {status}")?;
if let Some(request_id) = request_id {
write!(f, " [trace {request_id}]")?;
}
write!(f, ": {body}")?;
}
write!(
f,
"\nhint: check that your API key is valid and matches the target provider. \
For OpenAI-compatible providers set OPENAI_API_KEY or OPENAI_BASE_URL. \
For Anthropic set ANTHROPIC_API_KEY. \
Run `claw doctor` to verify your credential configuration."
)
}
Self::Api {
status,
error_type,
@ -447,6 +423,45 @@ fn looks_like_context_window_error(text: &str) -> bool {
.any(|marker| lowered.contains(marker))
}
const BALANCE_ERROR_MARKERS: &[&str] = &[
"insufficient_quota",
"insufficient quota",
"insufficient balance",
"insufficient_balance",
"balance is insufficient",
"your account balance",
"account balance is",
"no credits",
"out of credits",
"credit balance",
"insufficient credits",
"balance is too low",
"余额不足",
"payment required",
];
/// Returns true when the provider error (error_type, message or raw body)
/// indicates the account has run out of credits/balance. Such errors are
/// deterministic: retrying cannot fix them, so they must never enter the
/// retry/backoff loop (which would otherwise stall the CLI for minutes on a
/// 429 rate-limit style response from a relay/gateway).
fn looks_like_balance_error(error_type: Option<&str>, message: Option<&str>, body: &str) -> bool {
let mut haystack = String::new();
if let Some(error_type) = error_type {
haystack.push_str(error_type);
haystack.push(' ');
}
if let Some(message) = message {
haystack.push_str(message);
haystack.push(' ');
}
haystack.push_str(body);
let lowered = haystack.to_ascii_lowercase();
BALANCE_ERROR_MARKERS
.iter()
.any(|marker| lowered.contains(marker))
}
/// Truncate `body` so the resulting snippet contains at most `max_chars`
/// characters (counted by Unicode scalar values, not bytes), preserving the
/// leading slice of the body that the caller most often needs to inspect.
@ -544,7 +559,6 @@ mod tests {
body: String::new(),
retryable: true,
suggested_action: None,
retry_after: None,
};
assert!(error.is_generic_fatal_wrapper());
@ -553,6 +567,18 @@ mod tests {
assert!(error.to_string().contains("[trace req_jobdori_123]"));
}
#[test]
fn stream_timeout_is_retryable_transport_error() {
let error = ApiError::StreamTimeout;
assert!(error.is_retryable(), "a stalled stream must be retryable");
assert_eq!(error.safe_failure_class(), "provider_transport");
assert_eq!(error.request_id(), None);
assert!(
error.to_string().contains("stream idle timeout"),
"display should name the failure: {error}"
);
}
#[test]
fn retries_exhausted_preserves_nested_request_id_and_failure_class() {
let error = ApiError::RetriesExhausted {
@ -568,7 +594,6 @@ mod tests {
body: String::new(),
retryable: true,
suggested_action: None,
retry_after: None,
}),
};
@ -590,7 +615,6 @@ mod tests {
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
assert!(error.is_context_window_failure());
@ -598,33 +622,12 @@ mod tests {
assert_eq!(error.request_id(), Some("req_ctx_123"));
}
#[test]
fn classifies_openai_configured_limit_errors_as_context_window_failures() {
let error = ApiError::Api {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_request_error".to_string()),
message: Some(
"Input tokens exceed the configured limit of 922000 tokens. Your messages resulted in 1860900 tokens. Please reduce the length of the messages."
.to_string(),
),
request_id: Some("req_ctx_openai_123".to_string()),
body: String::new(),
retryable: false,
suggested_action: None,
retry_after: None,
};
assert!(error.is_context_window_failure());
assert_eq!(error.safe_failure_class(), "context_window");
assert_eq!(error.request_id(), Some("req_ctx_openai_123"));
}
#[test]
fn missing_credentials_without_hint_renders_the_canonical_message() {
// given
let error = ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
&["ANTHROPIC_API_KEY"],
);
// when
@ -633,7 +636,7 @@ mod tests {
// then
assert!(
rendered.starts_with(
"missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY before calling the Anthropic API"
"missing Anthropic credentials; export ANTHROPIC_API_KEY before calling the Anthropic API"
),
"rendered error should lead with the canonical missing-credential message: {rendered}"
);
@ -643,12 +646,82 @@ mod tests {
);
}
#[test]
fn api_429_insufficient_quota_is_not_retryable() {
let error = ApiError::Api {
status: reqwest::StatusCode::TOO_MANY_REQUESTS,
error_type: Some("insufficient_quota".to_string()),
message: Some("Your account balance is insufficient. Please top up.".to_string()),
request_id: Some("req_balance_123".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
};
assert!(
!error.is_retryable(),
"insufficient_quota must not trigger retry backoff"
);
}
#[test]
fn api_429_chinese_balance_insufficient_is_not_retryable() {
let error = ApiError::Api {
status: reqwest::StatusCode::TOO_MANY_REQUESTS,
error_type: Some("rate_limit_error".to_string()),
message: Some("余额不足,请充值".to_string()),
request_id: Some("req_balance_456".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
};
assert!(
!error.is_retryable(),
"余额不足 must not trigger retry backoff"
);
}
#[test]
fn api_429_plain_rate_limit_slow_down_remains_retryable() {
let error = ApiError::Api {
status: reqwest::StatusCode::TOO_MANY_REQUESTS,
error_type: Some("rate_limit_error".to_string()),
message: Some("slow down".to_string()),
request_id: Some("req_rate_789".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
};
assert!(
error.is_retryable(),
"a plain rate-limit 'slow down' must remain retryable"
);
}
#[test]
fn api_429_billing_plan_wording_is_not_mistaken_for_balance_error() {
let error = ApiError::Api {
status: reqwest::StatusCode::TOO_MANY_REQUESTS,
error_type: Some("rate_limit_error".to_string()),
message: Some(
"Your current billing plan allows 100 requests per minute".to_string(),
),
request_id: Some("req_billing_plan".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
};
assert!(
error.is_retryable(),
"billing-plan rate-limit wording must not be flagged as a balance error"
);
}
#[test]
fn missing_credentials_with_hint_appends_the_hint_after_base_message() {
// given
let error = ApiError::missing_credentials_with_hint(
"Anthropic",
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
&["ANTHROPIC_API_KEY"],
"I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.",
);
@ -660,16 +733,11 @@ mod tests {
rendered.starts_with("missing Anthropic credentials;"),
"hint should be appended, not replace the base message: {rendered}"
);
// #754: hint is now newline-delimited so split_error_hint() can extract it
let hint_text = "I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.";
let hint_marker = " — hint: I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.";
assert!(
rendered.ends_with(hint_text),
rendered.ends_with(hint_marker),
"rendered error should end with the hint: {rendered}"
);
assert!(
rendered.contains('\n'),
"rendered error must contain newline separator so split_error_hint works: {rendered}"
);
// Classification semantics are unaffected by the presence of a hint.
assert_eq!(error.safe_failure_class(), "provider_auth");
assert!(!error.is_retryable());

View File

@ -1,68 +1,21 @@
use std::time::Duration;
use crate::error::ApiError;
use std::time::Duration;
const HTTP_PROXY_KEYS: [&str; 2] = ["HTTP_PROXY", "http_proxy"];
const HTTPS_PROXY_KEYS: [&str; 2] = ["HTTPS_PROXY", "https_proxy"];
const NO_PROXY_KEYS: [&str; 2] = ["NO_PROXY", "no_proxy"];
/// Timeout configuration for outbound HTTP requests.
///
/// When set, the `reqwest::Client` will abort requests that take longer
/// than the configured duration and return a timeout error (which is
/// retryable by the existing exponential backoff logic).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimeoutConfig {
/// Maximum time to wait for a connection to be established.
/// Defaults to 30 seconds.
pub connect_timeout: Duration,
/// Maximum time for the entire request (including reading the response
/// body). For streaming responses this is the timeout for the initial
/// handshake only; the stream itself is governed by SSE parsing.
/// Defaults to 5 minutes (300 seconds).
pub request_timeout: Duration,
}
impl Default for TimeoutConfig {
fn default() -> Self {
Self {
connect_timeout: Duration::from_secs(30),
request_timeout: Duration::from_secs(300),
}
}
}
impl TimeoutConfig {
/// Read timeout settings from the process environment.
/// - `CLAW_API_CONNECT_TIMEOUT` — connect timeout in seconds
/// - `CLAW_API_REQUEST_TIMEOUT` — overall request timeout in seconds
#[must_use]
pub fn from_env() -> Self {
let connect_timeout = std::env::var("CLAW_API_CONNECT_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(30));
let request_timeout = std::env::var("CLAW_API_REQUEST_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(300));
Self {
connect_timeout,
request_timeout,
}
}
/// Create from explicit second values (used by config file parsing).
#[must_use]
pub fn from_seconds(connect_secs: u64, request_secs: u64) -> Self {
Self {
connect_timeout: Duration::from_secs(connect_secs),
request_timeout: Duration::from_secs(request_secs),
}
}
}
/// Maximum time allowed for establishing the TCP connection. Bounds connect
/// stalls for every request (streaming and non-streaming alike).
pub const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
/// Overall deadline for non-streaming requests (send_message, count_tokens).
/// NOT applied to streaming requests: a long generation stream legitimately
/// exceeds this window, so streaming is bounded per-chunk instead.
pub const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(600);
/// Idle timeout between SSE chunks. A provider that accepts the connection and
/// sends headers but then stalls (half-open TCP, proxy hang, throttling
/// without bytes) errors here instead of blocking the caller forever.
pub const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
/// Snapshot of the proxy-related environment variables that influence the
/// outbound HTTP client. Captured up front so callers can inspect, log, and
@ -121,7 +74,7 @@ impl ProxyConfig {
/// `HTTPS_PROXY`, and `NO_PROXY` environment variables. When no proxy is
/// configured the client behaves identically to `reqwest::Client::new()`.
pub fn build_http_client() -> Result<reqwest::Client, ApiError> {
build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env())
build_http_client_with(&ProxyConfig::from_env())
}
/// Infallible counterpart to [`build_http_client`] for constructors that
@ -131,13 +84,12 @@ pub fn build_http_client() -> Result<reqwest::Client, ApiError> {
/// first outbound request instead of at construction time.
#[must_use]
pub fn build_http_client_or_default() -> reqwest::Client {
build_http_client_with_opts(&ProxyConfig::from_env(), &TimeoutConfig::from_env())
.unwrap_or_else(|_| {
reqwest::Client::builder()
.user_agent("clawd-rust-tools/0.1")
.build()
.expect("default client with user_agent should always succeed")
})
build_http_client().unwrap_or_else(|_| {
reqwest::Client::builder()
.connect_timeout(HTTP_CONNECT_TIMEOUT)
.build()
.unwrap_or_else(|_| reqwest::Client::new())
})
}
/// Build a `reqwest::Client` from an explicit [`ProxyConfig`]. Used by tests
@ -147,20 +99,9 @@ pub fn build_http_client_or_default() -> reqwest::Client {
/// and `https_proxy` fields and is registered as both an HTTP and HTTPS
/// proxy so a single value can route every outbound request.
pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, ApiError> {
build_http_client_with_opts(config, &TimeoutConfig::from_env())
}
/// Build a `reqwest::Client` from explicit [`ProxyConfig`] and [`TimeoutConfig`].
/// Used by callers that want to control both proxy routing and request timing.
pub fn build_http_client_with_opts(
config: &ProxyConfig,
timeout: &TimeoutConfig,
) -> Result<reqwest::Client, ApiError> {
let mut builder = reqwest::Client::builder()
.no_proxy()
.user_agent("clawd-rust-tools/0.1")
.connect_timeout(timeout.connect_timeout)
.timeout(timeout.request_timeout);
.connect_timeout(HTTP_CONNECT_TIMEOUT);
let no_proxy = config
.no_proxy
@ -203,7 +144,7 @@ where
mod tests {
use std::collections::HashMap;
use super::{build_http_client_with, build_http_client_with_opts, ProxyConfig, TimeoutConfig};
use super::{build_http_client_with, ProxyConfig};
fn config_from_map(pairs: &[(&str, &str)]) -> ProxyConfig {
let map: HashMap<String, String> = pairs
@ -215,19 +156,30 @@ mod tests {
#[test]
fn proxy_config_is_empty_when_no_env_vars_are_set() {
// given
let config = config_from_map(&[]);
assert!(config.is_empty());
// when
let empty = config.is_empty();
// then
assert!(empty);
assert_eq!(config, ProxyConfig::default());
}
#[test]
fn proxy_config_reads_uppercase_http_https_and_no_proxy() {
// given
let pairs = [
("HTTP_PROXY", "http://proxy.internal:3128"),
("HTTPS_PROXY", "http://secure.internal:3129"),
("NO_PROXY", "localhost,127.0.0.1,.corp"),
];
// when
let config = config_from_map(&pairs);
// then
assert_eq!(
config.http_proxy.as_deref(),
Some("http://proxy.internal:3128")
@ -245,12 +197,17 @@ mod tests {
#[test]
fn proxy_config_falls_back_to_lowercase_keys() {
// given
let pairs = [
("http_proxy", "http://lower.internal:3128"),
("https_proxy", "http://lower-secure.internal:3129"),
("no_proxy", ".lower"),
];
// when
let config = config_from_map(&pairs);
// then
assert_eq!(
config.http_proxy.as_deref(),
Some("http://lower.internal:3128")
@ -264,11 +221,16 @@ mod tests {
#[test]
fn proxy_config_prefers_uppercase_over_lowercase_when_both_set() {
// given
let pairs = [
("HTTP_PROXY", "http://upper.internal:3128"),
("http_proxy", "http://lower.internal:3128"),
];
// when
let config = config_from_map(&pairs);
// then
assert_eq!(
config.http_proxy.as_deref(),
Some("http://upper.internal:3128")
@ -277,39 +239,59 @@ mod tests {
#[test]
fn proxy_config_treats_empty_strings_as_unset() {
// given
let pairs = [("HTTP_PROXY", ""), ("http_proxy", "")];
// when
let config = config_from_map(&pairs);
// then
assert!(config.http_proxy.is_none());
}
#[test]
fn build_http_client_succeeds_when_no_proxy_is_configured() {
// given
let config = ProxyConfig::default();
// when
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn build_http_client_succeeds_with_valid_http_and_https_proxies() {
// given
let config = ProxyConfig {
http_proxy: Some("http://proxy.internal:3128".to_string()),
https_proxy: Some("http://secure.internal:3129".to_string()),
no_proxy: Some("localhost,127.0.0.1".to_string()),
proxy_url: None,
};
// when
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn build_http_client_returns_http_error_for_invalid_proxy_url() {
// given
let config = ProxyConfig {
http_proxy: None,
https_proxy: Some("not a url".to_string()),
no_proxy: None,
proxy_url: None,
};
// when
let result = build_http_client_with(&config);
// then
let error = result.expect_err("invalid proxy URL must be reported as a build failure");
assert!(
matches!(error, crate::error::ApiError::Http(_)),
@ -319,7 +301,10 @@ mod tests {
#[test]
fn from_proxy_url_sets_unified_field_and_leaves_per_scheme_empty() {
// given / when
let config = ProxyConfig::from_proxy_url("http://unified.internal:3128");
// then
assert_eq!(
config.proxy_url.as_deref(),
Some("http://unified.internal:3128")
@ -331,56 +316,49 @@ mod tests {
#[test]
fn build_http_client_succeeds_with_unified_proxy_url() {
// given
let config = ProxyConfig {
proxy_url: Some("http://unified.internal:3128".to_string()),
no_proxy: Some("localhost".to_string()),
..ProxyConfig::default()
};
// when
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn proxy_url_takes_precedence_over_per_scheme_fields() {
// given both per-scheme and unified are set
let config = ProxyConfig {
http_proxy: Some("http://per-scheme.internal:1111".to_string()),
https_proxy: Some("http://per-scheme.internal:2222".to_string()),
no_proxy: None,
proxy_url: Some("http://unified.internal:3128".to_string()),
};
// when building succeeds (the unified URL is valid)
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn build_http_client_returns_error_for_invalid_unified_proxy_url() {
// given
let config = ProxyConfig::from_proxy_url("not a url");
// when
let result = build_http_client_with(&config);
// then
assert!(
matches!(result, Err(crate::error::ApiError::Http(_))),
"invalid unified proxy URL should fail: {result:?}"
);
}
#[test]
fn timeout_config_defaults() {
let config = TimeoutConfig::default();
assert_eq!(config.connect_timeout, std::time::Duration::from_secs(30));
assert_eq!(config.request_timeout, std::time::Duration::from_secs(300));
}
#[test]
fn timeout_config_from_seconds() {
let config = TimeoutConfig::from_seconds(10, 60);
assert_eq!(config.connect_timeout, std::time::Duration::from_secs(10));
assert_eq!(config.request_timeout, std::time::Duration::from_secs(60));
}
#[test]
fn build_http_client_with_custom_timeouts() {
let config = ProxyConfig::default();
let timeout = TimeoutConfig::from_seconds(5, 120);
let result = build_http_client_with_opts(&config, &timeout);
assert!(result.is_ok());
}
}

View File

@ -0,0 +1,493 @@
use serde_json::{json, Map, Value};
use crate::types::MessageRequest;
/// Incrementally-built JSON request body that caches per-message serialization
/// and avoids re-serializing the entire message list on every API call.
///
/// ## Typical workflow (per agentic-loop iteration)
/// 1. Build a fresh `MessageRequest` (or reuse the previous one with a new
/// message appended).
/// 2. Call `update(&request)` — only new/uncached messages are serialized.
/// 3. Call `build()` or `build_bytes()` to obtain the final body.
///
/// ## Base invalidation
/// The "base" portion (`model`, `max_tokens`, `system`, `tools`, `tool_choice`,
/// `stream`, tuning knobs) is cached until a field actually changes. Changes
/// are detected via a simplified content hash of the non-message fields.
///
/// ## Zero-alloc build\_bytes
/// Messages are cached as pre-serialized `Vec<u8>` so `build_bytes()` can
/// concatenate them directly into a single buffer without any intermediate
/// `Value` tree allocation.
#[derive(Debug, Clone)]
pub struct IncrementalBody {
/// Cached serialisation of the non-message fields (model, system, tools, …).
base: Option<Map<String, Value>>,
/// Per-message pre-serialised JSON bytes.
cached_message_bytes: Vec<Vec<u8>>,
/// Hash of the base-determining fields at the last rebuild.
base_hash: u64,
}
impl IncrementalBody {
pub fn new() -> Self {
Self {
base: None,
cached_message_bytes: Vec::new(),
base_hash: 0,
}
}
/// Update the cache with a new request.
///
/// * If the base (non-message fields) changed → rebuild base.
/// * If messages grew (delta) → serialise only the new messages.
/// * If messages shrunk (e.g. after compaction) → truncate internal cache.
///
/// When `request.cached_message_values` is non-empty, cached JSON values
/// from that vector are used for delta messages, skipping re-serialisation.
pub fn update(&mut self, request: &MessageRequest) {
let new_hash = hash_base(request);
if self.base.is_none() || new_hash != self.base_hash {
self.base = Some(serialise_base(request));
self.base_hash = new_hash;
}
let msg_count = request.messages.len();
if msg_count > self.cached_message_bytes.len() {
let base_len = self.cached_message_bytes.len();
for (i, msg) in request.messages[base_len..]
.iter()
.enumerate()
{
let abs_idx = base_len + i;
let bytes: Vec<u8> = request
.cached_message_values
.get(abs_idx)
.and_then(|v| v.clone())
.map(|val| serde_json::to_vec(&val).unwrap_or_default())
.unwrap_or_else(|| serde_json::to_vec(msg).unwrap_or_default());
self.cached_message_bytes.push(bytes);
}
} else if msg_count < self.cached_message_bytes.len() {
self.cached_message_bytes.truncate(msg_count);
}
}
/// Build the full request body as a JSON `Value`.
///
/// Post-processing (image normalisation, system-prompt cache-control,
/// tools cache-control) must be applied separately if needed.
pub fn build(&self) -> Value {
let mut body = self.base.clone().unwrap_or_default();
body.insert(
"messages".to_string(),
Value::Array(
self.cached_message_bytes
.iter()
.map(|b| serde_json::from_slice(b).unwrap_or(Value::Null))
.collect(),
),
);
Value::Object(body)
}
/// Build the full request body as serialised JSON bytes.
///
/// Concatenates pre-serialised base fields and pre-serialised messages
/// directly into a single buffer — no intermediate `Value` trees are
/// allocated beyond the base fields that are stored as `Value`.
pub fn build_bytes(&self) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(b'{');
let mut written = false;
if let Some(ref base) = self.base {
for (i, (key, val)) in base.iter().enumerate() {
if i > 0 {
buf.push(b',');
}
written = true;
append_json_string(&mut buf, key);
buf.push(b':');
append_json_value(&mut buf, val);
}
}
if written {
buf.push(b',');
}
buf.extend_from_slice(b"\"messages\":[");
for (i, msg_bytes) in self.cached_message_bytes.iter().enumerate() {
if i > 0 {
buf.push(b',');
}
buf.extend_from_slice(msg_bytes);
}
buf.push(b']');
buf.push(b'}');
buf
}
/// Clear the cache entirely (forces a full rebuild on next `update`).
pub fn invalidate(&mut self) {
self.base = None;
self.cached_message_bytes.clear();
self.base_hash = 0;
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// Build a `Map` of only the non-message fields from a `MessageRequest`.
///
/// Unlike serialising the full `MessageRequest` and removing `"messages"`,
/// this constructs the map directly from individual fields — never
/// touching (let alone serialising) the potentially-large message vector.
fn serialise_base(request: &MessageRequest) -> Map<String, Value> {
let mut map = Map::new();
map.insert("model".into(), Value::String(request.model.clone()));
map.insert("max_tokens".into(), json!(request.max_tokens));
serialise_system_cache_control(&mut map, request.system.as_deref());
if !request.skip_tools {
serialise_tools_cache_control(&mut map, &request.tools);
}
if let Some(ref tc) = request.tool_choice {
map.insert("tool_choice".into(), serde_json::to_value(tc).unwrap_or_default());
}
if request.stream {
map.insert("stream".into(), Value::Bool(true));
}
if let Some(ref v) = request.temperature {
map.insert("temperature".into(), json!(v));
}
if let Some(ref v) = request.top_p {
map.insert("top_p".into(), json!(v));
}
// frequency_penalty and presence_penalty are not supported by Anthropic's
// /v1/messages endpoint, so we intentionally omit them here.
// `stop` is renamed to `stop_sequences` for Anthropic.
if let Some(ref v) = request.stop {
if !v.is_empty() {
map.insert("stop_sequences".into(), serde_json::to_value(v).unwrap_or_default());
}
}
if let Some(ref v) = request.reasoning_effort {
map.insert("reasoning_effort".into(), Value::String(v.clone()));
}
if let Some(ref v) = request.thinking {
map.insert("thinking".into(), serde_json::to_value(v).unwrap_or_default());
}
map
}
/// Split the flat system-prompt string at the dynamic boundary and emit
/// the Anthropic block array with `cache_control: ephemeral` on the static
/// portion. Mirrors `MessageRequest::apply_system_prompt_cache_control`.
fn serialise_system_cache_control(map: &mut Map<String, Value>, system: Option<&str>) {
let Some(system_str) = system.filter(|s| !s.is_empty()) else {
return;
};
let boundary = runtime::SYSTEM_PROMPT_DYNAMIC_BOUNDARY;
let blocks = if let Some(split_pos) = system_str.find(boundary) {
let static_part = system_str[..split_pos].trim_end();
let dynamic_part = system_str[split_pos + boundary.len()..].trim_start();
let mut blocks = Vec::new();
if !static_part.is_empty() {
blocks.push(serde_json::json!({
"type": "text",
"text": static_part,
"cache_control": { "type": "ephemeral" }
}));
}
if !dynamic_part.is_empty() {
// The dynamic portion changes every request, so a cache breakpoint
// here is useless and fragments the prefix cache. Only the static
// block above keeps `cache_control`.
blocks.push(serde_json::json!({
"type": "text",
"text": dynamic_part
}));
}
blocks
} else {
vec![serde_json::json!({
"type": "text",
"text": system_str,
"cache_control": { "type": "ephemeral" }
})]
};
if !blocks.is_empty() {
map.insert("system".into(), Value::Array(blocks));
}
}
/// Add `cache_control: ephemeral` to the last tool definition.
/// Mirrors `MessageRequest::apply_tools_cache_control`.
fn serialise_tools_cache_control(map: &mut Map<String, Value>, tools: &Option<Vec<crate::types::ToolDefinition>>) {
let Some(ref tools) = tools else {
return;
};
if tools.is_empty() {
return;
}
let mut values: Vec<Value> = Vec::with_capacity(tools.len());
for (i, tool) in tools.iter().enumerate() {
let mut val = serde_json::to_value(tool).unwrap_or_default();
if i == tools.len() - 1 {
if let Some(obj) = val.as_object_mut() {
obj.insert(
"cache_control".to_string(),
serde_json::json!({ "type": "ephemeral" }),
);
}
}
values.push(val);
}
map.insert("tools".into(), Value::Array(values));
}
/// Deterministic hash of the non-message fields so we can detect changes.
fn hash_base(request: &MessageRequest) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
request.model.hash(&mut hasher);
request.max_tokens.hash(&mut hasher);
request.system.hash(&mut hasher);
request.stream.hash(&mut hasher);
if let Some(ref tools) = request.tools {
for t in tools {
t.name.hash(&mut hasher);
}
}
request.tool_choice.hash(&mut hasher);
request.temperature.map(|v| v.to_bits()).hash(&mut hasher);
request.top_p.map(|v| v.to_bits()).hash(&mut hasher);
request.frequency_penalty.map(|v| v.to_bits()).hash(&mut hasher);
request.presence_penalty.map(|v| v.to_bits()).hash(&mut hasher);
request.stop.hash(&mut hasher);
request.reasoning_effort.hash(&mut hasher);
request.thinking.hash(&mut hasher);
request.skip_tools.hash(&mut hasher);
hasher.finish()
}
fn append_json_string(buf: &mut Vec<u8>, s: &str) {
buf.push(b'"');
for byte in s.bytes() {
match byte {
b'"' => buf.extend_from_slice(b"\\\""),
b'\\' => buf.extend_from_slice(b"\\\\"),
b'\n' => buf.extend_from_slice(b"\\n"),
b'\r' => buf.extend_from_slice(b"\\r"),
b'\t' => buf.extend_from_slice(b"\\t"),
0x08 => buf.extend_from_slice(b"\\b"),
0x0C => buf.extend_from_slice(b"\\f"),
c if c < 0x20 => {
write_hex_escape(buf, c);
}
c => buf.push(c),
}
}
buf.push(b'"');
}
fn write_hex_escape(buf: &mut Vec<u8>, byte: u8) {
const HEX: &[u8; 16] = b"0123456789abcdef";
buf.push(b'\\');
buf.push(b'u');
buf.push(b'0');
buf.push(b'0');
buf.push(HEX[(byte >> 4) as usize]);
buf.push(HEX[(byte & 0x0F) as usize]);
}
fn append_json_value(buf: &mut Vec<u8>, val: &Value) {
match val {
Value::Null => buf.extend_from_slice(b"null"),
Value::Bool(true) => buf.extend_from_slice(b"true"),
Value::Bool(false) => buf.extend_from_slice(b"false"),
Value::Number(n) => {
buf.extend_from_slice(n.to_string().as_bytes());
}
Value::String(s) => append_json_string(buf, s),
Value::Array(arr) => {
buf.push(b'[');
for (i, v) in arr.iter().enumerate() {
if i > 0 {
buf.push(b',');
}
append_json_value(buf, v);
}
buf.push(b']');
}
Value::Object(obj) => {
buf.push(b'{');
for (i, (key, val)) in obj.iter().enumerate() {
if i > 0 {
buf.push(b',');
}
append_json_string(buf, key);
buf.push(b':');
append_json_value(buf, val);
}
buf.push(b'}');
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::types::{InputMessage, ToolDefinition, ToolChoice};
use super::*;
fn sample_request(msg_count: usize) -> MessageRequest {
MessageRequest {
model: "claude-sonnet-4-6".to_string(),
max_tokens: 1024,
messages: Arc::new(
(0..msg_count)
.map(|i| InputMessage::user_text(format!("message {i}")))
.collect(),
),
system: Some(Arc::from("You are a helpful assistant.")),
tools: Some(vec![ToolDefinition {
name: "bash".to_string(),
description: Some("Run a shell command".to_string()),
input_schema: serde_json::json!({"type": "object"}),
}]),
tool_choice: Some(ToolChoice::Auto),
stream: true,
..Default::default()
}
}
#[test]
fn full_build_produces_valid_json() {
let request = sample_request(3);
let mut body = IncrementalBody::new();
body.update(&request);
let value = body.build();
assert_eq!(value["model"], "claude-sonnet-4-6");
assert_eq!(value["max_tokens"], 1024);
// System prompt is now wrapped in cache_control array by serialise_base.
assert_eq!(
value["system"][0]["text"],
"You are a helpful assistant."
);
assert!(value.get("tools").is_some());
assert_eq!(
value["messages"].as_array().map(Vec::len),
Some(3)
);
}
#[test]
fn incremental_update_only_serialises_delta() {
let mut body = IncrementalBody::new();
let req1 = sample_request(2);
body.update(&req1);
assert_eq!(body.cached_message_bytes.len(), 2);
let req2 = sample_request(5);
body.update(&req2);
assert_eq!(body.cached_message_bytes.len(), 5);
let value = body.build();
assert_eq!(
value["messages"].as_array().map(Vec::len),
Some(5)
);
}
#[test]
fn truncation_handles_compaction() {
let mut body = IncrementalBody::new();
body.update(&sample_request(10));
assert_eq!(body.cached_message_bytes.len(), 10);
body.update(&sample_request(4));
assert_eq!(body.cached_message_bytes.len(), 4);
let value = body.build();
assert_eq!(
value["messages"].as_array().map(Vec::len),
Some(4)
);
}
#[test]
fn base_hash_changes_on_model_switch() {
let mut body = IncrementalBody::new();
let req1 = sample_request(1);
body.update(&req1);
let hash1 = body.base_hash;
let mut req2 = sample_request(1);
req2.model = "claude-opus-4-6".to_string();
body.update(&req2);
assert_ne!(body.base_hash, hash1, "model change should alter base hash");
}
#[test]
fn build_bytes_round_trips() {
let request = sample_request(3);
let mut body = IncrementalBody::new();
body.update(&request);
let bytes = body.build_bytes();
let parsed: serde_json::Value =
serde_json::from_slice(&bytes).expect("build_bytes should be valid JSON");
assert_eq!(parsed["model"], "claude-sonnet-4-6");
assert_eq!(parsed["max_tokens"], 1024);
// System prompt is now wrapped in cache_control array by serialise_base.
assert_eq!(
parsed["system"][0]["text"],
"You are a helpful assistant."
);
assert_eq!(
parsed["messages"].as_array().map(Vec::len),
Some(3)
);
assert_eq!(
parsed["messages"][0]["content"][0]["text"],
"message 0"
);
}
#[test]
fn serialise_base_omits_messages() {
let request = sample_request(100);
let map = serialise_base(&request);
assert!(
!map.contains_key("messages"),
"serialise_base must not include the messages field"
);
assert_eq!(map.get("model").and_then(|v| v.as_str()), Some("claude-sonnet-4-6"));
assert_eq!(map.get("max_tokens").and_then(|v| v.as_u64()), Some(1024));
// System is now wrapped in cache_control array rather than flat string.
assert!(
map.get("system").and_then(|v| v.as_array()).is_some(),
"system should be a cache-controlled array"
);
}
}

View File

@ -1,19 +1,22 @@
mod client;
mod convert;
mod error;
mod http_client;
pub mod incremental_body;
mod prompt_cache;
mod providers;
mod sse;
mod types;
pub use convert::{convert_messages, convert_messages_cached, convert_messages_inner};
pub use client::{
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
oauth_token_is_expired, read_base_url, resolve_saved_oauth_token,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
};
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,
build_http_client, build_http_client_or_default, build_http_client_with, ProxyConfig,
};
pub use prompt_cache::{
CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord,
@ -21,23 +24,21 @@ pub use prompt_cache::{
};
pub use providers::anthropic::{AnthropicClient, AnthropicClient as ApiClient, AuthSource};
pub use providers::openai_compat::{
build_chat_completion_request, check_request_body_size, estimate_request_body_size,
flatten_tool_result_content, is_reasoning_model, model_rejects_is_error_field,
model_requires_reasoning_content_in_history, translate_message, OpenAiCompatClient,
OpenAiCompatConfig,
build_chat_completion_request, flatten_tool_result_content, is_reasoning_model,
model_rejects_is_error_field, translate_message, OpenAiCompatClient, OpenAiCompatConfig,
};
pub use providers::{
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
model_family_identity_for, model_family_identity_for_kind, provider_diagnostics_for_model,
resolve_model_alias, ProviderDiagnostics, ProviderKind,
detect_provider_kind, is_local_inference, load_env_file_to_process, max_tokens_for_model,
max_tokens_for_model_with_override, resolve_model_alias, ProviderKind,
};
pub use sse::{parse_frame, SseParser};
pub use types::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
ThinkingConfig, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
};
pub use types::render_tools_block;
pub use telemetry::{
AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, JsonlTelemetrySink,

View File

@ -1,3 +1,4 @@
use std::collections::VecDeque;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
@ -13,6 +14,7 @@ const DEFAULT_BREAK_MIN_DROP: u32 = 2_000;
const MAX_SANITIZED_LENGTH: usize = 80;
const REQUEST_FINGERPRINT_VERSION: u32 = 1;
const REQUEST_FINGERPRINT_PREFIX: &str = "v1";
const PREVIOUS_WINDOW_SIZE: usize = 3;
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
@ -120,7 +122,13 @@ impl PromptCache {
pub fn with_config(config: PromptCacheConfig) -> Self {
let paths = PromptCachePaths::for_session(&config.session_id);
let stats = read_json::<PromptCacheStats>(&paths.stats_path).unwrap_or_default();
let previous = read_json::<TrackedPromptState>(&paths.session_state_path);
let previous = read_json::<TrackedPromptState>(&paths.session_state_path)
.map(|state| {
let mut deque = VecDeque::with_capacity(PREVIOUS_WINDOW_SIZE);
deque.push_back(state);
deque
})
.unwrap_or_default();
Self {
inner: Arc::new(Mutex::new(PromptCacheInner {
config,
@ -144,14 +152,10 @@ impl PromptCache {
#[must_use]
pub fn lookup_completion(&self, request: &MessageRequest) -> Option<MessageResponse> {
let request_hash = request_hash_hex(request);
let (paths, ttl) = {
let inner = self.lock();
(inner.paths.clone(), inner.config.completion_ttl)
};
let entry_path = paths.completion_entry_path(&request_hash);
let mut inner = self.lock();
let entry_path = inner.paths.completion_entry_path(&request_hash);
let entry = read_json::<CompletionCacheEntry>(&entry_path);
let Some(entry) = entry else {
let mut inner = self.lock();
inner.stats.completion_cache_misses += 1;
inner.stats.last_completion_cache_key = Some(request_hash);
persist_state(&inner);
@ -159,20 +163,18 @@ impl PromptCache {
};
if entry.fingerprint_version != current_fingerprint_version() {
let mut inner = self.lock();
inner.stats.completion_cache_misses += 1;
inner.stats.last_completion_cache_key = Some(request_hash.clone());
let _ = fs::remove_file(entry_path);
let _ = fs::remove_file(&entry_path);
persist_state(&inner);
return None;
}
let expired = now_unix_secs().saturating_sub(entry.cached_at_unix_secs) >= ttl.as_secs();
let mut inner = self.lock();
inner.stats.last_completion_cache_key = Some(request_hash.clone());
let expired = now_unix_secs().saturating_sub(entry.cached_at_unix_secs)
>= inner.config.completion_ttl.as_secs();
if expired {
inner.stats.completion_cache_misses += 1;
let _ = fs::remove_file(entry_path);
let _ = fs::remove_file(&entry_path);
persist_state(&inner);
return None;
}
@ -184,10 +186,12 @@ impl PromptCache {
&request_hash,
"completion-cache",
);
inner.previous = Some(TrackedPromptState::from_usage(
request,
&entry.response.usage,
));
inner
.previous
.push_back(TrackedPromptState::from_usage(request, &entry.response.usage));
if inner.previous.len() > PREVIOUS_WINDOW_SIZE {
inner.previous.pop_front();
}
persist_state(&inner);
Some(entry.response)
}
@ -214,7 +218,7 @@ impl PromptCache {
) -> PromptCacheRecord {
let request_hash = request_hash_hex(request);
let mut inner = self.lock();
let previous = inner.previous.clone();
let previous = inner.previous.back().cloned();
let current = TrackedPromptState::from_usage(request, usage);
let cache_break = detect_cache_break(&inner.config, previous.as_ref(), &current);
@ -229,7 +233,10 @@ impl PromptCache {
inner.stats.last_break_reason = Some(event.reason.clone());
}
inner.previous = Some(current);
inner.previous.push_back(current);
if inner.previous.len() > PREVIOUS_WINDOW_SIZE {
inner.previous.pop_front();
}
if let Some(response) = response {
write_completion_entry(&inner.paths, &request_hash, response);
inner.stats.completion_cache_writes += 1;
@ -254,7 +261,7 @@ struct PromptCacheInner {
config: PromptCacheConfig,
paths: PromptCachePaths,
stats: PromptCacheStats,
previous: Option<TrackedPromptState>,
previous: VecDeque<TrackedPromptState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -398,7 +405,7 @@ fn apply_usage_to_stats(
fn persist_state(inner: &PromptCacheInner) {
let _ = ensure_cache_dirs(&inner.paths);
let _ = write_json(&inner.paths.stats_path, &inner.stats);
if let Some(previous) = &inner.previous {
if let Some(previous) = inner.previous.back() {
let _ = write_json(&inner.paths.session_state_path, previous);
}
}
@ -440,7 +447,7 @@ fn request_hash_hex(request: &MessageRequest) -> String {
}
fn hash_serializable<T: Serialize>(value: &T) -> u64 {
let json = serde_json::to_vec(value).unwrap_or_default();
let json = serde_json::to_vec(value).expect("hash_serializable: serialization failed");
stable_hash_bytes(&json)
}
@ -500,7 +507,7 @@ fn stable_hash_bytes(bytes: &[u8]) -> u64 {
#[cfg(test)]
mod tests {
use std::sync::{Mutex, OnceLock};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::{
@ -699,8 +706,8 @@ mod tests {
MessageRequest {
model: "claude-3-7-sonnet-latest".to_string(),
max_tokens: 64,
messages: vec![InputMessage::user_text(text)],
system: Some("system".to_string()),
messages: Arc::new(vec![InputMessage::user_text(text)]),
system: Some(Arc::from("system")),
tools: None,
tool_choice: None,
stream: false,

View File

@ -0,0 +1,879 @@
#![allow(clippy::cast_possible_truncation)]
use std::future::Future;
use std::pin::Pin;
use serde::Serialize;
use crate::error::ApiError;
use crate::types::{MessageRequest, MessageResponse};
pub mod anthropic;
pub mod openai_compat;
#[allow(dead_code)]
pub type ProviderFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, ApiError>> + Send + 'a>>;
#[allow(dead_code)]
pub trait Provider {
type Stream;
fn send_message<'a>(
&'a self,
request: &'a MessageRequest,
) -> ProviderFuture<'a, MessageResponse>;
fn stream_message<'a>(
&'a self,
request: &'a MessageRequest,
) -> ProviderFuture<'a, Self::Stream>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderKind {
Anthropic,
OpenAi,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderMetadata {
pub provider: ProviderKind,
pub auth_env: &'static str,
pub base_url_env: &'static str,
pub default_base_url: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ModelTokenLimit {
pub max_output_tokens: u32,
pub context_window_tokens: u32,
}
const MODEL_REGISTRY: &[(&str, ProviderMetadata)] = &[
(
"opus",
ProviderMetadata {
provider: ProviderKind::Anthropic,
auth_env: "ANTHROPIC_API_KEY",
base_url_env: "ANTHROPIC_BASE_URL",
default_base_url: anthropic::DEFAULT_BASE_URL,
},
),
(
"sonnet",
ProviderMetadata {
provider: ProviderKind::Anthropic,
auth_env: "ANTHROPIC_API_KEY",
base_url_env: "ANTHROPIC_BASE_URL",
default_base_url: anthropic::DEFAULT_BASE_URL,
},
),
(
"haiku",
ProviderMetadata {
provider: ProviderKind::Anthropic,
auth_env: "ANTHROPIC_API_KEY",
base_url_env: "ANTHROPIC_BASE_URL",
default_base_url: anthropic::DEFAULT_BASE_URL,
},
),
];
#[must_use]
pub fn resolve_model_alias(model: &str) -> String {
let trimmed = model.trim();
let lower = trimmed.to_ascii_lowercase();
MODEL_REGISTRY
.iter()
.find_map(|(alias, metadata)| {
(*alias == lower).then_some(match metadata.provider {
ProviderKind::Anthropic => match *alias {
"opus" => "claude-opus-4-6",
"sonnet" => "claude-sonnet-4-6",
"haiku" => "claude-haiku-4-5-20251213",
_ => trimmed,
},
ProviderKind::OpenAi => trimmed,
})
})
.map_or_else(|| trimmed.to_string(), ToOwned::to_owned)
}
#[must_use]
pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
let canonical = resolve_model_alias(model);
if canonical.starts_with("claude") {
return Some(ProviderMetadata {
provider: ProviderKind::Anthropic,
auth_env: "ANTHROPIC_API_KEY",
base_url_env: "ANTHROPIC_BASE_URL",
default_base_url: anthropic::DEFAULT_BASE_URL,
});
}
// Explicit provider-namespaced models (e.g. "openai/gpt-4.1-mini") must
// route to the correct provider regardless of which auth env vars are set.
// Without this, detect_provider_kind falls through to the auth-sniffer
// order and misroutes to Anthropic if ANTHROPIC_API_KEY is present.
if canonical.starts_with("openai/") || canonical.starts_with("gpt-") {
return Some(ProviderMetadata {
provider: ProviderKind::OpenAi,
auth_env: "OPENAI_API_KEY",
base_url_env: "OPENAI_BASE_URL",
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
});
}
None
}
#[must_use]
pub fn detect_provider_kind(model: &str) -> ProviderKind {
if let Some(metadata) = metadata_for_model(model) {
return metadata.provider;
}
// When OPENAI_BASE_URL is set, the user explicitly configured an
// OpenAI-compatible endpoint. Prefer it over the Anthropic fallback
// even when the model name has no recognized prefix — this is the
// common case for local providers (Ollama, LM Studio, vLLM, etc.)
// where model names like "qwen2.5-coder:7b" don't match any prefix.
if std::env::var_os("OPENAI_BASE_URL").is_some() && openai_compat::has_api_key("OPENAI_API_KEY")
{
return ProviderKind::OpenAi;
}
if anthropic::has_auth_from_env_or_saved().unwrap_or(false) {
return ProviderKind::Anthropic;
}
if openai_compat::has_api_key("OPENAI_API_KEY") {
return ProviderKind::OpenAi;
}
// Last resort: if OPENAI_BASE_URL is set without OPENAI_API_KEY (some
// local providers like Ollama don't require auth), still route there.
if std::env::var_os("OPENAI_BASE_URL").is_some() {
return ProviderKind::OpenAi;
}
ProviderKind::Anthropic
}
#[must_use]
pub fn max_tokens_for_model(model: &str) -> u32 {
model_token_limit(model).map_or_else(
|| {
let canonical = resolve_model_alias(model);
if canonical.contains("opus") {
32_000
} else {
64_000
}
},
|limit| limit.max_output_tokens,
)
}
/// Returns the effective max output tokens for a model, preferring a plugin
/// override when present. Falls back to [`max_tokens_for_model`] when the
/// override is `None`.
#[must_use]
pub fn max_tokens_for_model_with_override(model: &str, plugin_override: Option<u32>) -> u32 {
plugin_override.unwrap_or_else(|| max_tokens_for_model(model))
}
#[must_use]
pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
let canonical = resolve_model_alias(model);
match canonical.as_str() {
"claude-opus-4-6" => Some(ModelTokenLimit {
max_output_tokens: 32_000,
context_window_tokens: 200_000,
}),
"claude-sonnet-4-6" | "claude-haiku-4-5-20251213" => Some(ModelTokenLimit {
max_output_tokens: 64_000,
context_window_tokens: 200_000,
}),
_ => None,
}
}
/// Detect whether the active provider is a local inference endpoint
/// (llama.cpp, LM Studio, Ollama, vLLM, mock servers, etc.) where
/// KV cache prefix stability is critical and tools-in-system-prompt
/// is beneficial.
///
/// Detection heuristics:
/// 1. Explicit opt-in via `CLAW_LOCAL_INFERENCE=true`
/// 2. `OPENAI_BASE_URL` or `ANTHROPIC_BASE_URL` points to a loopback address
/// 3. A base URL is set without its corresponding API key (no-auth local server)
#[must_use]
pub fn is_local_inference() -> bool {
if std::env::var_os("CLAW_LOCAL_INFERENCE")
.is_some_and(|v| v == "true" || v == "1")
{
return true;
}
let local_hosts = ["localhost", "127.0.0.1", "0.0.0.0"];
// Helper: check if a base URL env points to a loopback address.
let base_url_looks_local = |key: &str| -> bool {
std::env::var(key).is_ok_and(|url| {
if url.is_empty() {
return false;
}
local_hosts.iter().any(|h| url.contains(h))
})
};
if base_url_looks_local("OPENAI_BASE_URL") {
return true;
}
if base_url_looks_local("ANTHROPIC_BASE_URL") {
return true;
}
// Base URL set without its API key = likely a no-auth local server.
if std::env::var("OPENAI_BASE_URL").is_ok_and(|u| !u.is_empty())
&& std::env::var("OPENAI_API_KEY").is_err()
{
return true;
}
if std::env::var("ANTHROPIC_BASE_URL").is_ok_and(|u| !u.is_empty())
&& std::env::var("ANTHROPIC_API_KEY").is_err()
{
return true;
}
false
}
pub fn preflight_message_request(request: &MessageRequest) -> Result<(), ApiError> {
let Some(limit) = model_token_limit(&request.model) else {
return Ok(());
};
let estimated_input_tokens = estimate_message_request_input_tokens(request);
let estimated_total_tokens = estimated_input_tokens.saturating_add(request.max_tokens);
if estimated_total_tokens > limit.context_window_tokens {
return Err(ApiError::ContextWindowExceeded {
model: resolve_model_alias(&request.model),
estimated_input_tokens,
requested_output_tokens: request.max_tokens,
estimated_total_tokens,
context_window_tokens: limit.context_window_tokens,
});
}
Ok(())
}
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));
estimate = estimate.saturating_add(estimate_serialized_tokens(&request.tool_choice));
estimate
}
fn estimate_serialized_tokens<T: Serialize>(value: &T) -> u32 {
serde_json::to_vec(value)
.ok()
.map_or(0, |bytes| (bytes.len() / 4 + 1) as u32)
}
/// Env var names used by other provider backends. When Anthropic auth
/// resolution fails we sniff these so we can hint the user that their
/// credentials probably belong to a different provider and suggest the
/// model-prefix routing fix that would select it.
const FOREIGN_PROVIDER_ENV_VARS: &[(&str, &str, &str)] = &[(
"OPENAI_API_KEY",
"OpenAI-compat",
"prefix your model name with `openai/` (e.g. `--model openai/gpt-4.1-mini`) so prefix routing selects the OpenAI-compatible provider, and set `OPENAI_BASE_URL` if you are pointing at OpenRouter/Ollama/a local server",
)];
/// Check whether an env var is set to a non-empty value either in the real
/// process environment or in the working-directory `.env` file. Mirrors the
/// credential discovery path used by `read_env_non_empty` so the hint text
/// stays truthful when users rely on `.env` instead of a real export.
fn env_or_dotenv_present(key: &str) -> bool {
match std::env::var(key) {
Ok(value) if !value.is_empty() => true,
Ok(_) | Err(std::env::VarError::NotPresent) => {
dotenv_value(key).is_some_and(|value| !value.is_empty())
}
Err(_) => false,
}
}
/// Produce a hint string describing the first foreign provider credential
/// that is present in the environment when Anthropic auth resolution has
/// just failed. Returns `None` when no foreign credential is set, in which
/// case the caller should fall back to the plain `missing_credentials`
/// error without a hint.
pub(crate) fn anthropic_missing_credentials_hint() -> Option<String> {
for (env_var, provider_label, fix_hint) in FOREIGN_PROVIDER_ENV_VARS {
if env_or_dotenv_present(env_var) {
return Some(format!(
"I see {env_var} is set — if you meant to use the {provider_label} provider, {fix_hint}."
));
}
}
None
}
/// Build an Anthropic-specific `MissingCredentials` error, attaching a
/// hint suggesting the probable fix whenever a different provider's
/// credentials are already present in the environment. Anthropic call
/// sites should prefer this helper over `ApiError::missing_credentials`
/// so users who mistyped a model name or forgot the prefix get a useful
/// signal instead of a generic "missing Anthropic credentials" wall.
pub(crate) fn anthropic_missing_credentials() -> ApiError {
const PROVIDER: &str = "Anthropic";
const ENV_VARS: &[&str] = &["ANTHROPIC_API_KEY"];
match anthropic_missing_credentials_hint() {
Some(hint) => ApiError::missing_credentials_with_hint(PROVIDER, ENV_VARS, hint),
None => ApiError::missing_credentials(PROVIDER, ENV_VARS),
}
}
/// Parse a `.env` file body into key/value pairs using a minimal `KEY=VALUE`
/// grammar. Lines that are blank, start with `#`, or do not contain `=` are
/// ignored. Surrounding double or single quotes are stripped from the value.
/// An optional leading `export ` prefix on the key is also stripped so files
/// shared with shell `source` workflows still parse cleanly.
pub(crate) fn parse_dotenv(content: &str) -> std::collections::HashMap<String, String> {
let mut values = std::collections::HashMap::new();
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((raw_key, raw_value)) = line.split_once('=') else {
continue;
};
let trimmed_key = raw_key.trim();
let key = trimmed_key
.strip_prefix("export ")
.map_or(trimmed_key, str::trim)
.to_string();
if key.is_empty() {
continue;
}
let trimmed_value = raw_value.trim();
let unquoted = if (trimmed_value.starts_with('"') && trimmed_value.ends_with('"')
|| trimmed_value.starts_with('\'') && trimmed_value.ends_with('\''))
&& trimmed_value.len() >= 2
{
&trimmed_value[1..trimmed_value.len() - 1]
} else {
trimmed_value
};
values.insert(key, unquoted.to_string());
}
values
}
/// Load and parse a `.env` file from the given path. Missing files yield
/// `None` instead of an error so callers can use this as a soft fallback.
pub(crate) fn load_dotenv_file(
path: &std::path::Path,
) -> Option<std::collections::HashMap<String, String>> {
let content = std::fs::read_to_string(path).ok()?;
Some(parse_dotenv(&content))
}
/// Look up `key` in the first-found `.env` file.
/// Priority: `cwd/.env` → `cwd/.claw/.env` → `~/.claw/.env`
/// (`$CLAW_CONFIG_HOME/.env` overrides `~/.claw/.env`).
/// Returns `None` when the key is absent or its value is empty.
pub(crate) fn dotenv_value(key: &str) -> Option<String> {
let values = resolve_first_dotenv()?;
values.get(key).filter(|value| !value.is_empty()).cloned()
}
/// Load the first-found `.env` file into the process environment.
/// Priority: `cwd/.env` → `cwd/.claw/.env` → `~/.claw/.env`
/// Existing vars are NOT overwritten. Call early in `main()` so ALL
/// `std::env::var()` calls in any crate pick up `.env` values.
pub fn load_env_file_to_process() {
runtime::text_only_models::reload();
let Some(values) = resolve_first_dotenv() else { return };
for (key, value) in values {
if std::env::var(&key).is_err() {
std::env::set_var(&key, &value);
}
}
}
/// Resolve the user config home: `$CLAW_CONFIG_HOME` or `~/.claw`.
fn user_config_home() -> Option<std::path::PathBuf> {
if let Some(custom) = std::env::var_os("CLAW_CONFIG_HOME") {
return Some(std::path::PathBuf::from(custom));
}
#[cfg(windows)]
let home = std::env::var_os("USERPROFILE");
#[cfg(not(windows))]
let home = std::env::var_os("HOME");
home.map(|h| std::path::PathBuf::from(h).join(".claw"))
}
/// Try `.env` files in order: `cwd/.env` → `cwd/.claw/.env` → user home.
/// Returns the contents of the first existing file, or `None`.
fn resolve_first_dotenv() -> Option<std::collections::HashMap<String, String>> {
// Project-local candidates
if let Ok(cwd) = std::env::current_dir() {
for candidate in [cwd.join(".env"), cwd.join(".claw").join(".env")] {
if let Some(values) = load_dotenv_file(&candidate) {
return Some(values);
}
}
}
// User-level fallback
load_dotenv_file(&user_config_home()?.join(".env"))
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::sync::{Arc, Mutex, OnceLock};
use serde_json::json;
use crate::error::ApiError;
use crate::types::{
InputContentBlock, InputMessage, MessageRequest, ToolChoice, ToolDefinition,
};
use super::{
anthropic_missing_credentials, anthropic_missing_credentials_hint, detect_provider_kind,
load_dotenv_file, max_tokens_for_model, max_tokens_for_model_with_override,
model_token_limit, parse_dotenv, preflight_message_request, ProviderKind,
};
/// Serializes every test in this module that mutates process-wide
/// environment variables so concurrent test threads cannot observe
/// each other's partially-applied state while probing the foreign
/// provider credential sniffer.
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Snapshot-restore guard for a single environment variable. Captures
/// the original value on construction, applies the requested override
/// (set or remove), and restores the original on drop so tests leave
/// the process env untouched even when they panic mid-assertion.
struct EnvVarGuard {
key: &'static str,
original: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: Option<&str>) -> Self {
let original = std::env::var_os(key);
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.original.take() {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
#[test]
fn detects_provider_from_model_name_first() {
assert_eq!(
detect_provider_kind("claude-sonnet-4-6"),
ProviderKind::Anthropic
);
}
#[test]
fn openai_namespaced_model_routes_to_openai_not_anthropic() {
// Regression: "openai/gpt-4.1-mini" was misrouted to Anthropic when
// ANTHROPIC_API_KEY was set because metadata_for_model returned None
// and detect_provider_kind fell through to auth-sniffer order.
// The model prefix must win over env-var presence.
let kind = super::metadata_for_model("openai/gpt-4.1-mini").map_or_else(
|| detect_provider_kind("openai/gpt-4.1-mini"),
|m| m.provider,
);
assert_eq!(
kind,
ProviderKind::OpenAi,
"openai/ prefix must route to OpenAi regardless of ANTHROPIC_API_KEY"
);
// Also cover bare gpt- prefix
let kind2 = super::metadata_for_model("gpt-4o")
.map_or_else(|| detect_provider_kind("gpt-4o"), |m| m.provider);
assert_eq!(kind2, ProviderKind::OpenAi);
}
#[test]
fn keeps_existing_max_token_heuristic() {
assert_eq!(max_tokens_for_model("opus"), 32_000);
}
#[test]
fn plugin_config_max_output_tokens_overrides_model_default() {
// given
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!("api-plugin-max-tokens-{nanos}"));
let cwd = root.join("project");
let home = root.join("home").join(".claw");
std::fs::create_dir_all(cwd.join(".claw")).expect("project config dir");
std::fs::create_dir_all(&home).expect("home config dir");
std::fs::write(
home.join("settings.json"),
r#"{
"plugins": {
"maxOutputTokens": 12345
}
}"#,
)
.expect("write plugin settings");
// when
let loaded = runtime::ConfigLoader::new(&cwd, &home)
.load()
.expect("config should load");
let plugin_override = loaded.plugins().max_output_tokens();
let effective = max_tokens_for_model_with_override("claude-opus-4-6", plugin_override);
// then
assert_eq!(plugin_override, Some(12345));
assert_eq!(effective, 12345);
assert_ne!(effective, max_tokens_for_model("claude-opus-4-6"));
std::fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn max_tokens_for_model_with_override_falls_back_when_plugin_unset() {
// given
let plugin_override: Option<u32> = None;
// when
let effective = max_tokens_for_model_with_override("claude-opus-4-6", plugin_override);
// then
assert_eq!(effective, max_tokens_for_model("claude-opus-4-6"));
assert_eq!(effective, 32_000);
}
#[test]
fn returns_context_window_metadata_for_supported_models() {
assert_eq!(
model_token_limit("claude-sonnet-4-6")
.expect("claude-sonnet-4-6 should be registered")
.context_window_tokens,
200_000
);
}
#[test]
fn preflight_blocks_requests_that_exceed_the_model_context_window() {
let request = MessageRequest {
model: "claude-sonnet-4-6".to_string(),
max_tokens: 64_000,
messages: Arc::new(vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(600_000),
}],
}]),
system: Some(Arc::from("Keep the answer short.")),
tools: Some(vec![ToolDefinition {
name: "weather".to_string(),
description: Some("Fetches weather".to_string()),
input_schema: json!({
"type": "object",
"properties": { "city": { "type": "string" } },
}),
}]),
tool_choice: Some(ToolChoice::Auto),
stream: true,
..Default::default()
};
let error = preflight_message_request(&request)
.expect_err("oversized request should be rejected before the provider call");
match error {
ApiError::ContextWindowExceeded {
model,
estimated_input_tokens,
requested_output_tokens,
estimated_total_tokens,
context_window_tokens,
} => {
assert_eq!(model, "claude-sonnet-4-6");
assert!(estimated_input_tokens > 136_000);
assert_eq!(requested_output_tokens, 64_000);
assert!(estimated_total_tokens > context_window_tokens);
assert_eq!(context_window_tokens, 200_000);
}
other => panic!("expected context-window preflight failure, got {other:?}"),
}
}
#[test]
fn preflight_skips_unknown_models() {
let request = MessageRequest {
model: "unknown-model".to_string(),
max_tokens: 64_000,
messages: Arc::new(vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(600_000),
}],
}]),
system: None,
tools: None,
tool_choice: None,
stream: false,
..Default::default()
};
preflight_message_request(&request)
.expect("models without context metadata should skip the guarded preflight");
}
#[test]
fn parse_dotenv_extracts_keys_handles_comments_quotes_and_export_prefix() {
// given
let body = "\
# this is a comment
ANTHROPIC_API_KEY=plain-value
OPENAI_API_KEY='single-quoted'
PADDED_KEY = padded-value
EMPTY_VALUE=
NO_EQUALS_LINE
";
// when
let values = parse_dotenv(body);
// then
assert_eq!(
values.get("ANTHROPIC_API_KEY").map(String::as_str),
Some("plain-value")
);
assert_eq!(
values.get("OPENAI_API_KEY").map(String::as_str),
Some("single-quoted")
);
assert_eq!(
values.get("PADDED_KEY").map(String::as_str),
Some("padded-value")
);
assert_eq!(values.get("EMPTY_VALUE").map(String::as_str), Some(""));
assert!(!values.contains_key("NO_EQUALS_LINE"));
assert!(!values.contains_key("# this is a comment"));
}
#[test]
fn load_dotenv_file_reads_keys_from_disk_and_returns_none_when_missing() {
// given
let temp_root = std::env::temp_dir().join(format!(
"api-dotenv-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos())
));
std::fs::create_dir_all(&temp_root).expect("create temp dir");
let env_path = temp_root.join(".env");
std::fs::write(
&env_path,
"ANTHROPIC_API_KEY=secret-from-file\n# comment\n",
)
.expect("write .env");
let missing_path = temp_root.join("does-not-exist.env");
// when
let loaded = load_dotenv_file(&env_path).expect("file should load");
let missing = load_dotenv_file(&missing_path);
// then
assert_eq!(
loaded.get("ANTHROPIC_API_KEY").map(String::as_str),
Some("secret-from-file")
);
assert!(missing.is_none());
let _ = std::fs::remove_dir_all(&temp_root);
}
#[test]
fn anthropic_missing_credentials_hint_is_none_when_no_foreign_creds_present() {
// given
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", None);
// when
let hint = anthropic_missing_credentials_hint();
// then
assert!(
hint.is_none(),
"no hint should be produced when every foreign provider env var is absent, got {hint:?}"
);
}
#[test]
fn anthropic_missing_credentials_hint_detects_openai_api_key_and_recommends_openai_prefix() {
// given
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("sk-openrouter-varleg"));
// when
let hint = anthropic_missing_credentials_hint()
.expect("OPENAI_API_KEY presence should produce a hint");
// then
assert!(
hint.contains("OPENAI_API_KEY is set"),
"hint should name the detected env var so users recognize it: {hint}"
);
assert!(
hint.contains("OpenAI-compat"),
"hint should identify the target provider: {hint}"
);
assert!(
hint.contains("openai/"),
"hint should mention the `openai/` prefix routing fix: {hint}"
);
assert!(
hint.contains("OPENAI_BASE_URL"),
"hint should mention OPENAI_BASE_URL so OpenRouter users see the full picture: {hint}"
);
}
#[test]
fn anthropic_missing_credentials_builds_error_with_canonical_env_vars_and_no_hint_when_clean() {
// given
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", None);
// when
let error = anthropic_missing_credentials();
// then
match &error {
ApiError::MissingCredentials {
provider,
env_vars,
hint,
} => {
assert_eq!(*provider, "Anthropic");
assert_eq!(*env_vars, &["ANTHROPIC_API_KEY"]);
assert!(
hint.is_none(),
"clean environment should not generate a hint, got {hint:?}"
);
}
other => panic!("expected MissingCredentials variant, got {other:?}"),
}
let rendered = error.to_string();
assert!(
!rendered.contains(" — hint: "),
"rendered error should be a plain missing-creds message: {rendered}"
);
}
#[test]
fn anthropic_missing_credentials_builds_error_with_hint_when_openai_key_is_set() {
// given
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("sk-openrouter-varleg"));
// when
let error = anthropic_missing_credentials();
// then
match &error {
ApiError::MissingCredentials {
provider,
env_vars,
hint,
} => {
assert_eq!(*provider, "Anthropic");
assert_eq!(*env_vars, &["ANTHROPIC_API_KEY"]);
let hint_value = hint.as_deref().expect("hint should be populated");
assert!(
hint_value.contains("OPENAI_API_KEY is set"),
"hint should name the detected env var: {hint_value}"
);
}
other => panic!("expected MissingCredentials variant, got {other:?}"),
}
let rendered = error.to_string();
assert!(
rendered.starts_with("missing Anthropic credentials;"),
"canonical base message should still lead the rendered error: {rendered}"
);
assert!(
rendered.contains(" — hint: I see OPENAI_API_KEY is set"),
"rendered error should carry the env-driven hint: {rendered}"
);
}
#[test]
fn anthropic_missing_credentials_hint_ignores_empty_string_values() {
// given
let _lock = env_lock();
// An empty value is semantically equivalent to "not set" for the
// credential discovery path, so the sniffer must treat it that way
// to avoid false-positive hints for users who intentionally cleared
// a stale export with `OPENAI_API_KEY=`.
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some(""));
// when
let hint = anthropic_missing_credentials_hint();
// then
assert!(
hint.is_none(),
"empty env var should not trigger the hint sniffer, got {hint:?}"
);
}
#[test]
fn openai_base_url_overrides_anthropic_fallback_for_unknown_model() {
// given — user has OPENAI_BASE_URL + OPENAI_API_KEY but no Anthropic
// creds, and a model name with no recognized prefix.
let _lock = env_lock();
let _base_url = EnvVarGuard::set("OPENAI_BASE_URL", Some("http://127.0.0.1:11434/v1"));
let _api_key = EnvVarGuard::set("OPENAI_API_KEY", Some("dummy"));
let _anthropic_key = EnvVarGuard::set("ANTHROPIC_API_KEY", None);
// when
let provider = detect_provider_kind("qwen2.5-coder:7b");
// then — should route to OpenAI, not Anthropic
assert_eq!(
provider,
ProviderKind::OpenAi,
"OPENAI_BASE_URL should win over Anthropic fallback for unknown models"
);
}
// NOTE: a "OPENAI_BASE_URL without OPENAI_API_KEY" test is omitted
// because workspace-parallel test binaries can race on process env
// (env_lock only protects within a single binary). The detection logic
// is covered: OPENAI_BASE_URL alone routes to OpenAi as a last-resort
// fallback in detect_provider_kind().
}

View File

@ -122,9 +122,47 @@ pub(crate) fn parse_frame_with_provider(
return Ok(None);
}
serde_json::from_str::<StreamEvent>(&payload)
.map(Some)
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))
match serde_json::from_str::<StreamEvent>(&payload) {
Ok(event) => Ok(Some(event)),
Err(error) => {
// Unknown event type (e.g. "server_error", "error") - try to
// extract diagnostic info and return an ApiError, so the caller
// can handle it instead of crashing.
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&payload) {
if let Some(typ) = val.get("type").and_then(|v| v.as_str()) {
if typ == "error" || typ.ends_with("_error") {
let status = val
.get("code")
.and_then(|v| v.as_u64())
.map(|c| reqwest::StatusCode::try_from(c as u16).ok())
.flatten()
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
let msg = val
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("unknown streaming error")
.to_string();
return Err(ApiError::Api {
status,
error_type: Some(typ.to_string()),
message: Some(msg),
request_id: None,
body: payload,
retryable: true,
suggested_action: None,
});
}
}
}
// Unrecognisable payload — skip the frame rather than failing
// the entire stream. A future API extension may have introduced
// a new event type we don't understand.
eprintln!(
"[sse] skipping unparseable event from {provider}/{model}: {error}"
);
Ok(None)
}
}
}
#[cfg(test)]

View File

@ -0,0 +1,764 @@
use runtime::{pricing_for_model, TokenUsage, UsageCostEstimate};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
/// Anthropic extended thinking configuration.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ThinkingConfig {
#[serde(rename = "type")]
pub config_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub budget_tokens: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct MessageRequest {
pub model: String,
pub max_tokens: u32,
/// Shared message list wrapped in `Arc` so that `MessageRequest::clone()`
/// is O(1) for the (typically large) messages vector.
pub messages: Arc<Vec<InputMessage>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<Arc<str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ToolDefinition>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stream: bool,
/// OpenAI-compatible tuning parameters. Optional — omitted from payload when None.
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frequency_penalty: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub presence_penalty: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<String>>,
/// Reasoning effort level for OpenAI-compatible reasoning models (e.g. `o4-mini`).
/// Accepted values: `"low"`, `"medium"`, `"high"`. Omitted when `None`.
/// Silently ignored by backends that do not support it.
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
/// Anthropic extended thinking configuration. Omitted when `None`.
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<ThinkingConfig>,
/// Pre-cached serialised JSON `Value`s for each message, typically produced
/// by `convert_messages_cached`. The `IncrementalBody` will use these to
/// skip re-serialisation of unchanged messages.
/// Empty when not using the cache.
/// Wrapped in `Arc` so that `MessageRequest::clone()` is O(1).
#[serde(skip)]
pub cached_message_values: Arc<Vec<Option<Value>>>,
/// If `true`, omit the `tools` field when serialising the request body.
/// Set on requests 2+ when tool definitions haven't changed, saving ~24KB
/// per turn for Anthropic server-side prompt cache.
/// NOTE: only respected by the Anthropic provider — OpenAI-compat and xAI
/// always send full tool definitions.
#[serde(skip)]
pub skip_tools: bool,
/// If `true`, tool definitions have been embedded in the system prompt
/// text as a deterministic JSON block. The `tools` field should be omitted
/// from the wire format to avoid duplication.
/// Used for local inference (llama.cpp, LM Studio, Ollama) where KV cache
/// prefix stability depends on stable token sequences.
#[serde(skip)]
pub tools_in_system_prompt: bool,
}
impl MessageRequest {
#[must_use]
pub fn with_streaming(mut self) -> Self {
self.stream = true;
self
}
/// Render the request body in Anthropic API JSON format.
///
/// Post-processing steps:
/// 1. Strip tools when `skip_tools` is set (tools unchanged since prior
/// request — saves ~24KB per turn via Anthropic server-side cache).
/// 2. Split system prompt at `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` into blocks
/// with `cache_control: ephemeral` on the static portion.
/// 3. Add `cache_control: ephemeral` to the last tool definition.
#[inline]
pub fn render_anthropic_body(&self) -> Result<Value, serde_json::Error> {
let mut body = serde_json::to_value(self)?;
if self.skip_tools {
if let Value::Object(ref mut obj) = body {
obj.remove("tools");
}
} else {
Self::apply_tools_cache_control(&mut body);
}
Self::apply_system_prompt_cache_control(&mut body);
Self::apply_messages_cache_control(&mut body);
Self::apply_cache_reference(&mut body);
Ok(body)
}
/// Post-process the serialised body to add `cache_reference` to tool_result
/// blocks that fall within the cached prefix (before the last message-level
/// `cache_control` marker). This lets the server reuse cached tool results.
pub(crate) fn apply_cache_reference(body: &mut Value) {
let Some(messages) = body
.get_mut("messages")
.and_then(|v| v.as_array_mut())
else {
return;
};
// Find the last message index that has any cache_control marker
let mut last_cc_idx = None;
for (i, msg) in messages.iter().enumerate() {
if let Some(content) = msg.get("content").and_then(|v| v.as_array()) {
if content.iter().any(|b| b.get("cache_control").is_some()) {
last_cc_idx = Some(i);
}
}
}
let Some(end) = last_cc_idx else { return };
// Only messages strictly before the last cache_control marker qualify
for msg in messages[..end].iter_mut() {
if msg.get("role").and_then(|v| v.as_str()) != Some("user") {
continue;
}
let Some(content) = msg.get_mut("content").and_then(|v| v.as_array_mut()) else {
continue;
};
for block in content.iter_mut() {
if block.get("type").and_then(|v| v.as_str()) != Some("tool_result") {
continue;
}
let Some(tuid) = block
.get("tool_use_id")
.and_then(|v| v.as_str())
.map(String::from)
else {
continue;
};
block["cache_reference"] = Value::String(tuid);
}
}
}
/// Add `cache_control: ephemeral` to the **last** message's last suitable
/// content block, creating a cached prefix boundary that allows
/// `apply_cache_reference` to determine which tool_results are in the
/// cached portion. This mirrors claude-code's `addCacheBreakpoints`.
///
/// Skipped when the last block is a `tool_result` (Anthropic does not
/// support `cache_control` on tool_result blocks) or when it already
/// has a `cache_control`.
pub(crate) fn apply_messages_cache_control(body: &mut Value) {
let Some(messages) = body
.get_mut("messages")
.and_then(|v| v.as_array_mut())
else {
return;
};
let Some(last_msg) = messages.last_mut() else {
return;
};
let Some(content) = last_msg
.get_mut("content")
.and_then(|v| v.as_array_mut())
else {
return;
};
let Some(last_block) = content.last_mut() else {
return;
};
// Anthropic does not support cache_control on tool_result blocks
if last_block
.get("type")
.and_then(|v| v.as_str())
== Some("tool_result")
{
return;
}
if last_block.get("cache_control").is_some() {
return;
}
last_block["cache_control"] = serde_json::json!({"type": "ephemeral"});
}
/// Split the flat system prompt string at `SYSTEM_PROMPT_DYNAMIC_BOUNDARY`
/// into Anthropic's block format with `cache_control` on the static part.
///
/// Before: `"system": "static...\n\n__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__\n\ndynamic..."`
/// After: `"system": [{"type":"text","text":"static...","cache_control":{"type":"ephemeral"}},
/// {"type":"text","text":"dynamic..."}]`
pub(crate) fn apply_system_prompt_cache_control(body: &mut Value) {
let Some(system_str) = body
.get("system")
.and_then(|v| v.as_str())
.map(str::to_owned)
else {
return;
};
let boundary = runtime::SYSTEM_PROMPT_DYNAMIC_BOUNDARY;
let Some(split_pos) = system_str.find(boundary) else {
// No boundary marker — wrap entire system as cached
if !system_str.is_empty() {
body["system"] = serde_json::json!([{
"type": "text",
"text": system_str,
"cache_control": { "type": "ephemeral" }
}]);
}
return;
};
let static_part = system_str[..split_pos].trim_end().to_string();
let dynamic_part = system_str[split_pos + boundary.len()..]
.trim_start()
.to_string();
let mut blocks = Vec::new();
if !static_part.is_empty() {
blocks.push(serde_json::json!({
"type": "text",
"text": static_part,
"cache_control": { "type": "ephemeral" }
}));
}
if !dynamic_part.is_empty() {
blocks.push(serde_json::json!({
"type": "text",
"text": dynamic_part,
"cache_control": { "type": "ephemeral" }
}));
}
if !blocks.is_empty() {
body["system"] = Value::Array(blocks);
}
}
/// Add `cache_control: ephemeral` to the last tool definition so Anthropic
/// caches the tool schema across requests within the same turn.
pub(crate) fn apply_tools_cache_control(body: &mut Value) {
let Some(tools) = body
.get_mut("tools")
.and_then(|v| v.as_array_mut())
else {
return;
};
if let Some(last_tool) = tools.last_mut() {
if let Some(obj) = last_tool.as_object_mut() {
obj.insert(
"cache_control".to_string(),
serde_json::json!({ "type": "ephemeral" }),
);
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InputMessage {
pub role: String,
pub content: Vec<InputContentBlock>,
}
impl InputMessage {
#[must_use]
pub fn user_text(text: impl Into<String>) -> Self {
Self {
role: "user".to_string(),
content: vec![InputContentBlock::Text { text: text.into() }],
}
}
#[must_use]
pub fn user_tool_result(
tool_use_id: impl Into<String>,
content: impl Into<String>,
is_error: bool,
) -> Self {
Self {
role: "user".to_string(),
content: vec![InputContentBlock::ToolResult {
tool_use_id: tool_use_id.into(),
content: vec![ToolResultContentBlock::Text {
text: content.into(),
}],
is_error,
cache_reference: None,
}],
}
}
}
/// Nested source block for Anthropic's `{"type":"image","source":{...}}` format.
///
/// Serde serialises this directly into the shape that Anthropic's API expects,
/// eliminating the need for a post-processing pass that walks the entire
/// body tree looking for `Image` blocks to normalise.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ImageSource {
/// Always `"base64"`.
#[serde(rename = "type")]
pub source_type: String,
/// MIME type of the image (e.g. `"image/png"`, `"image/jpeg"`).
pub media_type: String,
/// Base64-encoded image data.
pub data: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum InputContentBlock {
Text {
text: String,
},
ToolUse {
id: String,
name: String,
input: Value,
},
ToolResult {
tool_use_id: String,
content: Vec<ToolResultContentBlock>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
is_error: bool,
/// When in the cached prefix, reference the tool_use_id so the
/// server can reuse the cached tool_result instead of re-processing.
#[serde(skip_serializing_if = "Option::is_none")]
cache_reference: Option<String>,
},
Image {
/// Nested `source` block in Anthropic's expected format, produced
/// directly at construction time so no JSON-level post-processing
/// is needed.
#[serde(rename = "source")]
source: ImageSource,
},
Thinking {
/// The reasoning content returned by the model. Must be echoed back
/// verbatim (with `signature`) when the assistant turn is included in
/// a follow-up request under Anthropic extended thinking.
thinking: String,
/// Opaque signature that the Anthropic API uses to authenticate the
/// thinking block. Mandatory for round-tripping thinking blocks.
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
RedactedThinking {
/// The encrypted redacted-thinking payload returned by the provider.
/// Must be echoed back verbatim for the tool-use round-trip; unlike a
/// normal thinking block it carries no signature, so the data itself
/// is the authentication token.
data: Value,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolResultContentBlock {
Text { text: String },
Json { value: Value },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolDefinition {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub input_schema: Value,
}
/// Serialize tool definitions to a deterministic JSON text block for embedding
/// in the system prompt. Same input → identical byte sequence.
/// This ensures KV cache prefix stability for local inference servers.
///
/// Output format:
/// ```text
/// # Tools
/// [{"name":"...","description":"...","parameters":{...}},...]
/// ```
#[must_use]
pub fn render_tools_block(tools: &[ToolDefinition]) -> String {
use std::fmt::Write;
let mut block = String::from("# Tools\n[");
for (i, tool) in tools.iter().enumerate() {
if i > 0 {
block.push(',');
}
block.push('{');
write!(&mut block, "\"name\":{}", serde_json::to_string(&tool.name).unwrap_or_default()).ok();
block.push(',');
if let Some(ref desc) = tool.description {
write!(&mut block, "\"description\":{}", serde_json::to_string(desc).unwrap_or_default()).ok();
block.push(',');
}
block.push_str("\"parameters\":");
block.push_str(&serde_json::to_string(&tool.input_schema).unwrap_or_default());
block.push('}');
}
block.push(']');
block
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolChoice {
Auto,
Any,
Tool { name: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MessageResponse {
pub id: String,
#[serde(rename = "type")]
pub kind: String,
pub role: String,
pub content: Vec<OutputContentBlock>,
pub model: String,
#[serde(default)]
pub stop_reason: Option<String>,
#[serde(default)]
pub stop_sequence: Option<String>,
#[serde(default)]
pub usage: Usage,
#[serde(default)]
pub request_id: Option<String>,
}
impl MessageResponse {
#[must_use]
pub fn total_tokens(&self) -> u32 {
self.usage.total_tokens()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum OutputContentBlock {
Text {
text: String,
},
ToolUse {
id: String,
name: String,
#[serde(default = "serde_json::Value::default")]
input: Value,
},
Thinking {
#[serde(default)]
thinking: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<String>,
},
RedactedThinking {
data: Value,
},
// Added image output block
Image {
data: String,
mime_type: String,
filename: Option<String>,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Usage {
#[serde(default)]
pub input_tokens: u32,
#[serde(default)]
pub cache_creation_input_tokens: u32,
#[serde(default)]
pub cache_read_input_tokens: u32,
#[serde(default)]
pub output_tokens: u32,
}
impl Usage {
#[must_use]
pub const fn total_tokens(&self) -> u32 {
self.input_tokens
+ self.output_tokens
+ self.cache_creation_input_tokens
+ self.cache_read_input_tokens
}
#[must_use]
pub const fn token_usage(&self) -> TokenUsage {
TokenUsage {
input_tokens: self.input_tokens,
output_tokens: self.output_tokens,
cache_creation_input_tokens: self.cache_creation_input_tokens,
cache_read_input_tokens: self.cache_read_input_tokens,
}
}
#[must_use]
pub fn estimated_cost_usd(&self, model: &str) -> UsageCostEstimate {
let usage = self.token_usage();
pricing_for_model(model).map_or_else(
|| usage.estimate_cost_usd(),
|pricing| usage.estimate_cost_usd_with_pricing(pricing),
)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MessageStartEvent {
pub message: MessageResponse,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MessageDeltaEvent {
pub delta: MessageDelta,
#[serde(default)]
pub usage: Usage,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MessageDelta {
#[serde(default)]
pub stop_reason: Option<String>,
#[serde(default)]
pub stop_sequence: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ContentBlockStartEvent {
pub index: u32,
pub content_block: OutputContentBlock,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ContentBlockDeltaEvent {
pub index: u32,
pub delta: ContentBlockDelta,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlockDelta {
TextDelta { text: String },
InputJsonDelta { partial_json: String },
ThinkingDelta { thinking: String },
SignatureDelta { signature: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContentBlockStopEvent {
pub index: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MessageStopEvent {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StreamEvent {
MessageStart(MessageStartEvent),
MessageDelta(MessageDeltaEvent),
ContentBlockStart(ContentBlockStartEvent),
ContentBlockDelta(ContentBlockDeltaEvent),
ContentBlockStop(ContentBlockStopEvent),
MessageStop(MessageStopEvent),
}
#[cfg(test)]
mod tests {
use runtime::format_usd;
use super::{MessageResponse, Usage};
#[test]
fn usage_total_tokens_includes_cache_tokens() {
let usage = Usage {
input_tokens: 10,
cache_creation_input_tokens: 2,
cache_read_input_tokens: 3,
output_tokens: 4,
};
assert_eq!(usage.total_tokens(), 19);
assert_eq!(usage.token_usage().total_tokens(), 19);
}
#[test]
fn message_response_estimates_cost_from_model_usage() {
let response = MessageResponse {
id: "msg_cost".to_string(),
kind: "message".to_string(),
role: "assistant".to_string(),
content: Vec::new(),
model: "claude-sonnet-4-20250514".to_string(),
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1_000_000,
cache_creation_input_tokens: 100_000,
cache_read_input_tokens: 200_000,
output_tokens: 500_000,
},
request_id: None,
};
let cost = response.usage.estimated_cost_usd(&response.model);
assert_eq!(format_usd(cost.total_cost_usd()), "$54.6750");
assert_eq!(response.total_tokens(), 1_800_000);
}
#[test]
fn apply_cache_reference_injects_tool_use_id_on_cached_prefix_tool_results() {
let mut body = serde_json::json!({
"model": "claude-sonnet-4-20250514",
"max_tokens": 100,
"system": "Be helpful.",
"messages": [
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "tu_abc", "content": "result"}
]},
{"role": "assistant", "content": [
{"type": "tool_use", "id": "tu_abc", "name": "test", "input": {}}
]},
{"role": "user", "content": [
{"type": "text", "text": "continue", "cache_control": {"type": "ephemeral"}}
]}
]
});
super::MessageRequest::apply_cache_reference(&mut body);
let messages = body["messages"].as_array().unwrap();
let blocks = messages[0]["content"].as_array().unwrap();
assert_eq!(blocks[0]["cache_reference"], "tu_abc");
for i in 1..messages.len() {
if let Some(content) = messages[i]["content"].as_array() {
for block in content {
assert!(
block.get("cache_reference").is_none(),
"message {i} should not have cache_reference"
);
}
}
}
}
#[test]
fn apply_cache_reference_skips_when_no_cache_control_marker() {
let mut body = serde_json::json!({
"messages": [
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "tu_xyz", "content": "ok"}
]}
]
});
super::MessageRequest::apply_cache_reference(&mut body);
let blocks = body["messages"][0]["content"].as_array().unwrap();
assert!(blocks[0].get("cache_reference").is_none());
}
#[test]
fn apply_cache_reference_skips_non_user_messages_in_prefix() {
let mut body = serde_json::json!({
"messages": [
{"role": "assistant", "content": [
{"type": "tool_result", "tool_use_id": "tu_xyz", "content": "ok"}
]},
{"role": "user", "content": [
{"type": "text", "text": "go", "cache_control": {"type": "ephemeral"}}
]}
]
});
super::MessageRequest::apply_cache_reference(&mut body);
// assistant tool_result should NOT get cache_reference
let blocks = body["messages"][0]["content"].as_array().unwrap();
assert!(blocks[0].get("cache_reference").is_none());
}
#[test]
fn apply_messages_cache_control_adds_to_last_text_block() {
let mut body = serde_json::json!({
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "hello"}
]},
{"role": "assistant", "content": [
{"type": "text", "text": "hi"}
]},
{"role": "user", "content": [
{"type": "text", "text": "continue"}
]}
]
});
super::MessageRequest::apply_messages_cache_control(&mut body);
let last = body["messages"][2]["content"].as_array().unwrap();
assert_eq!(
last[0]["cache_control"],
serde_json::json!({"type": "ephemeral"})
);
}
#[test]
fn apply_messages_cache_control_skips_tool_result_last_block() {
let mut body = serde_json::json!({
"messages": [
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "tu_1", "content": "result"}
]}
]
});
super::MessageRequest::apply_messages_cache_control(&mut body);
let blocks = body["messages"][0]["content"].as_array().unwrap();
assert!(blocks[0].get("cache_control").is_none());
}
#[test]
fn apply_messages_cache_control_skips_existing_cache_control() {
let mut body = serde_json::json!({
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "done", "cache_control": {"type": "ephemeral"}}
]}
]
});
super::MessageRequest::apply_messages_cache_control(&mut body);
let blocks = body["messages"][0]["content"].as_array().unwrap();
assert_eq!(
blocks[0]["cache_control"],
serde_json::json!({"type": "ephemeral"})
);
}
#[test]
fn apply_messages_cache_control_empty_messages_does_not_panic() {
let mut body = serde_json::json!({"messages": []});
super::MessageRequest::apply_messages_cache_control(&mut body);
// no panic = pass
}
#[test]
fn apply_messages_cache_control_no_messages_key_does_not_panic() {
let mut body = serde_json::json!({"model": "test"});
super::MessageRequest::apply_messages_cache_control(&mut body);
// no panic = pass
}
#[test]
fn apply_messages_cache_control_content_not_array_does_not_panic() {
let mut body = serde_json::json!({
"messages": [{"role": "user", "content": "string content"}]
});
super::MessageRequest::apply_messages_cache_control(&mut body);
// no panic = pass
}
#[test]
fn redacted_thinking_input_block_serializes_with_data() {
use super::InputContentBlock;
let block = InputContentBlock::RedactedThinking {
data: serde_json::json!("ciphertext_blob_abc"),
};
let value = serde_json::to_value(&block).expect("block should serialize");
assert_eq!(value["type"], "redacted_thinking");
assert_eq!(value["data"], "ciphertext_blob_abc");
}
}

View File

@ -45,7 +45,6 @@ async fn send_message_posts_json_and_parses_response() {
.await;
let client = ApiClient::new("test-key")
.with_auth_token(Some("proxy-token".to_string()))
.with_base_url(server.base_url());
let response = client
.send_message(&sample_request(false))
@ -72,21 +71,19 @@ async fn send_message_posts_json_and_parses_response() {
request.headers.get("x-api-key").map(String::as_str),
Some("test-key")
);
assert_eq!(
request.headers.get("authorization").map(String::as_str),
Some("Bearer proxy-token")
);
assert!(request.headers.get("authorization").is_none());
assert_eq!(
request.headers.get("anthropic-version").map(String::as_str),
Some("2023-06-01")
);
let expected_user_agent = format!("claude-code/{}", env!("CARGO_PKG_VERSION"));
assert_eq!(
request.headers.get("user-agent").map(String::as_str),
Some("claude-code/0.1.3")
Some(expected_user_agent.as_str())
);
assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str),
Some("claude-code-20250219,prompt-caching-scope-2026-01-05")
Some("claude-code-20250219,prompt-caching-scope-2026-01-05,effort-2025-11-24")
);
let body: serde_json::Value =
serde_json::from_str(&request.body).expect("request body should be json");
@ -103,58 +100,6 @@ async fn send_message_posts_json_and_parses_response() {
);
}
#[tokio::test]
async fn send_message_strips_anthropic_routing_prefix_on_wire() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![
http_response("200 OK", "application/json", "{\"input_tokens\":1}"),
http_response(
"200 OK",
"application/json",
concat!(
"{",
"\"id\":\"msg_prefixed\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],",
"\"model\":\"claude-opus-4-6\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":1,\"output_tokens\":1}",
"}"
),
),
],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
client
.send_message(&MessageRequest {
model: "anthropic/claude-opus-4-6".to_string(),
..sample_request(false)
})
.await
.expect("request should succeed");
let captured = state.lock().await;
assert_eq!(
captured.len(),
2,
"count_tokens and messages requests should be captured"
);
let count_tokens_body: serde_json::Value =
serde_json::from_str(&captured[0].body).expect("count_tokens body should be json");
let messages_body: serde_json::Value =
serde_json::from_str(&captured[1].body).expect("request body should be json");
assert_eq!(captured[0].path, "/v1/messages/count_tokens");
assert_eq!(captured[1].path, "/v1/messages");
assert_eq!(count_tokens_body["model"], json!("claude-opus-4-6"));
assert_eq!(messages_body["model"], json!("claude-opus-4-6"));
}
#[tokio::test]
async fn send_message_blocks_oversized_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
@ -169,13 +114,13 @@ async fn send_message_blocks_oversized_requests_before_the_http_call() {
.send_message(&MessageRequest {
model: "claude-sonnet-4-6".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
messages: Arc::new(vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(600_000),
}],
}],
system: Some("Keep the answer short.".to_string()),
}]),
system: Some(Arc::from("Keep the answer short.")),
tools: None,
tool_choice: None,
stream: false,
@ -235,7 +180,7 @@ async fn send_message_applies_request_profile_and_records_telemetry() {
let request = captured.first().expect("server should capture request");
assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str),
Some("claude-code-20250219,prompt-caching-scope-2026-01-05,tools-2026-04-01")
Some("claude-code-20250219,prompt-caching-scope-2026-01-05,effort-2025-11-24,tools-2026-04-01")
);
assert_eq!(
request.headers.get("user-agent").map(String::as_str),
@ -404,7 +349,6 @@ async fn stream_message_parses_sse_events_with_tool_use() {
.await;
let client = ApiClient::new("test-key")
.with_auth_token(Some("proxy-token".to_string()))
.with_base_url(server.base_url())
.with_prompt_cache(PromptCache::new("stream-session"));
let mut stream = client
@ -787,9 +731,9 @@ async fn live_stream_smoke_test() {
model: std::env::var("ANTHROPIC_MODEL")
.unwrap_or_else(|_| "claude-3-7-sonnet-latest".to_string()),
max_tokens: 32,
messages: vec![InputMessage::user_text(
messages: Arc::new(vec![InputMessage::user_text(
"Reply with exactly: hello from rust",
)],
)]),
system: None,
tools: None,
tool_choice: None,
@ -948,7 +892,7 @@ fn sample_request(stream: bool) -> MessageRequest {
MessageRequest {
model: "claude-3-7-sonnet-latest".to_string(),
max_tokens: 64,
messages: vec![InputMessage {
messages: Arc::new(vec![InputMessage {
role: "user".to_string(),
content: vec![
InputContentBlock::Text {
@ -960,10 +904,11 @@ fn sample_request(stream: bool) -> MessageRequest {
value: json!({"forecast": "sunny"}),
}],
is_error: false,
cache_reference: None,
},
],
}],
system: Some("Use tools when needed".to_string()),
}]),
system: Some(Arc::from("Use tools when needed")),
tools: Some(vec![ToolDefinition {
name: "get_weather".to_string(),
description: Some("Fetches the weather".to_string()),
@ -978,3 +923,83 @@ fn sample_request(stream: bool) -> MessageRequest {
..Default::default()
}
}
#[tokio::test]
async fn stream_message_returns_stream_timeout_when_provider_stalls() {
let _guard = env_lock();
let temp_root = std::env::temp_dir().join(format!(
"api-stream-stall-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
let stall = tokio::spawn(async move {
// Serve requests until the listener closes. The count_tokens preflight
// (when it runs) must get a 400 JSON so the best-effort heuristic
// falls back; the stream request gets SSE headers and then stalls.
loop {
let (mut socket, _) = match listener.accept().await {
Ok(accepted) => accepted,
Err(_) => break,
};
let mut buf = [0u8; 4096];
let _ = socket.read(&mut buf).await;
let request_line = String::from_utf8_lossy(&buf[..]);
if request_line.contains("/count_tokens") {
let body = "{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"mock\"}}";
let head = format!(
"HTTP/1.1 400 Bad Request\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n",
body.len()
);
socket.write_all(head.as_bytes()).await.expect("write preflight");
socket.write_all(body.as_bytes()).await.expect("write preflight body");
socket.flush().await.expect("flush preflight");
} else {
// The stream request: send SSE headers, then hold the
// connection open WITHOUT sending any bytes → idle stall.
let head = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\r\n";
socket.write_all(head.as_bytes()).await.expect("write stream head");
socket.flush().await.expect("flush stream head");
tokio::time::sleep(Duration::from_secs(30)).await;
let _ = socket.shutdown().await;
break;
}
}
});
let client = ApiClient::new("test-key")
.with_base_url(format!("http://{addr}"))
.with_stream_idle_timeout(Duration::from_millis(50));
let mut stream = client
.stream_message(&sample_request(false))
.await
.expect("stream should start");
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
let mut saw_stream_timeout = false;
loop {
match tokio::time::timeout_at(deadline, stream.next_event()).await {
Ok(Ok(Some(_event))) => continue,
Ok(Ok(None)) => break,
Ok(Err(ApiError::StreamTimeout)) => {
saw_stream_timeout = true;
break;
}
Ok(Err(other)) => panic!("unexpected error: {other}"),
Err(_elapsed) => panic!("test deadline exceeded"),
}
}
assert!(
saw_stream_timeout,
"a provider that opens the connection but sends no bytes must surface StreamTimeout"
);
stall.abort();
std::fs::remove_dir_all(temp_root).ok();
}

View File

@ -0,0 +1,238 @@
use std::collections::HashMap;
use std::sync::Arc;
use api::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OpenAiCompatClient,
OpenAiCompatConfig, OutputContentBlock, StreamEvent, ToolChoice, ToolDefinition,
};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn openai_streaming_requests_opt_into_usage_chunks() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"data: {\"id\":\"chatcmpl_openai_stream\",\"model\":\"gpt-5\",\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_openai_stream\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
"data: {\"id\":\"chatcmpl_openai_stream\",\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":4}}\n\n",
"data: [DONE]\n\n"
);
let server = spawn_server(
state.clone(),
vec![http_response_with_headers(
"200 OK",
"text/event-stream",
sse,
&[("x-request-id", "req_openai_stream")],
)],
)
.await;
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
.with_base_url(server.base_url());
let mut stream = client
.stream_message(&sample_request(false))
.await
.expect("stream should start");
assert_eq!(stream.request_id(), Some("req_openai_stream"));
let mut events = Vec::new();
while let Some(event) = stream.next_event().await.expect("event should parse") {
events.push(event);
}
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
assert!(matches!(
events[1],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
content_block: OutputContentBlock::Text { .. },
..
})
));
assert!(matches!(
events[2],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
delta: ContentBlockDelta::TextDelta { .. },
..
})
));
assert!(matches!(
events[3],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
));
assert!(matches!(
events[4],
StreamEvent::MessageDelta(MessageDeltaEvent { .. })
));
assert!(matches!(events[5], StreamEvent::MessageStop(_)));
match &events[4] {
StreamEvent::MessageDelta(MessageDeltaEvent { usage, .. }) => {
assert_eq!(usage.input_tokens, 9);
assert_eq!(usage.output_tokens, 4);
}
other => panic!("expected message delta, got {other:?}"),
}
let captured = state.lock().await;
let request = captured.first().expect("captured request");
assert_eq!(request.path, "/chat/completions");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["stream"], json!(true));
assert_eq!(body["stream_options"], json!({"include_usage": true}));
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CapturedRequest {
path: String,
headers: HashMap<String, String>,
body: String,
}
struct TestServer {
base_url: String,
join_handle: tokio::task::JoinHandle<()>,
}
impl TestServer {
fn base_url(&self) -> String {
self.base_url.clone()
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.join_handle.abort();
}
}
async fn spawn_server(
state: Arc<Mutex<Vec<CapturedRequest>>>,
responses: Vec<String>,
) -> TestServer {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let address = listener.local_addr().expect("listener addr");
let join_handle = tokio::spawn(async move {
for response in responses {
let (mut socket, _) = listener.accept().await.expect("accept");
let mut buffer = Vec::new();
let mut header_end = None;
loop {
let mut chunk = [0_u8; 1024];
let read = socket.read(&mut chunk).await.expect("read request");
if read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read]);
if let Some(position) = find_header_end(&buffer) {
header_end = Some(position);
break;
}
}
let header_end = header_end.expect("headers should exist");
let (header_bytes, remaining) = buffer.split_at(header_end);
let header_text = String::from_utf8(header_bytes.to_vec()).expect("utf8 headers");
let mut lines = header_text.split("\r\n");
let request_line = lines.next().expect("request line");
let path = request_line
.split_whitespace()
.nth(1)
.expect("path")
.to_string();
let mut headers = HashMap::new();
let mut content_length = 0_usize;
for line in lines {
if line.is_empty() {
continue;
}
let (name, value) = line.split_once(':').expect("header");
let value = value.trim().to_string();
if name.eq_ignore_ascii_case("content-length") {
content_length = value.parse().expect("content length");
}
headers.insert(name.to_ascii_lowercase(), value);
}
let mut body = remaining[4..].to_vec();
while body.len() < content_length {
let mut chunk = vec![0_u8; content_length - body.len()];
let read = socket.read(&mut chunk).await.expect("read body");
if read == 0 {
break;
}
body.extend_from_slice(&chunk[..read]);
}
state.lock().await.push(CapturedRequest {
path,
headers,
body: String::from_utf8(body).expect("utf8 body"),
});
socket
.write_all(response.as_bytes())
.await
.expect("write response");
}
});
TestServer {
base_url: format!("http://{address}"),
join_handle,
}
}
fn find_header_end(bytes: &[u8]) -> Option<usize> {
bytes.windows(4).position(|window| window == b"\r\n\r\n")
}
fn http_response_with_headers(
status: &str,
content_type: &str,
body: &str,
headers: &[(&str, &str)],
) -> String {
let mut extra_headers = String::new();
for (name, value) in headers {
use std::fmt::Write as _;
write!(&mut extra_headers, "{name}: {value}\r\n").expect("header write");
}
format!(
"HTTP/1.1 {status}\r\ncontent-type: {content_type}\r\n{extra_headers}content-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
}
fn sample_request(stream: bool) -> MessageRequest {
MessageRequest {
model: "grok-3".to_string(),
max_tokens: 64,
messages: Arc::new(vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "Say hello".to_string(),
}],
}]),
system: Some(Arc::from("Use tools when needed")),
tools: Some(vec![ToolDefinition {
name: "weather".to_string(),
description: Some("Fetches weather".to_string()),
input_schema: json!({
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}),
}]),
tool_choice: Some(ToolChoice::Auto),
stream,
..Default::default()
}
}

View File

@ -1,42 +1,12 @@
use std::ffi::OsString;
use std::sync::{Mutex, OnceLock};
use api::{read_xai_base_url, ApiError, AuthSource, ProviderClient, ProviderKind};
#[test]
fn provider_client_routes_grok_aliases_through_xai() {
let _lock = env_lock();
let _xai_api_key = EnvVarGuard::set("XAI_API_KEY", Some("xai-test-key"));
let client = ProviderClient::from_model("grok-mini").expect("grok alias should resolve");
assert_eq!(client.provider_kind(), ProviderKind::Xai);
}
#[test]
fn provider_client_reports_missing_xai_credentials_for_grok_models() {
let _lock = env_lock();
let _xai_api_key = EnvVarGuard::set("XAI_API_KEY", None);
let error = ProviderClient::from_model("grok-3")
.expect_err("grok requests without XAI_API_KEY should fail fast");
match error {
ApiError::MissingCredentials {
provider, env_vars, ..
} => {
assert_eq!(provider, "xAI");
assert_eq!(env_vars, &["XAI_API_KEY"]);
}
other => panic!("expected missing xAI credentials, got {other:?}"),
}
}
use api::{AuthSource, ProviderClient, ProviderKind};
#[test]
fn provider_client_uses_explicit_anthropic_auth_without_env_lookup() {
let _lock = env_lock();
let _anthropic_api_key = EnvVarGuard::set("ANTHROPIC_API_KEY", None);
let _anthropic_auth_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
let client = ProviderClient::from_model_with_anthropic_auth(
"claude-sonnet-4-6",
@ -47,14 +17,6 @@ fn provider_client_uses_explicit_anthropic_auth_without_env_lookup() {
assert_eq!(client.provider_kind(), ProviderKind::Anthropic);
}
#[test]
fn read_xai_base_url_prefers_env_override() {
let _lock = env_lock();
let _xai_base_url = EnvVarGuard::set("XAI_BASE_URL", Some("https://example.xai.test/v1"));
assert_eq!(read_xai_base_url(), "https://example.xai.test/v1");
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))

View File

@ -35,6 +35,7 @@ impl Drop for EnvVarGuard {
}
}
#[cfg(not(target_os = "windows"))]
#[test]
fn proxy_config_from_env_reads_uppercase_proxy_vars() {
// given
@ -123,6 +124,42 @@ fn proxy_config_from_env_treats_empty_values_as_unset() {
assert!(config.is_empty());
}
/// On Windows, environment variable names are case-insensitive, so `HTTP_PROXY`
/// and `http_proxy` are the same slot. Verify the single value is read correctly.
#[cfg(target_os = "windows")]
#[test]
fn proxy_config_from_env_reads_proxy_vars_windows_upper() {
let _lock = env_lock();
let _http = EnvVarGuard::set("HTTP_PROXY", Some("http://proxy.corp:3128"));
let _https = EnvVarGuard::set("HTTPS_PROXY", Some("http://secure.corp:3129"));
let _no = EnvVarGuard::set("NO_PROXY", Some("localhost,127.0.0.1"));
let config = ProxyConfig::from_env();
assert_eq!(config.http_proxy.as_deref(), Some("http://proxy.corp:3128"));
assert_eq!(config.https_proxy.as_deref(), Some("http://secure.corp:3129"));
assert_eq!(config.no_proxy.as_deref(), Some("localhost,127.0.0.1"));
assert!(!config.is_empty());
}
/// On Windows, setting the lowercase variant overwrites the uppercase due to
/// case-insensitive env var names. Verify the last-written value is read.
#[cfg(target_os = "windows")]
#[test]
fn proxy_config_from_env_reads_proxy_vars_windows_lower() {
let _lock = env_lock();
let _http = EnvVarGuard::set("http_proxy", Some("http://lower.corp:3128"));
let _https = EnvVarGuard::set("https_proxy", Some("http://lower-secure.corp:3129"));
let _no = EnvVarGuard::set("no_proxy", Some(".internal"));
let config = ProxyConfig::from_env();
assert_eq!(config.http_proxy.as_deref(), Some("http://lower.corp:3128"));
assert_eq!(config.https_proxy.as_deref(), Some("http://lower-secure.corp:3129"));
assert_eq!(config.no_proxy.as_deref(), Some(".internal"));
assert!(!config.is_empty());
}
#[test]
fn build_client_with_env_proxy_config_succeeds() {
// given
@ -154,6 +191,7 @@ fn build_client_with_proxy_url_config_succeeds() {
assert!(result.is_ok());
}
#[cfg(not(target_os = "windows"))]
#[test]
fn proxy_config_from_env_prefers_uppercase_over_lowercase() {
// given

Some files were not shown because too many files have changed in this diff Show More