refactor: major codebase restructure with new crate layout and removed legacy code
This commit is contained in:
parent
b71afddae1
commit
ddae3c6474
|
|
@ -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 +1 @@
|
|||
target/
|
||||
.omx/
|
||||
.clawd-agents/
|
||||
# Claw Code local artifacts
|
||||
.claw/settings.local.json
|
||||
.claw/sessions/
|
||||
.clawhip/
|
||||
/target/
|
||||
|
|
|
|||
|
|
@ -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 +1,29 @@
|
|||
# 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.
|
||||
### 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
|
||||
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"
|
||||
|
|
@ -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
|
||||
348
rust/README.md
348
rust/README.md
|
|
@ -1,231 +1,189 @@
|
|||
# 🦞 Claw Code — Rust Implementation
|
||||
# Claw Code
|
||||
|
||||
A high-performance Rust rewrite of the Claw Code CLI agent harness. Built for speed, safety, and native tool execution.
|
||||
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.
|
||||
|
||||
For a task-oriented guide with copy/paste examples, see [`../USAGE.md`](../USAGE.md).
|
||||

|
||||
|
||||
## Quick Start
|
||||
## Project Origin
|
||||
|
||||
```bash
|
||||
# Inspect available commands
|
||||
cd rust/
|
||||
cargo run -p rusty-claude-cli -- --help
|
||||
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.
|
||||
|
||||
# Build the workspace
|
||||
cargo build --workspace
|
||||
### Crate-Level Changes vs Original
|
||||
|
||||
# Run the interactive REPL
|
||||
cargo run -p rusty-claude-cli -- --model claude-opus-4-7
|
||||
**Removed crates (3):**
|
||||
|
||||
# One-shot prompt
|
||||
cargo run -p rusty-claude-cli -- prompt "explain this codebase"
|
||||
| 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` |
|
||||
|
||||
# JSON output for automation
|
||||
cargo run -p rusty-claude-cli -- --output-format json prompt "summarize src/main.rs"
|
||||
```
|
||||
**Added crates (4):**
|
||||
|
||||
## Configuration
|
||||
| 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) |
|
||||
|
||||
Set your API credentials:
|
||||
**Shared crate changes:**
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
# Or use a proxy
|
||||
export ANTHROPIC_BASE_URL="https://your-proxy.com"
|
||||
```
|
||||
| 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 |
|
||||
|
||||
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
|
||||
**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
|
||||
|
||||
| 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 | ✅ |
|
||||
- **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
|
||||
|
||||
## Model Aliases
|
||||
## Quick Start
|
||||
|
||||
Short names resolve to the latest model versions:
|
||||
### Prerequisites
|
||||
|
||||
| Alias | Resolves To |
|
||||
|-------|------------|
|
||||
| `opus` | `claude-opus-4-7` |
|
||||
| `sonnet` | `claude-sonnet-4-6` |
|
||||
| `haiku` | `claude-haiku-4-5-20251213` |
|
||||
- Rust 2021 edition
|
||||
- MSVC + Clang-CL 22.x (see `CompilePreSet.bat`)
|
||||
- NASM, Perl (optional, for OpenSSL)
|
||||
|
||||
## CLI Flags and Commands
|
||||
### Tool Dependencies
|
||||
|
||||
Representative current surface:
|
||||
- **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`).
|
||||
|
||||
```text
|
||||
claw [OPTIONS] [COMMAND]
|
||||
> 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`.
|
||||
|
||||
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
|
||||
### Build
|
||||
|
||||
Top-level commands:
|
||||
prompt <text>
|
||||
help
|
||||
version
|
||||
status
|
||||
sandbox
|
||||
acp [serve]
|
||||
dump-manifests
|
||||
bootstrap-plan
|
||||
agents
|
||||
mcp
|
||||
skills
|
||||
system-prompt
|
||||
init
|
||||
```bat
|
||||
CompilePreSet.bat && cargo build --release
|
||||
```
|
||||
|
||||
`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.
|
||||
### Run
|
||||
|
||||
The command surface is moving quickly. For the canonical live help text, run:
|
||||
|
||||
```bash
|
||||
cargo run -p rusty-claude-cli -- --help
|
||||
```bat
|
||||
start.bat
|
||||
```
|
||||
|
||||
## Slash Commands (REPL)
|
||||
Or with a local LLM via LM Studio:
|
||||
|
||||
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
|
||||
```bat
|
||||
run_local_openai.bat
|
||||
```
|
||||
|
||||
### Crate Responsibilities
|
||||
### Configure
|
||||
|
||||
- **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
|
||||
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/
|
||||
├── CLAUDE.md
|
||||
|
||||
## 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,4 @@
|
|||
ANTHROPIC_BASE_URL=http://127.0.0.1:1234
|
||||
ANTHROPIC_API_KEY=sk-your-key
|
||||
ANTHROPIC_MODEL=WhitchSupportImageReady
|
||||
#CLAW_WORKSPACE_POLICY=allow
|
||||
|
|
@ -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,4 @@
|
|||
ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
|
||||
ANTHROPIC_API_KEY=sk-yourkey
|
||||
ANTHROPIC_MODEL=deepseek-v4-flash
|
||||
CLAW_WORKSPACE_POLICY=allow
|
||||
|
|
@ -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,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,501 @@
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
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>,
|
||||
pub mode: Option<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 with_plugins(
|
||||
cwd: &Path,
|
||||
plugin_agent_paths: &BTreeMap<String, Vec<PathBuf>>,
|
||||
) -> 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);
|
||||
}
|
||||
let root_names: BTreeSet<String> = agents
|
||||
.iter()
|
||||
.filter(|a| a.shadowed_by.is_none())
|
||||
.map(|a| a.name.to_ascii_lowercase())
|
||||
.collect();
|
||||
let plugin_agents = load_plugin_agents(plugin_agent_paths);
|
||||
for mut agent in plugin_agents {
|
||||
if root_names.contains(&agent.name.to_ascii_lowercase()) {
|
||||
agent.shadowed_by = Some(DefinitionSource::ProjectClaw);
|
||||
}
|
||||
agents.push(agent);
|
||||
}
|
||||
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();
|
||||
|
||||
let home_boundaries: Vec<PathBuf> = std::env::var_os("HOME")
|
||||
.into_iter()
|
||||
.chain(std::env::var_os("USERPROFILE"))
|
||||
.filter_map(|p| std::fs::canonicalize(PathBuf::from(p)).ok())
|
||||
.collect();
|
||||
|
||||
for ancestor in cwd.ancestors() {
|
||||
if home_boundaries.iter().any(|b| {
|
||||
if let Ok(canon_ancestor) = std::fs::canonicalize(ancestor) {
|
||||
b == &canon_ancestor
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
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 = home_boundaries.first().cloned();
|
||||
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()),
|
||||
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()),
|
||||
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"),
|
||||
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 load_plugin_agents(
|
||||
plugin_agent_paths: &BTreeMap<String, Vec<PathBuf>>,
|
||||
) -> Vec<AgentSummary> {
|
||||
let mut agents = Vec::new();
|
||||
for (plugin_id, paths) in plugin_agent_paths {
|
||||
for path in paths {
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let contents = match read_file_lossy(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("[plugin agents] error reading {}: {e}", path.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
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(|| "unknown".to_string());
|
||||
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()),
|
||||
source: DefinitionSource::Plugin,
|
||||
shadowed_by: None,
|
||||
plugin: Some(plugin_id.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
//! 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,
|
||||
};
|
||||
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,436 @@
|
|||
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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TryAgain;
|
||||
|
||||
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);
|
||||
if result.is_ok() {
|
||||
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);
|
||||
Ok(result)
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => Err(TryAgain),
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
self.finished.store(true, Ordering::SeqCst);
|
||||
Ok(Err("agent disconnected".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.finished.load(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)),
|
||||
}
|
||||
}
|
||||
|
||||
#[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)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-utils")]
|
||||
pub fn join_with_timeout(self, timeout: Duration) -> Result<String, String> {
|
||||
let rx = match self.rx {
|
||||
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.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 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 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,
|
||||
});
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
struct AgentJobWithProgress {
|
||||
job: AgentJob,
|
||||
progress: SharedProgress,
|
||||
agent_id: String,
|
||||
}
|
||||
|
||||
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);
|
||||
let summary = runtime
|
||||
.run_turn(job.job.prompt.clone(), None)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(final_assistant_text(&summary))
|
||||
}
|
||||
|
||||
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) -> 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 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 trimmed.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Truly nothing to report — never return an empty tool result.
|
||||
"(agent returned no text)".to_string()
|
||||
}
|
||||
|
||||
#[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), "hello");
|
||||
}
|
||||
|
||||
#[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), "earlier result");
|
||||
}
|
||||
|
||||
#[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), "deep reasoning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_marker_when_no_blocks_at_all() {
|
||||
let summary = summary_with(vec![]);
|
||||
assert!(!final_assistant_text(&summary).is_empty());
|
||||
}
|
||||
|
||||
#[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), "real answer");
|
||||
}
|
||||
|
||||
#[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), "The real answer is 42");
|
||||
}
|
||||
|
||||
#[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), "actual result");
|
||||
}
|
||||
|
||||
#[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),
|
||||
"the answer is deep reasoning"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
use std::collections::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>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<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>,
|
||||
}
|
||||
|
||||
#[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>>,
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
|
@ -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,37 @@
|
|||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub fn unique_store_dir(label: &str) -> PathBuf {
|
||||
let pid = std::process::id();
|
||||
let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let dir = std::env::temp_dir()
|
||||
.join("claw-agents-tests")
|
||||
.join(format!("{label}-{pid}-{nanos}-{n}"));
|
||||
std::fs::create_dir_all(&dir).expect("create temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
pub fn make_manifest(dir: &std::path::Path, name: &str) -> agents::AgentOutput {
|
||||
let agent_id = format!("test-{name}-{}", TEST_COUNTER.fetch_add(1, Ordering::Relaxed));
|
||||
let manifest_file = dir.join(format!("{agent_id}.json"));
|
||||
agents::AgentOutput {
|
||||
agent_id,
|
||||
name: name.to_string(),
|
||||
description: format!("test manifest {name}"),
|
||||
subagent_type: Some("general-purpose".to_string()),
|
||||
model: Some("claude-opus-4-6".to_string()),
|
||||
mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn install_store_env(dir: &std::path::Path) {
|
||||
std::env::set_var("CLAW_AGENT_STORE", dir);
|
||||
std::env::remove_var("CLAWD_AGENT_STORE");
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
use crate::error::ApiError;
|
||||
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
|
||||
use crate::providers::anthropic::{self, AnthropicClient, AuthSource};
|
||||
use crate::providers::openai_compat::{self, OpenAiCompatClient, OpenAiCompatConfig};
|
||||
use crate::providers::openai_compat;
|
||||
use crate::providers::openai_compat::{OpenAiCompatClient, OpenAiCompatConfig};
|
||||
use crate::providers::{self, ProviderKind};
|
||||
use crate::types::{MessageRequest, MessageResponse, StreamEvent};
|
||||
|
||||
|
|
@ -9,7 +10,6 @@ use crate::types::{MessageRequest, MessageResponse, StreamEvent};
|
|||
#[derive(Debug, Clone)]
|
||||
pub enum ProviderClient {
|
||||
Anthropic(AnthropicClient),
|
||||
Xai(OpenAiCompatClient),
|
||||
OpenAi(OpenAiCompatClient),
|
||||
}
|
||||
|
||||
|
|
@ -28,30 +28,9 @@ impl ProviderClient {
|
|||
Some(auth) => AnthropicClient::from_auth(auth),
|
||||
None => AnthropicClient::from_env()?,
|
||||
})),
|
||||
ProviderKind::Xai => Ok(Self::Xai(OpenAiCompatClient::from_env(
|
||||
OpenAiCompatConfig::xai(),
|
||||
ProviderKind::OpenAi => Ok(Self::OpenAi(OpenAiCompatClient::from_env(
|
||||
OpenAiCompatConfig::openai(),
|
||||
)?)),
|
||||
ProviderKind::OpenAi => {
|
||||
// OLLAMA_HOST takes priority: local Ollama needs no API key
|
||||
// and ignores DashScope/OpenAI env-based dispatch.
|
||||
if std::env::var_os("OLLAMA_HOST").is_some() {
|
||||
Ok(Self::OpenAi(
|
||||
openai_compat::OpenAiCompatClient::from_ollama_env()
|
||||
.expect("from_ollama_env always returns Some"),
|
||||
))
|
||||
} else {
|
||||
// DashScope models (qwen-*) also return ProviderKind::OpenAi because they
|
||||
// speak the OpenAI wire format, but they need the DashScope config which
|
||||
// reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com.
|
||||
let config = match providers::metadata_for_model(&resolved_model) {
|
||||
Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => {
|
||||
OpenAiCompatConfig::dashscope()
|
||||
}
|
||||
_ => OpenAiCompatConfig::openai(),
|
||||
};
|
||||
Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +38,6 @@ impl ProviderClient {
|
|||
pub const fn provider_kind(&self) -> ProviderKind {
|
||||
match self {
|
||||
Self::Anthropic(_) => ProviderKind::Anthropic,
|
||||
Self::Xai(_) => ProviderKind::Xai,
|
||||
Self::OpenAi(_) => ProviderKind::OpenAi,
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +54,7 @@ impl ProviderClient {
|
|||
pub fn prompt_cache_stats(&self) -> Option<PromptCacheStats> {
|
||||
match self {
|
||||
Self::Anthropic(client) => client.prompt_cache_stats(),
|
||||
Self::Xai(_) | Self::OpenAi(_) => None,
|
||||
Self::OpenAi(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +62,16 @@ impl ProviderClient {
|
|||
pub fn take_last_prompt_cache_record(&self) -> Option<PromptCacheRecord> {
|
||||
match self {
|
||||
Self::Anthropic(client) => client.take_last_prompt_cache_record(),
|
||||
Self::Xai(_) | Self::OpenAi(_) => None,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +81,7 @@ impl ProviderClient {
|
|||
) -> Result<MessageResponse, ApiError> {
|
||||
match self {
|
||||
Self::Anthropic(client) => client.send_message(request).await,
|
||||
Self::Xai(client) | Self::OpenAi(client) => client.send_message(request).await,
|
||||
Self::OpenAi(client) => client.send_message(request).await,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,7 +94,7 @@ impl ProviderClient {
|
|||
.stream_message(request)
|
||||
.await
|
||||
.map(MessageStream::Anthropic),
|
||||
Self::Xai(client) | Self::OpenAi(client) => client
|
||||
Self::OpenAi(client) => client
|
||||
.stream_message(request)
|
||||
.await
|
||||
.map(MessageStream::OpenAiCompat),
|
||||
|
|
@ -146,120 +133,20 @@ pub fn read_base_url() -> String {
|
|||
anthropic::read_base_url()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn read_xai_base_url() -> String {
|
||||
openai_compat::read_base_url(OpenAiCompatConfig::xai())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::ProviderClient;
|
||||
use crate::providers::{detect_provider_kind, resolve_model_alias, 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.
|
||||
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)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_existing_and_grok_aliases() {
|
||||
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-7");
|
||||
assert_eq!(resolve_model_alias("grok"), "grok-3");
|
||||
assert_eq!(resolve_model_alias("grok-mini"), "grok-3-mini");
|
||||
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("grok-3"), ProviderKind::Xai);
|
||||
assert_eq!(
|
||||
detect_provider_kind("claude-sonnet-4-6"),
|
||||
ProviderKind::Anthropic
|
||||
);
|
||||
}
|
||||
|
||||
/// Snapshot-restore guard for a single environment variable. Mirrors
|
||||
/// the pattern used in `providers/mod.rs` tests: captures the original
|
||||
/// value on construction, applies the override, and restores on drop so
|
||||
/// tests leave the process env untouched even when they panic.
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
original: Option<std::ffi::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 dashscope_model_uses_dashscope_config_not_openai() {
|
||||
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
|
||||
// which reads OPENAI_API_KEY and points at api.openai.com, when it should
|
||||
// use OpenAiCompatConfig::dashscope() which reads DASHSCOPE_API_KEY and
|
||||
// points at dashscope.aliyuncs.com.
|
||||
let _lock = env_lock();
|
||||
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
|
||||
let _openai = EnvVarGuard::set("OPENAI_API_KEY", None);
|
||||
|
||||
let client = ProviderClient::from_model("qwen-plus");
|
||||
|
||||
// Must succeed (not fail with "missing OPENAI_API_KEY")
|
||||
assert!(
|
||||
client.is_ok(),
|
||||
"qwen-plus with DASHSCOPE_API_KEY set should build successfully, got: {:?}",
|
||||
client.err()
|
||||
);
|
||||
|
||||
// Verify it's the OpenAi variant pointed at the DashScope base URL.
|
||||
match client.unwrap() {
|
||||
ProviderClient::OpenAi(openai_client) => {
|
||||
assert!(
|
||||
openai_client.base_url().contains("dashscope.aliyuncs.com"),
|
||||
"qwen-plus should route to DashScope base URL (contains 'dashscope.aliyuncs.com'), got: {}",
|
||||
openai_client.base_url()
|
||||
);
|
||||
}
|
||||
other => panic!("Expected ProviderClient::OpenAi for qwen-plus, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_base_url_routes_authless_ollama_models() {
|
||||
let _lock = env_lock();
|
||||
let _base_url = EnvVarGuard::set("OPENAI_BASE_URL", Some("http://127.0.0.1:11434/v1"));
|
||||
let _openai_key = EnvVarGuard::set("OPENAI_API_KEY", None);
|
||||
let _anthropic_key = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key"));
|
||||
let _anthropic_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
|
||||
|
||||
let client = ProviderClient::from_model("qwen2.5-coder:7b")
|
||||
.expect("local model should route to OpenAI-compatible client without auth");
|
||||
match client {
|
||||
ProviderClient::OpenAi(openai_client) => {
|
||||
assert_eq!(openai_client.base_url(), "http://127.0.0.1:11434/v1")
|
||||
}
|
||||
other => panic!("Expected ProviderClient::OpenAi for local model, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
@ -61,9 +55,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 +123,23 @@ 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::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 { .. }
|
||||
|
|
@ -278,20 +271,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(())
|
||||
}
|
||||
|
|
@ -326,36 +316,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 +407,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 +543,6 @@ mod tests {
|
|||
body: String::new(),
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
assert!(error.is_generic_fatal_wrapper());
|
||||
|
|
@ -568,7 +566,6 @@ mod tests {
|
|||
body: String::new(),
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
@ -590,7 +587,6 @@ mod tests {
|
|||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
assert!(error.is_context_window_failure());
|
||||
|
|
@ -598,33 +594,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 +608,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 +618,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 +705,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,69 +1,9 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of the proxy-related environment variables that influence the
|
||||
/// outbound HTTP client. Captured up front so callers can inspect, log, and
|
||||
/// test the resolved configuration without re-reading the process environment.
|
||||
|
|
@ -121,7 +61,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 +71,7 @@ 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::new())
|
||||
}
|
||||
|
||||
/// Build a `reqwest::Client` from an explicit [`ProxyConfig`]. Used by tests
|
||||
|
|
@ -147,20 +81,7 @@ 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);
|
||||
let mut builder = reqwest::Client::builder().no_proxy();
|
||||
|
||||
let no_proxy = config
|
||||
.no_proxy
|
||||
|
|
@ -203,7 +124,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 +136,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 +177,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 +201,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 +219,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 +281,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 +296,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,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::incremental_body::IncrementalBody;
|
||||
|
||||
use runtime::format_usd;
|
||||
use runtime::{
|
||||
load_oauth_credentials, save_oauth_credentials, OAuthConfig, OAuthRefreshRequest,
|
||||
|
|
@ -17,10 +19,13 @@ use crate::http_client::build_http_client_or_default;
|
|||
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
|
||||
|
||||
use super::{
|
||||
anthropic_missing_credentials, model_token_limit, resolve_model_alias, Provider, ProviderFuture,
|
||||
anthropic_missing_credentials, is_local_inference, model_token_limit, resolve_model_alias,
|
||||
Provider, ProviderFuture,
|
||||
};
|
||||
use crate::sse::SseParser;
|
||||
use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage};
|
||||
use crate::types::{
|
||||
InputContentBlock, MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage,
|
||||
};
|
||||
|
||||
pub const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
|
||||
const REQUEST_ID_HEADER: &str = "request-id";
|
||||
|
|
@ -33,64 +38,38 @@ const DEFAULT_MAX_RETRIES: u32 = 8;
|
|||
pub enum AuthSource {
|
||||
None,
|
||||
ApiKey(String),
|
||||
BearerToken(String),
|
||||
ApiKeyAndBearer {
|
||||
api_key: String,
|
||||
bearer_token: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl AuthSource {
|
||||
pub fn from_env() -> Result<Self, ApiError> {
|
||||
let api_key = read_env_non_empty("ANTHROPIC_API_KEY")?;
|
||||
let auth_token = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?;
|
||||
match (api_key, auth_token) {
|
||||
(Some(api_key), Some(bearer_token)) => Ok(Self::ApiKeyAndBearer {
|
||||
api_key,
|
||||
bearer_token,
|
||||
}),
|
||||
(Some(api_key), None) => Ok(Self::ApiKey(api_key)),
|
||||
(None, Some(bearer_token)) => Ok(Self::BearerToken(bearer_token)),
|
||||
(None, None) => Err(anthropic_missing_credentials()),
|
||||
match read_env_non_empty("ANTHROPIC_API_KEY")? {
|
||||
Some(api_key) => Ok(Self::ApiKey(api_key)),
|
||||
None => Err(anthropic_missing_credentials()),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn api_key(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::ApiKey(api_key) | Self::ApiKeyAndBearer { api_key, .. } => Some(api_key),
|
||||
Self::None | Self::BearerToken(_) => None,
|
||||
Self::ApiKey(api_key) => Some(api_key),
|
||||
Self::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn bearer_token(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::BearerToken(token)
|
||||
| Self::ApiKeyAndBearer {
|
||||
bearer_token: token,
|
||||
..
|
||||
} => Some(token),
|
||||
Self::None | Self::ApiKey(_) => None,
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn masked_authorization_header(&self) -> &'static str {
|
||||
if self.bearer_token().is_some() {
|
||||
"Bearer [REDACTED]"
|
||||
} else {
|
||||
"<absent>"
|
||||
}
|
||||
"<absent>"
|
||||
}
|
||||
|
||||
pub fn apply(&self, mut request_builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
if let Some(api_key) = self.api_key() {
|
||||
request_builder = request_builder.header("x-api-key", api_key);
|
||||
}
|
||||
if let Some(token) = self.bearer_token() {
|
||||
request_builder = request_builder.bearer_auth(token);
|
||||
}
|
||||
request_builder
|
||||
}
|
||||
}
|
||||
|
|
@ -105,8 +84,8 @@ pub struct OAuthTokenSet {
|
|||
}
|
||||
|
||||
impl From<OAuthTokenSet> for AuthSource {
|
||||
fn from(value: OAuthTokenSet) -> Self {
|
||||
Self::BearerToken(value.access_token)
|
||||
fn from(_value: OAuthTokenSet) -> Self {
|
||||
Self::None
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +101,7 @@ pub struct AnthropicClient {
|
|||
session_tracer: Option<SessionTracer>,
|
||||
prompt_cache: Option<PromptCache>,
|
||||
last_prompt_cache_record: Arc<Mutex<Option<PromptCacheRecord>>>,
|
||||
incremental_body: Arc<std::sync::Mutex<Option<IncrementalBody>>>,
|
||||
}
|
||||
|
||||
impl AnthropicClient {
|
||||
|
|
@ -138,6 +118,7 @@ impl AnthropicClient {
|
|||
session_tracer: None,
|
||||
prompt_cache: None,
|
||||
last_prompt_cache_record: Arc::new(Mutex::new(None)),
|
||||
incremental_body: Arc::new(std::sync::Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,6 +135,7 @@ impl AnthropicClient {
|
|||
session_tracer: None,
|
||||
prompt_cache: None,
|
||||
last_prompt_cache_record: Arc::new(Mutex::new(None)),
|
||||
incremental_body: Arc::new(std::sync::Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -168,27 +150,11 @@ impl AnthropicClient {
|
|||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_auth_token(mut self, auth_token: Option<String>) -> Self {
|
||||
match (
|
||||
self.auth.api_key().map(ToOwned::to_owned),
|
||||
auth_token.filter(|token| !token.is_empty()),
|
||||
) {
|
||||
(Some(api_key), Some(bearer_token)) => {
|
||||
self.auth = AuthSource::ApiKeyAndBearer {
|
||||
api_key,
|
||||
bearer_token,
|
||||
};
|
||||
}
|
||||
(Some(api_key), None) => {
|
||||
self.auth = AuthSource::ApiKey(api_key);
|
||||
}
|
||||
(None, Some(bearer_token)) => {
|
||||
self.auth = AuthSource::BearerToken(bearer_token);
|
||||
}
|
||||
(None, None) => {
|
||||
self.auth = AuthSource::None;
|
||||
}
|
||||
}
|
||||
pub fn with_auth_token(mut self, _auth_token: Option<String>) -> Self {
|
||||
self.auth = match self.auth.api_key().map(ToOwned::to_owned) {
|
||||
Some(api_key) => AuthSource::ApiKey(api_key),
|
||||
None => AuthSource::None,
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
|
|
@ -211,19 +177,6 @@ impl AnthropicClient {
|
|||
self
|
||||
}
|
||||
|
||||
/// Replace the internal HTTP client with one that respects the given
|
||||
/// timeout configuration. This controls connect and request-level
|
||||
/// timeouts for all outbound API calls.
|
||||
#[must_use]
|
||||
pub fn with_timeout(mut self, timeout: &crate::http_client::TimeoutConfig) -> Self {
|
||||
self.http = crate::http_client::build_http_client_with_opts(
|
||||
&crate::http_client::ProxyConfig::from_env(),
|
||||
timeout,
|
||||
)
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self {
|
||||
self.session_tracer = Some(session_tracer);
|
||||
|
|
@ -248,6 +201,12 @@ impl AnthropicClient {
|
|||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_incremental_body(mut self) -> Self {
|
||||
self.incremental_body = Arc::new(std::sync::Mutex::new(Some(IncrementalBody::new())));
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_prompt_cache(mut self, prompt_cache: PromptCache) -> Self {
|
||||
self.prompt_cache = Some(prompt_cache);
|
||||
|
|
@ -354,9 +313,28 @@ impl AnthropicClient {
|
|||
request: &MessageRequest,
|
||||
) -> Result<MessageStream, ApiError> {
|
||||
self.preflight_message_request(request).await?;
|
||||
let response = self
|
||||
.send_with_retry(&request.clone().with_streaming())
|
||||
.await?;
|
||||
// Structural update: only flip `stream` flag; all Arc fields
|
||||
// (messages, system, cached_message_values) are O(1) clones.
|
||||
let streaming_request = MessageRequest {
|
||||
stream: true,
|
||||
model: request.model.clone(),
|
||||
max_tokens: request.max_tokens,
|
||||
messages: Arc::clone(&request.messages),
|
||||
system: request.system.clone(),
|
||||
tools: request.tools.clone(),
|
||||
tool_choice: request.tool_choice.clone(),
|
||||
temperature: request.temperature,
|
||||
top_p: request.top_p,
|
||||
frequency_penalty: request.frequency_penalty,
|
||||
presence_penalty: request.presence_penalty,
|
||||
stop: request.stop.clone(),
|
||||
reasoning_effort: request.reasoning_effort.clone(),
|
||||
thinking: request.thinking.clone(),
|
||||
cached_message_values: Arc::clone(&request.cached_message_values),
|
||||
skip_tools: request.skip_tools,
|
||||
tools_in_system_prompt: request.tools_in_system_prompt,
|
||||
};
|
||||
let response = self.send_with_retry(&streaming_request).await?;
|
||||
Ok(MessageStream {
|
||||
request_id: request_id_from_headers(response.headers()),
|
||||
response,
|
||||
|
|
@ -467,13 +445,7 @@ impl AnthropicClient {
|
|||
break;
|
||||
}
|
||||
|
||||
let delay = if let Some(retry_after) = last_error.as_ref().and_then(|e| e.retry_after())
|
||||
{
|
||||
retry_after
|
||||
} else {
|
||||
self.jittered_backoff_for_attempt(attempts)?
|
||||
};
|
||||
tokio::time::sleep(delay).await;
|
||||
tokio::time::sleep(self.jittered_backoff_for_attempt(attempts)?).await;
|
||||
}
|
||||
|
||||
Err(ApiError::RetriesExhausted {
|
||||
|
|
@ -487,8 +459,56 @@ impl AnthropicClient {
|
|||
request: &MessageRequest,
|
||||
) -> Result<reqwest::Response, ApiError> {
|
||||
let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
|
||||
let request_body = render_standard_messages_body(&self.request_profile, request)?;
|
||||
let request_builder = self.build_request(&request_url).json(&request_body);
|
||||
|
||||
let request_builder = self
|
||||
.build_request(&request_url)
|
||||
.header("content-type", "application/json");
|
||||
|
||||
let has_tool_results = request.messages.iter().any(|m| {
|
||||
m.content.iter().any(|b| matches!(b, InputContentBlock::ToolResult { .. }))
|
||||
});
|
||||
|
||||
let request_builder = match self
|
||||
.incremental_body
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.as_mut()
|
||||
{
|
||||
Some(cache) => {
|
||||
cache.update(request);
|
||||
if has_tool_results {
|
||||
// When tool_results exist in the cached prefix, add a
|
||||
// message-level cache_control marker so the server can
|
||||
// reuse the cached prefix, then inject cache_reference
|
||||
// on individual tool_result blocks.
|
||||
// Falls back to the Value path since build_bytes() can't
|
||||
// inject fields into pre-serialised message bytes.
|
||||
let mut body = cache.build();
|
||||
MessageRequest::apply_messages_cache_control(&mut body);
|
||||
MessageRequest::apply_cache_reference(&mut body);
|
||||
if let Some(object) = body.as_object_mut() {
|
||||
for (key, value) in &self.request_profile.extra_body {
|
||||
object.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
request_builder.body(serde_json::to_vec(&body)?)
|
||||
} else {
|
||||
// Zero-alloc path: no tool_results, no cache_reference needed.
|
||||
request_builder.body(cache.build_bytes())
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let mut body = request.render_anthropic_body()?;
|
||||
if let Some(object) = body.as_object_mut() {
|
||||
for (key, value) in &self.request_profile.extra_body {
|
||||
object.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
strip_unsupported_beta_body_fields(&mut body);
|
||||
request_builder.body(serde_json::to_vec(&body)?)
|
||||
}
|
||||
};
|
||||
|
||||
request_builder.send().await.map_err(ApiError::from)
|
||||
}
|
||||
|
||||
|
|
@ -505,18 +525,27 @@ impl AnthropicClient {
|
|||
}
|
||||
|
||||
async fn preflight_message_request(&self, request: &MessageRequest) -> Result<(), ApiError> {
|
||||
// Always run the local byte-estimate guard first. This catches
|
||||
// oversized requests even if the remote count_tokens endpoint is
|
||||
// unreachable, misconfigured, or unimplemented (e.g., third-party
|
||||
// Anthropic-compatible gateways). If byte estimation already flags
|
||||
// the request as oversized, reject immediately without a network
|
||||
// round trip.
|
||||
super::preflight_message_request(request)?;
|
||||
// Run the local byte-estimate guard for non-local servers.
|
||||
// Local inference endpoints (Ollama, LM Studio, mock services etc.)
|
||||
// are exempt from the heuristic context-window check because they
|
||||
// typically have different limits or none at all, and the heuristic
|
||||
// can overcount tool definitions that appear both in `system` and
|
||||
// the tools array.
|
||||
if !is_local_inference() {
|
||||
super::preflight_message_request(request)?;
|
||||
}
|
||||
|
||||
let Some(limit) = model_token_limit(&request.model) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Local inference endpoints (loopback mocks, Ollama, LM Studio) do not
|
||||
// implement `/v1/messages/count_tokens`. Skip the second round-trip
|
||||
// entirely under local inference to avoid an unsupported call.
|
||||
if is_local_inference() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Best-effort refinement using the Anthropic count_tokens endpoint.
|
||||
// On any failure (network, parse, auth), fall back to the local
|
||||
// byte-estimate result which already passed above.
|
||||
|
|
@ -547,7 +576,8 @@ impl AnthropicClient {
|
|||
"{}/v1/messages/count_tokens",
|
||||
self.base_url.trim_end_matches('/')
|
||||
);
|
||||
let request_body = render_standard_messages_body(&self.request_profile, request)?;
|
||||
let mut request_body = self.request_profile.render_json_body(request)?;
|
||||
strip_unsupported_beta_body_fields(&mut request_body);
|
||||
let response = self
|
||||
.build_request(&request_url)
|
||||
.json(&request_body)
|
||||
|
|
@ -617,9 +647,8 @@ fn jitter_for_base(base: Duration) -> Duration {
|
|||
}
|
||||
let raw_nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |elapsed| {
|
||||
u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX)
|
||||
});
|
||||
.map(|elapsed| u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX))
|
||||
.unwrap_or(0);
|
||||
let tick = JITTER_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
// splitmix64 finalizer — mixes the low bits so large bases still see
|
||||
// jitter across their full range instead of being clamped to subsec nanos.
|
||||
|
|
@ -636,19 +665,10 @@ fn jitter_for_base(base: Duration) -> Duration {
|
|||
|
||||
impl AuthSource {
|
||||
pub fn from_env_or_saved() -> Result<Self, ApiError> {
|
||||
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
|
||||
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
||||
Some(bearer_token) => Ok(Self::ApiKeyAndBearer {
|
||||
api_key,
|
||||
bearer_token,
|
||||
}),
|
||||
None => Ok(Self::ApiKey(api_key)),
|
||||
};
|
||||
match read_env_non_empty("ANTHROPIC_API_KEY")? {
|
||||
Some(api_key) => Ok(Self::ApiKey(api_key)),
|
||||
None => Err(anthropic_missing_credentials()),
|
||||
}
|
||||
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
||||
return Ok(Self::BearerToken(bearer_token));
|
||||
}
|
||||
Err(anthropic_missing_credentials())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -667,28 +687,17 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok
|
|||
}
|
||||
|
||||
pub fn has_auth_from_env_or_saved() -> Result<bool, ApiError> {
|
||||
Ok(read_env_non_empty("ANTHROPIC_API_KEY")?.is_some()
|
||||
|| read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some())
|
||||
Ok(read_env_non_empty("ANTHROPIC_API_KEY")?.is_some())
|
||||
}
|
||||
|
||||
pub fn resolve_startup_auth_source<F>(load_oauth_config: F) -> Result<AuthSource, ApiError>
|
||||
pub fn resolve_startup_auth_source<F>(_load_oauth_config: F) -> Result<AuthSource, ApiError>
|
||||
where
|
||||
F: FnOnce() -> Result<Option<OAuthConfig>, ApiError>,
|
||||
{
|
||||
let _ = load_oauth_config;
|
||||
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
|
||||
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
||||
Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
|
||||
api_key,
|
||||
bearer_token,
|
||||
}),
|
||||
None => Ok(AuthSource::ApiKey(api_key)),
|
||||
};
|
||||
match read_env_non_empty("ANTHROPIC_API_KEY")? {
|
||||
Some(api_key) => Ok(AuthSource::ApiKey(api_key)),
|
||||
None => Err(anthropic_missing_credentials()),
|
||||
}
|
||||
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
||||
return Ok(AuthSource::BearerToken(bearer_token));
|
||||
}
|
||||
Err(anthropic_missing_credentials())
|
||||
}
|
||||
|
||||
fn resolve_saved_oauth_token_set(
|
||||
|
|
@ -767,21 +776,17 @@ fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
|
|||
fn read_api_key() -> Result<String, ApiError> {
|
||||
let auth = AuthSource::from_env_or_saved()?;
|
||||
auth.api_key()
|
||||
.or_else(|| auth.bearer_token())
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(anthropic_missing_credentials)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn read_auth_token() -> Option<String> {
|
||||
read_env_non_empty("ANTHROPIC_AUTH_TOKEN")
|
||||
.ok()
|
||||
.and_then(std::convert::identity)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn read_base_url() -> String {
|
||||
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
|
||||
std::env::var("ANTHROPIC_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.or_else(|| super::dotenv_value("ANTHROPIC_BASE_URL"))
|
||||
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
|
||||
}
|
||||
|
||||
fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option<String> {
|
||||
|
|
@ -862,17 +867,19 @@ impl MessageStream {
|
|||
StreamEvent::MessageDelta(MessageDeltaEvent { usage, .. }) => {
|
||||
self.latest_usage = Some(usage.clone());
|
||||
}
|
||||
StreamEvent::MessageStop(_) if !self.usage_recorded => {
|
||||
if let (Some(prompt_cache), Some(usage)) =
|
||||
(&self.prompt_cache, self.latest_usage.as_ref())
|
||||
{
|
||||
let record = prompt_cache.record_usage(&self.request, usage);
|
||||
*self
|
||||
.last_prompt_cache_record
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(record);
|
||||
StreamEvent::MessageStop(_) => {
|
||||
if !self.usage_recorded {
|
||||
if let (Some(prompt_cache), Some(usage)) =
|
||||
(&self.prompt_cache, self.latest_usage.as_ref())
|
||||
{
|
||||
let record = prompt_cache.record_usage(&self.request, usage);
|
||||
*self
|
||||
.last_prompt_cache_record
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(record);
|
||||
}
|
||||
self.usage_recorded = true;
|
||||
}
|
||||
self.usage_recorded = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -885,12 +892,23 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
|||
return Ok(response);
|
||||
}
|
||||
|
||||
let headers = response.headers().clone();
|
||||
let request_id = request_id_from_headers(&headers);
|
||||
let request_id = request_id_from_headers(response.headers());
|
||||
let body = response.text().await.unwrap_or_else(|_| String::new());
|
||||
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body).ok();
|
||||
let parsed_error = serde_json::from_str::<AnthropicErrorEnvelope>(&body)
|
||||
.ok()
|
||||
// Fallback: also try flat format {"code":...,"message":...,"type":...} used by
|
||||
// some API gateways/proxies when Anthropic returns a non-standard error body.
|
||||
.or_else(|| {
|
||||
serde_json::from_str::<FlatErrorBody>(&body)
|
||||
.ok()
|
||||
.map(|flat| AnthropicErrorEnvelope {
|
||||
error: AnthropicErrorBody {
|
||||
error_type: flat.type_,
|
||||
message: flat.message,
|
||||
},
|
||||
})
|
||||
});
|
||||
let retryable = is_retryable_status(status);
|
||||
let retry_after = parse_retry_after(&headers, status);
|
||||
|
||||
Err(ApiError::Api {
|
||||
status,
|
||||
|
|
@ -904,149 +922,19 @@ async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response
|
|||
body,
|
||||
retryable,
|
||||
suggested_action: None,
|
||||
retry_after,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_retry_after(
|
||||
headers: &reqwest::header::HeaderMap,
|
||||
status: reqwest::StatusCode,
|
||||
) -> Option<std::time::Duration> {
|
||||
if status != reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
return None;
|
||||
}
|
||||
headers
|
||||
.get("retry-after")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(std::time::Duration::from_secs)
|
||||
}
|
||||
|
||||
const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
|
||||
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
|
||||
}
|
||||
|
||||
/// Some providers return HTTP 400 with an unparseable body when a gateway
|
||||
/// or proxy flakes (e.g. "HTTP 400 from backend (no parseable body)").
|
||||
/// These are transient network blips, not actual bad requests, and should
|
||||
/// be retried. We detect them by checking the body for known gateway error
|
||||
/// phrases.
|
||||
fn is_retryable_400(status: reqwest::StatusCode, body: &str) -> bool {
|
||||
if status != reqwest::StatusCode::BAD_REQUEST {
|
||||
return false;
|
||||
}
|
||||
let lowered = body.to_ascii_lowercase();
|
||||
lowered.contains("no parseable body")
|
||||
|| lowered.contains("connection reset")
|
||||
|| lowered.contains("broken pipe")
|
||||
|| lowered.contains("empty reply from server")
|
||||
}
|
||||
|
||||
/// Anthropic API keys (`sk-ant-*`) are accepted over the `x-api-key` header
|
||||
/// and rejected with HTTP 401 "Invalid bearer token" when sent as a Bearer
|
||||
/// token via `ANTHROPIC_AUTH_TOKEN`. This happens often enough in the wild
|
||||
/// (users copy-paste an `sk-ant-...` key into `ANTHROPIC_AUTH_TOKEN` because
|
||||
/// the env var name sounds auth-related) that a bare 401 error is useless.
|
||||
/// When we detect this exact shape, append a hint to the error message that
|
||||
/// points the user at the one-line fix.
|
||||
const SK_ANT_BEARER_HINT: &str = "sk-ant-* keys go in ANTHROPIC_API_KEY (x-api-key header), not ANTHROPIC_AUTH_TOKEN (Bearer header). Move your key to ANTHROPIC_API_KEY.";
|
||||
|
||||
fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
|
||||
let ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
..
|
||||
} = error
|
||||
else {
|
||||
return error;
|
||||
};
|
||||
if status.as_u16() != 401 {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
}
|
||||
let Some(bearer_token) = auth.bearer_token() else {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
};
|
||||
if !bearer_token.starts_with("sk-ant-") {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
}
|
||||
// Only append the hint when the AuthSource is pure BearerToken. If both
|
||||
// api_key and bearer_token are present (`ApiKeyAndBearer`), the x-api-key
|
||||
// header is already being sent alongside the Bearer header and the 401
|
||||
// is coming from a different cause — adding the hint would be misleading.
|
||||
if auth.api_key().is_some() {
|
||||
return ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
};
|
||||
}
|
||||
let enriched_message = match message {
|
||||
Some(existing) => Some(format!("{existing} — hint: {SK_ANT_BEARER_HINT}")),
|
||||
None => Some(format!("hint: {SK_ANT_BEARER_HINT}")),
|
||||
};
|
||||
ApiError::Api {
|
||||
status,
|
||||
error_type,
|
||||
message: enriched_message,
|
||||
request_id,
|
||||
body,
|
||||
retryable,
|
||||
suggested_action,
|
||||
retry_after,
|
||||
}
|
||||
}
|
||||
|
||||
fn anthropic_wire_model(model: &str) -> &str {
|
||||
model.strip_prefix("anthropic/").unwrap_or(model)
|
||||
}
|
||||
|
||||
fn render_standard_messages_body(
|
||||
request_profile: &AnthropicRequestProfile,
|
||||
request: &MessageRequest,
|
||||
) -> Result<Value, serde_json::Error> {
|
||||
let mut wire_request = request.clone();
|
||||
wire_request.model = anthropic_wire_model(&request.model).to_string();
|
||||
let mut body = request_profile.render_json_body(&wire_request)?;
|
||||
strip_unsupported_beta_body_fields(&mut body);
|
||||
Ok(body)
|
||||
/// `enrich_bearer_auth_error` is retained for the call site in the auth flow,
|
||||
/// but the `ANTHROPIC_AUTH_TOKEN` Bearer path was removed: `AuthSource` can now
|
||||
/// only be `ApiKey`/`None`, so `bearer_token()` is always `None` and this is a
|
||||
/// straight pass-through.
|
||||
fn enrich_bearer_auth_error(error: ApiError, _auth: &AuthSource) -> ApiError {
|
||||
error
|
||||
}
|
||||
|
||||
/// Remove beta-only body fields that the standard `/v1/messages` and
|
||||
|
|
@ -1080,12 +968,22 @@ struct AnthropicErrorBody {
|
|||
message: String,
|
||||
}
|
||||
|
||||
/// Flat error format: `{"code":500,"message":"...","type":"server_error"}`.
|
||||
/// Used by some API gateways/proxies as a fallback when the standard
|
||||
/// `{"error":{"type":...,"message":...}}` envelope is not available.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FlatErrorBody {
|
||||
#[serde(rename = "type")]
|
||||
type_: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
|
|
@ -1156,19 +1054,26 @@ mod tests {
|
|||
#[test]
|
||||
fn read_api_key_requires_presence() {
|
||||
let _guard = env_lock();
|
||||
let config_home = temp_config_home();
|
||||
std::fs::create_dir_all(&config_home).expect("create config home");
|
||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||
let error = super::read_api_key().expect_err("missing key should error");
|
||||
assert!(matches!(
|
||||
error,
|
||||
crate::error::ApiError::MissingCredentials { .. }
|
||||
));
|
||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||
cleanup_temp_config_home(&config_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_api_key_requires_non_empty_value() {
|
||||
let _guard = env_lock();
|
||||
let config_home = temp_config_home();
|
||||
std::fs::create_dir_all(&config_home).expect("create config home");
|
||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "");
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
let error = super::read_api_key().expect_err("empty key should error");
|
||||
|
|
@ -1177,6 +1082,8 @@ mod tests {
|
|||
crate::error::ApiError::MissingCredentials { .. }
|
||||
));
|
||||
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||
cleanup_temp_config_home(&config_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1193,34 +1100,28 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn read_auth_token_reads_auth_token_env() {
|
||||
let _guard = env_lock();
|
||||
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
|
||||
assert_eq!(super::read_auth_token().as_deref(), Some("auth-token"));
|
||||
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_token_maps_to_bearer_auth_source() {
|
||||
fn oauth_token_maps_to_no_auth_source() {
|
||||
// OAuth is no longer a source of API auth; the token set maps to None.
|
||||
let auth = AuthSource::from(OAuthTokenSet {
|
||||
access_token: "access-token".to_string(),
|
||||
refresh_token: Some("refresh".to_string()),
|
||||
expires_at: Some(123),
|
||||
scopes: vec!["scope:a".to_string()],
|
||||
});
|
||||
assert_eq!(auth.bearer_token(), Some("access-token"));
|
||||
assert_eq!(auth.api_key(), None);
|
||||
assert_eq!(auth, AuthSource::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_source_from_env_combines_api_key_and_bearer_token() {
|
||||
fn auth_source_from_env_uses_only_api_key() {
|
||||
// given
|
||||
let _guard = env_lock();
|
||||
std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
|
||||
std::env::set_var("ANTHROPIC_API_KEY", "legacy-key");
|
||||
// when
|
||||
let auth = AuthSource::from_env().expect("env auth");
|
||||
// then: only the API key path exists now.
|
||||
assert_eq!(auth.api_key(), Some("legacy-key"));
|
||||
assert_eq!(auth.bearer_token(), Some("auth-token"));
|
||||
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
||||
assert_eq!(auth, AuthSource::ApiKey("legacy-key".to_string()));
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
}
|
||||
|
||||
|
|
@ -1357,7 +1258,7 @@ mod tests {
|
|||
let request = MessageRequest {
|
||||
model: "claude-opus-4-6".to_string(),
|
||||
max_tokens: 64,
|
||||
messages: vec![],
|
||||
messages: Arc::new(vec![]),
|
||||
system: None,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
|
|
@ -1485,27 +1386,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_source_applies_headers() {
|
||||
let auth = AuthSource::ApiKeyAndBearer {
|
||||
api_key: "test-key".to_string(),
|
||||
bearer_token: "proxy-token".to_string(),
|
||||
};
|
||||
let request = auth
|
||||
.apply(reqwest::Client::new().post("https://example.test"))
|
||||
.build()
|
||||
.expect("request build");
|
||||
let headers = request.headers();
|
||||
assert_eq!(
|
||||
headers.get("x-api-key").and_then(|v| v.to_str().ok()),
|
||||
Some("test-key")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("authorization").and_then(|v| v.to_str().ok()),
|
||||
Some("Bearer proxy-token")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_unsupported_beta_body_fields_removes_betas_array() {
|
||||
let mut body = serde_json::json!({
|
||||
|
|
@ -1594,7 +1474,7 @@ mod tests {
|
|||
let request = MessageRequest {
|
||||
model: "claude-sonnet-4-6".to_string(),
|
||||
max_tokens: 64,
|
||||
messages: vec![],
|
||||
messages: Arc::new(vec![]),
|
||||
system: None,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
|
|
@ -1623,192 +1503,28 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn standard_messages_body_strips_anthropic_routing_prefix() {
|
||||
let client = AnthropicClient::new("test-key");
|
||||
let request = MessageRequest {
|
||||
model: "anthropic/claude-opus-4-6".to_string(),
|
||||
max_tokens: 64,
|
||||
messages: vec![],
|
||||
system: None,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
stream: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let rendered = super::render_standard_messages_body(client.request_profile(), &request)
|
||||
.expect("body should render");
|
||||
|
||||
assert_eq!(rendered["model"], serde_json::json!("claude-opus-4-6"));
|
||||
assert!(rendered.get("betas").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_bearer_auth_error_appends_sk_ant_hint_on_401_with_pure_bearer_token() {
|
||||
// given
|
||||
let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string());
|
||||
let error = crate::error::ApiError::Api {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid bearer token".to_string()),
|
||||
request_id: Some("req_varleg_001".to_string()),
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
|
||||
// then
|
||||
let rendered = enriched.to_string();
|
||||
assert!(
|
||||
rendered.contains("Invalid bearer token"),
|
||||
"existing provider message should be preserved: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains(
|
||||
"sk-ant-* keys go in ANTHROPIC_API_KEY (x-api-key header), not ANTHROPIC_AUTH_TOKEN (Bearer header). Move your key to ANTHROPIC_API_KEY."
|
||||
),
|
||||
"rendered error should include the sk-ant-* hint: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("[trace req_varleg_001]"),
|
||||
"request id should still flow through the enriched error: {rendered}"
|
||||
);
|
||||
match enriched {
|
||||
crate::error::ApiError::Api { status, .. } => {
|
||||
assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
other => panic!("expected Api variant, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_bearer_auth_error_leaves_non_401_errors_unchanged() {
|
||||
// given
|
||||
let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string());
|
||||
let error = crate::error::ApiError::Api {
|
||||
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
error_type: Some("api_error".to_string()),
|
||||
message: Some("internal server error".to_string()),
|
||||
request_id: None,
|
||||
body: String::new(),
|
||||
retryable: true,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
|
||||
// then
|
||||
let rendered = enriched.to_string();
|
||||
assert!(
|
||||
!rendered.contains("sk-ant-*"),
|
||||
"non-401 errors must not be annotated with the bearer hint: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("internal server error"),
|
||||
"original message must be preserved verbatim: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_bearer_auth_error_ignores_401_when_bearer_token_is_not_sk_ant() {
|
||||
// given
|
||||
let auth = AuthSource::BearerToken("oauth-access-token-opaque".to_string());
|
||||
let error = crate::error::ApiError::Api {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid bearer token".to_string()),
|
||||
request_id: None,
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
|
||||
// then
|
||||
let rendered = enriched.to_string();
|
||||
assert!(
|
||||
!rendered.contains("sk-ant-*"),
|
||||
"oauth-style bearer tokens must not trigger the sk-ant-* hint: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_bearer_auth_error_skips_hint_when_api_key_header_is_also_present() {
|
||||
// given
|
||||
let auth = AuthSource::ApiKeyAndBearer {
|
||||
api_key: "sk-ant-api03-legitimate".to_string(),
|
||||
bearer_token: "sk-ant-api03-deadbeef".to_string(),
|
||||
};
|
||||
let error = crate::error::ApiError::Api {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid bearer token".to_string()),
|
||||
request_id: None,
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
|
||||
// then
|
||||
let rendered = enriched.to_string();
|
||||
assert!(
|
||||
!rendered.contains("sk-ant-*"),
|
||||
"hint should be suppressed when x-api-key header is already being sent: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_bearer_auth_error_ignores_401_when_auth_source_has_no_bearer() {
|
||||
// given
|
||||
fn enrich_bearer_auth_error_is_noop_without_bearer_token() {
|
||||
// given: with the removed ANTHROPIC_AUTH_TOKEN Bearer path, AuthSource
|
||||
// can only be ApiKey/None, so enrich_bearer_auth_error is a pass-through.
|
||||
let auth = AuthSource::ApiKey("sk-ant-api03-legitimate".to_string());
|
||||
let error = crate::error::ApiError::Api {
|
||||
status: reqwest::StatusCode::UNAUTHORIZED,
|
||||
error_type: Some("authentication_error".to_string()),
|
||||
message: Some("Invalid x-api-key".to_string()),
|
||||
request_id: None,
|
||||
request_id: Some("req_varleg_001".to_string()),
|
||||
body: String::new(),
|
||||
retryable: false,
|
||||
suggested_action: None,
|
||||
retry_after: None,
|
||||
};
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
|
||||
// then
|
||||
let rendered = enriched.to_string();
|
||||
assert!(
|
||||
!rendered.contains("sk-ant-*"),
|
||||
"bearer hint must not apply when AuthSource is ApiKey-only: {rendered}"
|
||||
!enriched.to_string().contains("sk-ant-*"),
|
||||
"bearer hint must never apply now that the Bearer path is removed: {}",
|
||||
enriched
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrich_bearer_auth_error_passes_non_api_errors_through_unchanged() {
|
||||
// given
|
||||
let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string());
|
||||
let error = crate::error::ApiError::InvalidSseFrame("unterminated event");
|
||||
|
||||
// when
|
||||
let enriched = super::enrich_bearer_auth_error(error, &auth);
|
||||
|
||||
// then
|
||||
assert!(matches!(
|
||||
enriched,
|
||||
crate::error::ApiError::InvalidSseFrame(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
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)]
|
||||
|
|
|
|||
|
|
@ -1,16 +1,26 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
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,
|
||||
pub messages: Vec<InputMessage>,
|
||||
/// 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<String>,
|
||||
pub system: Option<Arc<str>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ToolDefinition>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -33,14 +43,30 @@ pub struct MessageRequest {
|
|||
/// Silently ignored by backends that do not support it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
/// Provider-specific OpenAI-compatible request body parameters. These are
|
||||
/// copied into the final JSON payload after core fields are populated so
|
||||
/// users can opt into gateway features such as `web_search_options`,
|
||||
/// `parallel_tool_calls`, or custom local-server switches without waiting
|
||||
/// for first-class typed fields. Core protocol keys are protected and cannot
|
||||
/// be overridden through this map.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub extra_body: BTreeMap<String, Value>,
|
||||
/// 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 {
|
||||
|
|
@ -49,6 +75,185 @@ impl MessageRequest {
|
|||
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)]
|
||||
|
|
@ -80,22 +285,34 @@ impl InputMessage {
|
|||
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,
|
||||
},
|
||||
Thinking {
|
||||
thinking: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
signature: Option<String>,
|
||||
},
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
|
|
@ -106,6 +323,34 @@ pub enum InputContentBlock {
|
|||
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,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +369,39 @@ pub struct ToolDefinition {
|
|||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
/// 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,
|
||||
|
|
@ -166,6 +443,7 @@ pub enum OutputContentBlock {
|
|||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(default = "serde_json::Value::default")]
|
||||
input: Value,
|
||||
},
|
||||
Thinking {
|
||||
|
|
@ -177,6 +455,12 @@ pub enum OutputContentBlock {
|
|||
RedactedThinking {
|
||||
data: Value,
|
||||
},
|
||||
// Added image output block
|
||||
Image {
|
||||
data: String,
|
||||
mime_type: String,
|
||||
filename: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -283,9 +567,8 @@ pub enum StreamEvent {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use runtime::format_usd;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{InputContentBlock, MessageResponse, Usage};
|
||||
use super::{MessageResponse, Usage};
|
||||
|
||||
#[test]
|
||||
fn usage_total_tokens_includes_cache_tokens() {
|
||||
|
|
@ -325,31 +608,157 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn input_content_block_thinking_serializes_with_snake_case_type() {
|
||||
// given
|
||||
let block = InputContentBlock::Thinking {
|
||||
thinking: "pondering".to_string(),
|
||||
signature: Some("sig_123".to_string()),
|
||||
};
|
||||
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);
|
||||
|
||||
// when
|
||||
let serialized = serde_json::to_value(&block).unwrap();
|
||||
let deserialized: InputContentBlock = serde_json::from_value(json!({
|
||||
"type": "thinking",
|
||||
"thinking": "pondering",
|
||||
"signature": "sig_123"
|
||||
}))
|
||||
.unwrap();
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// then
|
||||
#[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!(
|
||||
serialized,
|
||||
json!({
|
||||
"type": "thinking",
|
||||
"thinking": "pondering",
|
||||
"signature": "sig_123"
|
||||
})
|
||||
last[0]["cache_control"],
|
||||
serde_json::json!({"type": "ephemeral"})
|
||||
);
|
||||
assert_eq!(deserialized, block);
|
||||
}
|
||||
|
||||
#[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()),
|
||||
|
|
|
|||
|
|
@ -1,579 +1,16 @@
|
|||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Mutex as StdMutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use api::{
|
||||
build_http_client_with, ApiError, ContentBlockDelta, ContentBlockDeltaEvent,
|
||||
ContentBlockStartEvent, ContentBlockStopEvent, InputContentBlock, InputMessage,
|
||||
MessageDeltaEvent, MessageRequest, OpenAiCompatClient, OpenAiCompatConfig, OutputContentBlock,
|
||||
ProviderClient, ProxyConfig, StreamEvent, ToolChoice, ToolDefinition,
|
||||
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;
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_uses_openai_compatible_endpoint_and_auth() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let body = concat!(
|
||||
"{",
|
||||
"\"id\":\"chatcmpl_test\",",
|
||||
"\"model\":\"grok-3\",",
|
||||
"\"choices\":[{",
|
||||
"\"message\":{\"role\":\"assistant\",\"content\":\"Hello from Grok\",\"tool_calls\":[]},",
|
||||
"\"finish_reason\":\"stop\"",
|
||||
"}],",
|
||||
"\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5,\"prompt_tokens_details\":{\"cached_tokens\":3}}",
|
||||
"}"
|
||||
);
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![http_response("200 OK", "application/json", body)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
|
||||
.with_base_url(server.base_url());
|
||||
let response = client
|
||||
.send_message(&sample_request(false))
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.model, "grok-3");
|
||||
assert_eq!(response.usage.input_tokens, 8);
|
||||
assert_eq!(response.usage.cache_read_input_tokens, 3);
|
||||
assert_eq!(response.usage.output_tokens, 5);
|
||||
assert_eq!(response.total_tokens(), 16);
|
||||
assert_eq!(
|
||||
response.content,
|
||||
vec![OutputContentBlock::Text {
|
||||
text: "Hello from Grok".to_string(),
|
||||
}]
|
||||
);
|
||||
|
||||
let captured = state.lock().await;
|
||||
let request = captured.first().expect("server should capture request");
|
||||
assert_eq!(request.path, "/chat/completions");
|
||||
assert_eq!(
|
||||
request.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer xai-test-key")
|
||||
);
|
||||
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
|
||||
assert_eq!(body["model"], json!("grok-3"));
|
||||
assert_eq!(body["messages"][0]["role"], json!("system"));
|
||||
assert_eq!(body["tools"][0]["type"], json!("function"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_passes_optional_openai_compatible_parameters_on_wire() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let body = concat!(
|
||||
"{",
|
||||
"\"id\":\"chatcmpl_params\",",
|
||||
"\"model\":\"gpt-4o\",",
|
||||
"\"choices\":[{",
|
||||
"\"message\":{\"role\":\"assistant\",\"content\":\"Parameters preserved\",\"tool_calls\":[]},",
|
||||
"\"finish_reason\":\"stop\"",
|
||||
"}],",
|
||||
"\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2}",
|
||||
"}"
|
||||
);
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![http_response("200 OK", "application/json", body)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
|
||||
.with_base_url(server.base_url());
|
||||
let response = client
|
||||
.send_message(&MessageRequest {
|
||||
model: "gpt-4o".to_string(),
|
||||
temperature: Some(0.2),
|
||||
top_p: Some(0.8),
|
||||
frequency_penalty: Some(0.15),
|
||||
presence_penalty: Some(0.25),
|
||||
stop: Some(vec!["END".to_string()]),
|
||||
reasoning_effort: Some("low".to_string()),
|
||||
..sample_request(false)
|
||||
})
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.total_tokens(), 5);
|
||||
|
||||
let captured = state.lock().await;
|
||||
let request = captured.first().expect("server should capture request");
|
||||
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
|
||||
assert_eq!(body["model"], json!("gpt-4o"));
|
||||
assert_eq!(body["temperature"], json!(0.2));
|
||||
assert_eq!(body["top_p"], json!(0.8));
|
||||
assert_eq!(body["frequency_penalty"], json!(0.15));
|
||||
assert_eq!(body["presence_penalty"], json!(0.25));
|
||||
assert_eq!(body["stop"], json!(["END"]));
|
||||
assert_eq!(body["reasoning_effort"], json!("low"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_preserves_deepseek_reasoning_content_before_text() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let body = concat!(
|
||||
"{",
|
||||
"\"id\":\"chatcmpl_deepseek_reasoning\",",
|
||||
"\"model\":\"deepseek-v4-pro\",",
|
||||
"\"choices\":[{",
|
||||
"\"message\":{\"role\":\"assistant\",\"reasoning_content\":\"Think first\",\"content\":\"Answer second\",\"tool_calls\":[]},",
|
||||
"\"finish_reason\":\"stop\"",
|
||||
"}],",
|
||||
"\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5}",
|
||||
"}"
|
||||
);
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![http_response("200 OK", "application/json", body)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
|
||||
.with_base_url(server.base_url());
|
||||
let response = client
|
||||
.send_message(&MessageRequest {
|
||||
model: "openai/deepseek-v4-pro".to_string(),
|
||||
..sample_request(false)
|
||||
})
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(
|
||||
response.content,
|
||||
vec![
|
||||
OutputContentBlock::Thinking {
|
||||
thinking: "Think first".to_string(),
|
||||
signature: None,
|
||||
},
|
||||
OutputContentBlock::Text {
|
||||
text: "Answer second".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
let captured = state.lock().await;
|
||||
let request = captured.first().expect("server should capture request");
|
||||
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
|
||||
assert_eq!(body["thinking"], json!({"type": "enabled"}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_preserves_ollama_reasoning_before_text() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let body = concat!(
|
||||
"{",
|
||||
"\"id\":\"chatcmpl_ollama_reasoning\",",
|
||||
"\"model\":\"qwen3:latest\",",
|
||||
"\"choices\":[{",
|
||||
"\"message\":{\"role\":\"assistant\",\"reasoning\":\"Think locally\",\"content\":\"Answer locally\",\"tool_calls\":[]},",
|
||||
"\"finish_reason\":\"stop\"",
|
||||
"}],",
|
||||
"\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5}",
|
||||
"}"
|
||||
);
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![http_response("200 OK", "application/json", body)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = OpenAiCompatClient::new("ollama-test-key", OpenAiCompatConfig::openai())
|
||||
.with_base_url(server.base_url());
|
||||
let response = client
|
||||
.send_message(&MessageRequest {
|
||||
model: "openai/qwen3:latest".to_string(),
|
||||
..sample_request(false)
|
||||
})
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(
|
||||
response.content,
|
||||
vec![
|
||||
OutputContentBlock::Thinking {
|
||||
thinking: "Think locally".to_string(),
|
||||
signature: None,
|
||||
},
|
||||
OutputContentBlock::Text {
|
||||
text: "Answer locally".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
let captured = state.lock().await;
|
||||
let request = captured.first().expect("server should capture request");
|
||||
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
|
||||
assert_eq!(body["model"], json!("qwen3:latest"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_openai_gateway_strips_routing_prefix_and_preserves_extra_body_params() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let body = concat!(
|
||||
"{",
|
||||
"\"id\":\"chatcmpl_slash_model\",",
|
||||
"\"model\":\"openai/gpt-4.1-mini\",",
|
||||
"\"choices\":[{",
|
||||
"\"message\":{\"role\":\"assistant\",\"content\":\"Gateway accepted slug\",\"tool_calls\":[]},",
|
||||
"\"finish_reason\":\"stop\"",
|
||||
"}],",
|
||||
"\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2}",
|
||||
"}"
|
||||
);
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![http_response("200 OK", "application/json", body)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut extra_body = std::collections::BTreeMap::new();
|
||||
extra_body.insert(
|
||||
"web_search_options".to_string(),
|
||||
json!({"search_context_size": "low"}),
|
||||
);
|
||||
extra_body.insert("parallel_tool_calls".to_string(), json!(false));
|
||||
extra_body.insert("model".to_string(), json!("malicious-override"));
|
||||
|
||||
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
|
||||
.with_base_url(server.base_url());
|
||||
let response = client
|
||||
.send_message(&MessageRequest {
|
||||
model: "openai/gpt-4.1-mini".to_string(),
|
||||
extra_body,
|
||||
..sample_request(false)
|
||||
})
|
||||
.await
|
||||
.expect("gateway request should succeed");
|
||||
|
||||
assert_eq!(response.model, "openai/gpt-4.1-mini");
|
||||
assert_eq!(response.total_tokens(), 5);
|
||||
|
||||
let captured = state.lock().await;
|
||||
let request = captured.first().expect("captured request");
|
||||
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
|
||||
assert_eq!(body["model"], json!("gpt-4.1-mini"));
|
||||
assert_eq!(
|
||||
body["web_search_options"],
|
||||
json!({"search_context_size": "low"})
|
||||
);
|
||||
assert_eq!(body["parallel_tool_calls"], json!(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_blocks_oversized_xai_requests_before_the_http_call() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![http_response("200 OK", "application/json", "{}")],
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
|
||||
.with_base_url(server.base_url());
|
||||
let error = client
|
||||
.send_message(&MessageRequest {
|
||||
model: "grok-3".to_string(),
|
||||
max_tokens: 64_000,
|
||||
messages: vec![InputMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![InputContentBlock::Text {
|
||||
text: "x".repeat(300_000),
|
||||
}],
|
||||
}],
|
||||
system: Some("Keep the answer short.".to_string()),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
stream: false,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect_err("oversized request should fail local context-window preflight");
|
||||
|
||||
assert!(matches!(error, ApiError::ContextWindowExceeded { .. }));
|
||||
assert!(
|
||||
state.lock().await.is_empty(),
|
||||
"preflight failure should avoid any upstream HTTP request"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_accepts_full_chat_completions_endpoint_override() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let body = concat!(
|
||||
"{",
|
||||
"\"id\":\"chatcmpl_full_endpoint\",",
|
||||
"\"model\":\"grok-3\",",
|
||||
"\"choices\":[{",
|
||||
"\"message\":{\"role\":\"assistant\",\"content\":\"Endpoint override works\",\"tool_calls\":[]},",
|
||||
"\"finish_reason\":\"stop\"",
|
||||
"}],",
|
||||
"\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":3}",
|
||||
"}"
|
||||
);
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![http_response("200 OK", "application/json", body)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let endpoint_url = format!("{}/chat/completions", server.base_url());
|
||||
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
|
||||
.with_base_url(endpoint_url);
|
||||
let response = client
|
||||
.send_message(&sample_request(false))
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.total_tokens(), 10);
|
||||
|
||||
let captured = state.lock().await;
|
||||
let request = captured.first().expect("server should capture request");
|
||||
assert_eq!(request.path, "/chat/completions");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_message_normalizes_text_and_multiple_tool_calls() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let sse = concat!(
|
||||
"data: {\"id\":\"chatcmpl_stream\",\"model\":\"grok-3\",\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_stream\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}},{\"index\":1,\"id\":\"call_2\",\"function\":{\"name\":\"clock\",\"arguments\":\"{\\\"zone\\\":\\\"UTC\\\"}\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_stream\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\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_grok_stream")],
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
|
||||
.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_grok_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::ContentBlockStart(ContentBlockStartEvent {
|
||||
index: 1,
|
||||
content_block: OutputContentBlock::ToolUse { .. },
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
events[4],
|
||||
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||
index: 1,
|
||||
delta: ContentBlockDelta::InputJsonDelta { .. },
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
events[5],
|
||||
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||
index: 2,
|
||||
content_block: OutputContentBlock::ToolUse { .. },
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
events[6],
|
||||
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||
index: 2,
|
||||
delta: ContentBlockDelta::InputJsonDelta { .. },
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
events[7],
|
||||
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 1 })
|
||||
));
|
||||
assert!(matches!(
|
||||
events[8],
|
||||
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 2 })
|
||||
));
|
||||
assert!(matches!(
|
||||
events[9],
|
||||
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
|
||||
));
|
||||
assert!(matches!(events[10], StreamEvent::MessageDelta(_)));
|
||||
assert!(matches!(events[11], StreamEvent::MessageStop(_)));
|
||||
|
||||
let captured = state.lock().await;
|
||||
let request = captured.first().expect("captured request");
|
||||
assert_eq!(request.path, "/chat/completions");
|
||||
assert!(request.body.contains("\"stream\":true"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_message_preserves_ollama_reasoning_before_text() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let sse = concat!(
|
||||
"data: {\"id\":\"chatcmpl_stream_ollama_reasoning\",\"model\":\"qwen3:latest\",\"choices\":[{\"delta\":{\"reasoning\":\"Think\"}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_stream_ollama_reasoning\",\"choices\":[{\"delta\":{\"content\":\" answer\"},\"finish_reason\":\"stop\"}]}\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_ollama_reasoning_stream")],
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = OpenAiCompatClient::new("ollama-test-key", OpenAiCompatConfig::openai())
|
||||
.with_base_url(server.base_url());
|
||||
let mut stream = client
|
||||
.stream_message(&MessageRequest {
|
||||
model: "openai/qwen3:latest".to_string(),
|
||||
..sample_request(false)
|
||||
})
|
||||
.await
|
||||
.expect("stream should start");
|
||||
|
||||
assert_eq!(stream.request_id(), Some("req_ollama_reasoning_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 {
|
||||
index: 0,
|
||||
content_block: OutputContentBlock::Thinking { .. },
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
events[2],
|
||||
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||
index: 0,
|
||||
delta: ContentBlockDelta::ThinkingDelta { .. },
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
events[3],
|
||||
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
|
||||
));
|
||||
assert!(matches!(
|
||||
events[4],
|
||||
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||
index: 1,
|
||||
content_block: OutputContentBlock::Text { .. },
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
events[5],
|
||||
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||
index: 1,
|
||||
delta: ContentBlockDelta::TextDelta { .. },
|
||||
})
|
||||
));
|
||||
|
||||
let captured = state.lock().await;
|
||||
let request = captured.first().expect("captured request");
|
||||
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
|
||||
assert_eq!(body["model"], json!("qwen3:latest"));
|
||||
assert_eq!(body["stream"], json!(true));
|
||||
}
|
||||
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn stream_message_retries_retryable_sse_handshake_failures() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let sse = concat!(
|
||||
"data: {\"id\":\"chatcmpl_stream_retry\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"content\":\"Recovered\"}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_stream_retry\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![
|
||||
http_response(
|
||||
"500 Internal Server Error",
|
||||
"application/json",
|
||||
"{\"error\":{\"message\":\"try again\",\"type\":\"server_error\",\"code\":500}}",
|
||||
),
|
||||
http_response_with_headers(
|
||||
"200 OK",
|
||||
"text/event-stream",
|
||||
sse,
|
||||
&[("x-request-id", "req_stream_retry")],
|
||||
),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
|
||||
.with_base_url(server.base_url())
|
||||
.with_retry_policy(1, Duration::ZERO, Duration::ZERO);
|
||||
let mut stream = client
|
||||
.stream_message(&MessageRequest {
|
||||
model: "gpt-4o".to_string(),
|
||||
..sample_request(false)
|
||||
})
|
||||
.await
|
||||
.expect("stream should retry once then start");
|
||||
|
||||
assert_eq!(stream.request_id(), Some("req_stream_retry"));
|
||||
let mut events = Vec::new();
|
||||
while let Some(event) = stream.next_event().await.expect("event should parse") {
|
||||
events.push(event);
|
||||
}
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||
delta: ContentBlockDelta::TextDelta { text },
|
||||
..
|
||||
}) if text == "Recovered"
|
||||
)));
|
||||
|
||||
let captured = state.lock().await;
|
||||
assert_eq!(captured.len(), 2, "one original request plus one retry");
|
||||
for request in captured.iter() {
|
||||
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
|
||||
assert_eq!(body["stream"], json!(true));
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn openai_streaming_requests_opt_into_usage_chunks() {
|
||||
|
|
@ -581,7 +18,7 @@ async fn openai_streaming_requests_opt_into_usage_chunks() {
|
|||
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,\"prompt_tokens_details\":{\"cached_tokens\":2}}}\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(
|
||||
|
|
@ -636,10 +73,8 @@ async fn openai_streaming_requests_opt_into_usage_chunks() {
|
|||
|
||||
match &events[4] {
|
||||
StreamEvent::MessageDelta(MessageDeltaEvent { usage, .. }) => {
|
||||
assert_eq!(usage.input_tokens, 7);
|
||||
assert_eq!(usage.cache_read_input_tokens, 2);
|
||||
assert_eq!(usage.input_tokens, 9);
|
||||
assert_eq!(usage.output_tokens, 4);
|
||||
assert_eq!(usage.total_tokens(), 13);
|
||||
}
|
||||
other => panic!("expected message delta, got {other:?}"),
|
||||
}
|
||||
|
|
@ -652,85 +87,6 @@ async fn openai_streaming_requests_opt_into_usage_chunks() {
|
|||
assert_eq!(body["stream_options"], json!({"include_usage": true}));
|
||||
}
|
||||
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn openai_compatible_client_honors_http_proxy_for_requests() {
|
||||
let _lock = env_lock();
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let proxy = spawn_server(
|
||||
state.clone(),
|
||||
vec![http_response(
|
||||
"200 OK",
|
||||
"application/json",
|
||||
"{\"id\":\"chatcmpl_proxy\",\"model\":\"gpt-4o\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Via proxy\",\"tool_calls\":[]},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":3}}",
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
let proxied_http = build_http_client_with(&ProxyConfig::from_proxy_url(proxy.base_url()))
|
||||
.expect("proxy client should build");
|
||||
|
||||
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
|
||||
.with_http_client(proxied_http)
|
||||
.with_base_url("http://origin.invalid/v1");
|
||||
let response = client
|
||||
.send_message(&MessageRequest {
|
||||
model: "openai/gpt-4.1-mini".to_string(),
|
||||
..sample_request(false)
|
||||
})
|
||||
.await
|
||||
.expect("proxy should return the OpenAI-compatible response");
|
||||
|
||||
assert_eq!(response.model, "openai/gpt-4.1-mini");
|
||||
assert_eq!(response.total_tokens(), 7);
|
||||
let captured = state.lock().await;
|
||||
let request = captured.first().expect("proxy should capture request");
|
||||
assert_eq!(request.path, "http://origin.invalid/v1/chat/completions");
|
||||
assert_eq!(
|
||||
request.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer openai-test-key")
|
||||
);
|
||||
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
|
||||
assert_eq!(body["model"], json!("openai/gpt-4.1-mini"));
|
||||
}
|
||||
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn provider_client_dispatches_xai_requests_from_env() {
|
||||
let _lock = env_lock();
|
||||
let _api_key = ScopedEnvVar::set("XAI_API_KEY", "xai-test-key");
|
||||
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![http_response(
|
||||
"200 OK",
|
||||
"application/json",
|
||||
"{\"id\":\"chatcmpl_provider\",\"model\":\"grok-3\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Through provider client\",\"tool_calls\":[]},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":4}}",
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
let _base_url = ScopedEnvVar::set("XAI_BASE_URL", server.base_url());
|
||||
|
||||
let client =
|
||||
ProviderClient::from_model("grok").expect("xAI provider client should be constructed");
|
||||
assert!(matches!(client, ProviderClient::Xai(_)));
|
||||
|
||||
let response = client
|
||||
.send_message(&sample_request(false))
|
||||
.await
|
||||
.expect("provider-dispatched request should succeed");
|
||||
|
||||
assert_eq!(response.total_tokens(), 13);
|
||||
|
||||
let captured = state.lock().await;
|
||||
let request = captured.first().expect("captured request");
|
||||
assert_eq!(request.path, "/chat/completions");
|
||||
assert_eq!(
|
||||
request.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer xai-test-key")
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct CapturedRequest {
|
||||
path: String,
|
||||
|
|
@ -838,10 +194,6 @@ fn find_header_end(bytes: &[u8]) -> Option<usize> {
|
|||
bytes.windows(4).position(|window| window == b"\r\n\r\n")
|
||||
}
|
||||
|
||||
fn http_response(status: &str, content_type: &str, body: &str) -> String {
|
||||
http_response_with_headers(status, content_type, body, &[])
|
||||
}
|
||||
|
||||
fn http_response_with_headers(
|
||||
status: &str,
|
||||
content_type: &str,
|
||||
|
|
@ -863,13 +215,13 @@ fn sample_request(stream: bool) -> MessageRequest {
|
|||
MessageRequest {
|
||||
model: "grok-3".to_string(),
|
||||
max_tokens: 64,
|
||||
messages: vec![InputMessage {
|
||||
messages: Arc::new(vec![InputMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![InputContentBlock::Text {
|
||||
text: "Say hello".to_string(),
|
||||
}],
|
||||
}],
|
||||
system: Some("Use tools when needed".to_string()),
|
||||
}]),
|
||||
system: Some(Arc::from("Use tools when needed")),
|
||||
tools: Some(vec![ToolDefinition {
|
||||
name: "weather".to_string(),
|
||||
description: Some("Fetches weather".to_string()),
|
||||
|
|
@ -884,32 +236,3 @@ fn sample_request(stream: bool) -> MessageRequest {
|
|||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| StdMutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
struct ScopedEnvVar {
|
||||
key: &'static str,
|
||||
previous: Option<OsString>,
|
||||
}
|
||||
|
||||
impl ScopedEnvVar {
|
||||
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
|
||||
let previous = std::env::var_os(key);
|
||||
std::env::set_var(key, value);
|
||||
Self { key, previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScopedEnvVar {
|
||||
fn drop(&mut self) {
|
||||
match &self.previous {
|
||||
Some(value) => std::env::set_var(self.key, value),
|
||||
None => std::env::remove_var(self.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
[package]
|
||||
name = "claw-analog"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Minimal agent harness: tool loop with explicit permissions and workspace jail."
|
||||
|
||||
[lib]
|
||||
name = "claw_analog"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "claw-analog"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
api = { path = "../api" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4"
|
||||
globset = "0.4"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
runtime = { path = "../runtime" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
toml = "0.8"
|
||||
walkdir = "2"
|
||||
ignore = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
mock-anthropic-service = { path = "../mock-anthropic-service" }
|
||||
tempfile = "3"
|
||||
|
|
@ -1,489 +0,0 @@
|
|||
//! `claw-analog agents` — run multiple specialized sub-agents sequentially.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use api::InputMessage;
|
||||
use clap::{Parser, ValueEnum};
|
||||
use claw_analog::{
|
||||
enforce_non_interactive_permission_rules, load_analog_toml, resolve_analog_options,
|
||||
resolve_analog_profile_path, resolve_rag_base_url, AnalogConfig, AnalogDoctorOverrides,
|
||||
AnalogFileConfig, OutputFormat, PermissionMode, Preset, StreamOverride,
|
||||
};
|
||||
|
||||
const DEF_MAX_READ: u64 = 256 * 1024;
|
||||
const DEF_MAX_TURNS: u32 = 24;
|
||||
const DEF_MAX_LIST: usize = 500;
|
||||
const DEF_GREP_MAX: usize = 200;
|
||||
const DEF_GLOB_PATHS: usize = 2000;
|
||||
const DEF_GLOB_DEPTH: usize = 32;
|
||||
const DEF_RAG_TIMEOUT_SECS: u64 = 30;
|
||||
const DEF_RAG_TOP_K_MAX: u32 = 32;
|
||||
const RAG_TOP_K_ABS_CAP: u32 = 256;
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
pub enum AgentsPresetArg {
|
||||
Audit,
|
||||
Explain,
|
||||
Implement,
|
||||
}
|
||||
|
||||
impl From<AgentsPresetArg> for Preset {
|
||||
fn from(p: AgentsPresetArg) -> Self {
|
||||
match p {
|
||||
AgentsPresetArg::Audit => Preset::Audit,
|
||||
AgentsPresetArg::Explain => Preset::Explain,
|
||||
AgentsPresetArg::Implement => Preset::Implement,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
pub enum AgentsPermissionArg {
|
||||
ReadOnly,
|
||||
WorkspaceWrite,
|
||||
Prompt,
|
||||
#[value(name = "danger-full-access")]
|
||||
DangerFullAccess,
|
||||
Allow,
|
||||
}
|
||||
|
||||
impl From<AgentsPermissionArg> for PermissionMode {
|
||||
fn from(p: AgentsPermissionArg) -> Self {
|
||||
match p {
|
||||
AgentsPermissionArg::ReadOnly => PermissionMode::ReadOnly,
|
||||
AgentsPermissionArg::WorkspaceWrite => PermissionMode::WorkspaceWrite,
|
||||
AgentsPermissionArg::Prompt => PermissionMode::Prompt,
|
||||
AgentsPermissionArg::DangerFullAccess => PermissionMode::DangerFullAccess,
|
||||
AgentsPermissionArg::Allow => PermissionMode::Allow,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentSpec {
|
||||
pub name: String,
|
||||
pub preset: Preset,
|
||||
pub permission: PermissionMode,
|
||||
pub model: Option<String>,
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
|
||||
fn default_permission_for_preset(p: Preset) -> PermissionMode {
|
||||
match p {
|
||||
Preset::Audit | Preset::Explain => PermissionMode::ReadOnly,
|
||||
Preset::Implement => PermissionMode::WorkspaceWrite,
|
||||
Preset::None => PermissionMode::ReadOnly,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_agent_spec(s: &str) -> Result<AgentSpec, String> {
|
||||
// Allowed forms:
|
||||
// - "audit" | "explain" | "implement"
|
||||
// - "name=audit,preset=audit,permission=read-only,model=...,prompt=..."
|
||||
let raw = s.trim();
|
||||
if raw.is_empty() {
|
||||
return Err("empty --agent spec".to_string());
|
||||
}
|
||||
|
||||
if !raw.contains('=') {
|
||||
let preset = match raw.to_ascii_lowercase().as_str() {
|
||||
"audit" => Preset::Audit,
|
||||
"explain" => Preset::Explain,
|
||||
"implement" | "fix" => Preset::Implement,
|
||||
other => return Err(format!("unknown agent shorthand: {other}")),
|
||||
};
|
||||
return Ok(AgentSpec {
|
||||
name: raw.to_string(),
|
||||
preset,
|
||||
permission: default_permission_for_preset(preset),
|
||||
model: None,
|
||||
prompt: None,
|
||||
});
|
||||
}
|
||||
|
||||
let mut name: Option<String> = None;
|
||||
let mut preset: Option<Preset> = None;
|
||||
let mut permission: Option<PermissionMode> = None;
|
||||
let mut model: Option<String> = None;
|
||||
let mut prompt: Option<String> = None;
|
||||
|
||||
for part in raw.split(',') {
|
||||
let (k, v) = part
|
||||
.split_once('=')
|
||||
.ok_or_else(|| format!("invalid agent spec part {part:?} (expected k=v)"))?;
|
||||
let k = k.trim().to_ascii_lowercase();
|
||||
let v = v.trim();
|
||||
if v.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match k.as_str() {
|
||||
"name" => name = Some(v.to_string()),
|
||||
"preset" => {
|
||||
let p = match v.to_ascii_lowercase().as_str() {
|
||||
"audit" => Preset::Audit,
|
||||
"explain" => Preset::Explain,
|
||||
"implement" | "fix" => Preset::Implement,
|
||||
"none" => Preset::None,
|
||||
other => return Err(format!("unknown preset {other:?}")),
|
||||
};
|
||||
preset = Some(p);
|
||||
}
|
||||
"permission" => {
|
||||
let pm = match v.to_ascii_lowercase().replace('_', "-").as_str() {
|
||||
"read-only" | "readonly" => PermissionMode::ReadOnly,
|
||||
"workspace-write" | "write" => PermissionMode::WorkspaceWrite,
|
||||
"prompt" => PermissionMode::Prompt,
|
||||
"danger-full-access" | "danger" => PermissionMode::DangerFullAccess,
|
||||
"allow" => PermissionMode::Allow,
|
||||
other => return Err(format!("unknown permission {other:?}")),
|
||||
};
|
||||
permission = Some(pm);
|
||||
}
|
||||
"model" => model = Some(v.to_string()),
|
||||
"prompt" => prompt = Some(v.to_string()),
|
||||
other => return Err(format!("unknown agent spec key {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
let preset = preset.unwrap_or(Preset::Audit);
|
||||
let permission = permission.unwrap_or_else(|| default_permission_for_preset(preset));
|
||||
let name = name.unwrap_or_else(|| preset.label().unwrap_or("agent").to_string());
|
||||
|
||||
Ok(AgentSpec {
|
||||
name,
|
||||
preset,
|
||||
permission,
|
||||
model,
|
||||
prompt,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct AgentsCli {
|
||||
/// Workspace root.
|
||||
#[arg(short = 'w', long, default_value = ".", value_name = "DIR")]
|
||||
pub workspace: PathBuf,
|
||||
|
||||
/// Config path (default: `<workspace>/.claw-analog.toml`).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub config: Option<PathBuf>,
|
||||
|
||||
/// Base session path. If missing, it will be created from the base prompt.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub base_session: PathBuf,
|
||||
|
||||
/// Base prompt. If omitted, reads from stdin.
|
||||
#[arg(long)]
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Repeatable agent specs, e.g. `--agent audit` or `--agent name=fix,preset=implement,permission=workspace-write`.
|
||||
#[arg(long, required = true)]
|
||||
pub agent: Vec<String>,
|
||||
|
||||
/// If set, each agent writes its own session file next to base session.
|
||||
#[arg(long, default_value_t = true)]
|
||||
pub split_sessions: bool,
|
||||
}
|
||||
|
||||
fn load_file_config(path: &Path) -> AnalogFileConfig {
|
||||
if !path.is_file() {
|
||||
return AnalogFileConfig::default();
|
||||
}
|
||||
load_analog_toml(path).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn config_path(args: &AgentsCli) -> PathBuf {
|
||||
args.config
|
||||
.clone()
|
||||
.unwrap_or_else(|| args.workspace.join(".claw-analog.toml"))
|
||||
}
|
||||
|
||||
fn derive_agent_session_path(base: &Path, agent_name: &str) -> PathBuf {
|
||||
let base_s = base.to_string_lossy();
|
||||
PathBuf::from(format!("{base_s}.agent-{agent_name}.json"))
|
||||
}
|
||||
|
||||
fn read_stdin_prompt() -> Result<String, String> {
|
||||
use std::io::Read;
|
||||
let mut buf = String::new();
|
||||
std::io::stdin()
|
||||
.read_to_string(&mut buf)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let t = buf.trim();
|
||||
if t.is_empty() {
|
||||
return Err("empty prompt (pass --prompt or stdin)".to_string());
|
||||
}
|
||||
Ok(t.to_string())
|
||||
}
|
||||
|
||||
fn ensure_base_session(base_session: &Path, workspace: &Path, prompt: &str) -> Result<(), String> {
|
||||
if base_session.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let ws_s = workspace.display().to_string();
|
||||
let model = "base".to_string();
|
||||
let messages = if prompt.trim().is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![InputMessage::user_text(prompt.to_string())]
|
||||
};
|
||||
claw_analog::session_save(base_session, &ws_s, &model, Preset::None, &messages)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_agents(args: AgentsCli) -> Result<(), String> {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
rt.block_on(async { run_agents_async(args).await })
|
||||
}
|
||||
|
||||
pub async fn run_agents_async(args: AgentsCli) -> Result<(), String> {
|
||||
run_agents_inner(args, |cfg, out| {
|
||||
Box::pin(async move {
|
||||
claw_analog::run(cfg, out)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
type RunFuture<'a> = std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>> + 'a>>;
|
||||
|
||||
async fn run_agents_inner<F>(args: AgentsCli, mut run_one: F) -> Result<(), String>
|
||||
where
|
||||
for<'a> F: FnMut(AnalogConfig, &'a mut Vec<u8>) -> RunFuture<'a>,
|
||||
{
|
||||
let workspace = if args.workspace.is_absolute() {
|
||||
args.workspace.clone()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join(&args.workspace)
|
||||
};
|
||||
let cfg_path = config_path(&args);
|
||||
let file_cfg = load_file_config(&cfg_path);
|
||||
|
||||
let base_prompt = match args.prompt.clone() {
|
||||
Some(p) => p,
|
||||
None => read_stdin_prompt()?,
|
||||
};
|
||||
ensure_base_session(&args.base_session, &workspace, base_prompt.as_str())?;
|
||||
|
||||
let mut specs = Vec::new();
|
||||
for a in &args.agent {
|
||||
specs.push(parse_agent_spec(a)?);
|
||||
}
|
||||
|
||||
println!("claw-analog agents (sequential)\n");
|
||||
println!(" workspace: {}", workspace.display());
|
||||
println!(" base_session: {}", args.base_session.display());
|
||||
println!(" agents: {}", specs.len());
|
||||
println!();
|
||||
|
||||
for (i, spec) in specs.into_iter().enumerate() {
|
||||
println!(
|
||||
"== Agent {} / {}: {} ==",
|
||||
i + 1,
|
||||
args.agent.len(),
|
||||
spec.name
|
||||
);
|
||||
println!(" preset: {}", spec.preset.label().unwrap_or("none"));
|
||||
println!(" permission: {}", spec.permission.as_str());
|
||||
if let Some(m) = &spec.model {
|
||||
println!(" model: {m}");
|
||||
}
|
||||
|
||||
enforce_non_interactive_permission_rules(spec.permission, false)?;
|
||||
|
||||
let agent_session = if args.split_sessions {
|
||||
derive_agent_session_path(&args.base_session, spec.name.as_str())
|
||||
} else {
|
||||
args.base_session.clone()
|
||||
};
|
||||
if args.split_sessions {
|
||||
std::fs::copy(&args.base_session, &agent_session).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let overrides = AnalogDoctorOverrides {
|
||||
model: spec.model.clone(),
|
||||
permission: Some(spec.permission),
|
||||
preset: Some(spec.preset),
|
||||
output_format: Some(OutputFormat::Rich),
|
||||
stream: StreamOverride::ForceOff,
|
||||
..Default::default()
|
||||
};
|
||||
let resolved = resolve_analog_options(&file_cfg, &overrides);
|
||||
|
||||
let profile_path =
|
||||
resolve_analog_profile_path(&workspace, None, file_cfg.profile.as_deref());
|
||||
let profile_hint = if let Some(ref p) = profile_path {
|
||||
claw_analog::load_profile_hint(p).unwrap_or(None)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let rag_base_url = resolve_rag_base_url(&file_cfg);
|
||||
|
||||
let agent_prompt = spec.prompt.unwrap_or_else(|| {
|
||||
format!(
|
||||
"Agent {}: run preset {}",
|
||||
spec.name,
|
||||
resolved.preset.label().unwrap_or("none")
|
||||
)
|
||||
});
|
||||
|
||||
let cfg = AnalogConfig {
|
||||
model: resolved.model,
|
||||
workspace: workspace.clone(),
|
||||
permission_mode: resolved.permission_mode,
|
||||
accept_danger_non_interactive: false,
|
||||
use_stream: false,
|
||||
output_format: resolved.output_format,
|
||||
use_runtime_enforcer: resolved.use_runtime_enforcer,
|
||||
max_read_bytes: file_cfg.max_read_bytes.unwrap_or(DEF_MAX_READ),
|
||||
max_turns: file_cfg.max_turns.unwrap_or(DEF_MAX_TURNS),
|
||||
max_list_entries: file_cfg.max_list_entries.unwrap_or(DEF_MAX_LIST),
|
||||
grep_max_lines: file_cfg.grep_max_lines.unwrap_or(DEF_GREP_MAX),
|
||||
glob_max_paths: file_cfg.glob_max_paths.unwrap_or(DEF_GLOB_PATHS),
|
||||
glob_max_depth: file_cfg.glob_max_depth.unwrap_or(DEF_GLOB_DEPTH),
|
||||
preset: resolved.preset,
|
||||
language: file_cfg
|
||||
.language
|
||||
.as_deref()
|
||||
.and_then(claw_analog::AnalogLanguage::from_toml_str)
|
||||
.unwrap_or_default(),
|
||||
session_path: Some(agent_session.clone()),
|
||||
session_save_path: None,
|
||||
profile_hint,
|
||||
prompt: agent_prompt,
|
||||
rag_base_url,
|
||||
rag_http_timeout: std::time::Duration::from_secs(
|
||||
file_cfg.rag_timeout_secs.unwrap_or(DEF_RAG_TIMEOUT_SECS),
|
||||
),
|
||||
rag_top_k_max: file_cfg
|
||||
.rag_top_k_max
|
||||
.unwrap_or(DEF_RAG_TOP_K_MAX)
|
||||
.clamp(1, RAG_TOP_K_ABS_CAP),
|
||||
};
|
||||
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let run_res = run_one(cfg, &mut buf).await;
|
||||
match run_res {
|
||||
Ok(()) => {
|
||||
let text = String::from_utf8_lossy(&buf);
|
||||
let summary = tail_chars(text.as_ref(), 1600);
|
||||
println!(" result: OK");
|
||||
if args.split_sessions {
|
||||
println!(" session: {}", agent_session.display());
|
||||
}
|
||||
println!(" summary_tail:\n{}\n", indent_lines(&summary, 4));
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" result: FAIL — {e}\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tail_chars(s: &str, n: usize) -> String {
|
||||
let total = s.chars().count();
|
||||
if total <= n {
|
||||
return s.to_string();
|
||||
}
|
||||
s.chars().skip(total - n).collect()
|
||||
}
|
||||
|
||||
fn indent_lines(s: &str, spaces: usize) -> String {
|
||||
let pad = " ".repeat(spaces);
|
||||
s.lines()
|
||||
.map(|l| format!("{pad}{l}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn mock_env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_agent_shorthand() {
|
||||
let a = parse_agent_spec("audit").unwrap();
|
||||
assert_eq!(a.preset, Preset::Audit);
|
||||
assert_eq!(a.permission, PermissionMode::ReadOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_agent_kv() {
|
||||
let a = parse_agent_spec("name=fix,preset=implement,permission=workspace-write").unwrap();
|
||||
assert_eq!(a.name, "fix");
|
||||
assert_eq!(a.preset, Preset::Implement);
|
||||
assert_eq!(a.permission, PermissionMode::WorkspaceWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_two_agents_sequentially_with_stub_runner() {
|
||||
let _g = mock_env_lock();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().canonicalize().unwrap();
|
||||
std::fs::write(workspace.join("fixture.txt"), "hello parity fixture\n").unwrap();
|
||||
|
||||
let base_session = workspace.join(".claw").join("agents-base.json");
|
||||
std::fs::create_dir_all(base_session.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&base_session,
|
||||
format!(
|
||||
"{{\n \"version\": 1,\n \"workspace\": \"{}\",\n \"model\": \"base\",\n \"messages\": []\n}}\n",
|
||||
workspace.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let args = AgentsCli {
|
||||
workspace: workspace.clone(),
|
||||
config: None,
|
||||
base_session: base_session.clone(),
|
||||
prompt: Some(String::new()),
|
||||
agent: vec![
|
||||
"name=audit,preset=audit,permission=read-only,prompt=check 1".to_string(),
|
||||
"name=explain,preset=explain,permission=read-only,prompt=check 2".to_string(),
|
||||
],
|
||||
split_sessions: true,
|
||||
};
|
||||
let called = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let called2 = called.clone();
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("runtime");
|
||||
rt.block_on(async {
|
||||
run_agents_inner(args, move |_cfg, out| {
|
||||
let called3 = called2.clone();
|
||||
Box::pin(async move {
|
||||
called3.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
out.extend_from_slice(b"stub ok");
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.expect("agents should run");
|
||||
});
|
||||
assert_eq!(called.load(std::sync::atomic::Ordering::Relaxed), 2);
|
||||
|
||||
assert!(derive_agent_session_path(&base_session, "audit").is_file());
|
||||
assert!(derive_agent_session_path(&base_session, "explain").is_file());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
//! `claw-analog config validate` — parse TOML and profile without calling the API.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use claw_analog::{
|
||||
load_analog_toml, load_profile_hint, resolve_analog_options, resolve_analog_profile_path,
|
||||
AnalogDoctorOverrides, AnalogFileConfig, AnalogLanguage, OutputFormat,
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ValidateCli {
|
||||
#[arg(short = 'w', long, default_value = ".", value_name = "DIR")]
|
||||
pub workspace: PathBuf,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub config: Option<PathBuf>,
|
||||
/// Require `<workspace>/.claw-analog.toml` (or `--config`) to exist and parse.
|
||||
#[arg(long, default_value_t = false, action = clap::ArgAction::SetTrue)]
|
||||
pub strict: bool,
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub profile: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub fn run_validate(cli: ValidateCli) -> i32 {
|
||||
let cfg_path = cli
|
||||
.config
|
||||
.clone()
|
||||
.unwrap_or_else(|| cli.workspace.join(".claw-analog.toml"));
|
||||
|
||||
let file_cfg = if cfg_path.is_file() {
|
||||
match load_analog_toml(&cfg_path) {
|
||||
Ok(c) => {
|
||||
println!("OK: {} parses", cfg_path.display());
|
||||
c
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("ERROR: {}: {e}", cfg_path.display());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
} else if cli.strict {
|
||||
eprintln!(
|
||||
"ERROR: --strict: config file missing: {}",
|
||||
cfg_path.display()
|
||||
);
|
||||
return 1;
|
||||
} else {
|
||||
println!(
|
||||
"Note: {} absent — using empty TOML defaults for preview",
|
||||
cfg_path.display()
|
||||
);
|
||||
AnalogFileConfig::default()
|
||||
};
|
||||
|
||||
let prof_path = resolve_analog_profile_path(
|
||||
&cli.workspace,
|
||||
cli.profile.clone(),
|
||||
file_cfg.profile.as_deref(),
|
||||
);
|
||||
let mut ok = true;
|
||||
match &prof_path {
|
||||
None => println!(
|
||||
"Profile: (none — no CLI/TOML path and no default ~/.claw-analog/profile.toml)"
|
||||
),
|
||||
Some(p) => match load_profile_hint(p) {
|
||||
Ok(Some(line)) => println!(
|
||||
"OK: profile {} (line: {} chars)",
|
||||
p.display(),
|
||||
line.chars().count()
|
||||
),
|
||||
Ok(None) => println!("OK: profile {} (empty `line`)", p.display()),
|
||||
Err(e) => {
|
||||
eprintln!("ERROR: profile {}: {e}", p.display());
|
||||
ok = false;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
let lang = file_cfg
|
||||
.language
|
||||
.as_deref()
|
||||
.and_then(AnalogLanguage::from_toml_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
let r = resolve_analog_options(&file_cfg, &AnalogDoctorOverrides::default());
|
||||
println!("\nMerge preview (TOML + defaults only; main-run CLI flags not applied):");
|
||||
println!(" language (TOML): {}", lang.as_str());
|
||||
println!(" model: {}", r.model);
|
||||
println!(" permission: {}", r.permission_mode.as_str());
|
||||
println!(" preset: {}", r.preset.label().unwrap_or("none"));
|
||||
println!(
|
||||
" output_format: {}",
|
||||
match r.output_format {
|
||||
OutputFormat::Rich => "rich",
|
||||
OutputFormat::Json => "json",
|
||||
}
|
||||
);
|
||||
println!(" stream: {}", r.use_stream);
|
||||
println!(
|
||||
" runtime_enforcer: {}",
|
||||
if r.use_runtime_enforcer { "on" } else { "off" }
|
||||
);
|
||||
println!(
|
||||
" accept_danger_non_interactive: {}",
|
||||
r.accept_danger_non_interactive
|
||||
);
|
||||
println!(" Provenance:");
|
||||
for line in &r.provenance {
|
||||
println!(" - {line}");
|
||||
}
|
||||
|
||||
i32::from(!ok)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strict_fails_when_config_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let code = run_validate(ValidateCli {
|
||||
workspace: dir.path().to_path_buf(),
|
||||
config: None,
|
||||
strict: true,
|
||||
profile: None,
|
||||
});
|
||||
assert_eq!(code, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_when_config_present() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join(".claw-analog.toml");
|
||||
std::fs::write(&p, r#"model = "sonnet""#).unwrap();
|
||||
let code = run_validate(ValidateCli {
|
||||
workspace: dir.path().to_path_buf(),
|
||||
config: None,
|
||||
strict: true,
|
||||
profile: None,
|
||||
});
|
||||
assert_eq!(code, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,733 +0,0 @@
|
|||
//! `claw-analog doctor` — environment and Cargo sanity checks.
|
||||
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::ValueEnum;
|
||||
use claw_analog::{
|
||||
load_analog_toml, load_profile_hint, resolve_analog_options, AnalogDoctorOverrides,
|
||||
AnalogFileConfig, OutputFormat, PermissionMode, Preset, StreamOverride, NDJSON_FORMAT_VERSION,
|
||||
NDJSON_SCHEMA,
|
||||
};
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
|
||||
const ENV_CHECK: &[&str] = &[
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL",
|
||||
"XAI_API_KEY",
|
||||
"RAG_BASE_URL",
|
||||
];
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
pub enum DoctorPermissionArg {
|
||||
ReadOnly,
|
||||
WorkspaceWrite,
|
||||
Prompt,
|
||||
#[value(name = "danger-full-access")]
|
||||
DangerFullAccess,
|
||||
Allow,
|
||||
}
|
||||
|
||||
impl From<DoctorPermissionArg> for PermissionMode {
|
||||
fn from(p: DoctorPermissionArg) -> Self {
|
||||
match p {
|
||||
DoctorPermissionArg::ReadOnly => PermissionMode::ReadOnly,
|
||||
DoctorPermissionArg::WorkspaceWrite => PermissionMode::WorkspaceWrite,
|
||||
DoctorPermissionArg::Prompt => PermissionMode::Prompt,
|
||||
DoctorPermissionArg::DangerFullAccess => PermissionMode::DangerFullAccess,
|
||||
DoctorPermissionArg::Allow => PermissionMode::Allow,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
pub enum DoctorOutputArg {
|
||||
Rich,
|
||||
Json,
|
||||
}
|
||||
|
||||
impl From<DoctorOutputArg> for OutputFormat {
|
||||
fn from(o: DoctorOutputArg) -> Self {
|
||||
match o {
|
||||
DoctorOutputArg::Rich => OutputFormat::Rich,
|
||||
DoctorOutputArg::Json => OutputFormat::Json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
pub enum DoctorPresetCli {
|
||||
None,
|
||||
Audit,
|
||||
Explain,
|
||||
Implement,
|
||||
}
|
||||
|
||||
impl From<DoctorPresetCli> for Preset {
|
||||
fn from(p: DoctorPresetCli) -> Self {
|
||||
match p {
|
||||
DoctorPresetCli::None => Preset::None,
|
||||
DoctorPresetCli::Audit => Preset::Audit,
|
||||
DoctorPresetCli::Explain => Preset::Explain,
|
||||
DoctorPresetCli::Implement => Preset::Implement,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct DoctorCli {
|
||||
/// Workspace root (same as `claw-analog -w`; config defaults to `<workspace>/.claw-analog.toml`).
|
||||
#[arg(short = 'w', long, default_value = ".", value_name = "DIR")]
|
||||
pub workspace: PathBuf,
|
||||
/// Config path (default: `<workspace>/.claw-analog.toml`).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub config: Option<PathBuf>,
|
||||
/// Override model (same precedence as main CLI).
|
||||
#[arg(long)]
|
||||
pub model: Option<String>,
|
||||
#[arg(long, value_enum)]
|
||||
pub permission: Option<DoctorPermissionArg>,
|
||||
#[arg(long, value_enum)]
|
||||
pub preset: Option<DoctorPresetCli>,
|
||||
#[arg(long, value_enum)]
|
||||
pub output_format: Option<DoctorOutputArg>,
|
||||
#[arg(long, default_value_t = false, conflicts_with = "no_stream")]
|
||||
pub stream: bool,
|
||||
#[arg(long, default_value_t = false, conflicts_with = "stream")]
|
||||
pub no_stream: bool,
|
||||
/// Disable `runtime::PermissionEnforcer` (same as main CLI).
|
||||
#[arg(
|
||||
long = "no-runtime-enforcer",
|
||||
default_value_t = false,
|
||||
action = clap::ArgAction::SetTrue
|
||||
)]
|
||||
pub no_runtime_enforcer: bool,
|
||||
#[arg(
|
||||
long = "accept-danger-non-interactive",
|
||||
default_value_t = false,
|
||||
action = clap::ArgAction::SetTrue
|
||||
)]
|
||||
pub accept_danger_non_interactive: bool,
|
||||
/// Profile TOML path (optional; if omitted, uses TOML `profile` or default `~/.claw-analog/profile.toml`).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub profile: Option<PathBuf>,
|
||||
/// TCP connect to host:port from `ANTHROPIC_BASE_URL` (or default API URL); not a full HTTP check.
|
||||
#[arg(long, visible_alias = "mock")]
|
||||
pub tcp_ping: bool,
|
||||
/// Skip HTTPS/TLS + auth + quota header checks against configured providers.
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub no_http_check: bool,
|
||||
/// Also probe the embeddings endpoint for OpenAI-compatible providers (may incur minimal cost).
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub embeddings_check: bool,
|
||||
/// Skip compile check (`cargo check` / `build --release`).
|
||||
#[arg(long)]
|
||||
pub no_build: bool,
|
||||
/// Run `cargo build --release -p claw-analog` (writes `target/release/…`, safe while `cargo run` holds `target/debug/…` on Windows).
|
||||
#[arg(long, conflicts_with = "no_build")]
|
||||
pub release_build: bool,
|
||||
/// Directory containing the repo workspace `Cargo.toml` (default: search upward from cwd).
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub manifest_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub fn run_doctor(args: DoctorCli) -> i32 {
|
||||
println!("claw-analog doctor — environment and build checks\n");
|
||||
|
||||
let workspace = args.workspace.clone();
|
||||
let canon_ws = std::fs::canonicalize(&workspace).unwrap_or_else(|_| workspace.clone());
|
||||
let cfg_path = args
|
||||
.config
|
||||
.clone()
|
||||
.unwrap_or_else(|| workspace.join(".claw-analog.toml"));
|
||||
let (file_cfg, cfg_note) = if cfg_path.is_file() {
|
||||
match load_analog_toml(&cfg_path) {
|
||||
Ok(c) => (c, "loaded"),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[claw-analog] doctor: failed to parse {}: {e} (using empty TOML defaults)",
|
||||
cfg_path.display()
|
||||
);
|
||||
(AnalogFileConfig::default(), "parse error (defaults)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(AnalogFileConfig::default(), "file missing (defaults only)")
|
||||
};
|
||||
|
||||
let stream_ov = if args.no_stream {
|
||||
StreamOverride::ForceOff
|
||||
} else if args.stream {
|
||||
StreamOverride::ForceOn
|
||||
} else {
|
||||
StreamOverride::FromFile
|
||||
};
|
||||
let overrides = AnalogDoctorOverrides {
|
||||
model: args.model.clone(),
|
||||
permission: args.permission.map(Into::into),
|
||||
preset: args.preset.map(Into::into),
|
||||
output_format: args.output_format.map(Into::into),
|
||||
stream: stream_ov,
|
||||
no_runtime_enforcer: args.no_runtime_enforcer,
|
||||
accept_danger_non_interactive: args.accept_danger_non_interactive,
|
||||
};
|
||||
let resolved = resolve_analog_options(&file_cfg, &overrides);
|
||||
|
||||
println!("NDJSON contract (for `--output-format json` runs):");
|
||||
println!(" schema: {NDJSON_SCHEMA}");
|
||||
println!(" format_version: {NDJSON_FORMAT_VERSION}\n");
|
||||
|
||||
println!("Effective config (merge of `.claw-analog.toml` + flags below):");
|
||||
println!(" workspace: {}", canon_ws.display());
|
||||
println!(" config: {} ({cfg_note})", cfg_path.display());
|
||||
println!(" model: {}", resolved.model);
|
||||
println!(" permission: {}", resolved.permission_mode.as_str());
|
||||
println!(" preset: {}", resolved.preset.label().unwrap_or("none"));
|
||||
println!(
|
||||
" output_format: {}",
|
||||
match resolved.output_format {
|
||||
OutputFormat::Rich => "rich",
|
||||
OutputFormat::Json => "json",
|
||||
}
|
||||
);
|
||||
println!(" stream: {}", resolved.use_stream);
|
||||
println!(
|
||||
" runtime_enforcer: {}",
|
||||
if resolved.use_runtime_enforcer {
|
||||
"on"
|
||||
} else {
|
||||
"off"
|
||||
}
|
||||
);
|
||||
println!(
|
||||
" accept_danger_non_interactive: {}",
|
||||
resolved.accept_danger_non_interactive
|
||||
);
|
||||
println!(" Provenance (which side won src ← …):");
|
||||
for line in &resolved.provenance {
|
||||
println!(" - {line}");
|
||||
}
|
||||
println!();
|
||||
|
||||
let prof = resolve_profile_path_doctor(
|
||||
args.profile.as_ref(),
|
||||
file_cfg.profile.as_deref(),
|
||||
&workspace,
|
||||
);
|
||||
print_profile_hint_section(&prof);
|
||||
println!();
|
||||
|
||||
check_env();
|
||||
println!();
|
||||
let build_ok = if args.no_build {
|
||||
println!("cargo: skipped (--no-build)");
|
||||
true
|
||||
} else if args.release_build {
|
||||
run_cargo_release_build(args.manifest_dir.as_deref())
|
||||
} else {
|
||||
run_cargo_check(args.manifest_dir.as_deref())
|
||||
};
|
||||
println!();
|
||||
if args.tcp_ping {
|
||||
ping_print();
|
||||
println!();
|
||||
}
|
||||
if !args.no_http_check {
|
||||
http_checks_print(args.embeddings_check);
|
||||
println!();
|
||||
}
|
||||
if build_ok {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::env::var_os("USERPROFILE").map(PathBuf::from)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
std::env::var_os("HOME").map(PathBuf::from)
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_user_path(raw: &str) -> PathBuf {
|
||||
if let Some(rest) = raw.strip_prefix("~/") {
|
||||
home_dir()
|
||||
.map(|h| h.join(rest))
|
||||
.unwrap_or_else(|| PathBuf::from(raw))
|
||||
} else {
|
||||
PathBuf::from(raw)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_profile_path_doctor(
|
||||
cli: Option<&PathBuf>,
|
||||
file: Option<&str>,
|
||||
workspace: &Path,
|
||||
) -> Option<PathBuf> {
|
||||
if let Some(p) = cli {
|
||||
return Some(if p.is_absolute() {
|
||||
p.clone()
|
||||
} else {
|
||||
workspace.join(p)
|
||||
});
|
||||
}
|
||||
if let Some(s) = file {
|
||||
let p = expand_user_path(s.trim());
|
||||
return Some(if p.is_absolute() {
|
||||
p
|
||||
} else {
|
||||
workspace.join(p)
|
||||
});
|
||||
}
|
||||
let def = home_dir()?.join(".claw-analog").join("profile.toml");
|
||||
if def.is_file() {
|
||||
Some(def)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn print_profile_hint_section(path: &Option<PathBuf>) {
|
||||
println!("Profile (system prompt snippet):");
|
||||
match path {
|
||||
None => println!(" (none — no --profile, no `profile` in TOML, default file absent)"),
|
||||
Some(p) => {
|
||||
print!(" path: {}", p.display());
|
||||
match load_profile_hint(p) {
|
||||
Ok(Some(h)) => println!(" — loaded, {} chars", h.chars().count()),
|
||||
Ok(None) => println!(" — file ok, empty `line`"),
|
||||
Err(e) => println!(" — error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_env_line(name: &str) {
|
||||
match std::env::var(name) {
|
||||
Ok(v) if !v.trim().is_empty() => {
|
||||
println!(" {name}: set ({} chars)", v.chars().count());
|
||||
}
|
||||
Ok(_) => println!(" {name}: set but empty"),
|
||||
Err(_) => println!(" {name}: unset"),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_env() {
|
||||
println!("Environment (values are not printed):");
|
||||
for name in ENV_CHECK {
|
||||
mask_env_line(name);
|
||||
}
|
||||
let anthro_ok = std::env::var("ANTHROPIC_API_KEY")
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
|| std::env::var("ANTHROPIC_AUTH_TOKEN")
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
let openai_ok = std::env::var("OPENAI_API_KEY")
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
println!();
|
||||
if anthro_ok {
|
||||
println!("Anthropic credentials: OK (API key and/or auth token).");
|
||||
} else {
|
||||
println!("Anthropic credentials: not set — needed for default Claude/Anthropic models.");
|
||||
}
|
||||
if openai_ok {
|
||||
println!("OpenAI API key: set — use `openai/...` model prefix for that provider.");
|
||||
} else {
|
||||
println!("OpenAI API key: unset — only relevant for `openai/` models.");
|
||||
}
|
||||
if !anthro_ok && !openai_ok {
|
||||
println!("\nNote: neither Anthropic nor OpenAI keys are set; live runs will fail until you export credentials (see USAGE.md).");
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk upward from `start` for a `Cargo.toml` that defines `[workspace]`.
|
||||
pub fn discover_cargo_workspace(start: &Path) -> Option<PathBuf> {
|
||||
let mut dir = start.to_path_buf();
|
||||
for _ in 0..32 {
|
||||
let manifest = dir.join("Cargo.toml");
|
||||
if manifest.is_file() {
|
||||
if let Ok(txt) = std::fs::read_to_string(&manifest) {
|
||||
if txt.contains("[workspace]") {
|
||||
return Some(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
dir = dir.parent()?.to_path_buf();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn workspace_root_or_eprint(manifest_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
let start = manifest_dir
|
||||
.map(Path::to_path_buf)
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
discover_cargo_workspace(&start).or_else(|| {
|
||||
eprintln!(
|
||||
"cargo: could not find a [workspace] Cargo.toml above {}.\n Pass --manifest-dir pointing at the `rust` folder of claw-code.",
|
||||
start.display()
|
||||
);
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// `cargo check` does not replace `target/debug/claw-analog.exe`, so `cargo run … doctor` works on Windows.
|
||||
fn run_cargo_check(manifest_dir: Option<&Path>) -> bool {
|
||||
let Some(root) = workspace_root_or_eprint(manifest_dir) else {
|
||||
return false;
|
||||
};
|
||||
println!("cargo check -p claw-analog (workspace {})", root.display());
|
||||
println!(" (compile-only; avoids “access denied” replacing the running debug exe on Windows)");
|
||||
let status = Command::new("cargo")
|
||||
.args(["check", "-p", "claw-analog"])
|
||||
.current_dir(&root)
|
||||
.status();
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!("cargo check: OK");
|
||||
true
|
||||
}
|
||||
Ok(s) => {
|
||||
eprintln!("cargo check: failed ({s})");
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("cargo check: could not run `cargo` ({e}). Is Rust/Cargo on PATH?");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_cargo_release_build(manifest_dir: Option<&Path>) -> bool {
|
||||
let Some(root) = workspace_root_or_eprint(manifest_dir) else {
|
||||
return false;
|
||||
};
|
||||
println!(
|
||||
"cargo build --release -p claw-analog (workspace {})",
|
||||
root.display()
|
||||
);
|
||||
println!(" (output in target/release/; does not overwrite a running target/debug/ binary)");
|
||||
let status = Command::new("cargo")
|
||||
.args(["build", "--release", "-p", "claw-analog"])
|
||||
.current_dir(&root)
|
||||
.status();
|
||||
match status {
|
||||
Ok(s) if s.success() => {
|
||||
println!("cargo build --release: OK");
|
||||
true
|
||||
}
|
||||
Ok(s) => {
|
||||
eprintln!("cargo build --release: failed ({s})");
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("cargo build --release: could not run `cargo` ({e}). Is Rust/Cargo on PATH?");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_anthropic_base() -> String {
|
||||
std::env::var("ANTHROPIC_BASE_URL").unwrap_or_else(|_| "https://api.anthropic.com".into())
|
||||
}
|
||||
|
||||
fn parse_host_port(url: &str) -> Result<(String, u16), String> {
|
||||
let url = url.trim().trim_end_matches('/');
|
||||
let (scheme, rest) = if let Some(r) = url.strip_prefix("https://") {
|
||||
("https", r)
|
||||
} else if let Some(r) = url.strip_prefix("http://") {
|
||||
("http", r)
|
||||
} else {
|
||||
return Err("URL must start with http:// or https://".into());
|
||||
};
|
||||
let host_part = rest
|
||||
.split('/')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| "missing host".to_string())?;
|
||||
if let Some((host, port_s)) = host_part.rsplit_once(':') {
|
||||
if let Ok(p) = port_s.parse::<u16>() {
|
||||
let host = host.trim_start_matches('[').trim_end_matches(']');
|
||||
return Ok((host.to_string(), p));
|
||||
}
|
||||
}
|
||||
let default_port = if scheme == "https" { 443 } else { 80 };
|
||||
Ok((host_part.to_string(), default_port))
|
||||
}
|
||||
|
||||
fn ping_print() {
|
||||
let url = default_anthropic_base();
|
||||
println!("TCP check for ANTHROPIC_BASE_URL (default if unset): {url}");
|
||||
match parse_host_port(&url) {
|
||||
Ok((host, port)) => match tcp_ping(&host, port) {
|
||||
Ok(()) => println!(" reachability: OK ({host}:{port})"),
|
||||
Err(e) => println!(" reachability: FAIL ({host}:{port}) — {e}"),
|
||||
},
|
||||
Err(e) => println!(" could not parse URL: {e}"),
|
||||
}
|
||||
println!(" (HTTP/TLS application data is not validated; this is connect() only.)");
|
||||
}
|
||||
|
||||
fn tcp_ping(host: &str, port: u16) -> Result<(), String> {
|
||||
let addr = (host, port)
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| e.to_string())?
|
||||
.next()
|
||||
.ok_or_else(|| "no resolved addresses".to_string())?;
|
||||
TcpStream::connect_timeout(&addr, Duration::from_secs(3)).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn http_checks_print(embeddings_check: bool) {
|
||||
println!("HTTP/TLS checks (auth + TLS validation + quota headers when available):");
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build();
|
||||
let Ok(rt) = rt else {
|
||||
println!(" runtime: FAIL (could not build tokio runtime)");
|
||||
return;
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
// OpenAI-compatible providers (OPENAI_BASE_URL, OPENAI_API_KEY)
|
||||
if let Ok(key) = std::env::var("OPENAI_API_KEY") {
|
||||
if !key.trim().is_empty() {
|
||||
let base = std::env::var("OPENAI_BASE_URL")
|
||||
.ok()
|
||||
.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
|
||||
let url = openai_models_url(base.as_str());
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(v) = HeaderValue::from_str(format!("Bearer {}", key.trim()).as_str()) {
|
||||
headers.insert(reqwest::header::AUTHORIZATION, v);
|
||||
}
|
||||
let _ = http_check_and_print("openai", url.as_str(), headers).await;
|
||||
|
||||
if embeddings_check {
|
||||
let model = std::env::var("OPENAI_EMBEDDING_MODEL")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("CLAW_RAG_EMBEDDING_MODEL").ok())
|
||||
.unwrap_or_else(|| "text-embedding-3-small".to_string());
|
||||
let eurl = openai_embeddings_url(base.as_str());
|
||||
let mut eheaders = HeaderMap::new();
|
||||
if let Ok(v) = HeaderValue::from_str(format!("Bearer {}", key.trim()).as_str())
|
||||
{
|
||||
eheaders.insert(reqwest::header::AUTHORIZATION, v);
|
||||
}
|
||||
let _ = openai_embeddings_probe(
|
||||
"openai embeddings",
|
||||
eurl.as_str(),
|
||||
&model,
|
||||
eheaders,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
println!(" openai embeddings: skipped (pass --embeddings-check to enable)");
|
||||
}
|
||||
} else {
|
||||
println!(" openai: skipped (OPENAI_API_KEY empty)");
|
||||
}
|
||||
} else {
|
||||
println!(" openai: skipped (OPENAI_API_KEY unset)");
|
||||
}
|
||||
|
||||
// Anthropic (ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY/AUTH_TOKEN)
|
||||
let a_key = std::env::var("ANTHROPIC_API_KEY").ok();
|
||||
let a_tok = std::env::var("ANTHROPIC_AUTH_TOKEN").ok();
|
||||
let a_base = std::env::var("ANTHROPIC_BASE_URL")
|
||||
.ok()
|
||||
.unwrap_or_else(|| "https://api.anthropic.com".to_string());
|
||||
if a_key.as_deref().is_some_and(|s| !s.trim().is_empty())
|
||||
|| a_tok.as_deref().is_some_and(|s| !s.trim().is_empty())
|
||||
{
|
||||
let url = anthropic_models_url(a_base.as_str());
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("anthropic-version"),
|
||||
HeaderValue::from_static("2023-06-01"),
|
||||
);
|
||||
if let Some(k) = a_key.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if let Ok(v) = HeaderValue::from_str(k) {
|
||||
headers.insert(HeaderName::from_static("x-api-key"), v);
|
||||
}
|
||||
} else if let Some(t) = a_tok.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if let Ok(v) = HeaderValue::from_str(format!("Bearer {t}").as_str()) {
|
||||
headers.insert(reqwest::header::AUTHORIZATION, v);
|
||||
}
|
||||
}
|
||||
let _ = http_check_and_print("anthropic", url.as_str(), headers).await;
|
||||
} else {
|
||||
println!(" anthropic: skipped (no API key/token)");
|
||||
}
|
||||
|
||||
// RAG service (RAG_BASE_URL) — just basic health + stats.
|
||||
if let Ok(base) = std::env::var("RAG_BASE_URL") {
|
||||
let base = base.trim().trim_end_matches('/');
|
||||
if !base.is_empty() {
|
||||
let headers = HeaderMap::new();
|
||||
let _ =
|
||||
http_check_and_print("rag health", &format!("{base}/health"), headers.clone())
|
||||
.await;
|
||||
let _ =
|
||||
http_check_and_print("rag stats", &format!("{base}/v1/stats"), headers).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
println!(" (TLS validation is performed by the HTTP client; certificate errors surface as request failures.)");
|
||||
}
|
||||
|
||||
fn openai_models_url(base: &str) -> String {
|
||||
let b = base.trim().trim_end_matches('/');
|
||||
if b.ends_with("/v1") {
|
||||
format!("{b}/models")
|
||||
} else {
|
||||
format!("{b}/v1/models")
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_embeddings_url(base: &str) -> String {
|
||||
let b = base.trim().trim_end_matches('/');
|
||||
if b.ends_with("/v1") {
|
||||
format!("{b}/embeddings")
|
||||
} else {
|
||||
format!("{b}/v1/embeddings")
|
||||
}
|
||||
}
|
||||
|
||||
fn anthropic_models_url(base: &str) -> String {
|
||||
let b = base.trim().trim_end_matches('/');
|
||||
format!("{b}/v1/models?limit=1")
|
||||
}
|
||||
|
||||
async fn http_check_and_print(label: &str, url: &str, headers: HeaderMap) -> Result<(), ()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(8))
|
||||
.build();
|
||||
let Ok(client) = client else {
|
||||
println!(" {label}: FAIL (client build)");
|
||||
return Err(());
|
||||
};
|
||||
|
||||
let resp = client.get(url).headers(headers).send().await;
|
||||
match resp {
|
||||
Ok(r) => {
|
||||
let status = r.status();
|
||||
println!(" {label}: {status} ({url})");
|
||||
print_quota_headers(r.headers());
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.to_ascii_lowercase().contains("certificate")
|
||||
|| msg.to_ascii_lowercase().contains("tls")
|
||||
{
|
||||
println!(" {label}: FAIL (TLS/cert) ({url}) — {msg}");
|
||||
} else {
|
||||
println!(" {label}: FAIL ({url}) — {msg}");
|
||||
}
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_quota_headers(headers: &HeaderMap) {
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
for (k, v) in headers.iter() {
|
||||
let name = k.as_str().to_ascii_lowercase();
|
||||
if name.contains("ratelimit") || name.contains("quota") {
|
||||
if let Ok(s) = v.to_str() {
|
||||
out.push((k.as_str().to_string(), s.to_string()));
|
||||
}
|
||||
}
|
||||
// OpenAI-compatible common headers:
|
||||
if name.starts_with("x-ratelimit-") {
|
||||
if let Ok(s) = v.to_str() {
|
||||
out.push((k.as_str().to_string(), s.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
for (k, v) in out {
|
||||
println!(" {k}: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn openai_embeddings_probe(
|
||||
label: &str,
|
||||
url: &str,
|
||||
model: &str,
|
||||
headers: HeaderMap,
|
||||
) -> Result<(), ()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(12))
|
||||
.build();
|
||||
let Ok(client) = client else {
|
||||
println!(" {label}: FAIL (client build)");
|
||||
return Err(());
|
||||
};
|
||||
|
||||
// Minimal request: one short string. We don't parse the embedding content.
|
||||
let body = serde_json::json!({
|
||||
"model": model,
|
||||
"input": ["ping"]
|
||||
});
|
||||
|
||||
let resp = client.post(url).headers(headers).json(&body).send().await;
|
||||
match resp {
|
||||
Ok(r) => {
|
||||
let status = r.status();
|
||||
println!(" {label}: {status} ({url}) model={model}");
|
||||
print_quota_headers(r.headers());
|
||||
if !status.is_success() {
|
||||
let t = r.text().await.unwrap_or_default();
|
||||
if !t.trim().is_empty() {
|
||||
println!(" body: {}", t.chars().take(400).collect::<String>());
|
||||
}
|
||||
return Err(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.to_ascii_lowercase().contains("certificate")
|
||||
|| msg.to_ascii_lowercase().contains("tls")
|
||||
{
|
||||
println!(" {label}: FAIL (TLS/cert) ({url}) — {msg}");
|
||||
} else {
|
||||
println!(" {label}: FAIL ({url}) — {msg}");
|
||||
}
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_base_url_host_port() {
|
||||
assert_eq!(
|
||||
parse_host_port("http://127.0.0.1:8080/v1").unwrap(),
|
||||
("127.0.0.1".into(), 8080)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_host_port("https://api.anthropic.com").unwrap(),
|
||||
("api.anthropic.com".into(), 443)
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,522 +0,0 @@
|
|||
//! Binary wrapper for `claw_analog::run` — see `how_to_run.md` in repo root.
|
||||
|
||||
mod agents;
|
||||
mod config_cmd;
|
||||
mod doctor;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
|
||||
use clap_complete::{generate, Shell};
|
||||
use claw_analog::{
|
||||
load_analog_toml, load_profile_hint, permission_mode_from_toml_str, print_tools_dry_run,
|
||||
resolve_analog_profile_path, resolve_rag_base_url, AnalogConfig, AnalogFileConfig,
|
||||
AnalogLanguage, OutputFormat, PermissionMode, Preset, ANALOG_DEFAULT_MODEL,
|
||||
};
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum PermissionArg {
|
||||
ReadOnly,
|
||||
WorkspaceWrite,
|
||||
Prompt,
|
||||
#[value(name = "danger-full-access")]
|
||||
DangerFullAccess,
|
||||
/// Same unrestricted posture as danger-full-access for this narrow tool set.
|
||||
Allow,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum OutputFormatArg {
|
||||
Rich,
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum LangArg {
|
||||
En,
|
||||
Ru,
|
||||
}
|
||||
|
||||
impl From<LangArg> for AnalogLanguage {
|
||||
fn from(a: LangArg) -> Self {
|
||||
match a {
|
||||
LangArg::En => AnalogLanguage::En,
|
||||
LangArg::Ru => AnalogLanguage::Ru,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum PresetCli {
|
||||
None,
|
||||
/// Automatically infer a preset from the initial prompt.
|
||||
Auto,
|
||||
Audit,
|
||||
Explain,
|
||||
Implement,
|
||||
}
|
||||
|
||||
impl From<PresetCli> for Preset {
|
||||
fn from(p: PresetCli) -> Self {
|
||||
match p {
|
||||
PresetCli::None => Preset::None,
|
||||
PresetCli::Auto => Preset::None,
|
||||
PresetCli::Audit => Preset::Audit,
|
||||
PresetCli::Explain => Preset::Explain,
|
||||
PresetCli::Implement => Preset::Implement,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "claw-analog",
|
||||
version,
|
||||
about = "Lean tool-agent loop (read/list/grep/write) on claw-code `api` providers"
|
||||
)]
|
||||
#[command(args_conflicts_with_subcommands = true)]
|
||||
struct RootCli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
#[command(flatten)]
|
||||
run: RunCli,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Commands {
|
||||
/// Verify credentials, `cargo check -p claw-analog` (or `--release-build`), config merge preview, optional `--tcp-ping`.
|
||||
Doctor(doctor::DoctorCli),
|
||||
Config {
|
||||
#[command(subcommand)]
|
||||
command: ConfigSub,
|
||||
},
|
||||
/// Print shell completion script for this binary (redirect to a file or `source` it).
|
||||
Complete(CompleteCli),
|
||||
/// Run multiple specialized sub-agents sequentially (shared base session).
|
||||
Agents(agents::AgentsCli),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum ConfigSub {
|
||||
/// Parse `.claw-analog.toml` and profile; print a merge preview (no API calls).
|
||||
Validate(config_cmd::ValidateCli),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct CompleteCli {
|
||||
#[arg(value_enum)]
|
||||
shell: ShellKind,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||
enum ShellKind {
|
||||
Bash,
|
||||
Zsh,
|
||||
Fish,
|
||||
#[value(name = "powershell", alias = "pwsh")]
|
||||
Powershell,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct RunCli {
|
||||
/// Config file (default: `<workspace>/.claw-analog.toml` if that path exists).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(short, long)]
|
||||
model: Option<String>,
|
||||
#[arg(short = 'w', long, default_value = ".")]
|
||||
workspace: PathBuf,
|
||||
#[arg(long, value_enum)]
|
||||
permission: Option<PermissionArg>,
|
||||
#[arg(long, value_enum)]
|
||||
preset: Option<PresetCli>,
|
||||
/// Reply language hint for the assistant (`en` or `ru` in system prompt; not the API model id).
|
||||
#[arg(long, value_enum)]
|
||||
lang: Option<LangArg>,
|
||||
/// Print effective tools for merged `permission` / enforcer, then exit (no prompt, no API).
|
||||
#[arg(long, default_value_t = false, action = clap::ArgAction::SetTrue)]
|
||||
print_tools: bool,
|
||||
/// Persist message history for resume (JSON). See `how_to_run.md` for risks.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
session: Option<PathBuf>,
|
||||
/// Write session JSON to this path on each snapshot (export without `--session`, or an extra copy).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
save_session: Option<PathBuf>,
|
||||
/// Profile snippet TOML (`line = "..."`). Default: `~/.claw-analog/profile.toml` if it exists.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
profile: Option<PathBuf>,
|
||||
/// Stream assistant text to stdout as tokens arrive (uses `stream_message`).
|
||||
#[arg(long, default_value_t = false, conflicts_with = "no_stream")]
|
||||
stream: bool,
|
||||
/// Turn streaming off (overrides `stream` in config).
|
||||
#[arg(long, default_value_t = false, conflicts_with = "stream")]
|
||||
no_stream: bool,
|
||||
/// Newline-delimited JSON events on stdout (for agents / CI). Diagnostics stay on stderr.
|
||||
#[arg(long, value_enum)]
|
||||
output_format: Option<OutputFormatArg>,
|
||||
/// Disable `runtime::PermissionEnforcer` (paths are still jailed; policy checks are weakened).
|
||||
#[arg(long = "no-runtime-enforcer", default_value_t = false, action = clap::ArgAction::SetTrue)]
|
||||
no_runtime_enforcer: bool,
|
||||
/// Allow `danger-full-access` / `allow` when stdin is not a TTY (CI/automation; use with care).
|
||||
#[arg(long = "accept-danger-non-interactive", default_value_t = false, action = clap::ArgAction::SetTrue)]
|
||||
accept_danger_non_interactive: bool,
|
||||
#[arg(long)]
|
||||
max_read_bytes: Option<u64>,
|
||||
#[arg(long)]
|
||||
max_turns: Option<u32>,
|
||||
#[arg(long)]
|
||||
max_list_entries: Option<usize>,
|
||||
#[arg(long)]
|
||||
grep_max_lines: Option<usize>,
|
||||
#[arg(long)]
|
||||
glob_max_paths: Option<usize>,
|
||||
#[arg(long)]
|
||||
glob_max_depth: Option<usize>,
|
||||
prompt: Option<String>,
|
||||
}
|
||||
|
||||
const DEF_MAX_READ: u64 = 256 * 1024;
|
||||
const DEF_MAX_TURNS: u32 = 24;
|
||||
const DEF_MAX_LIST: usize = 500;
|
||||
const DEF_GREP_MAX: usize = 200;
|
||||
const DEF_GLOB_PATHS: usize = 2000;
|
||||
const DEF_GLOB_DEPTH: usize = 32;
|
||||
const DEF_RAG_TIMEOUT_SECS: u64 = 30;
|
||||
const DEF_RAG_TOP_K_MAX: u32 = 32;
|
||||
const RAG_TOP_K_ABS_CAP: u32 = 256;
|
||||
|
||||
fn config_file_path(cli: &RunCli) -> PathBuf {
|
||||
cli.config
|
||||
.clone()
|
||||
.unwrap_or_else(|| cli.workspace.join(".claw-analog.toml"))
|
||||
}
|
||||
|
||||
fn load_file_config(path: &Path) -> AnalogFileConfig {
|
||||
if !path.is_file() {
|
||||
return AnalogFileConfig::default();
|
||||
}
|
||||
match load_analog_toml(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[claw-analog] warning: failed to read {}: {e}",
|
||||
path.display()
|
||||
);
|
||||
AnalogFileConfig::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn output_format_from_toml(s: &str) -> Option<OutputFormat> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"json" => Some(OutputFormat::Json),
|
||||
"rich" => Some(OutputFormat::Rich),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_session_path(
|
||||
cli: Option<PathBuf>,
|
||||
file: Option<&str>,
|
||||
workspace: &Path,
|
||||
) -> Option<PathBuf> {
|
||||
let p = cli.or_else(|| file.map(PathBuf::from))?;
|
||||
Some(if p.is_absolute() {
|
||||
p
|
||||
} else {
|
||||
workspace.join(p)
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_language(cli: Option<LangArg>, file: Option<&str>) -> AnalogLanguage {
|
||||
if let Some(l) = cli {
|
||||
return l.into();
|
||||
}
|
||||
file.and_then(AnalogLanguage::from_toml_str)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn merge_preset(cli: Option<PresetCli>, file: Option<&str>, prompt: &str) -> Preset {
|
||||
if let Some(p) = cli {
|
||||
return match p {
|
||||
PresetCli::Auto => claw_analog::infer_preset_from_prompt(prompt),
|
||||
other => Preset::from(other),
|
||||
};
|
||||
}
|
||||
if file.is_some_and(|s| s.trim().eq_ignore_ascii_case("auto")) {
|
||||
return claw_analog::infer_preset_from_prompt(prompt);
|
||||
}
|
||||
if let Some(s) = file.and_then(Preset::from_toml_str) {
|
||||
return s;
|
||||
}
|
||||
claw_analog::infer_preset_from_prompt(prompt)
|
||||
}
|
||||
|
||||
fn merge_permission(
|
||||
cli: Option<PermissionArg>,
|
||||
file_perm: Option<String>,
|
||||
preset: Preset,
|
||||
) -> PermissionMode {
|
||||
if let Some(p) = cli {
|
||||
return match p {
|
||||
PermissionArg::ReadOnly => PermissionMode::ReadOnly,
|
||||
PermissionArg::WorkspaceWrite => PermissionMode::WorkspaceWrite,
|
||||
PermissionArg::Prompt => PermissionMode::Prompt,
|
||||
PermissionArg::DangerFullAccess => PermissionMode::DangerFullAccess,
|
||||
PermissionArg::Allow => PermissionMode::Allow,
|
||||
};
|
||||
}
|
||||
if let Some(s) = file_perm.as_deref().and_then(permission_mode_from_toml_str) {
|
||||
return s;
|
||||
}
|
||||
match preset {
|
||||
Preset::Implement => PermissionMode::WorkspaceWrite,
|
||||
_ => PermissionMode::ReadOnly,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_config(
|
||||
cli: &RunCli,
|
||||
file: &AnalogFileConfig,
|
||||
prompt: String,
|
||||
profile_hint: Option<String>,
|
||||
session_path: Option<PathBuf>,
|
||||
preset: Preset,
|
||||
permission_mode: PermissionMode,
|
||||
) -> AnalogConfig {
|
||||
let model = cli
|
||||
.model
|
||||
.clone()
|
||||
.or_else(|| file.model.clone())
|
||||
.unwrap_or_else(|| ANALOG_DEFAULT_MODEL.into());
|
||||
|
||||
let output_format = cli
|
||||
.output_format
|
||||
.map(|o| match o {
|
||||
OutputFormatArg::Rich => OutputFormat::Rich,
|
||||
OutputFormatArg::Json => OutputFormat::Json,
|
||||
})
|
||||
.or_else(|| {
|
||||
file.output_format
|
||||
.as_deref()
|
||||
.and_then(output_format_from_toml)
|
||||
})
|
||||
.unwrap_or(OutputFormat::Rich);
|
||||
|
||||
let use_stream = if cli.no_stream {
|
||||
false
|
||||
} else if cli.stream {
|
||||
true
|
||||
} else {
|
||||
file.stream.unwrap_or(false)
|
||||
};
|
||||
|
||||
let use_runtime_enforcer =
|
||||
!cli.no_runtime_enforcer && !file.no_runtime_enforcer.unwrap_or(false);
|
||||
|
||||
let accept_danger_non_interactive =
|
||||
cli.accept_danger_non_interactive || file.accept_danger_non_interactive.unwrap_or(false);
|
||||
|
||||
let max_read_bytes = cli
|
||||
.max_read_bytes
|
||||
.or(file.max_read_bytes)
|
||||
.unwrap_or(DEF_MAX_READ);
|
||||
let max_turns = cli.max_turns.or(file.max_turns).unwrap_or(DEF_MAX_TURNS);
|
||||
let max_list_entries = cli
|
||||
.max_list_entries
|
||||
.or(file.max_list_entries)
|
||||
.unwrap_or(DEF_MAX_LIST);
|
||||
let grep_max_lines = cli
|
||||
.grep_max_lines
|
||||
.or(file.grep_max_lines)
|
||||
.unwrap_or(DEF_GREP_MAX);
|
||||
let glob_max_paths = cli
|
||||
.glob_max_paths
|
||||
.or(file.glob_max_paths)
|
||||
.unwrap_or(DEF_GLOB_PATHS);
|
||||
let glob_max_depth = cli
|
||||
.glob_max_depth
|
||||
.or(file.glob_max_depth)
|
||||
.unwrap_or(DEF_GLOB_DEPTH);
|
||||
|
||||
let rag_base_url = resolve_rag_base_url(file);
|
||||
let rag_http_timeout =
|
||||
Duration::from_secs(file.rag_timeout_secs.unwrap_or(DEF_RAG_TIMEOUT_SECS).max(1));
|
||||
let rag_top_k_max = file
|
||||
.rag_top_k_max
|
||||
.unwrap_or(DEF_RAG_TOP_K_MAX)
|
||||
.clamp(1, RAG_TOP_K_ABS_CAP);
|
||||
|
||||
let session_save_path = cli.save_session.as_ref().map(|p| {
|
||||
if p.is_absolute() {
|
||||
p.clone()
|
||||
} else {
|
||||
cli.workspace.join(p)
|
||||
}
|
||||
});
|
||||
|
||||
let language = merge_language(cli.lang, file.language.as_deref());
|
||||
|
||||
AnalogConfig {
|
||||
model,
|
||||
workspace: cli.workspace.clone(),
|
||||
permission_mode,
|
||||
accept_danger_non_interactive,
|
||||
use_stream,
|
||||
output_format,
|
||||
use_runtime_enforcer,
|
||||
max_read_bytes,
|
||||
max_turns,
|
||||
max_list_entries,
|
||||
grep_max_lines,
|
||||
glob_max_paths,
|
||||
glob_max_depth,
|
||||
preset,
|
||||
language,
|
||||
session_path,
|
||||
session_save_path,
|
||||
profile_hint,
|
||||
prompt,
|
||||
rag_base_url,
|
||||
rag_http_timeout,
|
||||
rag_top_k_max,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let root = RootCli::parse();
|
||||
match root.command {
|
||||
Some(Commands::Doctor(d)) => {
|
||||
let code = doctor::run_doctor(d);
|
||||
std::process::exit(code);
|
||||
}
|
||||
Some(Commands::Agents(a)) => {
|
||||
let code = match agents::run_agents(a) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => {
|
||||
eprintln!("agents: {e}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
Some(Commands::Config { command }) => {
|
||||
let code = match command {
|
||||
ConfigSub::Validate(v) => config_cmd::run_validate(v),
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
Some(Commands::Complete(co)) => {
|
||||
let shell = match co.shell {
|
||||
ShellKind::Bash => Shell::Bash,
|
||||
ShellKind::Zsh => Shell::Zsh,
|
||||
ShellKind::Fish => Shell::Fish,
|
||||
ShellKind::Powershell => Shell::PowerShell,
|
||||
};
|
||||
let mut cmd = RootCli::command();
|
||||
generate(shell, &mut cmd, "claw-analog", &mut std::io::stdout());
|
||||
return Ok(());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
let cli = root.run;
|
||||
let cfg_path = config_file_path(&cli);
|
||||
let file_cfg = load_file_config(&cfg_path);
|
||||
|
||||
if cli.print_tools {
|
||||
let preset = merge_preset(
|
||||
cli.preset,
|
||||
file_cfg.preset.as_deref(),
|
||||
&cli.prompt.clone().unwrap_or_default(),
|
||||
);
|
||||
let permission_mode = merge_permission(cli.permission, file_cfg.permission.clone(), preset);
|
||||
let use_runtime_enforcer =
|
||||
!cli.no_runtime_enforcer && !file_cfg.no_runtime_enforcer.unwrap_or(false);
|
||||
let rag_url = resolve_rag_base_url(&file_cfg);
|
||||
print_tools_dry_run(
|
||||
permission_mode,
|
||||
use_runtime_enforcer,
|
||||
rag_url.as_deref(),
|
||||
&mut std::io::stdout(),
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pre_output_format = cli
|
||||
.output_format
|
||||
.map(|o| match o {
|
||||
OutputFormatArg::Rich => OutputFormat::Rich,
|
||||
OutputFormatArg::Json => OutputFormat::Json,
|
||||
})
|
||||
.or_else(|| {
|
||||
file_cfg
|
||||
.output_format
|
||||
.as_deref()
|
||||
.and_then(output_format_from_toml)
|
||||
})
|
||||
.unwrap_or(OutputFormat::Rich);
|
||||
|
||||
let prompt = if let Some(p) = cli.prompt.clone() {
|
||||
p
|
||||
} else {
|
||||
use std::io::Read;
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_to_string(&mut buf)?;
|
||||
if buf.trim().is_empty() {
|
||||
if matches!(pre_output_format, OutputFormat::Json) {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({"type": "error", "message": "empty prompt (pass as arg or stdin)"})
|
||||
);
|
||||
}
|
||||
return Err("empty prompt (pass as arg or stdin)".into());
|
||||
}
|
||||
buf
|
||||
};
|
||||
|
||||
let preset = merge_preset(cli.preset, file_cfg.preset.as_deref(), &prompt);
|
||||
let permission_mode = merge_permission(cli.permission, file_cfg.permission.clone(), preset);
|
||||
|
||||
let session_path = resolve_session_path(
|
||||
cli.session.clone(),
|
||||
file_cfg.session.as_deref(),
|
||||
&cli.workspace,
|
||||
);
|
||||
|
||||
let profile_path = resolve_analog_profile_path(
|
||||
&cli.workspace,
|
||||
cli.profile.clone(),
|
||||
file_cfg.profile.as_deref(),
|
||||
);
|
||||
|
||||
let profile_hint = if let Some(ref p) = profile_path {
|
||||
load_profile_hint(p)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let config = build_config(
|
||||
&cli,
|
||||
&file_cfg,
|
||||
prompt,
|
||||
profile_hint,
|
||||
session_path,
|
||||
preset,
|
||||
permission_mode,
|
||||
);
|
||||
let output_format = config.output_format;
|
||||
|
||||
let mut out = std::io::stdout();
|
||||
if let Err(e) = claw_analog::run(config, &mut out).await {
|
||||
if matches!(output_format, OutputFormat::Json) {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({"type": "error", "message": e.to_string()})
|
||||
);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
[package]
|
||||
name = "rusty-claude-cli"
|
||||
name = "claw-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
|
@ -12,7 +12,10 @@ path = "src/main.rs"
|
|||
[dependencies]
|
||||
api = { path = "../api" }
|
||||
commands = { path = "../commands" }
|
||||
dunce.workspace = true
|
||||
compat-harness = { path = "../compat-harness" }
|
||||
crossterm = "0.28"
|
||||
unicode-width = "0.2"
|
||||
pulldown-cmark = "0.13"
|
||||
rustyline = "15"
|
||||
runtime = { path = "../runtime" }
|
||||
|
|
@ -22,14 +25,26 @@ serde_json.workspace = true
|
|||
syntect = "5"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
|
||||
tools = { path = "../tools" }
|
||||
log = "0.4"
|
||||
mime_guess = "2.0.5"
|
||||
base64 = "0.22.1"
|
||||
chardetng = "0.1"
|
||||
image = "0.25"
|
||||
sha2 = "0.10"
|
||||
phf = { version = "0.11", features = ["macros"] }
|
||||
dialoguer = "0.11"
|
||||
inquire = "0.9.4"
|
||||
|
||||
[build-dependencies]
|
||||
# 2.x is the API our build.rs uses (compile(path, embed_resource::NONE)).
|
||||
# 0.3.x was never published; the crate jumped 1.x -> 2.x -> 3.x.
|
||||
embed-resource = "2.5"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
mock-anthropic-service = { path = "../mock-anthropic-service" }
|
||||
runtime = { path = "../runtime" }
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue