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:
parent
244a38c290
commit
abe386490b
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -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}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -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}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"messages":[],"version":1}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"permissions": {
|
||||
"defaultMode": "dontAsk"
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"created_at_ms":1775777421902,"session_id":"session-1775777421902-1","type":"session_meta","updated_at_ms":1775777421902,"version":1}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
target/
|
||||
.omx/
|
||||
.clawd-agents/
|
||||
# Claw Code local artifacts
|
||||
.claw/settings.local.json
|
||||
.claw/sessions/
|
||||
.clawhip/
|
||||
|
|
@ -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.1–1.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.2–4.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 0–3 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`*
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version = "12"
|
||||
|
||||
[overrides]
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`.
|
||||
148
rust/PARITY.md
148
rust/PARITY.md
|
|
@ -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
|
||||
231
rust/README.md
231
rust/README.md
|
|
@ -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.
|
||||
|
|
@ -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.1–1.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.2–4.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 0–3 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`*
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
|
@ -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.0–2.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.0–1.0, default: 0.10)
|
||||
# CLAW_COMPACT_ANTITHRASH_RATIO=0.10
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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 [...]
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
||||
[](https://github.com/username/repo/actions)
|
||||
[](https://www.npmjs.com/package/package-name)
|
||||
[](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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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`
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
/target/
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>>,
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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"] }
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -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(), ¤t);
|
||||
|
||||
|
|
@ -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,
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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().
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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)]
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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(()))
|
||||
|
|
@ -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
Loading…
Reference in New Issue