mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-12 18:18:10 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91721d8a17 | ||
|
|
1a04659a95 | ||
|
|
87884a1e84 | ||
|
|
ccefab8d44 | ||
|
|
5f90e1685e | ||
|
|
b556559fb4 | ||
|
|
19a9724008 | ||
|
|
4212ba9f26 | ||
|
|
6c05c3b3ac | ||
|
|
ab2c708ca7 | ||
|
|
fa3905041d | ||
|
|
ab87765c26 | ||
|
|
b20937ba55 | ||
|
|
6c55d80dea | ||
|
|
4e516a7c1f | ||
|
|
702f9c353c | ||
|
|
4ded9e2a4f | ||
|
|
9f47d78a03 | ||
|
|
67b371ca95 | ||
|
|
858649d458 | ||
|
|
433d7cb6e9 | ||
|
|
e0bccecd5c | ||
|
|
02ecc51e9c | ||
|
|
72d1c43d50 | ||
|
|
9e3aafeabb | ||
|
|
325aaefc00 | ||
|
|
62d2877b3b | ||
|
|
3f8d04178c | ||
|
|
18ce9f96f1 | ||
|
|
5466cd7a19 | ||
|
|
3c3c3f49ce | ||
|
|
a7c5ef9374 | ||
|
|
55148906f2 | ||
|
|
df45aece8f | ||
|
|
32443ae91b | ||
|
|
4ae8c8a3fa |
No files matched your search
Whitespace-only changes.
@@ -50,7 +50,15 @@ TODO*.md
|
||||
todo*.md
|
||||
CLAUDE.md
|
||||
NEXT_SESSION.md
|
||||
AI_HANDOFF.md
|
||||
result.json
|
||||
count_deps.py
|
||||
project_overhaul_prompt.md
|
||||
|
||||
.codex/
|
||||
docs/rlm-paper.txt
|
||||
.context/
|
||||
|
||||
# Local runtime state
|
||||
.deepseek/
|
||||
session_*.json
|
||||
@@ -12,7 +12,7 @@ This file provides context for AI assistants working on this project.
|
||||
- Format: `cargo fmt`
|
||||
- Lint: `cargo clippy`
|
||||
|
||||
### Project: deepseek-cli
|
||||
### Project: deepseek-tui
|
||||
|
||||
### Documentation
|
||||
See README.md for project overview.
|
||||
@@ -21,13 +21,56 @@ See README.md for project overview.
|
||||
This project uses Git. See .gitignore for excluded files.
|
||||
|
||||
|
||||
## Advanced Capabilities
|
||||
|
||||
### Model Context Protocol (MCP)
|
||||
This CLI supports MCP for extending tool access.
|
||||
- Use `mcp_read_resource` to read context from external servers.
|
||||
- Use `mcp_get_prompt` to leverage pre-defined expert prompts from servers.
|
||||
- You can connect to HTTP/SSE servers by adding their URL to `mcp.json`.
|
||||
|
||||
### Multi-Agent Orchestration
|
||||
For complex, multi-step tasks, you should delegate work:
|
||||
- **Sub-agents**: Use `agent_spawn` (or its alias `delegate_to_agent`) to launch a background assistant for a specific sub-task. Use `agent_result` to get their output.
|
||||
- **Swarms**: Use `agent_swarm` to orchestrate multiple sub-agents with dependencies. This is ideal for parallel exploration or complex refactoring where different parts of the project can be analyzed concurrently.
|
||||
|
||||
### Project Mapping
|
||||
- Use `project_map` to get a comprehensive view of the codebase structure. This tool respects `.gitignore` and provides a summary of key files.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Follow existing code style and patterns
|
||||
- Write tests for new functionality
|
||||
- Keep changes focused and atomic
|
||||
- Document public APIs
|
||||
- **Proactive Investigation**: Always start by exploring the codebase using `project_map` and `file_search`.
|
||||
- **Parallelism**: When you need to read multiple files or search across different areas, use parallel tool calls if possible.
|
||||
- **Delegation**: If a task is large, break it down into sub-tasks and use `agent_swarm` or `agent_spawn`.
|
||||
- **Testing**: Rigorously verify changes using `cargo test` and `cargo check`.
|
||||
|
||||
## Important Notes
|
||||
|
||||
<!-- Add project-specific notes here -->
|
||||
|
||||
- **Finance tool currently unavailable**: The finance tool relies on Stooq which frequently returns no data. As a workaround, use `web.run` to fetch financial data from web sources.
|
||||
- **Token/cost tracking inaccuracies**: Token counting and cost estimation may be inflated due to thinking token accounting bugs. Use `/compact` to manage context, and treat cost estimates as approximate.
|
||||
- **Web.run tool name**: Note that the tool is named `web.run` (single dot), not `web..run`. Some earlier versions of the CLI may have had this typo.
|
||||
|
||||
### DeepSeek-Specific Capabilities
|
||||
|
||||
This project is built specifically for DeepSeek models, leveraging their unique features:
|
||||
|
||||
**Thinking Tokens**: DeepSeek models can output thinking blocks (`ContentBlock::Thinking`) before providing final answers. The TUI supports streaming and displaying thinking tokens with visual distinction. You can use thinking tokens to reason step-by-step before committing to a response.
|
||||
|
||||
**Reasoning Models**: DeepSeek offers specialized reasoning models (e.g., `deepseek-reasoner`, `deepseek-r1`) that excel at step-by-step problem solving. Consider using these models for complex tasks.
|
||||
|
||||
**Large Context Window**: DeepSeek models have 128k context windows, allowing you to process large codebases. Use `project_map` and `file_search` to navigate efficiently.
|
||||
|
||||
**DeepSeek API**: The CLI uses DeepSeek's OpenAI‑compatible API with support for the Responses API endpoint. The base URL can be configured for global (`api.deepseek.com`) or China (`api.deepseeki.com`).
|
||||
|
||||
**Web Browsing**: For up‑to‑date information about DeepSeek models, documentation, or API changes, use `web.run` with citations. Example search: “DeepSeek API documentation”.
|
||||
|
||||
### Dogfooding Tips
|
||||
|
||||
As a DeepSeek model working on this project, you are “dogfooding” your own tool. Use this opportunity to:
|
||||
- Test the toolset thoroughly and report any issues.
|
||||
- Suggest improvements that would make DeepSeek models more effective.
|
||||
- Keep changes small, focused, and well‑tested.
|
||||
|
||||
Remember to run `cargo test` and `cargo check` after any changes.
|
||||
+152
-4
@@ -5,7 +5,143 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.3.17] - 2026-02-16
|
||||
|
||||
### Fixed
|
||||
- Config loading now expands `~` in `DEEPSEEK_CONFIG_PATH` and `--config` paths.
|
||||
- When `DEEPSEEK_CONFIG_PATH` points to a missing file, config loading now falls back to `~/.deepseek/config.toml` if it exists.
|
||||
|
||||
### Changed
|
||||
- Removed committed transient runtime artifacts (`session_*.json`, `.deepseek/trusted`) and added ignore rules to prevent re-commit.
|
||||
|
||||
## [0.3.16] - 2026-02-15
|
||||
|
||||
### Added
|
||||
- `deepseek models` CLI command to fetch and list models from the configured `/v1/models` endpoint (with `--json` output mode).
|
||||
- `/models` slash command to fetch and display live model IDs in the TUI.
|
||||
- Slash-command autocomplete hints in the composer plus `Tab` completion for `/` commands.
|
||||
- Command palette modal (`Ctrl+K`) for quick insertion of slash commands and skills.
|
||||
- Persistent right sidebar in wide terminals showing live plan/todo/sub-agent state.
|
||||
- Expandable tool payload views (`v` in transcript, `v` in approval modal) for full params/output inspection.
|
||||
- Runtime HTTP/SSE API (`deepseek serve --http`) with durable thread/turn/item lifecycle, interrupt/steer, and replayable event timeline.
|
||||
- Background task queue (`/task add|list|show|cancel` and `POST /v1/tasks`) with persistent storage, bounded worker pool, and timeline/artifact tracking.
|
||||
|
||||
### Changed
|
||||
- Centralized the default text model (`DEFAULT_TEXT_MODEL`) and shared common model list to reduce drift across runtime/config paths.
|
||||
- `/model` now clarifies that any valid DeepSeek model ID is accepted (including future releases), while still showing common model IDs.
|
||||
|
||||
### Fixed
|
||||
- Expanded reasoning-model detection for chat history reconstruction (supports R-series and reasoner-style naming without hardcoding single versions).
|
||||
- Aligned docs/config examples with actual runtime default model (`deepseek-v3.2`).
|
||||
|
||||
## [0.3.14] - 2026-02-05
|
||||
|
||||
### Added
|
||||
- `web.run` now supports `image_query` (DuckDuckGo image search)
|
||||
- `multi_tool_use.parallel` now supports safe MCP meta tools (`list_mcp_resources`, `mcp_read_resource`, etc.)
|
||||
|
||||
### Fixed
|
||||
- Encode tool-call function names when rebuilding Chat Completions history (keeps dotted tool names API-safe)
|
||||
|
||||
### Changed
|
||||
- Prompts: stronger `web.run` citation placement and quote-limit guidance
|
||||
|
||||
## [0.3.13] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
- Restore an in-app scrollbar for the transcript view
|
||||
|
||||
## [0.3.12] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
- Map dotted tool names to API-safe identifiers for DeepSeek tool calls
|
||||
- Encode any invalid tool names for API tool lists while preserving internal names
|
||||
|
||||
## [0.3.11] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
- Fix tool name mapping for DeepSeek API
|
||||
|
||||
## [0.3.10] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
- Always enable mouse wheel scrolling in the TUI (even without alt screen)
|
||||
|
||||
## [0.3.9] - 2026-02-04
|
||||
|
||||
### Removed
|
||||
- RLM mode, tools, and documentation pending a faithful implementation of the MIT RLM design
|
||||
- Duo mode tools and prompts pending a citable research spec
|
||||
|
||||
### Fixed
|
||||
- Footer context usage bar remains visible while status toasts are shown
|
||||
|
||||
### Changed
|
||||
- Updated prompts and docs to reflect the simplified mode/tool surface
|
||||
|
||||
## [0.3.8] - 2026-02-03
|
||||
|
||||
### Fixed
|
||||
- Resolve clippy warnings (CI `-D warnings`) in new tool implementations
|
||||
|
||||
## [0.3.7] - 2026-02-03
|
||||
|
||||
### Added
|
||||
- Tooling parity updates: `weather`, `finance`, `sports`, `time`, `calculator`, `request_user_input`, `multi_tool_use.parallel`, `web.run`
|
||||
- Shell streaming helpers: `exec_shell_wait` and `exec_shell_interact`
|
||||
- Sub-agent controls: `send_input` and `wait` (with aliases)
|
||||
- MCP resource helpers: `list_mcp_resources`, `list_mcp_resource_templates`, and `read_mcp_resource` alias
|
||||
|
||||
### Changed
|
||||
- Skills directory selection now prefers workspace `.agents/skills`, then `./skills`, then global
|
||||
- Docs and prompts updated to reflect new tool surface and parity notes
|
||||
|
||||
## [0.3.6] - 2026-02-02
|
||||
|
||||
### Added
|
||||
- New welcome banner on startup showing "Welcome to DeepSeek TUI!" with directory, session ID, and model info
|
||||
- Visual context progress bar in footer showing usage with block characters [████░░░░░░] and percentage
|
||||
|
||||
### Changed
|
||||
- Removed custom block-character scrollbar from chat area - now uses terminal's native scroll
|
||||
- Simplified header bar: removed context percentage indicator (moved to footer as progress bar)
|
||||
|
||||
## [0.3.5] - 2026-01-30
|
||||
|
||||
### Added
|
||||
- Intelligent context offloading: large tool results (>15k chars) are automatically moved to RLM memory to preserve the context window
|
||||
- Persistent history context: compacted messages are offloaded to RLM `history` variable for recall
|
||||
- Full MCP protocol support: SSE transport, Resources (`resources/list`, `resources/read`), and Prompts (`prompts/list`, `prompts/get`)
|
||||
- `mcp_read_resource` and `mcp_get_prompt` virtual tools exposed to the model
|
||||
- Dialectical Duo mode with specialized TUI rendering (`Player` / `Coach` history cells)
|
||||
- Dynamic system prompt refreshing at each turn for up-to-date RLM/Duo/working-set context
|
||||
- `project_map` tool for automatic codebase structure discovery
|
||||
- `delegate_to_agent` alias for streamlined sub-agent delegation
|
||||
|
||||
### Changed
|
||||
- Default theme changed to 'Whale' with updated color palette
|
||||
- `with_agent_tools` now includes `project_map`, `test_runner`, and conditionally RLM tools for all agent modes
|
||||
- MCP `McpServerConfig.command` is now `Option<String>` to support URL-only (SSE) servers
|
||||
|
||||
### Fixed
|
||||
- MCP test compilation errors for updated `McpServerConfig` struct shape
|
||||
|
||||
## [0.3.4] - 2026-01-29
|
||||
|
||||
### Changed
|
||||
- Updated Cargo.lock dependencies
|
||||
|
||||
### Fixed
|
||||
- Compaction tool-call pairing: enforce bidirectional tool-call/tool-result integrity with fixpoint convergence
|
||||
- Safety net scanning to drop orphan tool results in the request builder
|
||||
- Double-dispatch race in parallel tool execution
|
||||
|
||||
## [0.3.3] - 2026-01-28
|
||||
|
||||
### Added
|
||||
- TUI polish: Kimi-style footer with mode/model/token display
|
||||
- Streaming thinking blocks with dedicated rendering
|
||||
- Loading animation improvements
|
||||
|
||||
## [0.3.2] - 2026-01-28
|
||||
|
||||
@@ -145,12 +281,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Hooks system and config profiles
|
||||
- Example skills and launch assets
|
||||
|
||||
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.2...HEAD
|
||||
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.14...HEAD
|
||||
[0.3.14]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.13...v0.3.14
|
||||
[0.3.13]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.12...v0.3.13
|
||||
[0.3.12]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.11...v0.3.12
|
||||
[0.3.11]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.10...v0.3.11
|
||||
[0.3.10]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
|
||||
[0.3.9]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
|
||||
[0.3.8]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
|
||||
[0.3.7]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
|
||||
[0.3.6]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.5...v0.3.6
|
||||
[0.3.5]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.4...v0.3.5
|
||||
[0.3.4]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.3...v0.3.4
|
||||
[0.3.3]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.2...v0.3.0
|
||||
[0.2.2]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.1...v0.2.2
|
||||
[0.2.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.0...v0.2.1
|
||||
[0.2.2]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.0...v0.2.2
|
||||
[0.2.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.0...v0.2.2
|
||||
[0.2.0]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.2.0
|
||||
[0.0.2]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.0.2
|
||||
[0.0.1]: https://github.com/Hmbown/DeepSeek-CLI/releases/tag/v0.0.1
|
||||
|
||||
Generated
+458
-1
@@ -18,6 +18,15 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "adobe-cmap-parser"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3"
|
||||
dependencies = [
|
||||
"pom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -233,6 +242,58 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -272,6 +333,15 @@ version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
@@ -571,6 +641,16 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "0.1.26"
|
||||
@@ -646,12 +726,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.2"
|
||||
version = "0.3.19"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"bytes",
|
||||
"chrono",
|
||||
@@ -666,6 +747,8 @@ dependencies = [
|
||||
"indicatif",
|
||||
"libc",
|
||||
"multimap",
|
||||
"pdf-extract",
|
||||
"portable-pty",
|
||||
"pretty_assertions",
|
||||
"ratatui",
|
||||
"regex",
|
||||
@@ -693,6 +776,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derivative"
|
||||
version = "2.2.0"
|
||||
@@ -732,6 +824,16 @@ version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
@@ -811,6 +913,12 @@ version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dupe"
|
||||
version = "0.9.1"
|
||||
@@ -852,6 +960,15 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endian-type"
|
||||
version = "0.1.2"
|
||||
@@ -889,6 +1006,15 @@ version = "3.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
|
||||
|
||||
[[package]]
|
||||
name = "euclid"
|
||||
version = "0.20.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
@@ -935,6 +1061,17 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"thiserror 1.0.69",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.7"
|
||||
@@ -1091,6 +1228,16 @@ dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
@@ -1561,6 +1708,15 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ioctl-rs"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
@@ -1737,6 +1893,24 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lopdf"
|
||||
version = "0.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"log",
|
||||
"md-5",
|
||||
"nom",
|
||||
"rangemap",
|
||||
"time",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.12.5"
|
||||
@@ -1765,6 +1939,22 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.6"
|
||||
@@ -1796,6 +1986,12 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -1869,6 +2065,20 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"bitflags 1.3.2",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"memoffset",
|
||||
"pin-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.28.0"
|
||||
@@ -1893,6 +2103,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
@@ -1903,6 +2123,12 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
@@ -2095,6 +2321,21 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pdf-extract"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbb3a5387b94b9053c1e69d8abfd4dd6dae7afda65a5c5279bc1f42ab39df575"
|
||||
dependencies = [
|
||||
"adobe-cmap-parser",
|
||||
"encoding_rs",
|
||||
"euclid",
|
||||
"lopdf",
|
||||
"postscript",
|
||||
"type1-encoding-parser",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -2151,12 +2392,45 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pom"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950"
|
||||
|
||||
[[package]]
|
||||
name = "portable-pty"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 1.3.2",
|
||||
"downcast-rs",
|
||||
"filedescriptor",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"nix 0.25.1",
|
||||
"serial",
|
||||
"shared_library",
|
||||
"shell-words",
|
||||
"winapi",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postscript"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -2166,6 +2440,12 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -2231,6 +2511,12 @@ dependencies = [
|
||||
"nibble_vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rangemap"
|
||||
version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
|
||||
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.29.0"
|
||||
@@ -2641,6 +2927,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -2661,6 +2958,76 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86"
|
||||
dependencies = [
|
||||
"serial-core",
|
||||
"serial-unix",
|
||||
"serial-windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial-core"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial-unix"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7"
|
||||
dependencies = [
|
||||
"ioctl-rs",
|
||||
"libc",
|
||||
"serial-core",
|
||||
"termios",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serial-windows"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serial-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_library"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shell-words"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||
|
||||
[[package]]
|
||||
name = "shellexpand"
|
||||
version = "3.1.1"
|
||||
@@ -2961,6 +3328,15 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termios"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.11.0"
|
||||
@@ -3024,6 +3400,37 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
@@ -3055,6 +3462,21 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec_macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.49.0"
|
||||
@@ -3179,6 +3601,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3217,6 +3640,7 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -3248,6 +3672,21 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "type1-encoding-parser"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3d6cc09e1a99c7e01f2afe4953789311a1c50baebbdac5b477ecf78e2e92a5b"
|
||||
dependencies = [
|
||||
"pom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
@@ -3260,6 +3699,15 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.12.0"
|
||||
@@ -3846,6 +4294,15 @@ version = "0.7.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wiremock"
|
||||
version = "0.6.5"
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.2"
|
||||
version = "0.3.19"
|
||||
edition = "2024"
|
||||
description = "Unofficial DeepSeek CLI - Just run 'deepseek' to start chatting"
|
||||
license = "MIT"
|
||||
@@ -19,6 +19,7 @@ async-stream = "0.3.6"
|
||||
async-trait = "0.1"
|
||||
bytes = "1.11.0"
|
||||
base64 = "0.22.1"
|
||||
axum = { version = "0.8.4", features = ["json"] }
|
||||
clap = { version = "4.5.54", features = ["derive"] }
|
||||
clap_complete = "4.5"
|
||||
colored = "3.0.0"
|
||||
@@ -50,8 +51,10 @@ multimap = "0.10.0"
|
||||
shlex = "1.3.0"
|
||||
starlark = "0.13.0"
|
||||
tiny_http = "0.12"
|
||||
portable-pty = "0.8"
|
||||
zeroize = "1.8.2"
|
||||
ignore = "0.4"
|
||||
pdf-extract = "0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = "0.6"
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
# Parity Spec: Codex vs Claude Code
|
||||
|
||||
This document defines "parity" as measurable behavior in this repository.
|
||||
It is intended to be short, testable, and easy to run during reviews.
|
||||
|
||||
## Scope
|
||||
|
||||
Parity is evaluated on:
|
||||
|
||||
- Instruction following (including `AGENTS.md` and task constraints)
|
||||
- Rust/Cargo workflow discipline
|
||||
- Change quality and scope control
|
||||
- Safety and repo hygiene
|
||||
- Clear, audit-friendly reporting
|
||||
|
||||
Unless a task says otherwise, parity targets the default Rust workflow:
|
||||
|
||||
1) search with `rg` 2) edit minimally 3) validate with Cargo commands.
|
||||
|
||||
## Parity Behaviors (Measurable)
|
||||
|
||||
An agent is considered at parity when it reliably exhibits the following
|
||||
behaviors on eval tasks.
|
||||
|
||||
### 1) Instruction and Scope Compliance
|
||||
|
||||
Required behaviors:
|
||||
|
||||
- Respects path constraints (for example: "do not edit `src/*`")
|
||||
- Does not revert or disturb unrelated user changes
|
||||
- Avoids destructive git commands (for example: `git reset --hard`)
|
||||
- Stops and reports if unexpected repo changes appear mid-task
|
||||
|
||||
Suggested metrics:
|
||||
|
||||
- `scope_violations = 0` (no edits outside allowed paths)
|
||||
- `destructive_git_cmds = 0`
|
||||
- `unrelated_reverts = 0`
|
||||
|
||||
### 2) Rust/Cargo Workflow Discipline
|
||||
|
||||
Required behaviors:
|
||||
|
||||
- Uses Cargo as the source of truth for validation
|
||||
- Chooses appropriate checks for the task size/scope
|
||||
- Reports validation outcomes clearly (pass/fail + command)
|
||||
|
||||
Suggested metrics (binary unless noted):
|
||||
|
||||
- `cargo_check_pass`
|
||||
- `cargo_test_pass` (required for most parity gates)
|
||||
- `cargo_fmt_check_pass` (when formatting could be affected)
|
||||
- `cargo_clippy_pass` (recommended for non-trivial code edits)
|
||||
- `validation_reported = 1` (commands + outcomes are stated)
|
||||
|
||||
### 3) Change Quality and Minimality
|
||||
|
||||
Required behaviors:
|
||||
|
||||
- Keeps edits focused and atomic
|
||||
- Preserves existing style and patterns
|
||||
- Updates documentation when public behavior changes
|
||||
|
||||
Suggested metrics:
|
||||
|
||||
- `task_acceptance_pass = 1` (task-specific checks succeed)
|
||||
- `files_touched_within_expectation = 1`
|
||||
- `style_regressions = 0` (via `fmt`/`clippy`/review)
|
||||
|
||||
### 4) Reporting Quality
|
||||
|
||||
Required behaviors:
|
||||
|
||||
- States what changed, where, and why
|
||||
- Provides clickable file references
|
||||
- Separates results from speculation
|
||||
|
||||
Suggested metrics:
|
||||
|
||||
- `changed_files_listed = 1`
|
||||
- `key_paths_cited = 1`
|
||||
- `claims_match_repo_state = 1`
|
||||
|
||||
## Parity Metrics and Gates
|
||||
|
||||
Use these gates for pass/fail decisions.
|
||||
|
||||
### Hard Gates (must pass)
|
||||
|
||||
- No scope violations
|
||||
- No destructive git commands
|
||||
- `cargo test` exits 0
|
||||
- Task-specific acceptance checks pass
|
||||
|
||||
### Soft Gates (should pass; track as %)
|
||||
|
||||
- `cargo check` exits 0
|
||||
- `cargo fmt --check` exits 0
|
||||
- `cargo clippy --all-targets --all-features` exits 0
|
||||
- Edits are minimal and well-scoped
|
||||
- Reporting is complete and auditable
|
||||
|
||||
A simple parity score can be computed as:
|
||||
|
||||
- Fail immediately on any hard-gate violation
|
||||
- Otherwise: `score = soft_gates_passed / soft_gates_total`
|
||||
|
||||
Target: `score >= 0.8` over a representative eval set.
|
||||
|
||||
## Evaluation Rubric (Short)
|
||||
|
||||
Score each dimension 0-2. Parity requires both conditions:
|
||||
|
||||
- No hard-gate violations
|
||||
- Total score >= 7/8
|
||||
|
||||
Dimensions:
|
||||
|
||||
- Correctness: solution satisfies the task and acceptance checks
|
||||
- Scope/Safety: constraints honored; no risky repo operations
|
||||
- Rust Workflow: appropriate Cargo validation is used and reported
|
||||
- Communication: changes and evidence are clear and well-referenced
|
||||
|
||||
Suggested anchors:
|
||||
|
||||
- 2 = consistently strong, no notable gaps
|
||||
- 1 = acceptable but with minor gaps or ambiguity
|
||||
- 0 = missing, incorrect, or risky
|
||||
|
||||
## Rust/Cargo Eval Task Categories
|
||||
|
||||
Use a small mix from each category to assess parity.
|
||||
|
||||
### A. Cargo Validation Loops
|
||||
|
||||
- Fix a failing test, then run `cargo test`
|
||||
- Resolve a compiler error, validate with `cargo check`
|
||||
- Address a lint warning, validate with `cargo clippy`
|
||||
|
||||
### B. Tests and Behavior Lock-In
|
||||
|
||||
- Add unit tests for a small module
|
||||
- Add an integration test under `tests/`
|
||||
- Convert a bug report into a regression test + fix
|
||||
|
||||
### C. Dependencies and Features
|
||||
|
||||
- Add a small crate and wire it into `Cargo.toml`
|
||||
- Gate behavior behind a feature flag
|
||||
- Make code compile cleanly with `--all-features`
|
||||
|
||||
### D. CLI and Config Surface
|
||||
|
||||
- Adjust a Clap flag/help string and update docs
|
||||
- Add/modify a config field and update documentation
|
||||
- Ensure `--help` output remains accurate
|
||||
|
||||
### E. Repo-Safe Documentation Tasks
|
||||
|
||||
- Update `README.md` or `docs/*` without touching `src/*`
|
||||
- Add a short spec doc (like this one) and validate with tests
|
||||
- Reconcile docs with current Cargo commands and project norms
|
||||
|
||||
## Milestone Checklist
|
||||
|
||||
Track parity progress in small, observable steps.
|
||||
|
||||
### M1: Safety + Docs Parity
|
||||
|
||||
- [ ] No scope violations on doc-only tasks
|
||||
- [ ] No destructive git commands across evals
|
||||
- [ ] `cargo test` is run and reported
|
||||
|
||||
### M2: Core Rust Workflow Parity
|
||||
|
||||
- [ ] `cargo check`/`test` used appropriately by default
|
||||
- [ ] Formatting and linting considered when relevant
|
||||
- [ ] Changes remain minimal and consistent with repo patterns
|
||||
|
||||
### M3: Feature and Regression Parity
|
||||
|
||||
- [ ] Bugs are captured with tests before or with fixes
|
||||
- [ ] `--all-features` and integration tests are handled cleanly
|
||||
- [ ] Public behavior changes include doc updates
|
||||
|
||||
### M4: Review-Ready Parity
|
||||
|
||||
- [ ] Reports include commands, outcomes, and key file refs
|
||||
- [ ] Soft-gate score >= 0.8 across the eval set
|
||||
- [ ] Maintainers can reproduce validation steps quickly
|
||||
|
||||
@@ -1,288 +1,175 @@
|
||||
# DeepSeek CLI 🤖
|
||||
# DeepSeek CLI
|
||||
|
||||
Your AI-powered terminal companion for DeepSeek models
|
||||
An unofficial terminal UI and CLI for the [DeepSeek platform](https://platform.deepseek.com).
|
||||
|
||||
[](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml)
|
||||
[](https://crates.io/crates/deepseek-tui)
|
||||
|
||||
Unofficial terminal UI (TUI) + CLI for the [DeepSeek platform](https://platform.deepseek.com) — chat with DeepSeek models and collaborate with AI assistants that can read, write, execute, and plan with approval-gated tool access.
|
||||
Chat with DeepSeek models directly from your terminal. The assistant can read and write files, run shell commands, search the web, manage tasks, and coordinate sub-agents — all with configurable approval gating.
|
||||
|
||||
**Not affiliated with DeepSeek Inc.**
|
||||
|
||||
## ✨ Features
|
||||
## Getting Started
|
||||
|
||||
- **Interactive TUI** with multiple modes (Normal, Plan, Agent, YOLO, RLM, Duo)
|
||||
- **Comprehensive tool access** – File operations, shell execution, task management, and sub-agent systems
|
||||
- **File operations**: List directories, read/write/edit files, apply patches, search files with regex
|
||||
- **Shell execution**: Run commands with timeout support, background execution with task management
|
||||
- **Task management**: Todo lists, implementation plans, persistent notes
|
||||
- **Sub-agent system**: Spawn, coordinate, and cancel background agents (including swarms)
|
||||
- **Web search**: Integrated web search with DuckDuckGo
|
||||
- **Multi‑model support** – DeepSeek‑Reasoner, DeepSeek‑Chat, and other DeepSeek models
|
||||
- **Context‑aware** – loads project‑specific instructions from `AGENTS.md`
|
||||
- **Session management** – resume, fork, and search past conversations
|
||||
- **Skills system** – reusable workflows stored as `SKILL.md` directories
|
||||
- **Model Context Protocol (MCP)** – integrate external tool servers
|
||||
- **Sandboxed execution** (macOS) for safe shell commands
|
||||
- **Git integration** – code review, patch application, diff analysis
|
||||
- **Cross‑platform** – works on macOS, Linux, and Windows
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
1. **Get an API key** from [https://platform.deepseek.com](https://platform.deepseek.com)
|
||||
2. **Install and run**:
|
||||
### 1. Install
|
||||
|
||||
```bash
|
||||
# Install via Cargo
|
||||
# From crates.io (requires Rust 1.85+)
|
||||
cargo install deepseek-tui --locked
|
||||
|
||||
# Set your API key
|
||||
export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
|
||||
|
||||
# Bootstrap MCP + skills templates (recommended)
|
||||
deepseek setup
|
||||
|
||||
# Start chatting
|
||||
deepseek
|
||||
```
|
||||
|
||||
3. Press `F1` or type `/help` for the in‑app command list.
|
||||
|
||||
If anything looks off, run `deepseek doctor` to diagnose configuration issues.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### From crates.io
|
||||
|
||||
```bash
|
||||
cargo install deepseek-tui --locked
|
||||
```
|
||||
|
||||
### Build from source
|
||||
|
||||
```bash
|
||||
# Or build from source
|
||||
git clone https://github.com/Hmbown/DeepSeek-TUI.git
|
||||
cd DeepSeek-TUI
|
||||
cargo build --release
|
||||
./target/release/deepseek --help
|
||||
cargo install --path . --locked
|
||||
```
|
||||
|
||||
### Direct download
|
||||
Prebuilt binaries are also available on [GitHub Releases](https://github.com/Hmbown/DeepSeek-TUI/releases).
|
||||
|
||||
Download a prebuilt binary from [GitHub Releases](https://github.com/Hmbown/DeepSeek-TUI/releases) and put it on your `PATH` as `deepseek`.
|
||||
### 2. Set your API key
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
On first run, the TUI can prompt for your API key and save it to `~/.deepseek/config.toml`. You can also create the file manually:
|
||||
Get a key from [platform.deepseek.com](https://platform.deepseek.com). On first run the TUI will prompt you to enter and save it, or create the config manually:
|
||||
|
||||
```toml
|
||||
# ~/.deepseek/config.toml
|
||||
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non‑empty
|
||||
default_text_model = "deepseek-reasoner" # optional
|
||||
allow_shell = false # optional
|
||||
max_subagents = 3 # optional (1‑20)
|
||||
api_key = "YOUR_DEEPSEEK_API_KEY"
|
||||
```
|
||||
|
||||
Useful environment variables:
|
||||
|
||||
- `DEEPSEEK_API_KEY` (overrides `api_key`)
|
||||
- `DEEPSEEK_BASE_URL` (default: `https://api.deepseek.com`; China users may use `https://api.deepseeki.com`)
|
||||
- `DEEPSEEK_PROFILE` (selects `[profiles.<name>]` from the config; errors if missing)
|
||||
- `DEEPSEEK_CONFIG_PATH` (override config path)
|
||||
- `DEEPSEEK_MCP_CONFIG`, `DEEPSEEK_SKILLS_DIR`, `DEEPSEEK_NOTES_PATH`, `DEEPSEEK_MEMORY_PATH`, `DEEPSEEK_ALLOW_SHELL`, `DEEPSEEK_MAX_SUBAGENTS`
|
||||
|
||||
To bootstrap MCP and skills at their resolved locations, run `deepseek setup`. To
|
||||
only create an MCP template, run `deepseek mcp init`.
|
||||
|
||||
See `config.example.toml` and `docs/CONFIGURATION.md` for a full reference.
|
||||
|
||||
## 🎮 Modes
|
||||
|
||||
In the TUI, press `Tab` to cycle modes: **Normal → Plan → Agent → YOLO → RLM → Duo → Normal**.
|
||||
|
||||
| Mode | Description | Approval Behavior |
|
||||
|------|-------------|-------------------|
|
||||
| **Normal** | Chat; asks before file writes or shell | Manual approval for writes & shell |
|
||||
| **Plan** | Design‑first prompting; same approvals as Normal | Manual approval for writes & shell |
|
||||
| **Agent** | Multi‑step tool use; asks before shell | Manual approval for shell, auto‑approve file writes |
|
||||
| **YOLO** | Enables shell + trust + auto‑approves all tools (dangerous) | Auto‑approve all tools |
|
||||
| **RLM** | Externalized context + REPL helpers; auto‑approves tools (best for large files) | Auto‑approve tools |
|
||||
| **Duo** | Player‑coach autocoding with iterative validation (based on g3 paper) | Depends on phase |
|
||||
|
||||
Approval behavior is mode‑dependent, but you can also override it at runtime with `/set approval_mode auto|suggest|never`.
|
||||
|
||||
## 🛠️ Tools
|
||||
|
||||
DeepSeek CLI exposes a comprehensive set of tools to the model across 5 categories, with 16+ individual tools available, all with approval gating based on the current mode.
|
||||
|
||||
### Tool Categories
|
||||
|
||||
#### File Operations
|
||||
- **`list_dir`** – List directory contents with file/directory metadata
|
||||
- **`read_file`** – Read UTF‑8 files from the workspace
|
||||
- **`write_file`** – Create or overwrite files
|
||||
- **`edit_file`** – Search and replace text in files
|
||||
- **`apply_patch`** – Apply unified diff patches with fuzzy matching
|
||||
- **`grep_files`** – Search files by regex pattern with context lines
|
||||
- **`web_search`** – Search the web and return concise results
|
||||
|
||||
#### Shell Execution
|
||||
- **`exec_shell`** – Run shell commands with timeout support
|
||||
- **Background execution** – Run commands in background with task ID return
|
||||
|
||||
#### Task Management
|
||||
- **`todo_write`** – Create and update todo lists with status tracking
|
||||
- **`update_plan`** – Manage structured implementation plans
|
||||
- **`note`** – Append persistent notes across sessions
|
||||
|
||||
#### Sub‑Agents
|
||||
- **`agent_spawn`** – Create background sub‑agents for focused tasks
|
||||
- **`agent_swarm`** – Launch a dependency‑aware swarm of sub‑agents
|
||||
- **`agent_result`** – Retrieve results from sub‑agents
|
||||
- **`agent_list`** – List all active and completed agents
|
||||
- **`agent_cancel`** – Cancel running sub‑agents
|
||||
|
||||
### System Behavior
|
||||
|
||||
- **Workspace boundary**: File tools are restricted to `--workspace` unless you enable `/trust` (YOLO enables trust automatically).
|
||||
- **Approvals**: The TUI requests approval depending on mode and tool category (file writes, shell).
|
||||
- **Web search**: `web_search` uses DuckDuckGo HTML results and is auto‑approved.
|
||||
- **Skills**: Reusable workflows stored as `SKILL.md` directories (default: `~/.deepseek/skills`, or `./skills` per workspace). Use `/skills` and `/skill <name>`. Bootstrap with `deepseek setup --skills` (add `--local` for `./skills`).
|
||||
- **MCP**: Load external tool servers via `~/.deepseek/mcp.json` (supports `servers` and `mcpServers`). MCP tools currently execute without TUI approval prompts, so only enable servers you trust. See `docs/MCP.md`.
|
||||
|
||||
## 🧠 RLM (Reasoning & Large‑scale Memory)
|
||||
|
||||
RLM mode is designed for "too big for context" tasks: large files, whole‑doc sweeps, and big pasted blocks.
|
||||
|
||||
- Auto‑switch triggers: "largest file", explicit "RLM", large file requests, and large pastes.
|
||||
- Shortcut: `/rlm` (or `/aleph`) enters RLM mode directly.
|
||||
- In **RLM mode**, `/load @path` loads a file into the external context store (outside RLM mode, `/load` loads a saved chat JSON).
|
||||
- Use `/repl` to enter expression mode (e.g. `search("pattern")`, `lines(1, 80)`).
|
||||
- Power tools: `rlm_load`, `rlm_exec`, `rlm_status`, `rlm_query`.
|
||||
|
||||
`rlm_query` can be expensive: prefer batching and check `/status` if you're doing lots of sub‑queries.
|
||||
|
||||
## 👥 Duo Mode
|
||||
|
||||
> **Note:** Duo mode is experimental and may not work correctly in all cases. Use with caution.
|
||||
|
||||
Duo mode implements the player‑coach autocoding paradigm for iterative development with built‑in validation:
|
||||
|
||||
- **Player**: implements requirements (builder role)
|
||||
- **Coach**: validates implementation against requirements (critic role)
|
||||
- Tools: `duo_init`, `duo_player`, `duo_coach`, `duo_advance`, `duo_status`
|
||||
|
||||
Workflow: `init → player → coach → advance → (repeat until approved)`
|
||||
|
||||
## 📚 Examples
|
||||
|
||||
### Interactive chat
|
||||
### 3. Run
|
||||
|
||||
```bash
|
||||
deepseek
|
||||
```
|
||||
|
||||
### One‑shot prompt (non‑interactive)
|
||||
Press **Tab** to switch modes, **F1** for help, and **Esc** to cancel a running request.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Description | Approvals |
|
||||
|------|-------------|-----------|
|
||||
| **Plan** | Design-first prompting; produces a plan before implementing | Manual for writes and shell |
|
||||
| **Agent** | Multi-step autonomous tool use | Auto-approve file writes, manual for shell |
|
||||
| **YOLO** | Full auto-approve (use with caution) | All tools auto-approved |
|
||||
|
||||
Press `Tab` to cycle modes. Normal mode (manual approval for everything) is also available via `/set mode normal`.
|
||||
|
||||
## What It Can Do
|
||||
|
||||
The assistant has access to 30+ tools:
|
||||
|
||||
- **File operations** — read, write, edit, patch, search, and grep across your workspace
|
||||
- **Shell execution** — run commands with timeout, background execution, and interactive I/O
|
||||
- **Web browsing** — search the web, open pages, screenshot, and extract content with citations
|
||||
- **Git** — inspect repo status, diffs, and staged changes
|
||||
- **Code review** — structured review for files, diffs, or GitHub PRs
|
||||
- **Sub-agents** — spawn background agents or coordinate agent swarms for parallel work
|
||||
- **Task management** — to-do lists, implementation plans, persistent notes, and a background task queue
|
||||
- **Structured data** — weather, finance, sports scores, time zones, and a calculator
|
||||
- **MCP integration** — connect external tool servers via the [Model Context Protocol](docs/MCP.md)
|
||||
|
||||
All file tools respect the `--workspace` boundary unless `/trust` is enabled.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
deepseek -p "Write a haiku about Rust"
|
||||
```
|
||||
# Interactive chat
|
||||
deepseek
|
||||
|
||||
### Agentic execution with tool access
|
||||
# One-shot prompt
|
||||
deepseek -p "Explain the borrow checker in two sentences"
|
||||
|
||||
```bash
|
||||
deepseek exec --auto "Fix lint errors in the current directory"
|
||||
```
|
||||
# Agentic execution with auto-approve
|
||||
deepseek exec --auto "Fix all clippy warnings in this project"
|
||||
|
||||
### Resume latest session
|
||||
|
||||
```bash
|
||||
# Resume latest session
|
||||
deepseek --continue
|
||||
```
|
||||
|
||||
### Work on a specific project
|
||||
|
||||
```bash
|
||||
# Work on a specific project
|
||||
deepseek --workspace /path/to/project
|
||||
```
|
||||
|
||||
### Review staged git changes
|
||||
|
||||
```bash
|
||||
# Review staged git changes
|
||||
deepseek review --staged
|
||||
|
||||
# Start the runtime API server
|
||||
deepseek serve --http
|
||||
|
||||
# Verify your environment
|
||||
deepseek doctor
|
||||
```
|
||||
|
||||
### Apply a patch file
|
||||
## Configuration
|
||||
|
||||
```bash
|
||||
deepseek apply patch.diff
|
||||
Config lives at `~/.deepseek/config.toml`:
|
||||
|
||||
```toml
|
||||
api_key = "sk-..."
|
||||
default_text_model = "deepseek-reasoner" # optional (or "deepseek-chat")
|
||||
allow_shell = true # optional (sandboxed by default)
|
||||
max_subagents = 3 # optional (1-20)
|
||||
```
|
||||
|
||||
### List saved sessions
|
||||
Key environment variables:
|
||||
|
||||
```bash
|
||||
deepseek sessions --limit 50
|
||||
```
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `DEEPSEEK_API_KEY` | API key (overrides config file) |
|
||||
| `DEEPSEEK_BASE_URL` | API endpoint (default: `https://api.deepseek.com`) |
|
||||
|
||||
### Generate shell completions
|
||||
See [`config.example.toml`](config.example.toml) and [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md) for the full reference.
|
||||
|
||||
```bash
|
||||
deepseek completions zsh > _deepseek
|
||||
deepseek completions bash > deepseek.bash
|
||||
deepseek completions fish > deepseek.fish
|
||||
```
|
||||
## Runtime API
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
`deepseek serve --http` starts a local HTTP/SSE API on `127.0.0.1:7878` for external clients. Supports threads, multi-turn conversations, task queues, and live event streaming.
|
||||
|
||||
### No API key
|
||||
Set `DEEPSEEK_API_KEY` environment variable or run `deepseek` and complete onboarding.
|
||||
See [`docs/RUNTIME_API.md`](docs/RUNTIME_API.md) for endpoints and usage.
|
||||
|
||||
### Config not found
|
||||
Check `~/.deepseek/config.toml` (or `DEEPSEEK_CONFIG_PATH`).
|
||||
## Keyboard Shortcuts
|
||||
|
||||
### Wrong region / base URL
|
||||
Set `DEEPSEEK_BASE_URL` to `https://api.deepseeki.com` (China).
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Enter` | Send message |
|
||||
| `Alt+Enter` / `Ctrl+J` | Insert newline |
|
||||
| `Tab` | Autocomplete or cycle modes |
|
||||
| `Esc` | Cancel request / clear input |
|
||||
| `Ctrl+C` | Cancel or exit |
|
||||
| `Ctrl+K` | Command palette |
|
||||
| `Ctrl+R` | Search past sessions |
|
||||
| `F1` | Help overlay |
|
||||
| `PageUp` / `PageDown` | Scroll transcript |
|
||||
|
||||
### Session issues
|
||||
Run `deepseek sessions` and try `deepseek --resume latest`.
|
||||
## Troubleshooting
|
||||
|
||||
### Skills missing
|
||||
Run `deepseek setup --skills` to create a global skills directory, or add `--local`
|
||||
to create `./skills` for the current workspace. Then run `deepseek doctor` to see
|
||||
which skills directory is selected.
|
||||
| Problem | Fix |
|
||||
|---------|-----|
|
||||
| No API key | Set `DEEPSEEK_API_KEY` or run `deepseek` to complete onboarding |
|
||||
| Config not found | Check `~/.deepseek/config.toml` (or set `DEEPSEEK_CONFIG_PATH`) |
|
||||
| Wrong region | Set `DEEPSEEK_BASE_URL` to `https://api.deepseeki.com` (China) |
|
||||
| Sandbox errors (macOS) | Run `deepseek doctor` |
|
||||
|
||||
### MCP tools missing
|
||||
Run `deepseek mcp init` (or `deepseek setup --mcp`), then restart. `deepseek doctor`
|
||||
now checks the MCP path resolved from your config/env overrides.
|
||||
## Documentation
|
||||
|
||||
### Sandbox errors (macOS)
|
||||
Run `deepseek doctor` to confirm sandbox availability. On macOS, ensure
|
||||
`/usr/bin/sandbox-exec` exists. For other platforms, sandboxing is limited.
|
||||
- [Configuration Reference](docs/CONFIGURATION.md)
|
||||
- [Architecture](docs/ARCHITECTURE.md)
|
||||
- [Mode Comparison](docs/MODES.md)
|
||||
- [MCP Integration](docs/MCP.md)
|
||||
- [Runtime API](docs/RUNTIME_API.md)
|
||||
- [Operations Runbook](docs/OPERATIONS_RUNBOOK.md)
|
||||
- [Contributing](CONTRIBUTING.md)
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
- `docs/CONFIGURATION.md` – Complete configuration reference
|
||||
- `docs/MCP.md` – Model Context Protocol guide
|
||||
- `docs/ARCHITECTURE.md` – Project architecture
|
||||
- `docs/RLM.md` – RLM mode deep‑dive
|
||||
- `docs/MODES.md` – Mode comparison and usage
|
||||
- `CONTRIBUTING.md` – How to contribute to the project
|
||||
|
||||
## 🧪 Development
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
cargo test
|
||||
cargo fmt
|
||||
cargo clippy
|
||||
cargo fmt
|
||||
```
|
||||
|
||||
See `CONTRIBUTING.md` for detailed guidelines.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
## 📄 License
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
DeepSeek is a trademark of DeepSeek Inc. This is an unofficial project.
|
||||
DeepSeek is a trademark of DeepSeek Inc. This is an unofficial, community-driven project.
|
||||
+33
-14
@@ -20,7 +20,7 @@ base_url = "https://api.deepseek.com"
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Default Models
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
default_text_model = "deepseek-reasoner" # also: deepseek-chat, deepseek-r1, deepseek-v3, deepseek-v3.2
|
||||
default_text_model = "deepseek-reasoner" # supported: deepseek-reasoner, deepseek-chat
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Paths
|
||||
@@ -29,16 +29,23 @@ skills_dir = "~/.deepseek/skills"
|
||||
mcp_config_path = "~/.deepseek/mcp.json"
|
||||
notes_path = "~/.deepseek/notes.txt"
|
||||
|
||||
memory_path = "~/.deepseek/memory.md"
|
||||
|
||||
# Parsed but currently unused (reserved for future versions):
|
||||
# tools_file = "./tools.json"
|
||||
# memory_path = "~/.deepseek/memory.md"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Security
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
allow_shell = false
|
||||
allow_shell = true
|
||||
approval_policy = "on-request" # on-request | untrusted | never
|
||||
sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox
|
||||
max_subagents = 5 # optional (1-20)
|
||||
|
||||
# Optional managed policy paths (defaults to /etc/deepseek/*.toml on unix):
|
||||
# managed_config_path = "/etc/deepseek/managed_config.toml"
|
||||
# requirements_path = "/etc/deepseek/requirements.toml"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# TUI
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -51,11 +58,9 @@ alternate_screen = "auto" # auto | always | never
|
||||
[features]
|
||||
shell_tool = true
|
||||
subagents = true
|
||||
web_search = true
|
||||
web_search = true # enables web.run and web_search
|
||||
apply_patch = true
|
||||
mcp = true
|
||||
rlm = true
|
||||
duo = true
|
||||
exec_policy = true
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -69,7 +74,7 @@ max_delay = 60.0
|
||||
exponential_base = 2.0
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Context Compaction (PLANNED - not yet implemented)
|
||||
# Context Compaction (config-level tuning not yet wired; use /set auto_compact on|off)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# [compaction]
|
||||
# enabled = false # Enable auto-compaction
|
||||
@@ -79,14 +84,22 @@ exponential_base = 2.0
|
||||
# cache_summary = true # Cache the summary block
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# RLM Sandbox Configuration (PLANNED - not yet implemented)
|
||||
# Capacity Controller (runtime pressure guardrails)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# [rlm]
|
||||
# max_context_chars = 10000000 # Max characters for context (10MB)
|
||||
# max_search_results = 100 # Max search results
|
||||
# default_chunk_size = 2000 # Default chunk size
|
||||
# default_overlap = 200 # Default chunk overlap
|
||||
# session_dir = "~/.deepseek/rlm" # Directory for RLM sessions
|
||||
[capacity]
|
||||
enabled = true
|
||||
low_risk_max = 0.34
|
||||
medium_risk_max = 0.62
|
||||
severe_min_slack = -0.25
|
||||
severe_violation_ratio = 0.40
|
||||
refresh_cooldown_turns = 2
|
||||
replan_cooldown_turns = 5
|
||||
max_replay_per_turn = 1
|
||||
min_turns_before_guardrail = 2
|
||||
profile_window = 8
|
||||
deepseek_v3_2_chat_prior = 3.9
|
||||
deepseek_v3_2_reasoner_prior = 4.1
|
||||
fallback_default_prior = 3.8
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Profile Example (for multiple environments)
|
||||
@@ -113,3 +126,9 @@ allow_shell = true
|
||||
# [[hooks.hooks]]
|
||||
# event = "session_start"
|
||||
# command = "echo 'DeepSeek CLI session started'"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Requirements (admin constraints) example file
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# allowed_approval_policies = ["on-request", "untrusted", "never"]
|
||||
# allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
+52
-2
@@ -35,6 +35,15 @@ This document provides an overview of the DeepSeek CLI architecture for develope
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Runtime API + Task Management │
|
||||
│ ┌─────────────────────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ HTTP/SSE Runtime API │ │ Persistent Task Manager │ │
|
||||
│ │ (runtime_api.rs) │ │ (task_manager.rs) │ │
|
||||
│ └─────────────────────────────┘ └──────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ LLM Layer │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ LLM Client Abstraction (llm_client.rs) │ │
|
||||
@@ -121,11 +130,13 @@ Responses API (with automatic fallback if needed).
|
||||
- **`utils.rs`** - Common utilities
|
||||
- **`logging.rs`** - Logging infrastructure
|
||||
- **`compaction.rs`** - Context compaction for long conversations
|
||||
- **`rlm.rs`** - Reflection/reasoning utilities
|
||||
- **`pricing.rs`** - Cost estimation
|
||||
- **`prompts.rs`** - System prompt templates
|
||||
- **`project_doc.rs`** - Project documentation handling
|
||||
- **`session.rs`** - Session serialization
|
||||
- **`runtime_api.rs`** - HTTP/SSE runtime API (`deepseek serve --http`)
|
||||
- **`runtime_threads.rs`** - Durable thread/turn/item store + replayable event timeline
|
||||
- **`task_manager.rs`** - Durable queue, worker pool, task timelines and artifacts
|
||||
|
||||
## Data Flow
|
||||
|
||||
@@ -140,6 +151,14 @@ Responses API (with automatic fallback if needed).
|
||||
7. Results aggregated and sent back to LLM
|
||||
8. Final response rendered in TUI
|
||||
|
||||
### Crash Recovery + Offline Queue
|
||||
|
||||
1. Before sending user input, the TUI writes a checkpoint snapshot to `~/.deepseek/sessions/checkpoints/latest.json`
|
||||
2. Startup remains fresh by default; prior sessions are resumed explicitly via `--resume`/`--continue` (or `Ctrl+R` in TUI)
|
||||
3. While degraded/offline, new prompts are queued in-memory and mirrored to `~/.deepseek/sessions/checkpoints/offline_queue.json`
|
||||
4. Queue edits (`/queue ...`) are persisted continuously so drafts and queued prompts survive restarts
|
||||
5. Successful turn completion clears the active checkpoint and writes a durable session snapshot
|
||||
|
||||
### Tool Execution
|
||||
|
||||
1. LLM requests tool via `tool_use` content block
|
||||
@@ -150,6 +169,31 @@ Responses API (with automatic fallback if needed).
|
||||
6. Post-execution hooks run
|
||||
7. Result returned to agent loop
|
||||
|
||||
### Background Tasks
|
||||
|
||||
1. Client enqueues task (`/task add ...` or `POST /v1/tasks`)
|
||||
2. `task_manager.rs` persists task + queue entry under `~/.deepseek/tasks`
|
||||
3. Worker picks queued task (bounded pool), transitions to `running`
|
||||
4. Task creates/uses a runtime thread and starts a runtime turn
|
||||
5. `runtime_threads.rs` persists thread/turn/item records + monotonic event sequence
|
||||
6. Timeline/tool summaries/artifact references are persisted incrementally
|
||||
7. Final state (`completed|failed|canceled`) is durable and queryable via TUI/API
|
||||
|
||||
### Runtime Thread/Turn Timeline
|
||||
|
||||
1. API/TUI creates or resumes a thread (`/v1/threads*`)
|
||||
2. Turn starts on the thread (`/v1/threads/{id}/turns`)
|
||||
3. Engine events are mapped to item lifecycle events (`item.started|item.delta|item.completed`)
|
||||
4. Interrupt/steer operations apply to the active turn only
|
||||
5. Compaction (auto/manual) is emitted as `context_compaction` item lifecycle
|
||||
6. Clients replay history and resume with `/v1/threads/{id}/events?since_seq=<n>`
|
||||
|
||||
### Durable Schema Gates
|
||||
|
||||
- `session_manager.rs`, `runtime_threads.rs`, and `task_manager.rs` embed `schema_version` on persisted records.
|
||||
- On load, newer schema versions are rejected with explicit errors instead of silently truncating/overwriting data.
|
||||
- This allows safe forward migrations and prevents corruption when binaries and stored state are out of sync.
|
||||
|
||||
## Extension Points
|
||||
|
||||
### Adding a New Tool
|
||||
@@ -183,14 +227,20 @@ command = "echo 'Running tool: $TOOL_NAME'"
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Streaming-first**: All LLM responses stream for responsiveness
|
||||
2. **Tool safety**: Non-yolo mode requires approval for destructive operations
|
||||
2. **Tool safety**: Non-yolo mode requires approval for destructive operations, including side-effectful MCP tools
|
||||
3. **Extensibility**: MCP, skills, and hooks allow customization without code changes
|
||||
4. **Cross-platform**: Core works on Linux/macOS/Windows, sandboxing macOS-only
|
||||
5. **Minimal dependencies**: Careful dependency selection for build speed
|
||||
6. **Local-first runtime API**: HTTP/SSE endpoints are intended for trusted localhost access
|
||||
|
||||
## Configuration Files
|
||||
|
||||
- `~/.deepseek/config.toml` - Main configuration
|
||||
- `/etc/deepseek/managed_config.toml` - Optional managed defaults layer (Unix)
|
||||
- `/etc/deepseek/requirements.toml` - Optional allowed-policy constraints (Unix)
|
||||
- `~/.deepseek/mcp.json` - MCP server configuration
|
||||
- `~/.deepseek/skills/` - User skills directory
|
||||
- `~/.deepseek/sessions/` - Session history
|
||||
- `~/.deepseek/sessions/checkpoints/` - Crash checkpoint + offline queue persistence
|
||||
- `~/.deepseek/tasks/` - Background task records, queue, timelines, artifacts
|
||||
- `~/.deepseek/audit.log` - Append-only audit events for credential + approval/elevation actions
|
||||
+65
-7
@@ -49,7 +49,26 @@ These override config values:
|
||||
- `DEEPSEEK_NOTES_PATH`
|
||||
- `DEEPSEEK_MEMORY_PATH`
|
||||
- `DEEPSEEK_ALLOW_SHELL` (`1`/`true` enables)
|
||||
- `DEEPSEEK_APPROVAL_POLICY` (`on-request|untrusted|never`)
|
||||
- `DEEPSEEK_SANDBOX_MODE` (`read-only|workspace-write|danger-full-access|external-sandbox`)
|
||||
- `DEEPSEEK_MANAGED_CONFIG_PATH`
|
||||
- `DEEPSEEK_REQUIREMENTS_PATH`
|
||||
- `DEEPSEEK_MAX_SUBAGENTS` (clamped to `1..=20`)
|
||||
- `DEEPSEEK_TASKS_DIR` (runtime task queue/artifact storage, default `~/.deepseek/tasks`)
|
||||
- `DEEPSEEK_ALLOW_INSECURE_HTTP` (`1`/`true` allows non-local `http://` base URLs; default is reject)
|
||||
- `DEEPSEEK_CAPACITY_ENABLED`
|
||||
- `DEEPSEEK_CAPACITY_LOW_RISK_MAX`
|
||||
- `DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX`
|
||||
- `DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK`
|
||||
- `DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO`
|
||||
- `DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS`
|
||||
- `DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS`
|
||||
- `DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN`
|
||||
- `DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL`
|
||||
- `DEEPSEEK_CAPACITY_PROFILE_WINDOW`
|
||||
- `DEEPSEEK_CAPACITY_PRIOR_CHAT`
|
||||
- `DEEPSEEK_CAPACITY_PRIOR_REASONER`
|
||||
- `DEEPSEEK_CAPACITY_PRIOR_FALLBACK`
|
||||
|
||||
## Settings File (Persistent UI Preferences)
|
||||
|
||||
@@ -67,7 +86,7 @@ Common settings keys:
|
||||
- `auto_compact` (on/off)
|
||||
- `show_thinking` (on/off)
|
||||
- `show_tool_details` (on/off)
|
||||
- `default_mode` (normal, agent, plan, yolo, rlm, duo)
|
||||
- `default_mode` (normal, agent, plan, yolo)
|
||||
- `max_history` (number of input history entries)
|
||||
- `default_model` (model name override)
|
||||
|
||||
@@ -77,10 +96,14 @@ Common settings keys:
|
||||
|
||||
- `api_key` (string, required): must be non-empty (or set `DEEPSEEK_API_KEY`).
|
||||
- `base_url` (string, optional): defaults to `https://api.deepseek.com` (OpenAI-compatible Responses API).
|
||||
- `default_text_model` (string, optional): defaults to `deepseek-reasoner`. Other available models include `deepseek-chat`, `deepseek-r1`, `deepseek-v3`, `deepseek-v3.2`. Check the DeepSeek API for the latest model list.
|
||||
- `allow_shell` (bool, optional): defaults to `false`.
|
||||
- `default_text_model` (string, optional): defaults to `deepseek-reasoner`. Supported IDs are `deepseek-reasoner` and `deepseek-chat`.
|
||||
- `allow_shell` (bool, optional): defaults to `true` (sandboxed).
|
||||
- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `/set approval_mode` also accepts `on-request` and `untrusted` aliases.
|
||||
- `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`.
|
||||
- `managed_config_path` (string, optional): managed config file loaded after user/env config.
|
||||
- `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values.
|
||||
- `max_subagents` (int, optional): defaults to `5` and is clamped to `1..=20`.
|
||||
- `skills_dir` (string, optional): defaults to `~/.deepseek/skills` (each skill is a directory containing `SKILL.md`).
|
||||
- `skills_dir` (string, optional): defaults to `~/.deepseek/skills` (each skill is a directory containing `SKILL.md`). Workspace-local `.agents/skills` or `./skills` are preferred when present.
|
||||
- `mcp_config_path` (string, optional): defaults to `~/.deepseek/mcp.json`.
|
||||
- `notes_path` (string, optional): defaults to `~/.deepseek/notes.txt` and is used by the `note` tool.
|
||||
- `memory_path` (string, optional): defaults to `~/.deepseek/memory.md`.
|
||||
@@ -90,6 +113,20 @@ Common settings keys:
|
||||
- `[retry].initial_delay` (float seconds, default `1.0`)
|
||||
- `[retry].max_delay` (float seconds, default `60.0`)
|
||||
- `[retry].exponential_base` (float, default `2.0`)
|
||||
- `capacity.*` (optional): runtime context-capacity controller:
|
||||
- `[capacity].enabled` (bool, default `true`)
|
||||
- `[capacity].low_risk_max` (float, default `0.34`)
|
||||
- `[capacity].medium_risk_max` (float, default `0.62`)
|
||||
- `[capacity].severe_min_slack` (float, default `-0.25`)
|
||||
- `[capacity].severe_violation_ratio` (float, default `0.40`)
|
||||
- `[capacity].refresh_cooldown_turns` (int, default `2`)
|
||||
- `[capacity].replan_cooldown_turns` (int, default `5`)
|
||||
- `[capacity].max_replay_per_turn` (int, default `1`)
|
||||
- `[capacity].min_turns_before_guardrail` (int, default `2`)
|
||||
- `[capacity].profile_window` (int, default `8`)
|
||||
- `[capacity].deepseek_v3_2_chat_prior` (float, default `3.9`)
|
||||
- `[capacity].deepseek_v3_2_reasoner_prior` (float, default `4.1`)
|
||||
- `[capacity].fallback_default_prior` (float, default `3.8`)
|
||||
- `tui.alternate_screen` (string, optional): `auto`, `always`, or `never`. `auto` disables the alternate screen in Zellij; `--no-alt-screen` forces inline mode.
|
||||
- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).
|
||||
- `features.*` (optional): feature flag overrides (see below).
|
||||
@@ -110,11 +147,9 @@ want to force on or off.
|
||||
[features]
|
||||
shell_tool = true
|
||||
subagents = true
|
||||
web_search = true
|
||||
web_search = true # enables web.run and web_search
|
||||
apply_patch = true
|
||||
mcp = true
|
||||
rlm = true
|
||||
duo = true
|
||||
exec_policy = true
|
||||
```
|
||||
|
||||
@@ -125,6 +160,29 @@ You can also override features for a single run:
|
||||
|
||||
Use `deepseek features list` to inspect known flags and their effective state.
|
||||
|
||||
## Managed Configuration and Requirements
|
||||
|
||||
DeepSeek CLI supports a policy layering model:
|
||||
|
||||
1. user config + profile + env overrides
|
||||
2. managed config (if present)
|
||||
3. requirements validation (if present)
|
||||
|
||||
By default on Unix:
|
||||
- managed config: `/etc/deepseek/managed_config.toml`
|
||||
- requirements: `/etc/deepseek/requirements.toml`
|
||||
|
||||
Requirements file shape:
|
||||
|
||||
```toml
|
||||
allowed_approval_policies = ["on-request", "untrusted", "never"]
|
||||
allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
```
|
||||
|
||||
If configured values violate requirements, startup fails with a descriptive error.
|
||||
|
||||
See `docs/capacity_controller.md` for formulas, intervention behavior, and telemetry.
|
||||
|
||||
## Notes On `deepseek doctor`
|
||||
|
||||
`deepseek doctor` now follows the same config resolution rules as the rest of the CLI.
|
||||
|
||||
+34
-2
@@ -2,6 +2,10 @@
|
||||
|
||||
DeepSeek CLI can load additional tools via MCP (Model Context Protocol). MCP servers are local processes that the CLI starts and communicates with over stdio.
|
||||
|
||||
Server mode note:
|
||||
- `deepseek serve --mcp` runs the MCP stdio server.
|
||||
- `deepseek serve --http` runs the runtime HTTP/SSE API (separate mode).
|
||||
|
||||
## Bootstrap MCP Config
|
||||
|
||||
Create a starter MCP config at your resolved MCP path:
|
||||
@@ -12,6 +16,19 @@ deepseek mcp init
|
||||
|
||||
`deepseek setup --mcp` performs the same MCP bootstrap alongside skills setup.
|
||||
|
||||
Common management commands:
|
||||
|
||||
```bash
|
||||
deepseek mcp list
|
||||
deepseek mcp tools [server]
|
||||
deepseek mcp add <name> --command "<cmd>" --arg "<arg>"
|
||||
deepseek mcp add <name> --url "http://localhost:3000/mcp"
|
||||
deepseek mcp enable <name>
|
||||
deepseek mcp disable <name>
|
||||
deepseek mcp remove <name>
|
||||
deepseek mcp validate
|
||||
```
|
||||
|
||||
## Config File Location
|
||||
|
||||
Default path:
|
||||
@@ -35,6 +52,15 @@ Discovered MCP tools are exposed to the model as:
|
||||
|
||||
Example: a server named `git` with a tool named `status` becomes `mcp_git_status`.
|
||||
|
||||
## Resource and Prompt Helpers
|
||||
|
||||
The CLI also exposes helper tools when MCP is enabled:
|
||||
|
||||
- `list_mcp_resources` (optional `server` filter)
|
||||
- `list_mcp_resource_templates` (optional `server` filter)
|
||||
- `mcp_read_resource` / `read_mcp_resource` (aliases)
|
||||
- `mcp_get_prompt`
|
||||
|
||||
## Minimal Example
|
||||
|
||||
```json
|
||||
@@ -66,10 +92,16 @@ Per-server settings:
|
||||
- `env` (object, optional)
|
||||
- `connect_timeout`, `execute_timeout`, `read_timeout` (seconds, optional)
|
||||
- `disabled` (bool, optional)
|
||||
- `enabled` (bool, optional, default `true`)
|
||||
- `required` (bool, optional): startup/connect validation fails if this server cannot initialize.
|
||||
- `enabled_tools` (array, optional): allowlist of tool names for this server.
|
||||
- `disabled_tools` (array, optional): denylist applied after `enabled_tools`.
|
||||
|
||||
## Safety Caveat (Important)
|
||||
## Safety Notes
|
||||
|
||||
MCP tools currently execute without TUI approval prompts. Only configure MCP servers you trust, and treat MCP server configuration as equivalent to running code on your machine.
|
||||
MCP tools now flow through the same tool-approval framework as built-in tools. Read-only MCP helpers (resource/prompt listing and reads) can run without prompts in suggestive approval modes, while side-effectful MCP tools require approval.
|
||||
|
||||
You should still only configure MCP servers you trust, and treat MCP server configuration as equivalent to running code on your machine.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
+5
-6
@@ -2,18 +2,17 @@
|
||||
|
||||
DeepSeek CLI has two related concepts:
|
||||
|
||||
- **TUI mode**: what kind of interaction you’re in (Normal/Plan/Agent/YOLO/RLM).
|
||||
- **TUI mode**: what kind of interaction you’re in (Normal/Plan/Agent/YOLO).
|
||||
- **Approval mode**: how aggressively the UI asks before executing tools.
|
||||
|
||||
## TUI Modes
|
||||
|
||||
Press `Tab` to cycle: **Normal → Plan → Agent → YOLO → RLM → Normal**.
|
||||
Press `Tab` to cycle: **Plan → Agent → YOLO → Plan**.
|
||||
|
||||
- **Normal**: chat-first. Approvals for file writes, shell, and paid tools.
|
||||
- **Plan**: design-first prompting. Approvals match Normal.
|
||||
- **Agent**: multi-step tool use. Approvals for shell and paid tools (file writes are allowed without a prompt).
|
||||
- **YOLO**: enables shell + trust mode and auto-approves all tools. Use only in trusted repos.
|
||||
- **RLM**: externalized context store + REPL helpers. Tools are auto-approved (best for large files and long-context work).
|
||||
|
||||
## Approval Mode
|
||||
|
||||
@@ -26,7 +25,7 @@ You can override approval behavior at runtime:
|
||||
```
|
||||
|
||||
- `suggest` (default): uses the per-mode rules above.
|
||||
- `auto`: auto-approves all tools (similar to YOLO/RLM approval behavior, but without forcing YOLO mode).
|
||||
- `auto`: auto-approves all tools (similar to YOLO approval behavior, but without forcing YOLO mode).
|
||||
- `never`: blocks any tool that isn’t considered safe/read-only.
|
||||
|
||||
## Workspace Boundary and Trust Mode
|
||||
@@ -39,9 +38,9 @@ By default, file tools are restricted to the `--workspace` directory. Enable tru
|
||||
|
||||
YOLO mode enables trust mode automatically.
|
||||
|
||||
## MCP Caveat (Important)
|
||||
## MCP Behavior
|
||||
|
||||
MCP tools are exposed as `mcp_<server>_<tool>` and currently execute without TUI approval prompts. Only configure MCP servers you trust.
|
||||
MCP tools are exposed as `mcp_<server>_<tool>` and use the same approval flow as built-in tools. Read-only MCP helpers may auto-run in suggestive approval modes; MCP tools with possible side effects require approval.
|
||||
|
||||
See `MCP.md`.
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# DeepSeek CLI Operations Runbook
|
||||
|
||||
This runbook covers practical debugging and incident response for the local CLI/TUI runtime.
|
||||
|
||||
## Quick Triage
|
||||
|
||||
1. Confirm binary + config:
|
||||
- `cargo run -- --version`
|
||||
- `cat ~/.deepseek/config.toml` (or inspect configured profile)
|
||||
2. Enable verbose logs:
|
||||
- `RUST_LOG=deepseek_cli=debug cargo run`
|
||||
- For HTTP retries/reconnects: `RUST_LOG=deepseek_cli::client=debug cargo run`
|
||||
3. Capture current state:
|
||||
- `ls ~/.deepseek/sessions`
|
||||
- `ls ~/.deepseek/sessions/checkpoints`
|
||||
- `ls ~/.deepseek/tasks`
|
||||
|
||||
## Incident: Turn Hangs or Stream Stops
|
||||
|
||||
Symptoms:
|
||||
- TUI remains in loading state
|
||||
- partial assistant output with no completion
|
||||
|
||||
Checks:
|
||||
1. Inspect retry/health logs (`deepseek_cli::client`)
|
||||
2. Verify endpoint connectivity:
|
||||
- `curl -sS https://api.deepseek.com/v1/models -H "Authorization: Bearer $DEEPSEEK_API_KEY"`
|
||||
3. Confirm no local sandbox/permission deadlock in tool output
|
||||
|
||||
Actions:
|
||||
1. Cancel current turn (`Esc` in TUI)
|
||||
2. Retry prompt; if still failing, restart TUI
|
||||
3. On restart, verify crash checkpoint recovery message appears
|
||||
|
||||
## Incident: Network Outage / Offline Behavior
|
||||
|
||||
Expected behavior:
|
||||
- New prompts are queued while offline mode is active
|
||||
- Queue state persists to `~/.deepseek/sessions/checkpoints/offline_queue.json`
|
||||
|
||||
Checks:
|
||||
1. Open queue in TUI: `/queue list`
|
||||
2. Confirm persisted queue file exists and updates timestamp
|
||||
|
||||
Actions:
|
||||
1. Restore connectivity
|
||||
2. Re-send queued entries (from `/queue edit <n>` + Enter, or normal input flow)
|
||||
3. Ensure queue file clears when queue is empty
|
||||
|
||||
## Incident: Crash Recovery Needed
|
||||
|
||||
Expected behavior:
|
||||
- Checkpoint stored at `~/.deepseek/sessions/checkpoints/latest.json`
|
||||
- Startup begins a fresh session unless `--resume`/`--continue` is supplied
|
||||
|
||||
Actions:
|
||||
1. Resume prior work explicitly via `deepseek --resume <id>` or `Ctrl+R` in TUI
|
||||
2. If checkpoint inspection is needed, inspect `latest.json` for schema mismatch/details
|
||||
3. If schema is newer than binary supports, upgrade binary or remove stale checkpoint
|
||||
|
||||
## Incident: Persistent State Schema Errors
|
||||
|
||||
Symptoms:
|
||||
- Errors like `schema vX is newer than supported vY`
|
||||
|
||||
Affected stores:
|
||||
- sessions (`~/.deepseek/sessions/*.json`)
|
||||
- runtime thread/turn/item records
|
||||
- tasks (`~/.deepseek/tasks/tasks/*.json`)
|
||||
|
||||
Actions:
|
||||
1. Confirm binary version and migration expectations
|
||||
2. Back up the state directory before editing
|
||||
3. Either:
|
||||
- run with a newer compatible binary, or
|
||||
- archive incompatible records and regenerate state
|
||||
|
||||
## Incident: MCP/Tool Execution Failures
|
||||
|
||||
Checks:
|
||||
1. Validate `~/.deepseek/mcp.json` schema and server command paths
|
||||
2. Confirm server process can start manually
|
||||
3. Check sandbox denials in TUI history / logs
|
||||
|
||||
Actions:
|
||||
1. Retry with required approvals (or YOLO only when appropriate)
|
||||
2. Temporarily disable failing MCP server and isolate issue
|
||||
3. Re-enable after verification with `/mcp` diagnostics
|
||||
|
||||
## Post-Incident Checklist
|
||||
|
||||
1. Preserve logs and relevant state files
|
||||
2. Record trigger, impact, and mitigation
|
||||
3. Add or update regression tests (retry/recovery/schema)
|
||||
4. Update this runbook and architecture docs if behavior changed
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
# RLM Mode
|
||||
|
||||
RLM mode (“Recursive Language Model” mode) is DeepSeek CLI’s long-context workflow: it stores large context externally (Aleph-style external memory) and provides REPL-like tools to explore and query it without stuffing everything into the model’s context window.
|
||||
|
||||
If you’re curious about the research inspiration and implementation notes, see:
|
||||
|
||||
- `docs/rlm-paper.txt`
|
||||
- `docs/rlm_gap_analysis.md`
|
||||
|
||||
## When To Use It
|
||||
|
||||
RLM mode is best for:
|
||||
|
||||
- “Analyze this large file / doc”
|
||||
- “Summarize the whole repository”
|
||||
- “Search for every occurrence of X and explain it”
|
||||
- Big pasted blocks of text
|
||||
|
||||
The UI may auto-switch to RLM for large file requests, “largest file”, explicit “RLM” requests, and large pastes.
|
||||
|
||||
## How To Use It
|
||||
|
||||
### Switch modes
|
||||
|
||||
- Press `Tab` until you reach **RLM**
|
||||
- Or type `/rlm` (or `/aleph`) to jump directly into RLM mode
|
||||
|
||||
### Load context
|
||||
|
||||
In RLM mode, `/load` loads external context (in other modes, `/load` loads a saved chat JSON):
|
||||
|
||||
```text
|
||||
/load @path/to/file.rs
|
||||
```
|
||||
|
||||
`@path` is workspace-relative.
|
||||
|
||||
### Inspect and query
|
||||
|
||||
- `/status` shows which contexts are loaded and basic usage totals.
|
||||
- `/repl` toggles expression input mode.
|
||||
|
||||
Typical REPL helpers include:
|
||||
|
||||
- `lines(1, 80)` (show a slice of the context)
|
||||
- `search("pattern")`
|
||||
- `chunk(2000)` (create fixed-size chunks for later querying)
|
||||
|
||||
Under the hood, the model uses tools like `rlm_load`, `rlm_exec`, `rlm_status`, and `rlm_query`.
|
||||
|
||||
## Cost and Safety Notes
|
||||
|
||||
- `rlm_query` can be expensive because it triggers additional model calls. Prefer batching related questions.
|
||||
- RLM mode auto-approves tools; keep `--workspace` scoped to the repo you want it to access.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Runtime API (HTTP/SSE)
|
||||
|
||||
DeepSeek CLI can expose a local runtime API for external clients:
|
||||
|
||||
```bash
|
||||
deepseek serve --http --host 127.0.0.1 --port 7878 --workers 2
|
||||
```
|
||||
|
||||
Defaults:
|
||||
- bind: `127.0.0.1:7878`
|
||||
- workers: `2` (clamped to `1..8`)
|
||||
|
||||
## Security Model (Local-First)
|
||||
|
||||
- The server is designed for trusted local use.
|
||||
- There is no built-in auth, user isolation, or TLS termination.
|
||||
- Do not expose this API directly to untrusted networks.
|
||||
- If remote access is required, place it behind your own authenticated reverse proxy/VPN.
|
||||
|
||||
## Runtime Data Model
|
||||
|
||||
The runtime uses a durable Thread/Turn/Item lifecycle.
|
||||
|
||||
- `ThreadRecord`
|
||||
- `id`, `created_at`, `updated_at`
|
||||
- `model`, `workspace`, `mode`
|
||||
- `latest_turn_id`, `latest_response_bookmark`, `archived`
|
||||
- `TurnRecord`
|
||||
- `id`, `thread_id`
|
||||
- `status`: `queued|in_progress|completed|failed|interrupted|canceled`
|
||||
- timestamps, duration, usage, error summary
|
||||
- `TurnItemRecord`
|
||||
- `id`, `turn_id`
|
||||
- `kind`: `user_message|agent_message|tool_call|file_change|command_execution|context_compaction|status|error`
|
||||
- lifecycle `status`: `queued|in_progress|completed|failed|interrupted|canceled`
|
||||
|
||||
The event log is append-only with global monotonic `seq` for replay/resume.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Health and Session
|
||||
|
||||
- `GET /health`
|
||||
- `GET /v1/sessions?limit=50&search=<substring>`
|
||||
|
||||
### Compatibility Stream (Single Turn)
|
||||
|
||||
- `POST /v1/stream`
|
||||
|
||||
Backwards-compatible one-shot SSE wrapper. Internally creates an archived runtime thread+turn.
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "Summarize recent commits",
|
||||
"model": "deepseek-reasoner",
|
||||
"mode": "agent",
|
||||
"workspace": ".",
|
||||
"allow_shell": false,
|
||||
"trust_mode": false,
|
||||
"auto_approve": true
|
||||
}
|
||||
```
|
||||
|
||||
Typical SSE events:
|
||||
- `turn.started`
|
||||
- `message.delta`
|
||||
- `tool.started`
|
||||
- `tool.progress`
|
||||
- `tool.completed`
|
||||
- `approval.required`
|
||||
- `sandbox.denied`
|
||||
- `status`
|
||||
- `error`
|
||||
- `turn.completed`
|
||||
- `done`
|
||||
|
||||
### Thread Lifecycle
|
||||
|
||||
- `POST /v1/threads`
|
||||
- `GET /v1/threads?limit=50&include_archived=false`
|
||||
- `GET /v1/threads/{id}`
|
||||
- `POST /v1/threads/{id}/resume`
|
||||
- `POST /v1/threads/{id}/fork`
|
||||
|
||||
Create thread request example:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "deepseek-reasoner",
|
||||
"workspace": ".",
|
||||
"mode": "agent",
|
||||
"allow_shell": false,
|
||||
"trust_mode": false,
|
||||
"auto_approve": true,
|
||||
"archived": false
|
||||
}
|
||||
```
|
||||
|
||||
### Turn Lifecycle
|
||||
|
||||
- `POST /v1/threads/{id}/turns`
|
||||
- `POST /v1/threads/{id}/turns/{turn_id}/steer`
|
||||
- `POST /v1/threads/{id}/turns/{turn_id}/interrupt`
|
||||
- `POST /v1/threads/{id}/compact`
|
||||
|
||||
Notes:
|
||||
- Only one active turn is allowed per thread (`409 Conflict` on overlap).
|
||||
- `interrupt` returns quickly and marks `turn.interrupt_requested`.
|
||||
- Terminal turn status becomes `interrupted` only after cleanup completes.
|
||||
- Manual compaction is exposed as a turn with `context_compaction` item lifecycle events.
|
||||
|
||||
### Replayable Events
|
||||
|
||||
- `GET /v1/threads/{id}/events?since_seq=<u64>`
|
||||
|
||||
Returns SSE replay backlog, then live events for that thread.
|
||||
|
||||
SSE payload shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": 42,
|
||||
"timestamp": "2026-02-11T20:18:49.123Z",
|
||||
"thread_id": "thr_1234abcd",
|
||||
"turn_id": "turn_5678efgh",
|
||||
"item_id": "item_90ab12cd",
|
||||
"event": "item.delta",
|
||||
"payload": {
|
||||
"delta": "partial output",
|
||||
"kind": "agent_message"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Common event names:
|
||||
- `thread.started`
|
||||
- `thread.forked`
|
||||
- `turn.started`
|
||||
- `turn.lifecycle`
|
||||
- `turn.steered`
|
||||
- `turn.interrupt_requested`
|
||||
- `turn.completed`
|
||||
- `item.started`
|
||||
- `item.delta`
|
||||
- `item.completed`
|
||||
- `item.failed`
|
||||
- `item.interrupted`
|
||||
- `approval.required`
|
||||
- `sandbox.denied`
|
||||
|
||||
Compaction visibility:
|
||||
- auto compaction emits `item.started`/`item.completed` with item kind `context_compaction` and `auto=true`
|
||||
- manual compaction emits the same with `auto=false`
|
||||
|
||||
### Background Tasks
|
||||
|
||||
- `GET /v1/tasks`
|
||||
- `POST /v1/tasks`
|
||||
- `GET /v1/tasks/{id}`
|
||||
- `POST /v1/tasks/{id}/cancel`
|
||||
|
||||
Tasks execute through the same runtime thread/turn pipeline and include:
|
||||
- linked `thread_id` / `turn_id`
|
||||
- runtime event count
|
||||
- timeline + tool summaries + artifact references
|
||||
|
||||
## Persistence
|
||||
|
||||
Runtime store (default under task data root):
|
||||
- `runtime/threads/*.json`
|
||||
- `runtime/turns/*.json`
|
||||
- `runtime/items/*.json`
|
||||
- `runtime/events/{thread_id}.jsonl`
|
||||
- `runtime/state.json` (monotonic sequence)
|
||||
|
||||
Task store:
|
||||
- default `~/.deepseek/tasks` (override with `DEEPSEEK_TASKS_DIR`)
|
||||
|
||||
Both runtime and task state are restart-safe.
|
||||
@@ -0,0 +1,136 @@
|
||||
# Capacity Controller
|
||||
|
||||
`deepseek-tui` includes a capacity-aware context controller that keeps active prompt context near coherent operating range while preserving full history on disk.
|
||||
|
||||
## Policy Overview
|
||||
|
||||
Each checkpoint computes:
|
||||
|
||||
- `H_hat` (runtime pressure proxy)
|
||||
- `C_hat` (model capacity prior)
|
||||
- `slack = C_hat - H_hat`
|
||||
- dynamic slack profile over last `N=8` observations
|
||||
|
||||
### Runtime Pressure Proxy (`H_hat`)
|
||||
|
||||
- `action_complexity_bits = log2(1 + action_count_this_turn)`
|
||||
- `tool_complexity_bits = log2(1 + tool_calls_recent_window)`
|
||||
- `ref_complexity_bits = log2(1 + unique_reference_ids_recent_window)`
|
||||
- `context_pressure_bits = 6.0 * context_used_ratio`
|
||||
|
||||
Formula:
|
||||
|
||||
`H_hat = 0.35*action_complexity_bits + 0.30*tool_complexity_bits + 0.20*ref_complexity_bits + 0.15*context_pressure_bits`
|
||||
|
||||
### Capacity Prior (`C_hat`)
|
||||
|
||||
Per-model priors:
|
||||
|
||||
- `deepseek_v3_2_chat = 3.9`
|
||||
- `deepseek_v3_2_reasoner = 4.1`
|
||||
- fallback `3.8`
|
||||
|
||||
### Failure Probability
|
||||
|
||||
Using rolling profile fields:
|
||||
|
||||
- `final_slack`
|
||||
- `min_slack`
|
||||
- `violation_ratio`
|
||||
- `slack_volatility`
|
||||
- `slack_drop`
|
||||
|
||||
Formula:
|
||||
|
||||
`z = -1.65*final_slack -0.85*min_slack +1.35*violation_ratio +0.70*slack_volatility +0.28*slack_drop -0.12`
|
||||
|
||||
`p_fail = sigmoid(z)` clamped to `[0,1]`.
|
||||
|
||||
Risk bands:
|
||||
|
||||
- low: `p_fail <= low_risk_max`
|
||||
- medium: `p_fail <= medium_risk_max`
|
||||
- high: otherwise
|
||||
|
||||
Action mapping:
|
||||
|
||||
- low -> `NoIntervention`
|
||||
- medium -> `TargetedContextRefresh`
|
||||
- high + severe dynamics (`min_slack <= severe_min_slack` or `violation_ratio >= severe_violation_ratio`) -> `VerifyAndReplan`
|
||||
- otherwise high -> `VerifyWithToolReplay`
|
||||
|
||||
## Checkpoints
|
||||
|
||||
The engine evaluates controller policy at:
|
||||
|
||||
1. Pre-request checkpoint (before `MessageRequest` assembly).
|
||||
2. Post-tool checkpoint (after tool result append).
|
||||
3. Error-escalation checkpoint (tool error streak path).
|
||||
|
||||
## Interventions
|
||||
|
||||
### `TargetedContextRefresh`
|
||||
|
||||
- Runs compaction (`compact_messages_safe`) when possible.
|
||||
- Falls back to local trim if compaction path fails.
|
||||
- Persists canonical state.
|
||||
- Replaces long-tail active context with compact canonical prompt + memory pointer.
|
||||
|
||||
### `VerifyWithToolReplay`
|
||||
|
||||
- Replays one read-only critical tool call from recent turn context.
|
||||
- Appends verification note with pass/fail + diff summary.
|
||||
- On replay conflict/error, marks escalation candidate and disables replay for current turn.
|
||||
|
||||
### `VerifyAndReplan`
|
||||
|
||||
- Persists canonical snapshot.
|
||||
- Clears volatile prompt tail while preserving latest user ask and latest verification note.
|
||||
- Injects canonical replan instruction into system prompt.
|
||||
- Continues turn loop from compact canonical state.
|
||||
|
||||
## Safety Controls
|
||||
|
||||
- Max one intervention per turn.
|
||||
- Cooldowns for refresh and replan.
|
||||
- Replay budget per turn (`max_replay_per_turn`).
|
||||
- Fail-open behavior when controller inputs are unavailable.
|
||||
- Compaction/replay failures are logged; turn continues.
|
||||
|
||||
## Memory Store
|
||||
|
||||
Path:
|
||||
|
||||
- `DEEPSEEK_CAPACITY_MEMORY_DIR` (if set)
|
||||
- otherwise `~/.deepseek/memory/<session_id>.jsonl`
|
||||
- fallback: `<workspace>/.deepseek/memory/<session_id>.jsonl` when home path is unavailable/unwritable
|
||||
|
||||
Record fields:
|
||||
|
||||
- `id`, `ts`, `turn_index`, `action_trigger`
|
||||
- `h_hat`, `c_hat`, `slack`, `risk_band`
|
||||
- `canonical_state`
|
||||
- `source_message_ids`
|
||||
- optional `replay_info`
|
||||
|
||||
Loader utility supports fetching last `K` snapshots for rehydration.
|
||||
|
||||
## Configuration
|
||||
|
||||
`[capacity]` keys:
|
||||
|
||||
- `enabled`
|
||||
- `low_risk_max`
|
||||
- `medium_risk_max`
|
||||
- `severe_min_slack`
|
||||
- `severe_violation_ratio`
|
||||
- `refresh_cooldown_turns`
|
||||
- `replan_cooldown_turns`
|
||||
- `max_replay_per_turn`
|
||||
- `min_turns_before_guardrail`
|
||||
- `profile_window`
|
||||
- `deepseek_v3_2_chat_prior`
|
||||
- `deepseek_v3_2_reasoner_prior`
|
||||
- `fallback_default_prior`
|
||||
|
||||
Equivalent environment overrides are available with `DEEPSEEK_CAPACITY_*`.
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Lightweight audit logging for sensitive operations.
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Append an audit event to `~/.deepseek/audit.log`.
|
||||
///
|
||||
/// This helper is best-effort by design: callers should not fail critical flows
|
||||
/// if audit persistence fails.
|
||||
pub fn log_sensitive_event(event: &str, details: Value) {
|
||||
if let Err(err) = append_event(event, details) {
|
||||
crate::logging::warn(format!("audit log write failed: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn append_event(event: &str, details: Value) -> anyhow::Result<()> {
|
||||
let path = default_audit_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
|
||||
let record = json!({
|
||||
"ts": Utc::now().to_rfc3339(),
|
||||
"event": event,
|
||||
"details": details,
|
||||
});
|
||||
writeln!(file, "{}", serde_json::to_string(&record)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_audit_path() -> anyhow::Result<PathBuf> {
|
||||
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?;
|
||||
Ok(home.join(".deepseek").join("audit.log"))
|
||||
}
|
||||
+1436
-102
File diff suppressed because it is too large.
Load diff
+275
-16
@@ -1,8 +1,7 @@
|
||||
//! Config commands: config, set, settings, yolo, trust, logout
|
||||
|
||||
use super::CommandResult;
|
||||
use crate::compaction::CompactionConfig;
|
||||
use crate::config::clear_api_key;
|
||||
use crate::config::{COMMON_DEEPSEEK_MODELS, canonical_model_name, clear_api_key};
|
||||
use crate::palette;
|
||||
use crate::settings::Settings;
|
||||
use crate::tui::app::{App, AppAction, AppMode, OnboardingState};
|
||||
@@ -22,6 +21,7 @@ pub fn show_config(app: &mut App) -> CommandResult {
|
||||
Max sub-agents: {}\n\
|
||||
Trust mode: {}\n\
|
||||
Auto-compact: {}\n\
|
||||
Sidebar width: {}%\n\
|
||||
Total tokens: {}\n\
|
||||
Project doc: {}",
|
||||
app.mode.label(),
|
||||
@@ -32,6 +32,7 @@ pub fn show_config(app: &mut App) -> CommandResult {
|
||||
app.max_subagents,
|
||||
if app.trust_mode { "yes" } else { "no" },
|
||||
if app.auto_compact { "yes" } else { "no" },
|
||||
app.sidebar_width_percent,
|
||||
app.total_tokens,
|
||||
if has_project_doc {
|
||||
"loaded"
|
||||
@@ -83,13 +84,25 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
// Handle session-only settings first
|
||||
match key.as_str() {
|
||||
"model" => {
|
||||
app.model = value.to_string();
|
||||
return CommandResult::message(format!("model = {value}"));
|
||||
let Some(model) = canonical_model_name(value) else {
|
||||
return CommandResult::error(format!(
|
||||
"Invalid model '{value}'. Supported models: {}",
|
||||
COMMON_DEEPSEEK_MODELS.join(", ")
|
||||
));
|
||||
};
|
||||
app.model = model.to_string();
|
||||
app.update_model_compaction_budget();
|
||||
app.last_prompt_tokens = None;
|
||||
app.last_completion_tokens = None;
|
||||
return CommandResult::with_message_and_action(
|
||||
format!("model = {model}"),
|
||||
AppAction::UpdateCompaction(app.compaction_config()),
|
||||
);
|
||||
}
|
||||
"approval_mode" | "approval" => {
|
||||
let mode = match value.to_lowercase().as_str() {
|
||||
"auto" => Some(ApprovalMode::Auto),
|
||||
"suggest" | "suggested" => Some(ApprovalMode::Suggest),
|
||||
"suggest" | "suggested" | "on-request" | "untrusted" => Some(ApprovalMode::Suggest),
|
||||
"never" => Some(ApprovalMode::Never),
|
||||
_ => None,
|
||||
};
|
||||
@@ -98,7 +111,9 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
app.approval_mode = m;
|
||||
CommandResult::message(format!("approval_mode = {}", m.label()))
|
||||
}
|
||||
None => CommandResult::error("Invalid approval_mode. Use: auto, suggest, never"),
|
||||
None => CommandResult::error(
|
||||
"Invalid approval_mode. Use: auto, suggest/on-request/untrusted, never",
|
||||
),
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
@@ -107,6 +122,12 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
// Load and update persistent settings
|
||||
let mut settings = match Settings::load() {
|
||||
Ok(s) => s,
|
||||
Err(e) if !should_save => {
|
||||
app.status_message = Some(format!(
|
||||
"Settings unavailable; applying session-only override ({e})"
|
||||
));
|
||||
Settings::default()
|
||||
}
|
||||
Err(e) => return CommandResult::error(format!("Failed to load settings: {e}")),
|
||||
};
|
||||
|
||||
@@ -119,14 +140,7 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
match key.as_str() {
|
||||
"auto_compact" | "compact" => {
|
||||
app.auto_compact = settings.auto_compact;
|
||||
let mut compaction = CompactionConfig::default();
|
||||
compaction.enabled = app.auto_compact;
|
||||
compaction.token_threshold = app.compact_threshold;
|
||||
compaction.model = app.model.clone();
|
||||
action = Some(AppAction::UpdateCompaction(compaction));
|
||||
}
|
||||
"auto_rlm" => {
|
||||
app.auto_rlm = settings.auto_rlm;
|
||||
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
|
||||
}
|
||||
"show_thinking" | "thinking" => {
|
||||
app.show_thinking = settings.show_thinking;
|
||||
@@ -141,8 +155,6 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
"agent" | "normal" => AppMode::Agent,
|
||||
"plan" => AppMode::Plan,
|
||||
"yolo" => AppMode::Yolo,
|
||||
"rlm" => AppMode::Rlm,
|
||||
"duo" => AppMode::Duo,
|
||||
_ => AppMode::Agent,
|
||||
};
|
||||
app.set_mode(mode);
|
||||
@@ -153,12 +165,20 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
"default_model" => {
|
||||
if let Some(ref model) = settings.default_model {
|
||||
app.model.clone_from(model);
|
||||
app.update_model_compaction_budget();
|
||||
app.last_prompt_tokens = None;
|
||||
app.last_completion_tokens = None;
|
||||
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
|
||||
}
|
||||
}
|
||||
"theme" => {
|
||||
app.ui_theme = palette::ui_theme(&settings.theme);
|
||||
app.mark_history_updated();
|
||||
}
|
||||
"sidebar_width" | "sidebar" => {
|
||||
app.sidebar_width_percent = settings.sidebar_width_percent;
|
||||
app.mark_history_updated();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -208,9 +228,85 @@ pub fn logout(app: &mut App) -> CommandResult {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::test_support::lock_test_env;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use crate::tui::approval::ApprovalMode;
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct EnvGuard {
|
||||
home: Option<OsString>,
|
||||
userprofile: Option<OsString>,
|
||||
deepseek_config_path: Option<OsString>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn new(home: &Path) -> Self {
|
||||
let home_str = OsString::from(home.as_os_str());
|
||||
let config_path = home.join(".deepseek").join("config.toml");
|
||||
let config_str = OsString::from(config_path.as_os_str());
|
||||
let home_prev = env::var_os("HOME");
|
||||
let userprofile_prev = env::var_os("USERPROFILE");
|
||||
let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH");
|
||||
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("HOME", &home_str);
|
||||
env::set_var("USERPROFILE", &home_str);
|
||||
env::set_var("DEEPSEEK_CONFIG_PATH", &config_str);
|
||||
}
|
||||
|
||||
Self {
|
||||
home: home_prev,
|
||||
userprofile: userprofile_prev,
|
||||
deepseek_config_path: deepseek_config_prev,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(value) = self.home.take() {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("HOME", value);
|
||||
}
|
||||
} else {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::remove_var("HOME");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = self.userprofile.take() {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("USERPROFILE", value);
|
||||
}
|
||||
} else {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::remove_var("USERPROFILE");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = self.deepseek_config_path.take() {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_CONFIG_PATH", value);
|
||||
}
|
||||
} else {
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::remove_var("DEEPSEEK_CONFIG_PATH");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
@@ -242,4 +338,167 @@ mod tests {
|
||||
assert_eq!(app.approval_mode, ApprovalMode::Auto);
|
||||
assert_eq!(app.mode, AppMode::Yolo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_config_displays_all_fields() {
|
||||
let mut app = create_test_app();
|
||||
app.total_tokens = 1234;
|
||||
let result = show_config(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Session Configuration"));
|
||||
assert!(msg.contains("Mode:"));
|
||||
assert!(msg.contains("Model:"));
|
||||
assert!(msg.contains("Workspace:"));
|
||||
assert!(msg.contains("Shell enabled:"));
|
||||
assert!(msg.contains("Approval mode:"));
|
||||
assert!(msg.contains("Max sub-agents:"));
|
||||
assert!(msg.contains("Trust mode:"));
|
||||
assert!(msg.contains("Auto-compact:"));
|
||||
assert!(msg.contains("Sidebar width:"));
|
||||
assert!(msg.contains("Total tokens:"));
|
||||
assert!(msg.contains("Project doc:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_settings_loads_from_file() {
|
||||
let _lock = lock_test_env();
|
||||
let mut app = create_test_app();
|
||||
let result = show_settings(&mut app);
|
||||
// Settings should load (may use defaults if file doesn't exist)
|
||||
assert!(result.message.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_without_args_shows_usage() {
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Usage: /set"));
|
||||
assert!(msg.contains("Available settings:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_model_updates_app_state() {
|
||||
let mut app = create_test_app();
|
||||
let _old_model = app.model.clone();
|
||||
let result = set_config(&mut app, Some("model deepseek-reasoner"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("model = deepseek-reasoner"));
|
||||
assert_eq!(app.model, "deepseek-reasoner");
|
||||
assert!(matches!(
|
||||
result.action,
|
||||
Some(AppAction::UpdateCompaction(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_model_with_save_flag() {
|
||||
let mut app = create_test_app();
|
||||
let _result = set_config(&mut app, Some("model deepseek-reasoner --save"));
|
||||
// Note: This test may fail in environments where settings can't be saved
|
||||
// The important thing is that the model is updated
|
||||
assert_eq!(app.model, "deepseek-reasoner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_approval_mode_valid_values() {
|
||||
let mut app = create_test_app();
|
||||
// Test auto
|
||||
let result = set_config(&mut app, Some("approval_mode auto"));
|
||||
assert!(result.message.is_some());
|
||||
assert_eq!(app.approval_mode, ApprovalMode::Auto);
|
||||
|
||||
// Test suggest
|
||||
let result = set_config(&mut app, Some("approval_mode suggest"));
|
||||
assert!(result.message.is_some());
|
||||
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
|
||||
|
||||
// Test never
|
||||
let result = set_config(&mut app, Some("approval_mode never"));
|
||||
assert!(result.message.is_some());
|
||||
assert_eq!(app.approval_mode, ApprovalMode::Never);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_approval_mode_invalid_value() {
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, Some("approval_mode invalid"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Invalid approval_mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_without_save_flag() {
|
||||
let _lock = lock_test_env();
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, Some("auto_compact true"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("(session only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_enables_flag() {
|
||||
let mut app = create_test_app();
|
||||
assert!(!app.trust_mode);
|
||||
let result = trust(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Trust mode enabled"));
|
||||
assert!(app.trust_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logout_clears_api_key_state() {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-cli-logout-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root).unwrap();
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
let config_path = temp_root.join(".deepseek").join("config.toml");
|
||||
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
|
||||
fs::write(&config_path, "api_key = \"test-key\"\n").unwrap();
|
||||
|
||||
let mut app = create_test_app();
|
||||
let result = logout(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
assert_eq!(app.onboarding, OnboardingState::ApiKey);
|
||||
assert!(app.onboarding_needs_api_key);
|
||||
assert!(app.api_key_input.is_empty());
|
||||
assert_eq!(app.api_key_cursor, 0);
|
||||
|
||||
let updated = fs::read_to_string(config_path).unwrap();
|
||||
assert!(!updated.contains("api_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_invalid_setting() {
|
||||
let _lock = lock_test_env();
|
||||
let mut app = create_test_app();
|
||||
let _result = set_config(&mut app, Some("nonexistent value"));
|
||||
// Should either error or handle as session setting
|
||||
// The current implementation tries to set it in Settings
|
||||
// which may succeed or fail depending on Settings implementation
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_key_without_value() {
|
||||
let mut app = create_test_app();
|
||||
let result = set_config(&mut app, Some("model"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Usage: /set"));
|
||||
}
|
||||
}
|
||||
+274
-27
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::tools::plan::PlanState;
|
||||
use crate::config::{COMMON_DEEPSEEK_MODELS, canonical_model_name};
|
||||
use crate::tui::app::{App, AppAction, AppMode};
|
||||
use crate::tui::views::{HelpView, ModalKind, SubAgentsView};
|
||||
|
||||
@@ -37,13 +37,36 @@ pub fn clear(app: &mut App) -> CommandResult {
|
||||
app.history.clear();
|
||||
app.mark_history_updated();
|
||||
app.api_messages.clear();
|
||||
app.system_prompt = None;
|
||||
app.transcript_selection.clear();
|
||||
app.queued_messages.clear();
|
||||
app.queued_draft = None;
|
||||
app.total_conversation_tokens = 0;
|
||||
app.clear_todos();
|
||||
let mut plan = app.plan_state.blocking_lock();
|
||||
*plan = PlanState::default();
|
||||
let todos_cleared = app.clear_todos();
|
||||
app.tool_log.clear();
|
||||
CommandResult::message("Conversation cleared")
|
||||
app.tool_cells.clear();
|
||||
app.tool_details_by_cell.clear();
|
||||
app.exploring_entries.clear();
|
||||
app.ignored_tool_calls.clear();
|
||||
app.pending_tool_uses.clear();
|
||||
app.last_exec_wait_command = None;
|
||||
app.last_prompt_tokens = None;
|
||||
app.last_completion_tokens = None;
|
||||
app.current_session_id = None;
|
||||
let message = if todos_cleared {
|
||||
"Conversation cleared".to_string()
|
||||
} else {
|
||||
"Conversation cleared (plan state busy; run /clear again if needed)".to_string()
|
||||
};
|
||||
CommandResult::with_message_and_action(
|
||||
message,
|
||||
AppAction::SyncSession {
|
||||
messages: Vec::new(),
|
||||
system_prompt: None,
|
||||
model: app.model.clone(),
|
||||
workspace: app.workspace.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Exit the application
|
||||
@@ -51,30 +74,38 @@ pub fn exit() -> CommandResult {
|
||||
CommandResult::action(AppAction::Quit)
|
||||
}
|
||||
|
||||
/// Available DeepSeek models
|
||||
const AVAILABLE_MODELS: &[&str] = &[
|
||||
"deepseek-reasoner",
|
||||
"deepseek-chat",
|
||||
"deepseek-r1",
|
||||
"deepseek-v3",
|
||||
"deepseek-v3.2",
|
||||
];
|
||||
|
||||
/// Switch or view current model
|
||||
pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
|
||||
if let Some(name) = model_name {
|
||||
let Some(canonical) = canonical_model_name(name) else {
|
||||
return CommandResult::error(format!(
|
||||
"Invalid model '{name}'. Supported models: {}",
|
||||
COMMON_DEEPSEEK_MODELS.join(", ")
|
||||
));
|
||||
};
|
||||
let old_model = app.model.clone();
|
||||
app.model = name.to_string();
|
||||
CommandResult::message(format!("Model changed: {old_model} → {name}"))
|
||||
app.model = canonical.to_string();
|
||||
app.update_model_compaction_budget();
|
||||
app.last_prompt_tokens = None;
|
||||
app.last_completion_tokens = None;
|
||||
CommandResult::with_message_and_action(
|
||||
format!("Model changed: {old_model} → {canonical}"),
|
||||
AppAction::UpdateCompaction(app.compaction_config()),
|
||||
)
|
||||
} else {
|
||||
let available = AVAILABLE_MODELS.join(", ");
|
||||
let supported = COMMON_DEEPSEEK_MODELS.join(", ");
|
||||
CommandResult::message(format!(
|
||||
"Current model: {}\nAvailable: {}",
|
||||
app.model, available
|
||||
"Current model: {}\nSupported models: {}",
|
||||
app.model, supported
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch and list available models from the configured API endpoint.
|
||||
pub fn models(_app: &mut App) -> CommandResult {
|
||||
CommandResult::action(AppAction::FetchModels)
|
||||
}
|
||||
|
||||
/// List sub-agent status from the engine
|
||||
pub fn subagents(app: &mut App) -> CommandResult {
|
||||
if app.view_stack.top_kind() != Some(ModalKind::SubAgents) {
|
||||
@@ -139,6 +170,7 @@ pub fn home_dashboard(app: &mut App) -> CommandResult {
|
||||
let _ = writeln!(stats, "/settings - Show persistent settings");
|
||||
let _ = writeln!(stats, "/model - Switch or view model");
|
||||
let _ = writeln!(stats, "/subagents - List sub-agent status");
|
||||
let _ = writeln!(stats, "/task list - Show background task queue");
|
||||
let _ = writeln!(stats, "/help - Show help");
|
||||
|
||||
// Mode-specific tips
|
||||
@@ -160,15 +192,230 @@ pub fn home_dashboard(app: &mut App) -> CommandResult {
|
||||
let _ = writeln!(stats, "Plan mode - Design before implementing");
|
||||
let _ = writeln!(stats, " Use /plan to create structured checklists");
|
||||
}
|
||||
AppMode::Rlm => {
|
||||
let _ = writeln!(stats, "RLM mode - Recursive language model sandbox");
|
||||
let _ = writeln!(stats, " Use /repl to toggle REPL input");
|
||||
}
|
||||
AppMode::Duo => {
|
||||
let _ = writeln!(stats, "Duo mode - Dialectical autocoding");
|
||||
let _ = writeln!(stats, " Player-coach loop for complex tasks");
|
||||
}
|
||||
}
|
||||
|
||||
CommandResult::message(stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::models::Message;
|
||||
use crate::tui::app::{App, AppMode, TuiOptions};
|
||||
use crate::tui::history::HistoryCell;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-reasoner".to_string(),
|
||||
workspace: PathBuf::from("/tmp/test-workspace"),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("/tmp/test-skills"),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_unknown_command() {
|
||||
let mut app = create_test_app();
|
||||
let result = help(&mut app, Some("nonexistent"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Unknown command"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_known_command() {
|
||||
let mut app = create_test_app();
|
||||
let result = help(&mut app, Some("clear"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("clear"));
|
||||
assert!(msg.contains("Clear conversation history"));
|
||||
assert!(msg.contains("Usage: /clear"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_pushes_overlay() {
|
||||
let mut app = create_test_app();
|
||||
assert_ne!(app.view_stack.top_kind(), Some(ModalKind::Help));
|
||||
let result = help(&mut app, None);
|
||||
assert_eq!(result.message, None);
|
||||
assert_eq!(result.action, None);
|
||||
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_does_not_duplicate_overlay() {
|
||||
let mut app = create_test_app();
|
||||
help(&mut app, None);
|
||||
let initial_kind = app.view_stack.top_kind();
|
||||
help(&mut app, None);
|
||||
assert_eq!(app.view_stack.top_kind(), initial_kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_resets_all_state() {
|
||||
let mut app = create_test_app();
|
||||
// Set up some state
|
||||
app.history.push(HistoryCell::User {
|
||||
content: "test".to_string(),
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![],
|
||||
});
|
||||
app.total_conversation_tokens = 100;
|
||||
app.tool_log.push("test".to_string());
|
||||
app.current_session_id = Some("existing-session".to_string());
|
||||
|
||||
let result = clear(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
assert!(app.history.is_empty());
|
||||
assert!(app.api_messages.is_empty());
|
||||
assert_eq!(app.total_conversation_tokens, 0);
|
||||
assert!(app.tool_log.is_empty());
|
||||
assert!(app.tool_cells.is_empty());
|
||||
assert!(app.tool_details_by_cell.is_empty());
|
||||
assert!(app.current_session_id.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exit_returns_quit_action() {
|
||||
let result = exit();
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::Quit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_change_updates_state() {
|
||||
let mut app = create_test_app();
|
||||
let old_model = app.model.clone();
|
||||
let result = model(&mut app, Some("deepseek-reasoner"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains(&old_model));
|
||||
assert!(msg.contains("deepseek-reasoner"));
|
||||
assert!(matches!(
|
||||
result.action,
|
||||
Some(AppAction::UpdateCompaction(_))
|
||||
));
|
||||
assert_eq!(app.model, "deepseek-reasoner");
|
||||
assert_eq!(app.last_prompt_tokens, None);
|
||||
assert_eq!(app.last_completion_tokens, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_change_rejects_invalid_model() {
|
||||
let mut app = create_test_app();
|
||||
let result = model(&mut app, Some("gpt-4"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Invalid model"));
|
||||
assert!(msg.contains("deepseek-chat"));
|
||||
assert!(msg.contains("deepseek-reasoner"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_without_args_shows_info() {
|
||||
let mut app = create_test_app();
|
||||
let result = model(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Current model:"));
|
||||
assert!(msg.contains("Supported models:"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_models_triggers_fetch_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = models(&mut app);
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::FetchModels)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subagents_pushes_view_and_sets_status() {
|
||||
let mut app = create_test_app();
|
||||
let result = subagents(&mut app);
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::ListSubAgents)));
|
||||
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::SubAgents));
|
||||
assert_eq!(
|
||||
app.status_message,
|
||||
Some("Fetching sub-agent status...".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_links() {
|
||||
let result = deepseek_links();
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("DeepSeek Links"));
|
||||
assert!(msg.contains("https://platform.deepseek.com"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_home_dashboard_includes_all_sections() {
|
||||
let mut app = create_test_app();
|
||||
app.total_conversation_tokens = 1234;
|
||||
let result = home_dashboard(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("DeepSeek CLI Home Dashboard"));
|
||||
assert!(msg.contains("Model:"));
|
||||
assert!(msg.contains("Mode:"));
|
||||
assert!(msg.contains("Workspace:"));
|
||||
assert!(msg.contains("History:"));
|
||||
assert!(msg.contains("Tokens:"));
|
||||
assert!(msg.contains("Quick Actions"));
|
||||
assert!(msg.contains("Mode Tips"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_home_dashboard_shows_queued_when_present() {
|
||||
let mut app = create_test_app();
|
||||
app.queued_messages
|
||||
.push_back(crate::tui::app::QueuedMessage::new(
|
||||
"test".to_string(),
|
||||
None,
|
||||
));
|
||||
let result = home_dashboard(&mut app);
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Queued:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_home_dashboard_mode_tips_for_each_mode() {
|
||||
let modes = [
|
||||
AppMode::Normal,
|
||||
AppMode::Agent,
|
||||
AppMode::Yolo,
|
||||
AppMode::Plan,
|
||||
];
|
||||
for mode in modes {
|
||||
let mut app = create_test_app();
|
||||
app.mode = mode;
|
||||
let result = home_dashboard(&mut app);
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Mode Tips"), "Missing tips for mode {mode:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+250
-5
@@ -1,7 +1,10 @@
|
||||
#![allow(clippy::items_after_test_module)]
|
||||
|
||||
//! Debug commands: tokens, cost, system, context, undo, retry
|
||||
|
||||
use super::CommandResult;
|
||||
use crate::models::{SystemPrompt, context_window_for_model};
|
||||
use crate::compaction::estimate_input_tokens_conservative;
|
||||
use crate::models::{DEFAULT_CONTEXT_WINDOW_TOKENS, SystemPrompt, context_window_for_model};
|
||||
use crate::tui::app::{App, AppAction};
|
||||
use crate::tui::history::HistoryCell;
|
||||
use crate::utils::estimate_message_chars;
|
||||
@@ -75,6 +78,8 @@ pub fn system_prompt(app: &mut App) -> CommandResult {
|
||||
/// Show context window usage
|
||||
pub fn context(app: &mut App) -> CommandResult {
|
||||
let mut total_chars = estimate_message_chars(&app.api_messages);
|
||||
let estimated_tokens =
|
||||
estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref());
|
||||
|
||||
// System prompt
|
||||
if let Some(SystemPrompt::Text(text)) = &app.system_prompt {
|
||||
@@ -85,10 +90,8 @@ pub fn context(app: &mut App) -> CommandResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Rough token estimate (4 chars per token on average)
|
||||
let estimated_tokens = total_chars / 4;
|
||||
|
||||
let context_size = context_window_for_model(&app.model).unwrap_or(128_000);
|
||||
let context_size =
|
||||
context_window_for_model(&app.model).unwrap_or(DEFAULT_CONTEXT_WINDOW_TOKENS);
|
||||
let estimated_tokens_u32 = u32::try_from(estimated_tokens).unwrap_or(u32::MAX);
|
||||
let usage_pct = (f64::from(estimated_tokens_u32) / f64::from(context_size) * 100.0).min(100.0);
|
||||
|
||||
@@ -110,6 +113,243 @@ pub fn context(app: &mut App) -> CommandResult {
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::models::{ContentBlock, Message, SystemBlock};
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v3.2".to_string(),
|
||||
workspace: PathBuf::from("/tmp/test-workspace"),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("/tmp/test-skills"),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tokens_shows_usage_info() {
|
||||
let mut app = create_test_app();
|
||||
app.total_tokens = 1234;
|
||||
app.session_cost = 0.05;
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![],
|
||||
});
|
||||
app.history.push(HistoryCell::User {
|
||||
content: "test".to_string(),
|
||||
});
|
||||
|
||||
let result = tokens(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Token Usage"));
|
||||
assert!(msg.contains("Total tokens:"));
|
||||
assert!(msg.contains("Session cost:"));
|
||||
assert!(msg.contains("API messages:"));
|
||||
assert!(msg.contains("Chat messages:"));
|
||||
assert!(msg.contains("Model:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_shows_spending_info() {
|
||||
let mut app = create_test_app();
|
||||
app.session_cost = 0.1234;
|
||||
let result = cost(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Session Cost"));
|
||||
assert!(msg.contains("Total spent:"));
|
||||
assert!(msg.contains("$0.1234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_displays_text() {
|
||||
let mut app = create_test_app();
|
||||
app.system_prompt = Some(SystemPrompt::Text("Test system prompt".to_string()));
|
||||
let result = system_prompt(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("System Prompt"));
|
||||
assert!(msg.contains("Test system prompt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_displays_blocks() {
|
||||
let mut app = create_test_app();
|
||||
app.system_prompt = Some(SystemPrompt::Blocks(vec![
|
||||
SystemBlock {
|
||||
block_type: "text".to_string(),
|
||||
text: "Block 1".to_string(),
|
||||
cache_control: None,
|
||||
},
|
||||
SystemBlock {
|
||||
block_type: "text".to_string(),
|
||||
text: "Block 2".to_string(),
|
||||
cache_control: None,
|
||||
},
|
||||
]));
|
||||
let result = system_prompt(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("System Prompt"));
|
||||
assert!(msg.contains("Block 1"));
|
||||
assert!(msg.contains("Block 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_none() {
|
||||
let mut app = create_test_app();
|
||||
app.system_prompt = None;
|
||||
let result = system_prompt(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("(no system prompt)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_prompt_truncates_long_text() {
|
||||
let mut app = create_test_app();
|
||||
let long_text = "x".repeat(600);
|
||||
app.system_prompt = Some(SystemPrompt::Text(long_text));
|
||||
let result = system_prompt(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("..."));
|
||||
assert!(msg.contains("chars total"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_shows_usage_stats() {
|
||||
let mut app = create_test_app();
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
app.history.push(HistoryCell::User {
|
||||
content: "Hello".to_string(),
|
||||
});
|
||||
|
||||
let result = context(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Context Usage"));
|
||||
assert!(msg.contains("Characters:"));
|
||||
assert!(msg.contains("Estimated tokens:"));
|
||||
assert!(msg.contains("Context window:"));
|
||||
assert!(msg.contains("Usage:"));
|
||||
assert!(msg.contains("Messages:"));
|
||||
assert!(msg.contains("API messages:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_removes_last_exchange() {
|
||||
let mut app = create_test_app();
|
||||
app.history.push(HistoryCell::User {
|
||||
content: "Hello".to_string(),
|
||||
});
|
||||
app.history.push(HistoryCell::Assistant {
|
||||
content: "Hi".to_string(),
|
||||
streaming: false,
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![],
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![],
|
||||
});
|
||||
|
||||
let initial_history_len = app.history.len();
|
||||
let initial_api_len = app.api_messages.len();
|
||||
let result = undo(&mut app);
|
||||
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Removed"));
|
||||
assert!(app.history.len() < initial_history_len);
|
||||
assert!(app.api_messages.len() < initial_api_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_nothing_to_undo() {
|
||||
let mut app = create_test_app();
|
||||
// Clear any default history
|
||||
app.history.clear();
|
||||
app.api_messages.clear();
|
||||
let result = undo(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Nothing to undo") || msg.contains("Removed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_with_previous_message() {
|
||||
let mut app = create_test_app();
|
||||
app.history.push(HistoryCell::User {
|
||||
content: "Test message".to_string(),
|
||||
});
|
||||
app.history.push(HistoryCell::Assistant {
|
||||
content: "Response".to_string(),
|
||||
streaming: false,
|
||||
});
|
||||
|
||||
let result = retry(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Retrying"));
|
||||
assert!(msg.contains("Test message"));
|
||||
assert!(matches!(result.action, Some(AppAction::SendMessage(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_no_previous_message() {
|
||||
let mut app = create_test_app();
|
||||
let result = retry(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("No previous request to retry"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_truncates_long_input() {
|
||||
let mut app = create_test_app();
|
||||
let long_input = "x".repeat(100);
|
||||
app.history.push(HistoryCell::User {
|
||||
content: long_input.clone(),
|
||||
});
|
||||
app.history.push(HistoryCell::Assistant {
|
||||
content: "Response".to_string(),
|
||||
streaming: false,
|
||||
});
|
||||
|
||||
let result = retry(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Retrying"));
|
||||
assert!(msg.contains("..."));
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove last message pair (user + assistant)
|
||||
pub fn undo(app: &mut App) -> CommandResult {
|
||||
// Remove from display history (up to the last user message)
|
||||
@@ -133,6 +373,11 @@ pub fn undo(app: &mut App) -> CommandResult {
|
||||
}
|
||||
|
||||
if removed_count > 0 {
|
||||
// Keep tool/index mappings consistent after truncation.
|
||||
app.tool_cells.clear();
|
||||
app.tool_details_by_cell.clear();
|
||||
app.exploring_entries.clear();
|
||||
app.ignored_tool_calls.clear();
|
||||
app.mark_history_updated();
|
||||
CommandResult::message(format!("Removed {removed_count} message(s)"))
|
||||
} else {
|
||||
|
||||
@@ -151,3 +151,122 @@ fn extract_cargo_name(content: &str) -> Option<String> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v3.2".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_creates_agents_md() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = init(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Created AGENTS.md"));
|
||||
let agents_path = tmpdir.path().join("AGENTS.md");
|
||||
assert!(agents_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_fails_if_exists() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
// Create file first
|
||||
std::fs::write(tmpdir.path().join("AGENTS.md"), "existing").unwrap();
|
||||
let result = init(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("already exists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_project_type_rust() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
std::fs::write(
|
||||
tmpdir.path().join("Cargo.toml"),
|
||||
"[package]\nname = \"test\"",
|
||||
)
|
||||
.unwrap();
|
||||
let info = detect_project_type(tmpdir.path());
|
||||
assert!(info.contains("Project Type: Rust"));
|
||||
assert!(info.contains("cargo build"));
|
||||
assert!(info.contains("cargo test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_project_type_node() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
std::fs::write(tmpdir.path().join("package.json"), "{}").unwrap();
|
||||
let info = detect_project_type(tmpdir.path());
|
||||
assert!(info.contains("Project Type: Node.js"));
|
||||
assert!(info.contains("npm install"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_project_type_python() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
std::fs::write(tmpdir.path().join("pyproject.toml"), "[project]").unwrap();
|
||||
let info = detect_project_type(tmpdir.path());
|
||||
assert!(info.contains("Project Type: Python"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_project_type_go() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
std::fs::write(tmpdir.path().join("go.mod"), "module test").unwrap();
|
||||
let info = detect_project_type(tmpdir.path());
|
||||
assert!(info.contains("Project Type: Go"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_project_type_unknown() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let info = detect_project_type(tmpdir.path());
|
||||
assert!(info.contains("Project Type: Unknown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_cargo_name() {
|
||||
let cargo = r#"
|
||||
[package]
|
||||
name = "my-project"
|
||||
version = "1.0.0"
|
||||
"#;
|
||||
assert_eq!(extract_cargo_name(cargo), Some("my-project".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_cargo_name_single_quotes() {
|
||||
let cargo = r#"name = 'single-quoted'"#;
|
||||
assert_eq!(extract_cargo_name(cargo), Some("single-quoted".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_cargo_name_not_found() {
|
||||
let cargo = "[package]\nversion = \"1.0.0\"";
|
||||
assert_eq!(extract_cargo_name(cargo), None);
|
||||
}
|
||||
}
|
||||
+20
-94
@@ -10,11 +10,11 @@ mod init;
|
||||
mod note;
|
||||
mod queue;
|
||||
mod review;
|
||||
pub mod rlm;
|
||||
mod session;
|
||||
mod skills;
|
||||
mod task;
|
||||
|
||||
use crate::tui::app::{App, AppAction, AppMode};
|
||||
use crate::tui::app::{App, AppAction};
|
||||
|
||||
/// Result of executing a command
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -104,6 +104,12 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
description: "Switch or view current model",
|
||||
usage: "/model [name]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "models",
|
||||
aliases: &[],
|
||||
description: "List available models from API",
|
||||
usage: "/models",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "queue",
|
||||
aliases: &["queued"],
|
||||
@@ -134,6 +140,12 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
description: "Append note to persistent notes file (.deepseek/notes.md)",
|
||||
usage: "/note <text>",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "task",
|
||||
aliases: &["tasks"],
|
||||
description: "Manage background tasks",
|
||||
usage: "/task [add <prompt>|list|show <id>|cancel <id>]",
|
||||
},
|
||||
// Session commands
|
||||
CommandInfo {
|
||||
name: "save",
|
||||
@@ -150,43 +162,13 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
CommandInfo {
|
||||
name: "load",
|
||||
aliases: &[],
|
||||
description: "Load session from file (or RLM context in RLM mode)",
|
||||
description: "Load session from file",
|
||||
usage: "/load [path]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "rlm",
|
||||
aliases: &[],
|
||||
description: "Enter RLM (Aleph) mode and show quickstart",
|
||||
usage: "/rlm",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "aleph",
|
||||
aliases: &[],
|
||||
description: "Alias for /rlm (external memory quickstart)",
|
||||
usage: "/aleph",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "save-session",
|
||||
aliases: &["save_session"],
|
||||
description: "Save RLM session to file",
|
||||
usage: "/save-session [path]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "status",
|
||||
aliases: &[],
|
||||
description: "Show RLM context status",
|
||||
usage: "/status",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "repl",
|
||||
aliases: &[],
|
||||
description: "Toggle RLM REPL mode",
|
||||
usage: "/repl",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "compact",
|
||||
aliases: &[],
|
||||
description: "Toggle auto-compaction",
|
||||
description: "Trigger context compaction to free up space",
|
||||
usage: "/compact",
|
||||
},
|
||||
CommandInfo {
|
||||
@@ -311,27 +293,18 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"clear" => core::clear(app),
|
||||
"exit" | "quit" | "q" => core::exit(),
|
||||
"model" => core::model(app, arg),
|
||||
"models" => core::models(app),
|
||||
"queue" | "queued" => queue::queue(app, arg),
|
||||
"subagents" | "agents" => core::subagents(app),
|
||||
"deepseek" | "dashboard" | "api" => core::deepseek_links(),
|
||||
"home" | "stats" | "overview" => core::home_dashboard(app),
|
||||
"note" => note::note(app, arg),
|
||||
"task" | "tasks" => task::task(app, arg),
|
||||
|
||||
// Session commands
|
||||
"save" => session::save(app, arg),
|
||||
"sessions" | "resume" => session::sessions(app),
|
||||
"load" => {
|
||||
let force_rlm = arg.is_some_and(|raw| raw.trim_start().starts_with('@'));
|
||||
if app.mode == AppMode::Rlm || force_rlm {
|
||||
rlm::load(app, arg)
|
||||
} else {
|
||||
session::load(app, arg)
|
||||
}
|
||||
}
|
||||
"rlm" | "aleph" => rlm::enter(app),
|
||||
"save-session" | "save_session" => rlm::save_session(app, arg),
|
||||
"status" => rlm::status(app),
|
||||
"repl" => rlm::repl(app),
|
||||
"load" => session::load(app, arg),
|
||||
"compact" => session::compact(app),
|
||||
"export" => session::export(app, arg),
|
||||
|
||||
@@ -387,52 +360,5 @@ pub fn commands_matching(prefix: &str) -> Vec<&'static CommandInfo> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::execute;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, AppMode, TuiOptions};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn make_test_app(workspace: PathBuf) -> App {
|
||||
let skills_dir = workspace.join("skills");
|
||||
let _ = fs::create_dir_all(&skills_dir);
|
||||
let options = TuiOptions {
|
||||
model: "test-model".to_string(),
|
||||
workspace,
|
||||
allow_shell: false,
|
||||
use_alt_screen: false,
|
||||
max_subagents: 1,
|
||||
skills_dir,
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_at_path_uses_rlm_outside_rlm_mode() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let file = tmp.path().join("example.txt");
|
||||
fs::write(&file, "hello").expect("write");
|
||||
|
||||
let mut app = make_test_app(tmp.path().to_path_buf());
|
||||
app.mode = AppMode::Normal;
|
||||
|
||||
let result = execute("/load @example.txt", &mut app);
|
||||
let message = result.message.unwrap_or_default();
|
||||
assert!(
|
||||
message.starts_with("Loaded "),
|
||||
"expected RLM load message, got: {message}"
|
||||
);
|
||||
|
||||
let session = app.rlm_session.lock().expect("lock session");
|
||||
assert!(!session.contexts.is_empty());
|
||||
}
|
||||
// No unit tests currently required for command routing.
|
||||
}
|
||||
@@ -48,3 +48,79 @@ pub fn note(app: &mut App, content: Option<&str>) -> CommandResult {
|
||||
|
||||
CommandResult::message(format!("Note appended to {}", notes_path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v3.2".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_without_content_returns_error() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = note(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Usage: /note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_with_empty_content_returns_error() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = note(&mut app, Some(" "));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("cannot be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_appends_to_file() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = note(&mut app, Some("Test note content"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Note appended to"));
|
||||
|
||||
let notes_path = tmpdir.path().join(".deepseek").join("notes.md");
|
||||
assert!(notes_path.exists());
|
||||
let content = std::fs::read_to_string(¬es_path).unwrap();
|
||||
assert!(content.contains("Test note content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_multiple_appends() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
note(&mut app, Some("First note"));
|
||||
note(&mut app, Some("Second note"));
|
||||
|
||||
let notes_path = tmpdir.path().join(".deepseek").join("notes.md");
|
||||
let content = std::fs::read_to_string(¬es_path).unwrap();
|
||||
assert!(content.contains("First note"));
|
||||
assert!(content.contains("Second note"));
|
||||
// Should have two separators
|
||||
assert_eq!(content.matches("---").count(), 2);
|
||||
}
|
||||
}
|
||||
@@ -127,3 +127,177 @@ fn truncate_preview(text: &str) -> String {
|
||||
out.push_str("...");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, QueuedMessage, TuiOptions};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v3.2".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_list_empty() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = queue(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("No queued messages"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_list_with_messages() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("First message".to_string(), None));
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("Second message".to_string(), None));
|
||||
let result = queue(&mut app, Some("list"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Queued messages (2)"));
|
||||
assert!(msg.contains("1. First message"));
|
||||
assert!(msg.contains("2. Second message"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_edit_missing_index() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("Test".to_string(), None));
|
||||
let result = queue(&mut app, Some("edit"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Missing index"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_edit_invalid_index() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = queue(&mut app, Some("edit abc"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(
|
||||
result
|
||||
.message
|
||||
.unwrap()
|
||||
.contains("must be a positive number")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_edit_not_found() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = queue(&mut app, Some("edit 1"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_edit_already_editing() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("First".to_string(), None));
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("Second".to_string(), None));
|
||||
// Start editing
|
||||
queue(&mut app, Some("edit 1"));
|
||||
// Try to edit another
|
||||
let result = queue(&mut app, Some("edit 2"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Already editing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_edit_success() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("Original message".to_string(), None));
|
||||
let result = queue(&mut app, Some("edit 1"));
|
||||
assert!(result.message.is_some());
|
||||
assert_eq!(app.input, "Original message");
|
||||
assert_eq!(app.cursor_position, app.input.len());
|
||||
assert!(app.queued_draft.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_drop_success() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("To drop".to_string(), None));
|
||||
let initial_count = app.queued_messages.len();
|
||||
let result = queue(&mut app, Some("drop 1"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Dropped queued message"));
|
||||
assert_eq!(app.queued_messages.len(), initial_count - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_clear() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("Message 1".to_string(), None));
|
||||
app.queued_messages
|
||||
.push_back(QueuedMessage::new("Message 2".to_string(), None));
|
||||
let result = queue(&mut app, Some("clear"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Queue cleared"));
|
||||
assert!(app.queued_messages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_clear_already_empty() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = queue(&mut app, Some("clear"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Queue already empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_short_text() {
|
||||
let result = truncate_preview("Short text");
|
||||
assert_eq!(result, "Short text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_long_text() {
|
||||
let long_text = "x".repeat(200);
|
||||
let result = truncate_preview(&long_text);
|
||||
assert!(result.len() <= PREVIEW_LIMIT + 3);
|
||||
assert!(result.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_preview_unicode() {
|
||||
let text = "Hello 世界 🌍";
|
||||
let result = truncate_preview(text);
|
||||
assert_eq!(result, text);
|
||||
}
|
||||
}
|
||||
+87
-2
@@ -6,6 +6,14 @@ use crate::tui::history::HistoryCell;
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
fn warnings_suffix(registry: &SkillRegistry) -> String {
|
||||
if registry.warnings().is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
format!("\n\nWarnings:\n- {}", registry.warnings().join("\n- "))
|
||||
}
|
||||
|
||||
pub fn review(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
let target = args.unwrap_or("").trim();
|
||||
if target.is_empty() {
|
||||
@@ -14,11 +22,17 @@ pub fn review(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
|
||||
let skills_dir = app.skills_dir.clone();
|
||||
let registry = SkillRegistry::discover(&skills_dir);
|
||||
let mut warnings = warnings_suffix(®istry);
|
||||
let mut skill = registry.get("review").cloned();
|
||||
|
||||
let global_dir = default_skills_dir();
|
||||
if skill.is_none() && global_dir != skills_dir {
|
||||
let registry = SkillRegistry::discover(&global_dir);
|
||||
if warnings.is_empty() {
|
||||
warnings = warnings_suffix(®istry);
|
||||
} else if !registry.warnings().is_empty() {
|
||||
warnings.push_str(&format!("\n- {}", registry.warnings().join("\n- ")));
|
||||
}
|
||||
skill = registry.get("review").cloned();
|
||||
}
|
||||
|
||||
@@ -27,9 +41,10 @@ pub fn review(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
None => {
|
||||
let global_display = global_dir.display();
|
||||
return CommandResult::error(format!(
|
||||
"Review skill not found in {} or {}. Create ~/.deepseek/skills/review/SKILL.md.",
|
||||
"Review skill not found in {} or {}. Create ~/.deepseek/skills/review/SKILL.md.{}",
|
||||
skills_dir.display(),
|
||||
global_display
|
||||
global_display,
|
||||
warnings
|
||||
));
|
||||
}
|
||||
};
|
||||
@@ -46,3 +61,73 @@ pub fn review(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
|
||||
CommandResult::action(AppAction::SendMessage(target.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v3.2".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
fn create_review_skill_dir(tmpdir: &TempDir) {
|
||||
let skill_dir = tmpdir.path().join("skills").join("review");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: review\ndescription: Code review skill\n---\nReview the code",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_review_without_target() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = review(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Usage: /review"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_review_without_skill_installed() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
// Set skills dir to empty temp dir
|
||||
app.skills_dir = tmpdir.path().join("nonexistent_skills");
|
||||
let result = review(&mut app, Some("file.rs"));
|
||||
// The command should either error about missing skill or work if global skill exists
|
||||
assert!(result.message.is_some() || result.action.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_review_with_skill_activates_and_sends() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
create_review_skill_dir(&tmpdir);
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = review(&mut app, Some("file.rs"));
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::SendMessage(_))));
|
||||
assert!(app.active_skill.is_some());
|
||||
assert!(!app.history.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
//! RLM commands for the TUI (load/status/repl/save-session).
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::rlm::{context_id_from_path, unique_context_id};
|
||||
use crate::tui::app::{App, AppMode};
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
const DEFAULT_CHUNK_SIZE: usize = 2000;
|
||||
const DEFAULT_CHUNK_OVERLAP: usize = 200;
|
||||
|
||||
pub fn welcome_message() -> String {
|
||||
[
|
||||
"DeepSeek RLM / Aleph Sandbox",
|
||||
"Commands: /rlm, /aleph, /load <file>, /repl, /status, /save-session",
|
||||
"Press Tab to exit RLM mode",
|
||||
"Use /repl to toggle expression mode (chat is the default)",
|
||||
"Tip: /load @path forces workspace-relative paths (e.g. @docs/rlm-paper.txt)",
|
||||
"",
|
||||
"Expressions:",
|
||||
" len(ctx)",
|
||||
" search(\"pattern\")",
|
||||
" lines(1, 20)",
|
||||
" chunk(2000, 200)",
|
||||
" chunk_sections(20000)",
|
||||
" chunk_auto(20000)",
|
||||
" vars(), get(\"name\"), set(\"name\", \"value\")",
|
||||
"",
|
||||
"Tip: rlm_query auto_chunks runs the same question over chunk_auto slices.",
|
||||
"Tip: /save-session <path> persists the current RLM session.",
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub fn overview_message() -> String {
|
||||
[
|
||||
"RLM / Aleph Quickstart",
|
||||
"Use /rlm or /aleph to enter external-memory mode.",
|
||||
"Use /load @path to load a file into the RLM context store.",
|
||||
"Use /status to list contexts and usage totals.",
|
||||
"Use /repl to toggle expression mode (chat is default).",
|
||||
"Tip: rlm_query auto_chunks runs the same question over chunk_auto slices.",
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub fn enter(app: &mut App) -> CommandResult {
|
||||
if app.mode != AppMode::Rlm {
|
||||
app.set_mode(AppMode::Rlm);
|
||||
}
|
||||
app.rlm_repl_active = false;
|
||||
CommandResult::message(overview_message())
|
||||
}
|
||||
|
||||
pub fn repl(app: &mut App) -> CommandResult {
|
||||
if app.mode != AppMode::Rlm {
|
||||
app.set_mode(AppMode::Rlm);
|
||||
}
|
||||
if app.rlm_repl_active {
|
||||
app.rlm_repl_active = false;
|
||||
return CommandResult::message("Exited RLM REPL mode. Chat is active.");
|
||||
}
|
||||
app.rlm_repl_active = true;
|
||||
CommandResult::message(welcome_message())
|
||||
}
|
||||
|
||||
pub fn status(app: &mut App) -> CommandResult {
|
||||
let session = match app.rlm_session.lock() {
|
||||
Ok(session) => session,
|
||||
Err(_) => return CommandResult::error("Failed to access RLM session"),
|
||||
};
|
||||
|
||||
if session.contexts.is_empty() {
|
||||
return CommandResult::message("No RLM contexts loaded. Use /load <path>.");
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
lines.push("RLM Session".to_string());
|
||||
lines.push(format!("Active context: {}", session.active_context));
|
||||
lines.push(format!("Loaded contexts: {}", session.contexts.len()));
|
||||
lines.push(format!(
|
||||
"Queries: {} | Input tokens: {} | Output tokens: {}",
|
||||
session.usage.queries, session.usage.input_tokens, session.usage.output_tokens
|
||||
));
|
||||
|
||||
let mut ids: Vec<_> = session.contexts.keys().collect();
|
||||
ids.sort();
|
||||
for id in ids {
|
||||
if let Some(ctx) = session.contexts.get(id) {
|
||||
let source = ctx
|
||||
.source_path
|
||||
.as_ref()
|
||||
.map(|s| format!(" (source: {s})"))
|
||||
.unwrap_or_default();
|
||||
let chunk_count = ctx.chunk(DEFAULT_CHUNK_SIZE, DEFAULT_CHUNK_OVERLAP).len();
|
||||
let section_count = ctx.chunk_sections(20_000).len();
|
||||
lines.push(format!(
|
||||
"- {id}: {} lines, {} chars, {} chunks, {} sections{source}",
|
||||
ctx.line_count, ctx.char_count, chunk_count, section_count
|
||||
));
|
||||
if !ctx.variables.is_empty() {
|
||||
lines.push(format!(" variables: {}", ctx.variables.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CommandResult::message(lines.join("\n"))
|
||||
}
|
||||
|
||||
pub fn load(app: &mut App, path: Option<&str>) -> CommandResult {
|
||||
let Some(raw) = path else {
|
||||
return CommandResult::error("Usage: /load <path>");
|
||||
};
|
||||
|
||||
let resolved = match resolve_path(app, raw) {
|
||||
Ok(path) => path,
|
||||
Err(err) => return CommandResult::error(err),
|
||||
};
|
||||
|
||||
let mut session = match app.rlm_session.lock() {
|
||||
Ok(session) => session,
|
||||
Err(_) => return CommandResult::error("Failed to access RLM session"),
|
||||
};
|
||||
|
||||
let base_id = context_id_from_path(&resolved);
|
||||
let id = unique_context_id(&session, &base_id);
|
||||
let (line_count, char_count) = match session.load_file(&id, &resolved) {
|
||||
Ok(stats) => stats,
|
||||
Err(err) => {
|
||||
return CommandResult::error(format!("Failed to load {}: {err}", resolved.display()));
|
||||
}
|
||||
};
|
||||
|
||||
CommandResult::message(format!(
|
||||
"Loaded {} ({} lines, {} chars)",
|
||||
resolved.display(),
|
||||
line_count,
|
||||
char_count
|
||||
))
|
||||
}
|
||||
|
||||
pub fn save_session(app: &mut App, path: Option<&str>) -> CommandResult {
|
||||
let save_path = if let Some(p) = path {
|
||||
PathBuf::from(p)
|
||||
} else {
|
||||
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
|
||||
PathBuf::from(format!("rlm_session_{timestamp}.json"))
|
||||
};
|
||||
|
||||
let parent_dir = save_path
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.map(std::path::Path::to_path_buf);
|
||||
if let Some(dir) = parent_dir
|
||||
&& let Err(err) = fs::create_dir_all(&dir)
|
||||
{
|
||||
return CommandResult::error(format!(
|
||||
"Failed to create directory {}: {err}",
|
||||
dir.display()
|
||||
));
|
||||
}
|
||||
|
||||
let session = match app.rlm_session.lock() {
|
||||
Ok(session) => session,
|
||||
Err(_) => return CommandResult::error("Failed to access RLM session"),
|
||||
};
|
||||
let json = match serde_json::to_string_pretty(&*session) {
|
||||
Ok(json) => json,
|
||||
Err(err) => return CommandResult::error(format!("Failed to serialize session: {err}")),
|
||||
};
|
||||
|
||||
match fs::write(&save_path, json) {
|
||||
Ok(()) => CommandResult::message(format!("RLM session saved to {}", save_path.display())),
|
||||
Err(err) => CommandResult::error(format!("Failed to save session: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_path(app: &App, raw: &str) -> Result<PathBuf, String> {
|
||||
let raw = raw.trim();
|
||||
let (raw, force_workspace) = if let Some(stripped) = raw.strip_prefix('@') {
|
||||
(stripped.trim(), true)
|
||||
} else {
|
||||
(raw, false)
|
||||
};
|
||||
if raw.is_empty() {
|
||||
return Err("Usage: /load <path> (use @ for workspace-relative paths)".to_string());
|
||||
}
|
||||
|
||||
let candidate = if force_workspace {
|
||||
app.workspace.join(raw.trim_start_matches(['/', '\\']))
|
||||
} else if Path::new(raw).is_absolute() {
|
||||
PathBuf::from(raw)
|
||||
} else {
|
||||
app.workspace.join(raw)
|
||||
};
|
||||
let canonical = candidate.canonicalize().map_err(|err| {
|
||||
let mut message = format!("Failed to resolve path {}: {err}", candidate.display());
|
||||
if !force_workspace {
|
||||
message.push_str("\nTip: use /load @path to resolve relative to the workspace.");
|
||||
}
|
||||
message
|
||||
})?;
|
||||
let workspace_root = app
|
||||
.workspace
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| app.workspace.clone());
|
||||
if !app.trust_mode && !canonical.starts_with(&workspace_root) {
|
||||
return Err("Path is outside workspace. Use /trust to allow access.".to_string());
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use std::fs;
|
||||
|
||||
fn make_app(workspace: PathBuf) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "test-model".to_string(),
|
||||
workspace,
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: false,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_path_with_at_prefix_uses_workspace_root() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let docs_dir = tmp.path().join("docs");
|
||||
fs::create_dir_all(&docs_dir).expect("create docs dir");
|
||||
let file_path = docs_dir.join("rlm-paper.txt");
|
||||
fs::write(&file_path, "hello").expect("write file");
|
||||
|
||||
let app = make_app(tmp.path().to_path_buf());
|
||||
let resolved = resolve_path(&app, "@/docs/rlm-paper.txt").expect("resolve path with @");
|
||||
assert_eq!(resolved, file_path.canonicalize().expect("canonicalize"));
|
||||
}
|
||||
}
|
||||
+211
-15
@@ -3,7 +3,6 @@
|
||||
use std::fmt::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::compaction::CompactionConfig;
|
||||
use crate::session_manager::create_saved_session_with_mode;
|
||||
use crate::tui::app::{App, AppAction};
|
||||
use crate::tui::history::{HistoryCell, history_cells_from_message};
|
||||
@@ -91,9 +90,12 @@ pub fn load(app: &mut App, path: Option<&str>) -> CommandResult {
|
||||
app.mark_history_updated();
|
||||
app.transcript_selection.clear();
|
||||
app.model.clone_from(&session.metadata.model);
|
||||
app.update_model_compaction_budget();
|
||||
app.workspace.clone_from(&session.metadata.workspace);
|
||||
app.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX);
|
||||
app.total_conversation_tokens = app.total_tokens;
|
||||
app.last_prompt_tokens = None;
|
||||
app.last_completion_tokens = None;
|
||||
app.current_session_id = Some(session.metadata.id.clone());
|
||||
if let Some(sp) = session.system_prompt {
|
||||
app.system_prompt = Some(crate::models::SystemPrompt::Text(sp));
|
||||
@@ -116,20 +118,12 @@ pub fn load(app: &mut App, path: Option<&str>) -> CommandResult {
|
||||
)
|
||||
}
|
||||
|
||||
/// Toggle auto-compaction
|
||||
pub fn compact(app: &mut App) -> CommandResult {
|
||||
app.auto_compact = !app.auto_compact;
|
||||
let mut compaction = CompactionConfig::default();
|
||||
compaction.enabled = app.auto_compact;
|
||||
compaction.token_threshold = app.compact_threshold;
|
||||
compaction.model = app.model.clone();
|
||||
|
||||
/// Trigger context compaction
|
||||
pub fn compact(_app: &mut App) -> CommandResult {
|
||||
// Trigger immediate compaction via engine
|
||||
CommandResult::with_message_and_action(
|
||||
format!(
|
||||
"Auto-compact: {}",
|
||||
if app.auto_compact { "ON" } else { "OFF" }
|
||||
),
|
||||
AppAction::UpdateCompaction(compaction),
|
||||
"Context compaction triggered...".to_string(),
|
||||
AppAction::CompactContext,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,7 +152,7 @@ pub fn export(app: &mut App, path: Option<&str>) -> CommandResult {
|
||||
HistoryCell::User { content } => ("**You:**", content.clone()),
|
||||
HistoryCell::Assistant { content, .. } => ("**Assistant:**", content.clone()),
|
||||
HistoryCell::System { content } => ("*System:*", content.clone()),
|
||||
HistoryCell::ThinkingSummary { summary } => ("*Thinking:*", summary.clone()),
|
||||
HistoryCell::Thinking { content, .. } => ("*Thinking:*", content.clone()),
|
||||
HistoryCell::Tool(tool) => ("**Tool:**", render_tool_cell(tool, 80)),
|
||||
};
|
||||
|
||||
@@ -191,3 +185,205 @@ fn line_to_string(line: ratatui::text::Line<'static>) -> String {
|
||||
.map(|span| span.content.to_string())
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v3.2".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_creates_file_and_sets_session_id() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let save_path = tmpdir.path().join("test_session.json");
|
||||
|
||||
let result = save(&mut app, Some(save_path.to_str().unwrap()));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Session saved to"));
|
||||
assert!(msg.contains("ID:"));
|
||||
assert!(app.current_session_id.is_some());
|
||||
assert!(save_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_with_default_path_uses_workspace() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = save(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
// Should create file in workspace with timestamp name
|
||||
// Give it a moment to ensure file is written
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
let entries: Vec<_> = std::fs::read_dir(tmpdir.path())
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with("session_"))
|
||||
.collect();
|
||||
// Test passes if file was created or if save returned success message
|
||||
assert!(!entries.is_empty() || msg.contains("Session saved"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_serialization_error() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
// This should work normally since SavedSession is serializable
|
||||
// Testing error path would require mocking, which is complex
|
||||
let save_path = tmpdir.path().join("test.json");
|
||||
let result = save(&mut app, Some(save_path.to_str().unwrap()));
|
||||
assert!(result.message.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_without_path_returns_error() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = load(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Usage: /load"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_nonexistent_file_returns_error() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = load(&mut app, Some("nonexistent.json"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Failed to read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_invalid_json_returns_error() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let bad_file = tmpdir.path().join("bad.json");
|
||||
std::fs::write(&bad_file, "not valid json").unwrap();
|
||||
let result = load(&mut app, Some(bad_file.to_str().unwrap()));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Failed to parse"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_valid_session_restores_state() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app1 = create_test_app_with_tmpdir(&tmpdir);
|
||||
// Set up some state to save
|
||||
app1.api_messages.push(crate::models::Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![crate::models::ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
app1.total_tokens = 500;
|
||||
let save_path = tmpdir.path().join("test.json");
|
||||
save(&mut app1, Some(save_path.to_str().unwrap()));
|
||||
|
||||
// Create new app and load
|
||||
let mut app2 = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = load(&mut app2, Some(save_path.to_str().unwrap()));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Session loaded from"));
|
||||
assert!(msg.contains("ID:"));
|
||||
assert!(msg.contains("messages"));
|
||||
assert_eq!(app2.api_messages.len(), 1);
|
||||
assert_eq!(app2.total_tokens, 500);
|
||||
assert!(app2.current_session_id.is_some());
|
||||
assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compact_toggles_state() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
|
||||
let result = compact(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("compaction") || msg.contains("Compact"));
|
||||
assert!(matches!(result.action, Some(AppAction::CompactContext)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_crees_markdown_file() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
app.history.push(HistoryCell::User {
|
||||
content: "Hello".to_string(),
|
||||
});
|
||||
app.history.push(HistoryCell::Assistant {
|
||||
content: "Hi there".to_string(),
|
||||
streaming: false,
|
||||
});
|
||||
|
||||
let export_path = tmpdir.path().join("export.md");
|
||||
let result = export(&mut app, Some(export_path.to_str().unwrap()));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Exported to"));
|
||||
assert!(export_path.exists());
|
||||
|
||||
let content = std::fs::read_to_string(&export_path).unwrap();
|
||||
assert!(content.contains("# Chat Export"));
|
||||
assert!(content.contains("**Model:**"));
|
||||
assert!(content.contains("**You:**"));
|
||||
assert!(content.contains("**Assistant:**"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_export_with_default_path() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = export(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
// Should create file with timestamp name in current dir
|
||||
let entries: Vec<_> = std::fs::read_dir(".")
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with("chat_export_"))
|
||||
.collect();
|
||||
// Clean up
|
||||
for entry in &entries {
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
assert!(!entries.is_empty() || result.message.unwrap().contains("Exported to"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sessions_pushes_picker_view() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let initial_kind = app.view_stack.top_kind();
|
||||
|
||||
let result = sessions(&mut app);
|
||||
assert_eq!(result.message, None);
|
||||
assert!(result.action.is_none());
|
||||
// View should have changed (session picker should be on top)
|
||||
assert_ne!(app.view_stack.top_kind(), initial_kind);
|
||||
}
|
||||
}
|
||||
+121
-6
@@ -8,10 +8,24 @@ use crate::tui::history::HistoryCell;
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
fn render_skill_warnings(registry: &SkillRegistry) -> String {
|
||||
if registry.warnings().is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(out, "\nWarnings ({}):", registry.warnings().len());
|
||||
for warning in registry.warnings() {
|
||||
let _ = writeln!(out, " - {warning}");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// List all available skills
|
||||
pub fn list_skills(app: &mut App) -> CommandResult {
|
||||
let skills_dir = app.skills_dir.clone();
|
||||
let registry = SkillRegistry::discover(&skills_dir);
|
||||
let warnings = render_skill_warnings(®istry);
|
||||
|
||||
if registry.is_empty() {
|
||||
let msg = format!(
|
||||
@@ -25,7 +39,7 @@ pub fn list_skills(app: &mut App) -> CommandResult {
|
||||
description: What this skill does\n \
|
||||
allowed-tools: read_file, list_dir\n \
|
||||
---\n\n \
|
||||
<instructions here>",
|
||||
<instructions here>{warnings}",
|
||||
skills_dir.display(),
|
||||
skills_dir.display()
|
||||
);
|
||||
@@ -39,8 +53,9 @@ pub fn list_skills(app: &mut App) -> CommandResult {
|
||||
}
|
||||
let _ = write!(
|
||||
output,
|
||||
"\nUse /skill <name> to run a skill\nSkills location: {}",
|
||||
skills_dir.display()
|
||||
"\nUse /skill <name> to run a skill\nSkills location: {}{}",
|
||||
skills_dir.display(),
|
||||
warnings
|
||||
);
|
||||
|
||||
CommandResult::message(output)
|
||||
@@ -76,17 +91,117 @@ pub fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult {
|
||||
))
|
||||
} else {
|
||||
let available: Vec<String> = registry.list().iter().map(|s| s.name.clone()).collect();
|
||||
let warnings = render_skill_warnings(®istry);
|
||||
|
||||
if available.is_empty() {
|
||||
CommandResult::error(format!(
|
||||
"Skill '{name}' not found. No skills installed.\n\nUse /skills to see how to add skills."
|
||||
"Skill '{name}' not found. No skills installed.\n\nUse /skills to see how to add skills.{warnings}"
|
||||
))
|
||||
} else {
|
||||
CommandResult::error(format!(
|
||||
"Skill '{}' not found.\n\nAvailable skills: {}",
|
||||
"Skill '{}' not found.\n\nAvailable skills: {}{}",
|
||||
name,
|
||||
available.join(", ")
|
||||
available.join(", "),
|
||||
warnings
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v3.2".to_string(),
|
||||
workspace: tmpdir.path().to_path_buf(),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: tmpdir.path().join("skills"),
|
||||
memory_path: tmpdir.path().join("memory.md"),
|
||||
notes_path: tmpdir.path().join("notes.txt"),
|
||||
mcp_config_path: tmpdir.path().join("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
fn create_skill_dir(tmpdir: &TempDir, skill_name: &str, skill_content: &str) {
|
||||
let skill_dir = tmpdir.path().join("skills").join(skill_name);
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(skill_dir.join("SKILL.md"), skill_content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_skills_empty_directory() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = list_skills(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("No skills found"));
|
||||
assert!(msg.contains("Skills location:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_skills_with_skills() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
create_skill_dir(
|
||||
&tmpdir,
|
||||
"test-skill",
|
||||
"---\nname: test-skill\ndescription: A test skill\n---\nDo something",
|
||||
);
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = list_skills(&mut app);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Available skills"));
|
||||
assert!(msg.contains("/test-skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_skill_without_name() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = run_skill(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.message.unwrap().contains("Usage: /skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_skill_not_found() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = run_skill(&mut app, Some("nonexistent"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_skill_activates() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
create_skill_dir(
|
||||
&tmpdir,
|
||||
"test-skill",
|
||||
"---\nname: test-skill\ndescription: A test skill\n---\nDo something special",
|
||||
);
|
||||
let mut app = create_test_app_with_tmpdir(&tmpdir);
|
||||
let result = run_skill(&mut app, Some("test-skill"));
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Skill 'test-skill' activated"));
|
||||
assert!(msg.contains("A test skill"));
|
||||
assert!(app.active_skill.is_some());
|
||||
assert!(!app.history.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Task commands: add/list/show/cancel
|
||||
|
||||
use crate::tui::app::{App, AppAction};
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
pub fn task(_app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
let raw = args.unwrap_or("").trim();
|
||||
if raw.is_empty() || raw.eq_ignore_ascii_case("list") {
|
||||
return CommandResult::action(AppAction::TaskList);
|
||||
}
|
||||
|
||||
let mut parts = raw.splitn(2, char::is_whitespace);
|
||||
let action = parts.next().unwrap_or("").to_ascii_lowercase();
|
||||
let remainder = parts.next().map(str::trim).filter(|s| !s.is_empty());
|
||||
|
||||
match action.as_str() {
|
||||
"add" => {
|
||||
let Some(prompt) = remainder else {
|
||||
return CommandResult::error("Usage: /task add <prompt>");
|
||||
};
|
||||
CommandResult::action(AppAction::TaskAdd {
|
||||
prompt: prompt.to_string(),
|
||||
})
|
||||
}
|
||||
"list" => CommandResult::action(AppAction::TaskList),
|
||||
"show" => {
|
||||
let Some(id) = remainder else {
|
||||
return CommandResult::error("Usage: /task show <id>");
|
||||
};
|
||||
CommandResult::action(AppAction::TaskShow { id: id.to_string() })
|
||||
}
|
||||
"cancel" | "stop" => {
|
||||
let Some(id) = remainder else {
|
||||
return CommandResult::error("Usage: /task cancel <id>");
|
||||
};
|
||||
CommandResult::action(AppAction::TaskCancel { id: id.to_string() })
|
||||
}
|
||||
_ => CommandResult::error("Usage: /task [add <prompt>|list|show <id>|cancel <id>]"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::TuiOptions;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn app() -> App {
|
||||
App::new(
|
||||
TuiOptions {
|
||||
model: "deepseek-v3.2".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: false,
|
||||
max_subagents: 2,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
},
|
||||
&Config::default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_add_and_cancel() {
|
||||
let mut app = app();
|
||||
let add = task(&mut app, Some("add write tests"));
|
||||
assert!(matches!(
|
||||
add.action,
|
||||
Some(AppAction::TaskAdd { prompt }) if prompt == "write tests"
|
||||
));
|
||||
|
||||
let cancel = task(&mut app, Some("cancel task_1234"));
|
||||
assert!(matches!(
|
||||
cancel.action,
|
||||
Some(AppAction::TaskCancel { id }) if id == "task_1234"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_usage() {
|
||||
let mut app = app();
|
||||
let result = task(&mut app, Some("add"));
|
||||
assert!(result.message.is_some());
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
}
|
||||
+852
-37
File diff suppressed because it is too large.
Load diff
+552
-32
@@ -7,12 +7,27 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::audit::log_sensitive_event;
|
||||
use crate::features::{Features, FeaturesToml, is_known_feature_key};
|
||||
use crate::hooks::HooksConfig;
|
||||
|
||||
pub const DEFAULT_MAX_SUBAGENTS: usize = 5;
|
||||
pub const MAX_SUBAGENTS: usize = 20;
|
||||
pub const DEFAULT_TEXT_MODEL: &str = "deepseek-reasoner";
|
||||
const API_KEYRING_SENTINEL: &str = "__KEYRING__";
|
||||
pub const COMMON_DEEPSEEK_MODELS: &[&str] = &["deepseek-chat", "deepseek-reasoner"];
|
||||
|
||||
/// Canonicalize supported model aliases to one of the two supported IDs.
|
||||
#[must_use]
|
||||
pub fn canonical_model_name(model: &str) -> Option<&'static str> {
|
||||
match model.trim().to_ascii_lowercase().as_str() {
|
||||
"deepseek-chat" | "deepseek-v3" | "deepseek-v3.2" => Some("deepseek-chat"),
|
||||
"deepseek-reasoner" | "deepseek-r1" => Some("deepseek-reasoner"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// === Types ===
|
||||
|
||||
@@ -42,13 +57,34 @@ pub struct RetryPolicy {
|
||||
pub exponential_base: f64,
|
||||
}
|
||||
|
||||
/// Capacity-controller config loaded from config files/environment.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CapacityConfig {
|
||||
pub enabled: Option<bool>,
|
||||
pub low_risk_max: Option<f64>,
|
||||
pub medium_risk_max: Option<f64>,
|
||||
pub severe_min_slack: Option<f64>,
|
||||
pub severe_violation_ratio: Option<f64>,
|
||||
pub refresh_cooldown_turns: Option<u64>,
|
||||
pub replan_cooldown_turns: Option<u64>,
|
||||
pub max_replay_per_turn: Option<usize>,
|
||||
pub min_turns_before_guardrail: Option<u64>,
|
||||
pub profile_window: Option<usize>,
|
||||
pub deepseek_v3_2_chat_prior: Option<f64>,
|
||||
pub deepseek_v3_2_reasoner_prior: Option<f64>,
|
||||
pub fallback_default_prior: Option<f64>,
|
||||
}
|
||||
|
||||
impl RetryPolicy {
|
||||
/// Compute the backoff delay for a retry attempt.
|
||||
#[must_use]
|
||||
#[allow(dead_code)] // used by runtime_api; will be wired into client retry loop
|
||||
pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {
|
||||
let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);
|
||||
let delay = self.initial_delay * self.exponential_base.powi(exponent);
|
||||
let delay = delay.min(self.max_delay);
|
||||
// Clamp to a sane range to guard against NaN/negative from misconfigured values
|
||||
let delay = delay.clamp(0.0, 300.0);
|
||||
std::time::Duration::from_secs_f64(delay)
|
||||
}
|
||||
}
|
||||
@@ -65,8 +101,13 @@ pub struct Config {
|
||||
pub notes_path: Option<String>,
|
||||
pub memory_path: Option<String>,
|
||||
pub allow_shell: Option<bool>,
|
||||
pub approval_policy: Option<String>,
|
||||
pub sandbox_mode: Option<String>,
|
||||
pub managed_config_path: Option<String>,
|
||||
pub requirements_path: Option<String>,
|
||||
pub max_subagents: Option<usize>,
|
||||
pub retry: Option<RetryConfig>,
|
||||
pub capacity: Option<CapacityConfig>,
|
||||
pub features: Option<FeaturesToml>,
|
||||
|
||||
/// TUI configuration (alternate screen, etc.)
|
||||
@@ -84,6 +125,14 @@ struct ConfigFile {
|
||||
profiles: Option<HashMap<String, Config>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
struct RequirementsFile {
|
||||
#[serde(default)]
|
||||
allowed_approval_policies: Vec<String>,
|
||||
#[serde(default)]
|
||||
allowed_sandbox_modes: Vec<String>,
|
||||
}
|
||||
|
||||
// === Config Loading ===
|
||||
|
||||
impl Config {
|
||||
@@ -97,7 +146,7 @@ impl Config {
|
||||
/// # Ok::<(), anyhow::Error>(())
|
||||
/// ```
|
||||
pub fn load(path: Option<PathBuf>, profile: Option<&str>) -> Result<Self> {
|
||||
let path = path.or_else(default_config_path);
|
||||
let path = resolve_load_config_path(path);
|
||||
let mut config = if let Some(path) = path.as_ref() {
|
||||
if path.exists() {
|
||||
let contents = fs::read_to_string(path)
|
||||
@@ -113,6 +162,9 @@ impl Config {
|
||||
};
|
||||
|
||||
apply_env_overrides(&mut config);
|
||||
apply_managed_overrides(&mut config)?;
|
||||
apply_requirements(&mut config)?;
|
||||
normalize_model_config(&mut config);
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
@@ -131,6 +183,35 @@ impl Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(model) = self.default_text_model.as_deref()
|
||||
&& canonical_model_name(model).is_none()
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Invalid default_text_model '{model}': expected deepseek-chat or deepseek-reasoner."
|
||||
);
|
||||
}
|
||||
if let Some(policy) = self.approval_policy.as_deref() {
|
||||
let normalized = policy.trim().to_ascii_lowercase();
|
||||
if !matches!(
|
||||
normalized.as_str(),
|
||||
"on-request" | "untrusted" | "never" | "auto" | "suggest"
|
||||
) {
|
||||
anyhow::bail!(
|
||||
"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest."
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(mode) = self.sandbox_mode.as_deref() {
|
||||
let normalized = mode.trim().to_ascii_lowercase();
|
||||
if !matches!(
|
||||
normalized.as_str(),
|
||||
"read-only" | "workspace-write" | "danger-full-access" | "external-sandbox"
|
||||
) {
|
||||
anyhow::bail!(
|
||||
"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox."
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(tui) = &self.tui
|
||||
&& let Some(mode) = tui.alternate_screen.as_deref()
|
||||
{
|
||||
@@ -141,6 +222,36 @@ impl Config {
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(capacity) = &self.capacity {
|
||||
if let Some(v) = capacity.low_risk_max
|
||||
&& !(0.0..=1.0).contains(&v)
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0]."
|
||||
);
|
||||
}
|
||||
if let Some(v) = capacity.medium_risk_max
|
||||
&& !(0.0..=1.0).contains(&v)
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0]."
|
||||
);
|
||||
}
|
||||
if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)
|
||||
&& low > medium
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium})."
|
||||
);
|
||||
}
|
||||
if let Some(v) = capacity.severe_violation_ratio
|
||||
&& !(0.0..=1.0).contains(&v)
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0]."
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -156,11 +267,28 @@ impl Config {
|
||||
|
||||
/// Read the `DeepSeek` API key from config/environment.
|
||||
pub fn deepseek_api_key(&self) -> Result<String> {
|
||||
self.api_key
|
||||
.clone()
|
||||
.context(
|
||||
"Failed to load DeepSeek API key: DEEPSEEK_API_KEY missing. Set it in config.toml or environment.",
|
||||
)
|
||||
// First check environment variable (highest priority)
|
||||
if let Ok(key) = std::env::var("DEEPSEEK_API_KEY")
|
||||
&& !key.trim().is_empty()
|
||||
{
|
||||
return Ok(key);
|
||||
}
|
||||
|
||||
// Then check config file
|
||||
if let Some(configured) = self.api_key.clone()
|
||||
&& !configured.trim().is_empty()
|
||||
&& configured != API_KEYRING_SENTINEL
|
||||
{
|
||||
return Ok(configured);
|
||||
}
|
||||
|
||||
// Provide helpful error message with alternatives
|
||||
anyhow::bail!(
|
||||
"DeepSeek API key not found. Set it using one of these methods:\n\
|
||||
1. Set DEEPSEEK_API_KEY environment variable (recommended)\n\
|
||||
2. Run 'deepseek login' to save to ~/.deepseek/config.toml\n\
|
||||
3. Add 'api_key = \"your-key\"' to ~/.deepseek/config.toml"
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve the skills directory path.
|
||||
@@ -206,7 +334,7 @@ impl Config {
|
||||
/// Return whether shell execution is allowed.
|
||||
#[must_use]
|
||||
pub fn allow_shell(&self) -> bool {
|
||||
self.allow_shell.unwrap_or(false)
|
||||
self.allow_shell.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Return the maximum number of concurrent sub-agents.
|
||||
@@ -270,33 +398,135 @@ impl Config {
|
||||
// === Defaults ===
|
||||
|
||||
fn default_config_path() -> Option<PathBuf> {
|
||||
if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH")
|
||||
&& !path.trim().is_empty()
|
||||
env_config_path().or_else(home_config_path)
|
||||
}
|
||||
|
||||
fn effective_home_dir() -> Option<PathBuf> {
|
||||
if let Some(path) = std::env::var_os("HOME") {
|
||||
let path = PathBuf::from(path);
|
||||
if !path.as_os_str().is_empty() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path) = std::env::var_os("USERPROFILE") {
|
||||
let path = PathBuf::from(path);
|
||||
if !path.as_os_str().is_empty() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
return Some(PathBuf::from(path));
|
||||
if let (Some(drive), Some(homepath)) =
|
||||
(std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
|
||||
{
|
||||
let mut path = PathBuf::from(drive);
|
||||
path.push(homepath);
|
||||
if !path.as_os_str().is_empty() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dirs::home_dir()
|
||||
}
|
||||
|
||||
fn home_config_path() -> Option<PathBuf> {
|
||||
effective_home_dir().map(|home| home.join(".deepseek").join("config.toml"))
|
||||
}
|
||||
|
||||
fn env_config_path() -> Option<PathBuf> {
|
||||
if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
|
||||
let trimmed = path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(expand_path(trimmed));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn expand_pathbuf(path: PathBuf) -> PathBuf {
|
||||
if let Some(raw) = path.to_str() {
|
||||
return expand_path(raw);
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn resolve_load_config_path(path: Option<PathBuf>) -> Option<PathBuf> {
|
||||
if let Some(path) = path {
|
||||
return Some(expand_pathbuf(path));
|
||||
}
|
||||
|
||||
if let Some(path) = env_config_path() {
|
||||
if path.exists() {
|
||||
return Some(path);
|
||||
}
|
||||
|
||||
if let Some(home_path) = home_config_path()
|
||||
&& home_path.exists()
|
||||
{
|
||||
return Some(home_path);
|
||||
}
|
||||
|
||||
return Some(path);
|
||||
}
|
||||
|
||||
home_config_path()
|
||||
}
|
||||
|
||||
fn default_managed_config_path() -> Option<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Some(PathBuf::from("/etc/deepseek/managed_config.toml"))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
effective_home_dir().map(|home| home.join(".deepseek").join("managed_config.toml"))
|
||||
}
|
||||
}
|
||||
|
||||
fn default_requirements_path() -> Option<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Some(PathBuf::from("/etc/deepseek/requirements.toml"))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
effective_home_dir().map(|home| home.join(".deepseek").join("requirements.toml"))
|
||||
}
|
||||
dirs::home_dir().map(|home| home.join(".deepseek").join("config.toml"))
|
||||
}
|
||||
|
||||
fn expand_path(path: &str) -> PathBuf {
|
||||
if let Some(stripped) = path.strip_prefix('~')
|
||||
&& (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\'))
|
||||
&& let Some(mut home) = effective_home_dir()
|
||||
{
|
||||
let suffix = stripped.trim_start_matches(|ch| ch == '/' || ch == '\\');
|
||||
if !suffix.is_empty() {
|
||||
home.push(suffix);
|
||||
}
|
||||
return home;
|
||||
}
|
||||
|
||||
let expanded = shellexpand::tilde(path);
|
||||
PathBuf::from(expanded.as_ref())
|
||||
}
|
||||
|
||||
fn default_skills_dir() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".deepseek").join("skills"))
|
||||
effective_home_dir().map(|home| home.join(".deepseek").join("skills"))
|
||||
}
|
||||
|
||||
fn default_mcp_config_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".deepseek").join("mcp.json"))
|
||||
effective_home_dir().map(|home| home.join(".deepseek").join("mcp.json"))
|
||||
}
|
||||
|
||||
fn default_notes_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".deepseek").join("notes.txt"))
|
||||
effective_home_dir().map(|home| home.join(".deepseek").join("notes.txt"))
|
||||
}
|
||||
|
||||
fn default_memory_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".deepseek").join("memory.md"))
|
||||
effective_home_dir().map(|home| home.join(".deepseek").join("memory.md"))
|
||||
}
|
||||
|
||||
// === Environment Overrides ===
|
||||
@@ -323,11 +553,130 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_ALLOW_SHELL") {
|
||||
config.allow_shell = Some(value == "1" || value.eq_ignore_ascii_case("true"));
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_APPROVAL_POLICY") {
|
||||
config.approval_policy = Some(value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_SANDBOX_MODE") {
|
||||
config.sandbox_mode = Some(value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH") {
|
||||
config.managed_config_path = Some(value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_REQUIREMENTS_PATH") {
|
||||
config.requirements_path = Some(value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_MAX_SUBAGENTS")
|
||||
&& let Ok(parsed) = value.parse::<usize>()
|
||||
{
|
||||
config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));
|
||||
}
|
||||
|
||||
let capacity = config.capacity.get_or_insert(CapacityConfig {
|
||||
enabled: None,
|
||||
low_risk_max: None,
|
||||
medium_risk_max: None,
|
||||
severe_min_slack: None,
|
||||
severe_violation_ratio: None,
|
||||
refresh_cooldown_turns: None,
|
||||
replan_cooldown_turns: None,
|
||||
max_replay_per_turn: None,
|
||||
min_turns_before_guardrail: None,
|
||||
profile_window: None,
|
||||
deepseek_v3_2_chat_prior: None,
|
||||
deepseek_v3_2_reasoner_prior: None,
|
||||
fallback_default_prior: None,
|
||||
});
|
||||
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_ENABLED") {
|
||||
let val = value.trim().to_ascii_lowercase();
|
||||
capacity.enabled = Some(matches!(val.as_str(), "1" | "true" | "yes" | "on"));
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_LOW_RISK_MAX")
|
||||
&& let Ok(parsed) = value.parse::<f64>()
|
||||
{
|
||||
capacity.low_risk_max = Some(parsed);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX")
|
||||
&& let Ok(parsed) = value.parse::<f64>()
|
||||
{
|
||||
capacity.medium_risk_max = Some(parsed);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK")
|
||||
&& let Ok(parsed) = value.parse::<f64>()
|
||||
{
|
||||
capacity.severe_min_slack = Some(parsed);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO")
|
||||
&& let Ok(parsed) = value.parse::<f64>()
|
||||
{
|
||||
capacity.severe_violation_ratio = Some(parsed);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS")
|
||||
&& let Ok(parsed) = value.parse::<u64>()
|
||||
{
|
||||
capacity.refresh_cooldown_turns = Some(parsed);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS")
|
||||
&& let Ok(parsed) = value.parse::<u64>()
|
||||
{
|
||||
capacity.replan_cooldown_turns = Some(parsed);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN")
|
||||
&& let Ok(parsed) = value.parse::<usize>()
|
||||
{
|
||||
capacity.max_replay_per_turn = Some(parsed);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL")
|
||||
&& let Ok(parsed) = value.parse::<u64>()
|
||||
{
|
||||
capacity.min_turns_before_guardrail = Some(parsed);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_PROFILE_WINDOW")
|
||||
&& let Ok(parsed) = value.parse::<usize>()
|
||||
{
|
||||
capacity.profile_window = Some(parsed);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_PRIOR_CHAT")
|
||||
&& let Ok(parsed) = value.parse::<f64>()
|
||||
{
|
||||
capacity.deepseek_v3_2_chat_prior = Some(parsed);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_PRIOR_REASONER")
|
||||
&& let Ok(parsed) = value.parse::<f64>()
|
||||
{
|
||||
capacity.deepseek_v3_2_reasoner_prior = Some(parsed);
|
||||
}
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_PRIOR_FALLBACK")
|
||||
&& let Ok(parsed) = value.parse::<f64>()
|
||||
{
|
||||
capacity.fallback_default_prior = Some(parsed);
|
||||
}
|
||||
|
||||
if config.capacity.as_ref().is_some_and(|c| {
|
||||
c.enabled.is_none()
|
||||
&& c.low_risk_max.is_none()
|
||||
&& c.medium_risk_max.is_none()
|
||||
&& c.severe_min_slack.is_none()
|
||||
&& c.severe_violation_ratio.is_none()
|
||||
&& c.refresh_cooldown_turns.is_none()
|
||||
&& c.replan_cooldown_turns.is_none()
|
||||
&& c.max_replay_per_turn.is_none()
|
||||
&& c.min_turns_before_guardrail.is_none()
|
||||
&& c.profile_window.is_none()
|
||||
&& c.deepseek_v3_2_chat_prior.is_none()
|
||||
&& c.deepseek_v3_2_reasoner_prior.is_none()
|
||||
&& c.fallback_default_prior.is_none()
|
||||
}) {
|
||||
config.capacity = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_model_config(config: &mut Config) {
|
||||
if let Some(model) = config.default_text_model.as_deref()
|
||||
&& let Some(canonical) = canonical_model_name(model)
|
||||
{
|
||||
config.default_text_model = Some(canonical.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_base_url(base: &str) -> String {
|
||||
@@ -382,14 +731,97 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
|
||||
notes_path: override_cfg.notes_path.or(base.notes_path),
|
||||
memory_path: override_cfg.memory_path.or(base.memory_path),
|
||||
allow_shell: override_cfg.allow_shell.or(base.allow_shell),
|
||||
approval_policy: override_cfg.approval_policy.or(base.approval_policy),
|
||||
sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),
|
||||
managed_config_path: override_cfg
|
||||
.managed_config_path
|
||||
.or(base.managed_config_path),
|
||||
requirements_path: override_cfg.requirements_path.or(base.requirements_path),
|
||||
max_subagents: override_cfg.max_subagents.or(base.max_subagents),
|
||||
retry: override_cfg.retry.or(base.retry),
|
||||
capacity: override_cfg.capacity.or(base.capacity),
|
||||
tui: override_cfg.tui.or(base.tui),
|
||||
hooks: override_cfg.hooks.or(base.hooks),
|
||||
features: merge_features(base.features, override_cfg.features),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_single_config_file(path: &Path) -> Result<Config> {
|
||||
let contents = fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
|
||||
let parsed: ConfigFile = toml::from_str(&contents)
|
||||
.with_context(|| format!("Failed to parse config file: {}", path.display()))?;
|
||||
Ok(parsed.base)
|
||||
}
|
||||
|
||||
fn apply_managed_overrides(config: &mut Config) -> Result<()> {
|
||||
let path = config
|
||||
.managed_config_path
|
||||
.as_deref()
|
||||
.map(expand_path)
|
||||
.or_else(default_managed_config_path);
|
||||
let Some(path) = path else {
|
||||
return Ok(());
|
||||
};
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let managed = load_single_config_file(&path)?;
|
||||
*config = merge_config(config.clone(), managed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_requirements(config: &mut Config) -> Result<()> {
|
||||
let path = config
|
||||
.requirements_path
|
||||
.as_deref()
|
||||
.map(expand_path)
|
||||
.or_else(default_requirements_path);
|
||||
let Some(path) = path else {
|
||||
return Ok(());
|
||||
};
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let contents = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read requirements file: {}", path.display()))?;
|
||||
let requirements: RequirementsFile = toml::from_str(&contents)
|
||||
.with_context(|| format!("Failed to parse requirements file: {}", path.display()))?;
|
||||
|
||||
if !requirements.allowed_approval_policies.is_empty() {
|
||||
if let Some(policy) = config.approval_policy.as_ref() {
|
||||
let policy = policy.to_ascii_lowercase();
|
||||
if !requirements
|
||||
.allowed_approval_policies
|
||||
.iter()
|
||||
.any(|p| p.eq_ignore_ascii_case(&policy))
|
||||
{
|
||||
anyhow::bail!(
|
||||
"approval_policy '{policy}' is not allowed by requirements ({})",
|
||||
requirements.allowed_approval_policies.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !requirements.allowed_sandbox_modes.is_empty() {
|
||||
if let Some(mode) = config.sandbox_mode.as_ref() {
|
||||
let mode = mode.to_ascii_lowercase();
|
||||
if !requirements
|
||||
.allowed_sandbox_modes
|
||||
.iter()
|
||||
.any(|m| m.eq_ignore_ascii_case(&mode))
|
||||
{
|
||||
anyhow::bail!(
|
||||
"sandbox_mode '{mode}' is not allowed by requirements ({})",
|
||||
requirements.allowed_sandbox_modes.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_features(
|
||||
base: Option<FeaturesToml>,
|
||||
override_cfg: Option<FeaturesToml>,
|
||||
@@ -429,6 +861,10 @@ pub fn save_api_key(api_key: &str) -> Result<PathBuf> {
|
||||
|
||||
ensure_parent_dir(&config_path)?;
|
||||
|
||||
// Don't use keychain - just write directly to config file
|
||||
// Keychain causes permission prompts on macOS for unsigned binaries
|
||||
let key_to_write = api_key.to_string();
|
||||
|
||||
let content = if config_path.exists() {
|
||||
// Read existing config and update the api_key line
|
||||
let existing = fs::read_to_string(&config_path)?;
|
||||
@@ -437,7 +873,7 @@ pub fn save_api_key(api_key: &str) -> Result<PathBuf> {
|
||||
let mut result = String::new();
|
||||
for line in existing.lines() {
|
||||
if is_api_key_assignment(line) {
|
||||
let _ = writeln!(result, "api_key = \"{api_key}\"");
|
||||
let _ = writeln!(result, "api_key = \"{key_to_write}\"");
|
||||
} else {
|
||||
result.push_str(line);
|
||||
result.push('\n');
|
||||
@@ -446,38 +882,59 @@ pub fn save_api_key(api_key: &str) -> Result<PathBuf> {
|
||||
result
|
||||
} else {
|
||||
// Prepend api_key to existing config
|
||||
format!("api_key = \"{api_key}\"\n{existing}")
|
||||
format!("api_key = \"{key_to_write}\"\n{existing}")
|
||||
}
|
||||
} else {
|
||||
// Create new minimal config
|
||||
format!(
|
||||
r#"# DeepSeek CLI Configuration
|
||||
# Get your API key from https://platform.deepseek.com
|
||||
# Or set DEEPSEEK_API_KEY environment variable
|
||||
|
||||
api_key = "{api_key}"
|
||||
api_key = "{key_to_write}"
|
||||
|
||||
# Base URL (default: https://api.deepseek.com)
|
||||
# base_url = "https://api.deepseek.com"
|
||||
|
||||
# Default model
|
||||
default_text_model = "deepseek-reasoner"
|
||||
"#
|
||||
default_text_model = "{default_model}"
|
||||
"#,
|
||||
default_model = DEFAULT_TEXT_MODEL
|
||||
)
|
||||
};
|
||||
|
||||
fs::write(&config_path, content)
|
||||
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
|
||||
log_sensitive_event(
|
||||
"credential.save",
|
||||
json!({
|
||||
"backend": "config_file",
|
||||
"config_path": config_path.display().to_string(),
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(config_path)
|
||||
}
|
||||
|
||||
/// Check if an API key is configured (either in config or environment)
|
||||
pub fn has_api_key(config: &Config) -> bool {
|
||||
config.api_key.is_some()
|
||||
// Check environment variable first (highest priority)
|
||||
if std::env::var("DEEPSEEK_API_KEY").is_ok_and(|k| !k.trim().is_empty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Then check config file
|
||||
config
|
||||
.api_key
|
||||
.as_ref()
|
||||
.is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
|
||||
}
|
||||
|
||||
/// Clear the API key from the config file
|
||||
pub fn clear_api_key() -> Result<()> {
|
||||
// Don't clear keychain - we're not using it anymore
|
||||
// Just clear from config file
|
||||
|
||||
let config_path = default_config_path()
|
||||
.context("Failed to resolve config path: home directory not found.")?;
|
||||
|
||||
@@ -497,6 +954,13 @@ pub fn clear_api_key() -> Result<()> {
|
||||
|
||||
fs::write(&config_path, result)
|
||||
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
|
||||
log_sensitive_event(
|
||||
"credential.clear",
|
||||
json!({
|
||||
"backend": "config_file",
|
||||
"config_path": config_path.display().to_string(),
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -504,10 +968,10 @@ pub fn clear_api_key() -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::lock_test_env;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::ffi::OsString;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct EnvGuard {
|
||||
@@ -576,14 +1040,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_api_key_writes_config() -> Result<()> {
|
||||
let _lock = env_lock().lock().unwrap();
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
@@ -601,13 +1060,13 @@ mod tests {
|
||||
assert_eq!(path, expected);
|
||||
|
||||
let contents = fs::read_to_string(&path)?;
|
||||
assert!(contents.contains("api_key = \"test-key\""));
|
||||
assert!(contents.contains("api_key = \""));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tilde_expansion_in_paths() -> Result<()> {
|
||||
let _lock = env_lock().lock().unwrap();
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
@@ -635,6 +1094,67 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-cli-load-tilde-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root)?;
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
let config_path = temp_root.join(".custom-deepseek").join("config.toml");
|
||||
ensure_parent_dir(&config_path)?;
|
||||
fs::write(&config_path, "api_key = \"test-key\"\n")?;
|
||||
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var("DEEPSEEK_CONFIG_PATH", "~/.custom-deepseek/config.toml");
|
||||
}
|
||||
|
||||
let config = Config::load(None, None)?;
|
||||
assert_eq!(config.api_key.as_deref(), Some("test-key"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let temp_root = env::temp_dir().join(format!(
|
||||
"deepseek-cli-load-fallback-test-{}-{}",
|
||||
std::process::id(),
|
||||
nanos
|
||||
));
|
||||
fs::create_dir_all(&temp_root)?;
|
||||
let _guard = EnvGuard::new(&temp_root);
|
||||
|
||||
let home_config = temp_root.join(".deepseek").join("config.toml");
|
||||
ensure_parent_dir(&home_config)?;
|
||||
fs::write(&home_config, "api_key = \"home-key\"\n")?;
|
||||
|
||||
// Safety: test-only environment mutation guarded by a global mutex.
|
||||
unsafe {
|
||||
env::set_var(
|
||||
"DEEPSEEK_CONFIG_PATH",
|
||||
temp_root.join("missing-config.toml").as_os_str(),
|
||||
);
|
||||
}
|
||||
|
||||
let config = Config::load(None, None)?;
|
||||
assert_eq!(config.api_key.as_deref(), Some("home-key"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonexistent_profile_error() {
|
||||
let mut profiles = HashMap::new();
|
||||
@@ -664,7 +1184,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {
|
||||
let _lock = env_lock().lock().unwrap();
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
@@ -689,7 +1209,7 @@ mod tests {
|
||||
|
||||
let contents = fs::read_to_string(&config_path)?;
|
||||
assert!(contents.contains("api_key_backup = \"old\""));
|
||||
assert!(contents.contains("api_key = \"new-key\""));
|
||||
assert!(contents.contains("api_key = \""));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
//! Capacity-aware guardrail controller for context pressure management.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
/// Controller settings.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CapacityControllerConfig {
|
||||
pub enabled: bool,
|
||||
pub low_risk_max: f64,
|
||||
pub medium_risk_max: f64,
|
||||
pub severe_min_slack: f64,
|
||||
pub severe_violation_ratio: f64,
|
||||
pub refresh_cooldown_turns: u64,
|
||||
pub replan_cooldown_turns: u64,
|
||||
pub max_replay_per_turn: usize,
|
||||
pub min_turns_before_guardrail: u64,
|
||||
pub profile_window: usize,
|
||||
pub model_priors: HashMap<String, f64>,
|
||||
pub fallback_default: f64,
|
||||
}
|
||||
|
||||
impl Default for CapacityControllerConfig {
|
||||
fn default() -> Self {
|
||||
let mut model_priors = HashMap::new();
|
||||
model_priors.insert("deepseek_v3_2_chat".to_string(), 3.9);
|
||||
model_priors.insert("deepseek_v3_2_reasoner".to_string(), 4.1);
|
||||
|
||||
Self {
|
||||
enabled: true,
|
||||
low_risk_max: 0.34,
|
||||
medium_risk_max: 0.62,
|
||||
severe_min_slack: -0.25,
|
||||
severe_violation_ratio: 0.40,
|
||||
refresh_cooldown_turns: 2,
|
||||
replan_cooldown_turns: 5,
|
||||
max_replay_per_turn: 1,
|
||||
min_turns_before_guardrail: 2,
|
||||
profile_window: 8,
|
||||
model_priors,
|
||||
fallback_default: 3.8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CapacityControllerConfig {
|
||||
/// Build effective capacity config from app config.
|
||||
#[must_use]
|
||||
pub fn from_app_config(config: &crate::config::Config) -> Self {
|
||||
let mut out = Self::default();
|
||||
let Some(capacity) = config.capacity.as_ref() else {
|
||||
return out;
|
||||
};
|
||||
|
||||
if let Some(v) = capacity.enabled {
|
||||
out.enabled = v;
|
||||
}
|
||||
if let Some(v) = capacity.low_risk_max {
|
||||
out.low_risk_max = v;
|
||||
}
|
||||
if let Some(v) = capacity.medium_risk_max {
|
||||
out.medium_risk_max = v;
|
||||
}
|
||||
if let Some(v) = capacity.severe_min_slack {
|
||||
out.severe_min_slack = v;
|
||||
}
|
||||
if let Some(v) = capacity.severe_violation_ratio {
|
||||
out.severe_violation_ratio = v;
|
||||
}
|
||||
if let Some(v) = capacity.refresh_cooldown_turns {
|
||||
out.refresh_cooldown_turns = v;
|
||||
}
|
||||
if let Some(v) = capacity.replan_cooldown_turns {
|
||||
out.replan_cooldown_turns = v;
|
||||
}
|
||||
if let Some(v) = capacity.max_replay_per_turn {
|
||||
out.max_replay_per_turn = v;
|
||||
}
|
||||
if let Some(v) = capacity.min_turns_before_guardrail {
|
||||
out.min_turns_before_guardrail = v;
|
||||
}
|
||||
if let Some(v) = capacity.profile_window {
|
||||
out.profile_window = v.max(2);
|
||||
}
|
||||
|
||||
if let Some(v) = capacity.deepseek_v3_2_chat_prior {
|
||||
out.model_priors.insert("deepseek_v3_2_chat".to_string(), v);
|
||||
}
|
||||
if let Some(v) = capacity.deepseek_v3_2_reasoner_prior {
|
||||
out.model_priors
|
||||
.insert("deepseek_v3_2_reasoner".to_string(), v);
|
||||
}
|
||||
if let Some(v) = capacity.fallback_default_prior {
|
||||
out.fallback_default = v;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Guardrail decision output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GuardrailAction {
|
||||
NoIntervention,
|
||||
TargetedContextRefresh,
|
||||
VerifyWithToolReplay,
|
||||
VerifyAndReplan,
|
||||
}
|
||||
|
||||
impl GuardrailAction {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
GuardrailAction::NoIntervention => "no_intervention",
|
||||
GuardrailAction::TargetedContextRefresh => "targeted_context_refresh",
|
||||
GuardrailAction::VerifyWithToolReplay => "verify_with_tool_replay",
|
||||
GuardrailAction::VerifyAndReplan => "verify_and_replan",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Coarse failure risk band.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RiskBand {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
impl RiskBand {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
RiskBand::Low => "low",
|
||||
RiskBand::Medium => "medium",
|
||||
RiskBand::High => "high",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Input used to observe current turn pressure.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CapacityObservationInput {
|
||||
pub turn_index: u64,
|
||||
pub model: String,
|
||||
pub action_count_this_turn: usize,
|
||||
pub tool_calls_recent_window: usize,
|
||||
pub unique_reference_ids_recent_window: usize,
|
||||
pub context_used_ratio: f64,
|
||||
}
|
||||
|
||||
/// Rolling slack profile.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct DynamicSlackProfile {
|
||||
pub final_slack: f64,
|
||||
pub min_slack: f64,
|
||||
pub violation_ratio: f64,
|
||||
pub slack_volatility: f64,
|
||||
pub slack_drop: f64,
|
||||
}
|
||||
|
||||
/// Per-checkpoint capacity snapshot.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CapacitySnapshot {
|
||||
pub turn_index: u64,
|
||||
pub h_hat: f64,
|
||||
pub c_hat: f64,
|
||||
pub slack: f64,
|
||||
pub profile: DynamicSlackProfile,
|
||||
pub p_fail: f64,
|
||||
pub risk_band: RiskBand,
|
||||
pub severe: bool,
|
||||
}
|
||||
|
||||
/// Full controller decision including reason and block flags.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CapacityDecision {
|
||||
pub action: GuardrailAction,
|
||||
pub reason: String,
|
||||
pub cooldown_blocked: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct GuardrailRuntimeState {
|
||||
last_refresh_turn: Option<u64>,
|
||||
last_replan_turn: Option<u64>,
|
||||
replay_count_this_turn: usize,
|
||||
replay_disabled_turn: Option<u64>,
|
||||
intervention_applied_turn: Option<u64>,
|
||||
}
|
||||
|
||||
/// Capacity controller.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CapacityController {
|
||||
config: CapacityControllerConfig,
|
||||
slack_window: VecDeque<f64>,
|
||||
recent_tool_counts: VecDeque<usize>,
|
||||
recent_ref_counts: VecDeque<usize>,
|
||||
state: GuardrailRuntimeState,
|
||||
last_snapshot: Option<CapacitySnapshot>,
|
||||
}
|
||||
|
||||
impl CapacityController {
|
||||
#[must_use]
|
||||
pub fn new(config: CapacityControllerConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
slack_window: VecDeque::new(),
|
||||
recent_tool_counts: VecDeque::new(),
|
||||
recent_ref_counts: VecDeque::new(),
|
||||
state: GuardrailRuntimeState::default(),
|
||||
last_snapshot: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn config(&self) -> &CapacityControllerConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
pub fn observe_pre_turn(
|
||||
&mut self,
|
||||
input: CapacityObservationInput,
|
||||
) -> Option<CapacitySnapshot> {
|
||||
self.observe(input)
|
||||
}
|
||||
|
||||
pub fn observe_post_tool(
|
||||
&mut self,
|
||||
input: CapacityObservationInput,
|
||||
) -> Option<CapacitySnapshot> {
|
||||
self.observe(input)
|
||||
}
|
||||
|
||||
/// Decide intervention from the latest snapshot, with cooldown and safety gates.
|
||||
#[must_use]
|
||||
pub fn decide(
|
||||
&mut self,
|
||||
turn_index: u64,
|
||||
snapshot: Option<&CapacitySnapshot>,
|
||||
) -> CapacityDecision {
|
||||
if !self.config.enabled {
|
||||
return CapacityDecision {
|
||||
action: GuardrailAction::NoIntervention,
|
||||
reason: "capacity_controller_disabled".to_string(),
|
||||
cooldown_blocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
let Some(snapshot) = snapshot else {
|
||||
return CapacityDecision {
|
||||
action: GuardrailAction::NoIntervention,
|
||||
reason: "missing_capacity_data_fail_open".to_string(),
|
||||
cooldown_blocked: false,
|
||||
};
|
||||
};
|
||||
|
||||
if turn_index < self.config.min_turns_before_guardrail {
|
||||
return CapacityDecision {
|
||||
action: GuardrailAction::NoIntervention,
|
||||
reason: "min_turns_before_guardrail_not_reached".to_string(),
|
||||
cooldown_blocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
let proposed = decide_policy(&self.config, snapshot);
|
||||
if proposed == GuardrailAction::NoIntervention {
|
||||
return CapacityDecision {
|
||||
action: proposed,
|
||||
reason: "low_risk_no_intervention".to_string(),
|
||||
cooldown_blocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
if self
|
||||
.state
|
||||
.intervention_applied_turn
|
||||
.is_some_and(|t| t == turn_index)
|
||||
{
|
||||
return CapacityDecision {
|
||||
action: GuardrailAction::NoIntervention,
|
||||
reason: "intervention_already_applied_this_turn".to_string(),
|
||||
cooldown_blocked: true,
|
||||
};
|
||||
}
|
||||
|
||||
match proposed {
|
||||
GuardrailAction::TargetedContextRefresh => {
|
||||
if self
|
||||
.state
|
||||
.last_refresh_turn
|
||||
.is_some_and(|last| turn_index <= last + self.config.refresh_cooldown_turns)
|
||||
{
|
||||
return CapacityDecision {
|
||||
action: GuardrailAction::NoIntervention,
|
||||
reason: "refresh_cooldown_active".to_string(),
|
||||
cooldown_blocked: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
GuardrailAction::VerifyWithToolReplay => {
|
||||
if self
|
||||
.state
|
||||
.replay_disabled_turn
|
||||
.is_some_and(|t| t == turn_index)
|
||||
{
|
||||
return CapacityDecision {
|
||||
action: GuardrailAction::NoIntervention,
|
||||
reason: "replay_disabled_for_turn".to_string(),
|
||||
cooldown_blocked: true,
|
||||
};
|
||||
}
|
||||
if self.state.replay_count_this_turn >= self.config.max_replay_per_turn {
|
||||
return CapacityDecision {
|
||||
action: GuardrailAction::NoIntervention,
|
||||
reason: "max_replay_per_turn_reached".to_string(),
|
||||
cooldown_blocked: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
GuardrailAction::VerifyAndReplan => {
|
||||
if self
|
||||
.state
|
||||
.last_replan_turn
|
||||
.is_some_and(|last| turn_index <= last + self.config.replan_cooldown_turns)
|
||||
{
|
||||
return CapacityDecision {
|
||||
action: GuardrailAction::NoIntervention,
|
||||
reason: "replan_cooldown_active".to_string(),
|
||||
cooldown_blocked: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
GuardrailAction::NoIntervention => {}
|
||||
}
|
||||
|
||||
CapacityDecision {
|
||||
action: proposed,
|
||||
reason: "policy_selected_action".to_string(),
|
||||
cooldown_blocked: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_turn_start(&mut self, turn_index: u64) {
|
||||
let new_turn = match self.last_snapshot.as_ref() {
|
||||
None => true,
|
||||
Some(snapshot) => snapshot.turn_index != turn_index,
|
||||
};
|
||||
if new_turn {
|
||||
self.state.replay_count_this_turn = 0;
|
||||
self.state.replay_disabled_turn = None;
|
||||
self.state.intervention_applied_turn = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_intervention_applied(&mut self, turn_index: u64, action: GuardrailAction) {
|
||||
self.state.intervention_applied_turn = Some(turn_index);
|
||||
match action {
|
||||
GuardrailAction::TargetedContextRefresh => {
|
||||
self.state.last_refresh_turn = Some(turn_index);
|
||||
}
|
||||
GuardrailAction::VerifyWithToolReplay => {
|
||||
self.state.replay_count_this_turn =
|
||||
self.state.replay_count_this_turn.saturating_add(1);
|
||||
}
|
||||
GuardrailAction::VerifyAndReplan => {
|
||||
self.state.last_replan_turn = Some(turn_index);
|
||||
}
|
||||
GuardrailAction::NoIntervention => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_replay_failed(&mut self, turn_index: u64) {
|
||||
self.state.replay_disabled_turn = Some(turn_index);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn last_snapshot(&self) -> Option<&CapacitySnapshot> {
|
||||
self.last_snapshot.as_ref()
|
||||
}
|
||||
|
||||
fn observe(&mut self, input: CapacityObservationInput) -> Option<CapacitySnapshot> {
|
||||
if !self.config.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let context_used_ratio = input.context_used_ratio.clamp(0.0, 2.0);
|
||||
let action_complexity_bits = log2_1p(input.action_count_this_turn);
|
||||
let tool_complexity_bits = log2_1p(input.tool_calls_recent_window);
|
||||
let ref_complexity_bits = log2_1p(input.unique_reference_ids_recent_window);
|
||||
let context_pressure_bits = 6.0 * context_used_ratio;
|
||||
|
||||
let h_hat = (0.35 * action_complexity_bits)
|
||||
+ (0.30 * tool_complexity_bits)
|
||||
+ (0.20 * ref_complexity_bits)
|
||||
+ (0.15 * context_pressure_bits);
|
||||
let c_hat = self.model_prior(&input.model);
|
||||
let slack = c_hat - h_hat;
|
||||
|
||||
push_window(&mut self.slack_window, slack, self.config.profile_window);
|
||||
push_window(
|
||||
&mut self.recent_tool_counts,
|
||||
input.tool_calls_recent_window,
|
||||
self.config.profile_window,
|
||||
);
|
||||
push_window(
|
||||
&mut self.recent_ref_counts,
|
||||
input.unique_reference_ids_recent_window,
|
||||
self.config.profile_window,
|
||||
);
|
||||
|
||||
let profile = compute_profile(&self.slack_window);
|
||||
let z = (-1.65 * profile.final_slack)
|
||||
+ (-0.85 * profile.min_slack)
|
||||
+ (1.35 * profile.violation_ratio)
|
||||
+ (0.70 * profile.slack_volatility)
|
||||
+ (0.28 * profile.slack_drop)
|
||||
- 0.12;
|
||||
let p_fail = sigmoid(z).clamp(0.0, 1.0);
|
||||
let risk_band = if p_fail <= self.config.low_risk_max {
|
||||
RiskBand::Low
|
||||
} else if p_fail <= self.config.medium_risk_max {
|
||||
RiskBand::Medium
|
||||
} else {
|
||||
RiskBand::High
|
||||
};
|
||||
let severe = profile.min_slack <= self.config.severe_min_slack
|
||||
|| profile.violation_ratio >= self.config.severe_violation_ratio;
|
||||
|
||||
let snapshot = CapacitySnapshot {
|
||||
turn_index: input.turn_index,
|
||||
h_hat,
|
||||
c_hat,
|
||||
slack,
|
||||
profile,
|
||||
p_fail,
|
||||
risk_band,
|
||||
severe,
|
||||
};
|
||||
self.last_snapshot = Some(snapshot.clone());
|
||||
Some(snapshot)
|
||||
}
|
||||
|
||||
fn model_prior(&self, model: &str) -> f64 {
|
||||
let normalized = normalize_model_prior_key(model);
|
||||
self.config
|
||||
.model_priors
|
||||
.get(normalized)
|
||||
.copied()
|
||||
.unwrap_or(self.config.fallback_default)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure policy mapping for snapshot -> action.
|
||||
#[must_use]
|
||||
pub fn decide_policy(
|
||||
_config: &CapacityControllerConfig,
|
||||
snapshot: &CapacitySnapshot,
|
||||
) -> GuardrailAction {
|
||||
match snapshot.risk_band {
|
||||
RiskBand::Low => GuardrailAction::NoIntervention,
|
||||
RiskBand::Medium => GuardrailAction::TargetedContextRefresh,
|
||||
RiskBand::High if snapshot.severe => GuardrailAction::VerifyAndReplan,
|
||||
RiskBand::High => GuardrailAction::VerifyWithToolReplay,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_model_prior_key(model: &str) -> &str {
|
||||
let lower = model.to_ascii_lowercase();
|
||||
if lower.contains("reasoner") || lower.contains("r1") {
|
||||
"deepseek_v3_2_reasoner"
|
||||
} else if lower.contains("chat") || lower.contains("v3") {
|
||||
"deepseek_v3_2_chat"
|
||||
} else {
|
||||
"fallback_default"
|
||||
}
|
||||
}
|
||||
|
||||
fn log2_1p(v: usize) -> f64 {
|
||||
(1.0 + (v as f64)).log2()
|
||||
}
|
||||
|
||||
fn push_window<T>(window: &mut VecDeque<T>, value: T, max_len: usize) {
|
||||
window.push_back(value);
|
||||
while window.len() > max_len {
|
||||
window.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_profile(window: &VecDeque<f64>) -> DynamicSlackProfile {
|
||||
if window.is_empty() {
|
||||
return DynamicSlackProfile::default();
|
||||
}
|
||||
|
||||
let values: Vec<f64> = window.iter().copied().collect();
|
||||
let final_slack = *values.last().unwrap_or(&0.0);
|
||||
let min_slack = values.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let violations = values.iter().filter(|v| **v <= 0.0).count() as f64;
|
||||
let violation_ratio = violations / (values.len() as f64);
|
||||
|
||||
let deltas: Vec<f64> = values.windows(2).map(|w| w[1] - w[0]).collect();
|
||||
let slack_drop = if values.len() >= 2 {
|
||||
(values[values.len() - 2] - values[values.len() - 1]).max(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let slack_volatility = if deltas.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
let mean = deltas.iter().sum::<f64>() / (deltas.len() as f64);
|
||||
let var = deltas
|
||||
.iter()
|
||||
.map(|delta| {
|
||||
let centered = *delta - mean;
|
||||
centered * centered
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ (deltas.len() as f64);
|
||||
var.sqrt()
|
||||
};
|
||||
|
||||
DynamicSlackProfile {
|
||||
final_slack,
|
||||
min_slack,
|
||||
violation_ratio,
|
||||
slack_volatility,
|
||||
slack_drop,
|
||||
}
|
||||
}
|
||||
|
||||
fn sigmoid(z: f64) -> f64 {
|
||||
if z >= 0.0 {
|
||||
let ez = (-z).exp();
|
||||
1.0 / (1.0 + ez)
|
||||
} else {
|
||||
let ez = z.exp();
|
||||
ez / (1.0 + ez)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_snapshot(p_fail: f64, severe: bool, risk_band: RiskBand) -> CapacitySnapshot {
|
||||
CapacitySnapshot {
|
||||
turn_index: 3,
|
||||
h_hat: 1.0,
|
||||
c_hat: 3.8,
|
||||
slack: 2.8,
|
||||
profile: DynamicSlackProfile {
|
||||
final_slack: 2.8,
|
||||
min_slack: if severe { -0.5 } else { 0.2 },
|
||||
violation_ratio: if severe { 0.6 } else { 0.1 },
|
||||
slack_volatility: 0.2,
|
||||
slack_drop: 0.1,
|
||||
},
|
||||
p_fail,
|
||||
risk_band,
|
||||
severe,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_risk_maps_to_no_intervention() {
|
||||
let cfg = CapacityControllerConfig::default();
|
||||
let snap = make_snapshot(0.2, false, RiskBand::Low);
|
||||
assert_eq!(decide_policy(&cfg, &snap), GuardrailAction::NoIntervention);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_risk_maps_to_refresh() {
|
||||
let cfg = CapacityControllerConfig::default();
|
||||
let snap = make_snapshot(0.5, false, RiskBand::Medium);
|
||||
assert_eq!(
|
||||
decide_policy(&cfg, &snap),
|
||||
GuardrailAction::TargetedContextRefresh
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_non_severe_maps_to_replay() {
|
||||
let cfg = CapacityControllerConfig::default();
|
||||
let snap = make_snapshot(0.8, false, RiskBand::High);
|
||||
assert_eq!(
|
||||
decide_policy(&cfg, &snap),
|
||||
GuardrailAction::VerifyWithToolReplay
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_severe_maps_to_replan() {
|
||||
let cfg = CapacityControllerConfig::default();
|
||||
let snap = make_snapshot(0.9, true, RiskBand::High);
|
||||
assert_eq!(decide_policy(&cfg, &snap), GuardrailAction::VerifyAndReplan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_blocks_repeated_action() {
|
||||
let mut controller = CapacityController::new(CapacityControllerConfig::default());
|
||||
let turn_index = 5;
|
||||
controller.mark_turn_start(turn_index);
|
||||
controller.mark_intervention_applied(turn_index, GuardrailAction::TargetedContextRefresh);
|
||||
|
||||
let snapshot = make_snapshot(0.5, false, RiskBand::Medium);
|
||||
let decision = controller.decide(turn_index + 1, Some(&snapshot));
|
||||
assert_eq!(decision.action, GuardrailAction::NoIntervention);
|
||||
assert!(decision.cooldown_blocked);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
//! Persistent memory snapshots for capacity controller interventions.
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Canonical compact state persisted by interventions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct CanonicalState {
|
||||
pub goal: String,
|
||||
pub constraints: Vec<String>,
|
||||
pub confirmed_facts: Vec<String>,
|
||||
pub open_loops: Vec<String>,
|
||||
pub pending_actions: Vec<String>,
|
||||
pub critical_refs: Vec<String>,
|
||||
}
|
||||
|
||||
/// Replay verification metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReplayInfo {
|
||||
pub tool_id: String,
|
||||
pub tool_name: String,
|
||||
pub pass: bool,
|
||||
pub diff_summary: String,
|
||||
}
|
||||
|
||||
/// JSONL record written for each intervention.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CapacityMemoryRecord {
|
||||
pub id: String,
|
||||
pub ts: String,
|
||||
pub turn_index: u64,
|
||||
pub action_trigger: String,
|
||||
pub h_hat: f64,
|
||||
pub c_hat: f64,
|
||||
pub slack: f64,
|
||||
pub risk_band: String,
|
||||
pub canonical_state: CanonicalState,
|
||||
pub source_message_ids: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub replay_info: Option<ReplayInfo>,
|
||||
}
|
||||
|
||||
/// Resolve `~/.deepseek/memory`.
|
||||
#[must_use]
|
||||
pub fn default_capacity_memory_dir() -> PathBuf {
|
||||
capacity_memory_dirs()
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_else(|| PathBuf::from(".").join(".deepseek").join("memory"))
|
||||
}
|
||||
|
||||
fn capacity_memory_dirs() -> Vec<PathBuf> {
|
||||
if let Ok(raw) = std::env::var("DEEPSEEK_CAPACITY_MEMORY_DIR") {
|
||||
let trimmed = raw.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return vec![PathBuf::from(shellexpand::tilde(trimmed).as_ref())];
|
||||
}
|
||||
}
|
||||
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
dirs.push(home.join(".deepseek").join("memory"));
|
||||
}
|
||||
|
||||
let cwd = std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join(".deepseek")
|
||||
.join("memory");
|
||||
dirs.push(cwd);
|
||||
|
||||
dirs.dedup();
|
||||
dirs
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn session_memory_path(session_id: &str) -> PathBuf {
|
||||
default_capacity_memory_dir().join(format!("{session_id}.jsonl"))
|
||||
}
|
||||
|
||||
pub fn append_capacity_record(session_id: &str, record: &CapacityMemoryRecord) -> Result<PathBuf> {
|
||||
let candidates = candidate_session_memory_paths(session_id);
|
||||
append_capacity_record_to_candidates(&candidates, record)
|
||||
}
|
||||
|
||||
pub fn append_capacity_record_to_path(path: &Path, record: &CapacityMemoryRecord) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create memory directory {}", parent.display()))?;
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("Failed to open memory log {}", path.display()))?;
|
||||
let line =
|
||||
serde_json::to_string(record).context("Failed to serialize capacity memory record")?;
|
||||
writeln!(file, "{line}")
|
||||
.with_context(|| format!("Failed to write memory record {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_last_k_capacity_records(
|
||||
session_id: &str,
|
||||
k: usize,
|
||||
) -> Result<Vec<CapacityMemoryRecord>> {
|
||||
let candidates = candidate_session_memory_paths(session_id);
|
||||
load_last_k_capacity_records_from_candidates(&candidates, k)
|
||||
}
|
||||
|
||||
pub fn load_last_k_capacity_records_from_path(
|
||||
path: &Path,
|
||||
k: usize,
|
||||
) -> Result<Vec<CapacityMemoryRecord>> {
|
||||
if k == 0 || !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.open(path)
|
||||
.with_context(|| format!("Failed to open memory log {}", path.display()))?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut records = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line.with_context(|| format!("Failed reading {}", path.display()))?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(record) = serde_json::from_str::<CapacityMemoryRecord>(&line) {
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
if records.len() > k {
|
||||
Ok(records.split_off(records.len() - k))
|
||||
} else {
|
||||
Ok(records)
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate_session_memory_paths(session_id: &str) -> Vec<PathBuf> {
|
||||
capacity_memory_dirs()
|
||||
.into_iter()
|
||||
.map(|dir| dir.join(format!("{session_id}.jsonl")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn append_capacity_record_to_candidates(
|
||||
paths: &[PathBuf],
|
||||
record: &CapacityMemoryRecord,
|
||||
) -> Result<PathBuf> {
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for path in paths {
|
||||
match append_capacity_record_to_path(path, record) {
|
||||
Ok(()) => return Ok(path.clone()),
|
||||
Err(err) => last_err = Some(err),
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err.unwrap_or_else(|| anyhow!("No capacity memory path candidates available")))
|
||||
}
|
||||
|
||||
fn load_last_k_capacity_records_from_candidates(
|
||||
paths: &[PathBuf],
|
||||
k: usize,
|
||||
) -> Result<Vec<CapacityMemoryRecord>> {
|
||||
if k == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut newest: Option<(SystemTime, Vec<CapacityMemoryRecord>)> = None;
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
|
||||
for path in paths {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match load_last_k_capacity_records_from_path(path, k) {
|
||||
Ok(records) => {
|
||||
if records.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let modified = fs::metadata(path)
|
||||
.and_then(|meta| meta.modified())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
let should_replace = newest
|
||||
.as_ref()
|
||||
.map(|(current, _)| modified >= *current)
|
||||
.unwrap_or(true);
|
||||
if should_replace {
|
||||
newest = Some((modified, records));
|
||||
}
|
||||
}
|
||||
Err(err) => last_err = Some(err),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((_, records)) = newest {
|
||||
return Ok(records);
|
||||
}
|
||||
if let Some(err) = last_err {
|
||||
return Err(err);
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_record_id() -> String {
|
||||
format!("cap_{}", &uuid::Uuid::new_v4().to_string()[..8])
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn now_rfc3339() -> String {
|
||||
Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn memory_jsonl_round_trip() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let path = tmp.path().join("session.jsonl");
|
||||
|
||||
let record = CapacityMemoryRecord {
|
||||
id: "cap_1".to_string(),
|
||||
ts: now_rfc3339(),
|
||||
turn_index: 2,
|
||||
action_trigger: "targeted_context_refresh".to_string(),
|
||||
h_hat: 1.2,
|
||||
c_hat: 3.8,
|
||||
slack: 2.6,
|
||||
risk_band: "medium".to_string(),
|
||||
canonical_state: CanonicalState {
|
||||
goal: "Ship feature".to_string(),
|
||||
..CanonicalState::default()
|
||||
},
|
||||
source_message_ids: vec!["m1".to_string()],
|
||||
replay_info: None,
|
||||
};
|
||||
|
||||
append_capacity_record_to_path(&path, &record).expect("append");
|
||||
let records = load_last_k_capacity_records_from_path(&path, 1).expect("load");
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].canonical_state.goal, "Ship feature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_falls_back_to_next_candidate_path() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let blocked_root = tmp.path().join("blocked");
|
||||
fs::write(&blocked_root, "file").expect("create blocking file");
|
||||
let blocked_path = blocked_root.join("session.jsonl");
|
||||
let fallback_path = tmp.path().join("fallback").join("session.jsonl");
|
||||
|
||||
let record = CapacityMemoryRecord {
|
||||
id: "cap_fallback".to_string(),
|
||||
ts: now_rfc3339(),
|
||||
turn_index: 1,
|
||||
action_trigger: "targeted_context_refresh".to_string(),
|
||||
h_hat: 1.0,
|
||||
c_hat: 3.8,
|
||||
slack: 2.8,
|
||||
risk_band: "medium".to_string(),
|
||||
canonical_state: CanonicalState::default(),
|
||||
source_message_ids: vec!["m1".to_string()],
|
||||
replay_info: None,
|
||||
};
|
||||
|
||||
let chosen = append_capacity_record_to_candidates(
|
||||
&[blocked_path.clone(), fallback_path.clone()],
|
||||
&record,
|
||||
)
|
||||
.expect("append with fallback");
|
||||
assert_eq!(chosen, fallback_path);
|
||||
assert!(chosen.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_prefers_newest_candidate_records() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let older = tmp.path().join("older.jsonl");
|
||||
let newer = tmp.path().join("newer.jsonl");
|
||||
|
||||
let old_record = CapacityMemoryRecord {
|
||||
id: "cap_old".to_string(),
|
||||
ts: now_rfc3339(),
|
||||
turn_index: 1,
|
||||
action_trigger: "targeted_context_refresh".to_string(),
|
||||
h_hat: 1.0,
|
||||
c_hat: 3.8,
|
||||
slack: 2.8,
|
||||
risk_band: "medium".to_string(),
|
||||
canonical_state: CanonicalState {
|
||||
goal: "old".to_string(),
|
||||
..CanonicalState::default()
|
||||
},
|
||||
source_message_ids: vec!["m1".to_string()],
|
||||
replay_info: None,
|
||||
};
|
||||
let new_record = CapacityMemoryRecord {
|
||||
id: "cap_new".to_string(),
|
||||
ts: now_rfc3339(),
|
||||
turn_index: 2,
|
||||
action_trigger: "verify_and_replan".to_string(),
|
||||
h_hat: 1.4,
|
||||
c_hat: 3.8,
|
||||
slack: 2.4,
|
||||
risk_band: "high".to_string(),
|
||||
canonical_state: CanonicalState {
|
||||
goal: "new".to_string(),
|
||||
..CanonicalState::default()
|
||||
},
|
||||
source_message_ids: vec!["m2".to_string()],
|
||||
replay_info: None,
|
||||
};
|
||||
|
||||
append_capacity_record_to_path(&older, &old_record).expect("write older");
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
append_capacity_record_to_path(&newer, &new_record).expect("write newer");
|
||||
|
||||
let records = load_last_k_capacity_records_from_candidates(&[older, newer], 1)
|
||||
.expect("load newest records");
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].canonical_state.goal, "new");
|
||||
}
|
||||
}
|
||||
+2098
-212
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,313 @@
|
||||
use super::*;
|
||||
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn build_engine_with_capacity(capacity: CapacityControllerConfig) -> Engine {
|
||||
let mut engine_config = EngineConfig::default();
|
||||
engine_config.capacity = capacity;
|
||||
let (engine, _handle) = Engine::new(engine_config, &Config::default());
|
||||
engine
|
||||
}
|
||||
|
||||
fn make_plan(
|
||||
read_only: bool,
|
||||
supports_parallel: bool,
|
||||
approval_required: bool,
|
||||
interactive: bool,
|
||||
) -> ToolExecutionPlan {
|
||||
ToolExecutionPlan {
|
||||
index: 0,
|
||||
id: "tool-1".to_string(),
|
||||
name: "grep_files".to_string(),
|
||||
input: json!({"pattern": "test"}),
|
||||
interactive,
|
||||
approval_required,
|
||||
approval_description: "desc".to_string(),
|
||||
supports_parallel,
|
||||
read_only,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_handle_cancel_tracks_latest_turn_token() {
|
||||
let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default());
|
||||
let stale_token = engine.cancel_token.clone();
|
||||
|
||||
engine.reset_cancel_token();
|
||||
handle.cancel();
|
||||
|
||||
assert!(engine.cancel_token.is_cancelled());
|
||||
assert!(handle.is_cancelled());
|
||||
assert!(!stale_token.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_batch_requires_read_only_parallel_tools() {
|
||||
let plans = vec![make_plan(true, true, false, false)];
|
||||
assert!(should_parallelize_tool_batch(&plans));
|
||||
|
||||
let plans = vec![
|
||||
make_plan(true, true, false, false),
|
||||
make_plan(true, true, false, false),
|
||||
];
|
||||
assert!(should_parallelize_tool_batch(&plans));
|
||||
|
||||
let plans = vec![make_plan(false, true, false, false)];
|
||||
assert!(!should_parallelize_tool_batch(&plans));
|
||||
|
||||
let plans = vec![make_plan(true, false, false, false)];
|
||||
assert!(!should_parallelize_tool_batch(&plans));
|
||||
|
||||
let plans = vec![make_plan(true, true, true, false)];
|
||||
assert!(!should_parallelize_tool_batch(&plans));
|
||||
|
||||
let plans = vec![make_plan(true, true, false, true)];
|
||||
assert!(!should_parallelize_tool_batch(&plans));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_error_messages_include_actionable_hints() {
|
||||
let path_error = ToolError::path_escape(PathBuf::from("../escape.txt"));
|
||||
let formatted = format_tool_error(&path_error, "read_file");
|
||||
assert!(formatted.contains("escapes workspace"));
|
||||
|
||||
let missing_field = ToolError::missing_field("path");
|
||||
let formatted = format_tool_error(&missing_field, "read_file");
|
||||
assert!(formatted.contains("missing required field"));
|
||||
|
||||
let timeout = ToolError::Timeout { seconds: 5 };
|
||||
let formatted = format_tool_error(&timeout, "exec_shell");
|
||||
assert!(formatted.contains("timed out"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_exec_outcome_tracks_duration() {
|
||||
let outcome = ToolExecOutcome {
|
||||
index: 0,
|
||||
id: "tool-1".to_string(),
|
||||
name: "grep_files".to_string(),
|
||||
input: json!({"pattern": "test"}),
|
||||
started_at: Instant::now(),
|
||||
result: Ok(ToolResult::success("ok")),
|
||||
};
|
||||
|
||||
assert!(outcome.started_at.elapsed().as_nanos() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_context_length_errors_from_provider_payloads() {
|
||||
let msg = r#"SSE stream request failed: HTTP 400 Bad Request: {"error":{"message":"This model's maximum context length is 131072 tokens. However, you requested 153056 tokens (148960 in the messages, 4096 in the completion).","type":"invalid_request_error"}}"#;
|
||||
assert!(is_context_length_error_message(msg));
|
||||
assert!(!is_context_length_error_message(
|
||||
"SSE stream request failed: HTTP 400 Bad Request: model not found"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_budget_reserves_output_and_headroom() {
|
||||
let budget = context_input_budget("deepseek-reasoner", TURN_MAX_OUTPUT_TOKENS)
|
||||
.expect("deepseek models should have known context window");
|
||||
let expected = 128_000usize - 4_096usize - 1_024usize;
|
||||
assert_eq!(budget, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_request_refresh_invoked_when_medium_risk() {
|
||||
let mut capacity = CapacityControllerConfig::default();
|
||||
capacity.enabled = true;
|
||||
capacity.low_risk_max = 0.0;
|
||||
capacity.medium_risk_max = 1.0;
|
||||
capacity.min_turns_before_guardrail = 0;
|
||||
|
||||
let mut engine = build_engine_with_capacity(capacity.clone());
|
||||
engine.config.capacity = capacity.clone();
|
||||
engine.capacity_controller = CapacityController::new(capacity);
|
||||
engine.turn_counter = 5;
|
||||
engine
|
||||
.capacity_controller
|
||||
.mark_turn_start(engine.turn_counter);
|
||||
|
||||
let long = "x".repeat(5_000);
|
||||
for _ in 0..200 {
|
||||
engine.session.messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: long.clone(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
let before = engine.estimated_input_tokens();
|
||||
let turn = TurnContext::new(10);
|
||||
let applied = engine
|
||||
.run_capacity_pre_request_checkpoint(&turn, None, AppMode::Agent)
|
||||
.await;
|
||||
let after = engine.estimated_input_tokens();
|
||||
|
||||
assert!(applied);
|
||||
assert!(after < before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_tool_replay_invoked_when_high_non_severe_risk() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("sample.txt"), "hello replay").expect("write");
|
||||
|
||||
let mut capacity = CapacityControllerConfig::default();
|
||||
capacity.enabled = true;
|
||||
capacity.low_risk_max = 0.0;
|
||||
capacity.medium_risk_max = 0.0;
|
||||
capacity.severe_min_slack = -10.0;
|
||||
capacity.severe_violation_ratio = 2.0;
|
||||
capacity.min_turns_before_guardrail = 0;
|
||||
|
||||
let mut engine = build_engine_with_capacity(capacity.clone());
|
||||
engine.session.workspace = tmp.path().to_path_buf();
|
||||
engine.config.workspace = tmp.path().to_path_buf();
|
||||
engine.config.capacity = capacity.clone();
|
||||
engine.capacity_controller = CapacityController::new(capacity);
|
||||
engine.turn_counter = 4;
|
||||
engine
|
||||
.capacity_controller
|
||||
.mark_turn_start(engine.turn_counter);
|
||||
|
||||
let mut turn = TurnContext::new(10);
|
||||
let mut tool_call = TurnToolCall::new(
|
||||
"tool_read_1".to_string(),
|
||||
"read_file".to_string(),
|
||||
json!({ "path": "sample.txt" }),
|
||||
);
|
||||
tool_call.set_result(
|
||||
"hello replay".to_string(),
|
||||
std::time::Duration::from_millis(1),
|
||||
);
|
||||
turn.record_tool_call(tool_call);
|
||||
|
||||
let registry = ToolRegistryBuilder::new()
|
||||
.with_read_only_file_tools()
|
||||
.build(engine.build_tool_context(AppMode::Agent));
|
||||
|
||||
let restarted = engine
|
||||
.run_capacity_post_tool_checkpoint(
|
||||
&turn,
|
||||
AppMode::Agent,
|
||||
Some(®istry),
|
||||
Arc::new(RwLock::new(())),
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!restarted);
|
||||
let has_verification_note = engine.session.messages.iter().any(|msg| {
|
||||
msg.content.iter().any(|block| match block {
|
||||
ContentBlock::ToolResult { content, .. } => content.contains("[verification replay]"),
|
||||
_ => false,
|
||||
})
|
||||
});
|
||||
assert!(has_verification_note);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_escalation_triggers_replan_when_severe_or_repeated_failures() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
// Safety: scoped to test process; reset at end.
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
"DEEPSEEK_CAPACITY_MEMORY_DIR",
|
||||
tmp.path().to_string_lossy().to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut capacity = CapacityControllerConfig::default();
|
||||
capacity.enabled = true;
|
||||
capacity.low_risk_max = 0.0;
|
||||
capacity.medium_risk_max = 0.0;
|
||||
capacity.min_turns_before_guardrail = 0;
|
||||
|
||||
let mut engine = build_engine_with_capacity(capacity.clone());
|
||||
engine.config.capacity = capacity.clone();
|
||||
engine.capacity_controller = CapacityController::new(capacity);
|
||||
engine.turn_counter = 6;
|
||||
engine
|
||||
.capacity_controller
|
||||
.mark_turn_start(engine.turn_counter);
|
||||
|
||||
for i in 0..10 {
|
||||
engine.session.messages.push(Message {
|
||||
role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: format!("noise message {i}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
engine.session.messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Please finish task".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let before_len = engine.session.messages.len();
|
||||
let turn = TurnContext::new(10);
|
||||
let restarted = engine
|
||||
.run_capacity_error_escalation_checkpoint(&turn, AppMode::Agent, 2, 2)
|
||||
.await;
|
||||
|
||||
assert!(restarted);
|
||||
assert!(engine.session.messages.len() < before_len);
|
||||
assert!(engine.session.messages.len() <= 2);
|
||||
|
||||
let records = load_last_k_capacity_records(&engine.session.id, 1).expect("load memory");
|
||||
assert!(!records.is_empty());
|
||||
assert!(!records[0].canonical_state.goal.is_empty());
|
||||
unsafe {
|
||||
std::env::remove_var("DEEPSEEK_CAPACITY_MEMORY_DIR");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn controller_disabled_keeps_behavior_unchanged() {
|
||||
let mut capacity = CapacityControllerConfig::default();
|
||||
capacity.enabled = false;
|
||||
|
||||
let mut engine = build_engine_with_capacity(capacity.clone());
|
||||
engine.config.capacity = capacity.clone();
|
||||
engine.capacity_controller = CapacityController::new(capacity);
|
||||
engine.turn_counter = 3;
|
||||
engine
|
||||
.capacity_controller
|
||||
.mark_turn_start(engine.turn_counter);
|
||||
|
||||
let long = "y".repeat(5_000);
|
||||
for _ in 0..120 {
|
||||
engine.session.messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: long.clone(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
let before = engine.estimated_input_tokens();
|
||||
let before_len = engine.session.messages.len();
|
||||
let turn = TurnContext::new(10);
|
||||
let applied = engine
|
||||
.run_capacity_pre_request_checkpoint(&turn, None, AppMode::Agent)
|
||||
.await;
|
||||
let after = engine.estimated_input_tokens();
|
||||
let after_len = engine.session.messages.len();
|
||||
|
||||
assert!(!applied);
|
||||
assert_eq!(before, after);
|
||||
assert_eq!(before_len, after_len);
|
||||
}
|
||||
+79
-3
@@ -8,6 +8,15 @@ use serde_json::Value;
|
||||
use crate::models::Usage;
|
||||
use crate::tools::spec::{ToolError, ToolResult};
|
||||
use crate::tools::subagent::SubAgentResult;
|
||||
use crate::tools::user_input::UserInputRequest;
|
||||
|
||||
/// Final status for a turn.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TurnOutcomeStatus {
|
||||
Completed,
|
||||
Interrupted,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Events emitted by the engine to update the UI.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -51,12 +60,73 @@ pub enum Event {
|
||||
|
||||
// === Turn Lifecycle ===
|
||||
/// A new turn has started (user sent a message)
|
||||
TurnStarted,
|
||||
TurnStarted { turn_id: String },
|
||||
|
||||
/// The turn is complete (no more tool calls)
|
||||
TurnComplete { usage: Usage },
|
||||
TurnComplete {
|
||||
usage: Usage,
|
||||
status: TurnOutcomeStatus,
|
||||
error: Option<String>,
|
||||
},
|
||||
|
||||
// === Sub-Agent Events (for RLM mode) ===
|
||||
/// Context compaction started.
|
||||
CompactionStarted {
|
||||
id: String,
|
||||
auto: bool,
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Context compaction completed.
|
||||
CompactionCompleted {
|
||||
id: String,
|
||||
auto: bool,
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Context compaction failed.
|
||||
CompactionFailed {
|
||||
id: String,
|
||||
auto: bool,
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// Capacity decision telemetry.
|
||||
CapacityDecision {
|
||||
session_id: String,
|
||||
turn_id: String,
|
||||
h_hat: f64,
|
||||
c_hat: f64,
|
||||
slack: f64,
|
||||
min_slack: f64,
|
||||
violation_ratio: f64,
|
||||
p_fail: f64,
|
||||
risk_band: String,
|
||||
action: String,
|
||||
cooldown_blocked: bool,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// Capacity intervention telemetry.
|
||||
CapacityIntervention {
|
||||
session_id: String,
|
||||
turn_id: String,
|
||||
action: String,
|
||||
before_prompt_tokens: usize,
|
||||
after_prompt_tokens: usize,
|
||||
compaction_size_reduction: usize,
|
||||
replay_outcome: Option<String>,
|
||||
replan_performed: bool,
|
||||
},
|
||||
|
||||
/// Capacity memory persistence failure telemetry.
|
||||
CapacityMemoryPersistFailed {
|
||||
session_id: String,
|
||||
turn_id: String,
|
||||
action: String,
|
||||
error: String,
|
||||
},
|
||||
|
||||
// === Sub-Agent Events ===
|
||||
/// A sub-agent has been spawned
|
||||
AgentSpawned { id: String, prompt: String },
|
||||
|
||||
@@ -89,6 +159,12 @@ pub enum Event {
|
||||
description: String,
|
||||
},
|
||||
|
||||
/// Request user input for a tool call
|
||||
UserInputRequired {
|
||||
id: String,
|
||||
request: UserInputRequest,
|
||||
},
|
||||
|
||||
/// Request user decision after sandbox denial
|
||||
ElevationRequired {
|
||||
tool_id: String,
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod capacity;
|
||||
pub mod capacity_memory;
|
||||
pub mod engine;
|
||||
pub mod events;
|
||||
pub mod ops;
|
||||
|
||||
+4
-1
@@ -29,7 +29,7 @@ pub enum Op {
|
||||
/// Deny a tool call that requires permission
|
||||
DenyToolCall { id: String },
|
||||
|
||||
/// Spawn a sub-agent (for RLM mode)
|
||||
/// Spawn a sub-agent
|
||||
SpawnSubAgent { prompt: String },
|
||||
|
||||
/// List current sub-agents and their status
|
||||
@@ -52,6 +52,9 @@ pub enum Op {
|
||||
workspace: PathBuf,
|
||||
},
|
||||
|
||||
/// Run context compaction immediately.
|
||||
CompactContext,
|
||||
|
||||
/// Shutdown the engine
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ pub struct Session {
|
||||
|
||||
/// System prompt (optional)
|
||||
pub system_prompt: Option<SystemPrompt>,
|
||||
/// Persisted summary blocks generated by context compaction.
|
||||
pub compaction_summary_prompt: Option<SystemPrompt>,
|
||||
|
||||
/// Conversation history (API format)
|
||||
pub messages: Vec<Message>,
|
||||
@@ -88,6 +90,7 @@ impl Session {
|
||||
model,
|
||||
workspace,
|
||||
system_prompt: None,
|
||||
compaction_summary_prompt: None,
|
||||
messages: Vec::new(),
|
||||
total_usage: SessionUsage::default(),
|
||||
allow_shell,
|
||||
@@ -125,6 +128,7 @@ impl Session {
|
||||
/// Clear the conversation history
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
self.compaction_summary_prompt = None;
|
||||
}
|
||||
|
||||
/// Get the message count
|
||||
|
||||
-802
@@ -1,802 +0,0 @@
|
||||
//! Duo mode state machine for hegelion's autocoding (player-coach adversarial cooperation).
|
||||
//!
|
||||
//! Implements the g3 paper's coach-player paradigm where:
|
||||
//! - Player: implements requirements (builder role)
|
||||
//! - Coach: validates implementation against requirements (critic role)
|
||||
//!
|
||||
//! The loop continues until the coach approves or max turns are reached.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
// === Phase & Status Enums ===
|
||||
|
||||
/// The current phase in the autocoding loop.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DuoPhase {
|
||||
/// Session initialized, ready to start player phase
|
||||
Init,
|
||||
/// Player is implementing requirements
|
||||
Player,
|
||||
/// Coach is validating the implementation
|
||||
Coach,
|
||||
/// Coach approved the implementation
|
||||
Approved,
|
||||
/// Maximum turns reached without approval
|
||||
Timeout,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DuoPhase {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DuoPhase::Init => write!(f, "init"),
|
||||
DuoPhase::Player => write!(f, "player"),
|
||||
DuoPhase::Coach => write!(f, "coach"),
|
||||
DuoPhase::Approved => write!(f, "approved"),
|
||||
DuoPhase::Timeout => write!(f, "timeout"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The overall status of the autocoding session.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DuoStatus {
|
||||
/// Session is actively running
|
||||
Active,
|
||||
/// Coach has approved the implementation
|
||||
Approved,
|
||||
/// Coach has rejected (used for explicit rejection, not just iteration)
|
||||
Rejected,
|
||||
/// Maximum turns exhausted without approval
|
||||
Timeout,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DuoStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DuoStatus::Active => write!(f, "active"),
|
||||
DuoStatus::Approved => write!(f, "approved"),
|
||||
DuoStatus::Rejected => write!(f, "rejected"),
|
||||
DuoStatus::Timeout => write!(f, "timeout"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Turn History ===
|
||||
|
||||
/// Record of a single turn in the autocoding loop.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TurnRecord {
|
||||
/// Turn number (1-indexed)
|
||||
pub turn: u32,
|
||||
/// The phase this record is for
|
||||
pub phase: DuoPhase,
|
||||
/// Summary of what happened (player implementation or coach feedback)
|
||||
pub summary: String,
|
||||
/// Quality score from coach (0.0 to 1.0), if applicable
|
||||
pub quality_score: Option<f64>,
|
||||
/// Timestamp when this turn was recorded
|
||||
#[serde(default = "chrono::Utc::now")]
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl TurnRecord {
|
||||
/// Create a new turn record.
|
||||
#[must_use]
|
||||
pub fn new(turn: u32, phase: DuoPhase, summary: String, quality_score: Option<f64>) -> Self {
|
||||
Self {
|
||||
turn,
|
||||
phase,
|
||||
summary,
|
||||
quality_score,
|
||||
timestamp: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Main State ===
|
||||
|
||||
/// The complete state of a Duo autocoding session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DuoState {
|
||||
/// Unique session identifier
|
||||
pub session_id: String,
|
||||
/// Optional human-readable session name
|
||||
pub session_name: Option<String>,
|
||||
/// The requirements document (source of truth for validation)
|
||||
pub requirements: String,
|
||||
/// Current turn number (1-indexed)
|
||||
pub current_turn: u32,
|
||||
/// Maximum allowed turns before timeout
|
||||
pub max_turns: u32,
|
||||
/// Current phase in the autocoding loop
|
||||
pub phase: DuoPhase,
|
||||
/// Overall session status
|
||||
pub status: DuoStatus,
|
||||
/// History of all turns
|
||||
pub turn_history: Vec<TurnRecord>,
|
||||
/// Last feedback from the coach (used in next player prompt)
|
||||
pub last_coach_feedback: Option<String>,
|
||||
/// Quality scores from each coach review
|
||||
pub quality_scores: Vec<f64>,
|
||||
/// Threshold score needed for approval (0.0 to 1.0)
|
||||
pub approval_threshold: f64,
|
||||
/// Timestamp when session was created
|
||||
#[serde(default = "chrono::Utc::now")]
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
/// Timestamp of last update
|
||||
#[serde(default = "chrono::Utc::now")]
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl DuoState {
|
||||
/// Create a new Duo session with the given requirements.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `requirements` - The requirements document (source of truth)
|
||||
/// * `session_name` - Optional human-readable name
|
||||
/// * `max_turns` - Maximum turns before timeout (default: 10)
|
||||
/// * `approval_threshold` - Score needed for approval (default: 0.9)
|
||||
#[must_use]
|
||||
pub fn create(
|
||||
requirements: String,
|
||||
session_name: Option<String>,
|
||||
max_turns: Option<u32>,
|
||||
approval_threshold: Option<f64>,
|
||||
) -> Self {
|
||||
let now = chrono::Utc::now();
|
||||
Self {
|
||||
session_id: Uuid::new_v4().to_string(),
|
||||
session_name,
|
||||
requirements,
|
||||
current_turn: 1,
|
||||
max_turns: max_turns.unwrap_or(10),
|
||||
phase: DuoPhase::Init,
|
||||
status: DuoStatus::Active,
|
||||
turn_history: Vec::new(),
|
||||
last_coach_feedback: None,
|
||||
quality_scores: Vec::new(),
|
||||
approval_threshold: approval_threshold.unwrap_or(0.9),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Transition from Init or Player phase to Coach phase.
|
||||
///
|
||||
/// Records the player's implementation summary in turn history.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `player_summary` - Summary of what the player implemented
|
||||
///
|
||||
/// # Returns
|
||||
/// `Ok(())` on success, `Err` if not in a valid phase for this transition
|
||||
pub fn advance_to_coach(&mut self, player_summary: String) -> Result<(), DuoError> {
|
||||
match self.phase {
|
||||
DuoPhase::Init | DuoPhase::Player => {
|
||||
// Record player turn
|
||||
let record =
|
||||
TurnRecord::new(self.current_turn, DuoPhase::Player, player_summary, None);
|
||||
self.turn_history.push(record);
|
||||
self.phase = DuoPhase::Coach;
|
||||
self.updated_at = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(DuoError::InvalidPhaseTransition {
|
||||
from: self.phase,
|
||||
to: DuoPhase::Coach,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process coach feedback and determine the next phase.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `coach_feedback` - The coach's feedback text
|
||||
/// * `approved` - Whether the coach approved the implementation
|
||||
/// * `compliance_score` - Optional compliance score (0.0 to 1.0)
|
||||
///
|
||||
/// # Returns
|
||||
/// `Ok(())` on success, `Err` if not in coach phase
|
||||
pub fn advance_turn(
|
||||
&mut self,
|
||||
coach_feedback: String,
|
||||
approved: bool,
|
||||
compliance_score: Option<f64>,
|
||||
) -> Result<(), DuoError> {
|
||||
if self.phase != DuoPhase::Coach {
|
||||
return Err(DuoError::InvalidPhaseTransition {
|
||||
from: self.phase,
|
||||
to: DuoPhase::Player,
|
||||
});
|
||||
}
|
||||
|
||||
// Record coach turn
|
||||
let record = TurnRecord::new(
|
||||
self.current_turn,
|
||||
DuoPhase::Coach,
|
||||
coach_feedback.clone(),
|
||||
compliance_score,
|
||||
);
|
||||
self.turn_history.push(record);
|
||||
|
||||
// Track quality score if provided
|
||||
if let Some(score) = compliance_score {
|
||||
self.quality_scores.push(score);
|
||||
}
|
||||
|
||||
self.last_coach_feedback = Some(coach_feedback);
|
||||
self.updated_at = chrono::Utc::now();
|
||||
|
||||
if approved {
|
||||
// Coach approved - session complete
|
||||
self.phase = DuoPhase::Approved;
|
||||
self.status = DuoStatus::Approved;
|
||||
} else if self.current_turn >= self.max_turns {
|
||||
// Max turns reached - timeout
|
||||
self.phase = DuoPhase::Timeout;
|
||||
self.status = DuoStatus::Timeout;
|
||||
} else {
|
||||
// Continue to next turn
|
||||
self.current_turn += 1;
|
||||
self.phase = DuoPhase::Player;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if the session is complete (approved or timed out).
|
||||
#[must_use]
|
||||
pub fn is_complete(&self) -> bool {
|
||||
matches!(
|
||||
self.status,
|
||||
DuoStatus::Approved | DuoStatus::Rejected | DuoStatus::Timeout
|
||||
)
|
||||
}
|
||||
|
||||
/// Get the number of turns remaining before timeout.
|
||||
#[must_use]
|
||||
pub fn turns_remaining(&self) -> u32 {
|
||||
self.max_turns.saturating_sub(self.current_turn)
|
||||
}
|
||||
|
||||
/// Get the average quality score across all coach reviews.
|
||||
#[must_use]
|
||||
pub fn average_quality_score(&self) -> Option<f64> {
|
||||
if self.quality_scores.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let sum: f64 = self.quality_scores.iter().sum();
|
||||
Some(sum / self.quality_scores.len() as f64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a human-readable summary of the session state.
|
||||
#[must_use]
|
||||
pub fn summary(&self) -> String {
|
||||
let name = self
|
||||
.session_name
|
||||
.as_deref()
|
||||
.unwrap_or(&self.session_id[..8]);
|
||||
|
||||
let avg_score = self
|
||||
.average_quality_score()
|
||||
.map(|s| format!("{:.1}%", s * 100.0))
|
||||
.unwrap_or_else(|| "N/A".to_string());
|
||||
|
||||
let status_icon = match self.status {
|
||||
DuoStatus::Active => "🔄",
|
||||
DuoStatus::Approved => "✅",
|
||||
DuoStatus::Rejected => "❌",
|
||||
DuoStatus::Timeout => "⏰",
|
||||
};
|
||||
|
||||
format!(
|
||||
"{status_icon} Duo Session: {name}\n\
|
||||
Phase: {} | Turn: {}/{} | Status: {}\n\
|
||||
Avg Quality: {} | Threshold: {:.0}%\n\
|
||||
History: {} records",
|
||||
self.phase,
|
||||
self.current_turn,
|
||||
self.max_turns,
|
||||
self.status,
|
||||
avg_score,
|
||||
self.approval_threshold * 100.0,
|
||||
self.turn_history.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// === Error Types ===
|
||||
|
||||
/// Errors that can occur during Duo session operations.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DuoError {
|
||||
/// Invalid phase transition attempted
|
||||
InvalidPhaseTransition { from: DuoPhase, to: DuoPhase },
|
||||
/// Session not found (reserved for future multi-session management)
|
||||
#[allow(dead_code)]
|
||||
SessionNotFound { session_id: String },
|
||||
/// Session already complete (reserved for future session validation)
|
||||
#[allow(dead_code)]
|
||||
SessionAlreadyComplete { session_id: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DuoError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DuoError::InvalidPhaseTransition { from, to } => {
|
||||
write!(f, "Invalid phase transition from {} to {}", from, to)
|
||||
}
|
||||
DuoError::SessionNotFound { session_id } => {
|
||||
write!(f, "Session not found: {}", session_id)
|
||||
}
|
||||
DuoError::SessionAlreadyComplete { session_id } => {
|
||||
write!(f, "Session already complete: {}", session_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DuoError {}
|
||||
|
||||
// === Session Container ===
|
||||
|
||||
/// Container for managing multiple Duo sessions.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DuoSession {
|
||||
/// The currently active session state
|
||||
pub active_state: Option<DuoState>,
|
||||
/// Saved/completed session states indexed by session_id
|
||||
pub saved_states: HashMap<String, DuoState>,
|
||||
}
|
||||
|
||||
impl DuoSession {
|
||||
/// Create a new empty session container.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active_state: None,
|
||||
saved_states: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new Duo session.
|
||||
pub fn start_session(
|
||||
&mut self,
|
||||
requirements: String,
|
||||
session_name: Option<String>,
|
||||
max_turns: Option<u32>,
|
||||
approval_threshold: Option<f64>,
|
||||
) -> &DuoState {
|
||||
// Save any existing active session
|
||||
if let Some(state) = self.active_state.take() {
|
||||
self.saved_states.insert(state.session_id.clone(), state);
|
||||
}
|
||||
|
||||
// Create new session
|
||||
let state = DuoState::create(requirements, session_name, max_turns, approval_threshold);
|
||||
self.active_state = Some(state);
|
||||
self.active_state.as_ref().expect("just set active_state")
|
||||
}
|
||||
|
||||
/// Get the active session state.
|
||||
#[must_use]
|
||||
pub fn get_active(&self) -> Option<&DuoState> {
|
||||
self.active_state.as_ref()
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the active session state.
|
||||
pub fn get_active_mut(&mut self) -> Option<&mut DuoState> {
|
||||
self.active_state.as_mut()
|
||||
}
|
||||
|
||||
/// Get a saved session by ID (reserved for future multi-session management).
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn get_saved(&self, session_id: &str) -> Option<&DuoState> {
|
||||
self.saved_states.get(session_id)
|
||||
}
|
||||
|
||||
/// Save the current active session and clear it (reserved for future session management).
|
||||
#[allow(dead_code)]
|
||||
pub fn save_active(&mut self) -> Option<String> {
|
||||
if let Some(state) = self.active_state.take() {
|
||||
let id = state.session_id.clone();
|
||||
self.saved_states.insert(id.clone(), state);
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore a saved session as the active session (reserved for future session management).
|
||||
#[allow(dead_code)]
|
||||
pub fn restore_session(&mut self, session_id: &str) -> Result<(), DuoError> {
|
||||
let state =
|
||||
self.saved_states
|
||||
.remove(session_id)
|
||||
.ok_or_else(|| DuoError::SessionNotFound {
|
||||
session_id: session_id.to_string(),
|
||||
})?;
|
||||
|
||||
// Save current active if any
|
||||
if let Some(current) = self.active_state.take() {
|
||||
self.saved_states
|
||||
.insert(current.session_id.clone(), current);
|
||||
}
|
||||
|
||||
self.active_state = Some(state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all session IDs (active and saved, reserved for future session management).
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn list_sessions(&self) -> Vec<&str> {
|
||||
let mut ids: Vec<&str> = self.saved_states.keys().map(String::as_str).collect();
|
||||
if let Some(ref active) = self.active_state {
|
||||
ids.push(&active.session_id);
|
||||
}
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe shared Duo session.
|
||||
pub type SharedDuoSession = Arc<Mutex<DuoSession>>;
|
||||
|
||||
/// Create a new shared Duo session.
|
||||
#[must_use]
|
||||
pub fn new_shared_duo_session() -> SharedDuoSession {
|
||||
Arc::new(Mutex::new(DuoSession::new()))
|
||||
}
|
||||
|
||||
// === Prompt Generation ===
|
||||
|
||||
/// Generate the player (implementation) prompt for the current state.
|
||||
///
|
||||
/// The player focuses on implementing requirements and should NOT declare success.
|
||||
#[must_use]
|
||||
pub fn generate_player_prompt(state: &DuoState) -> String {
|
||||
let mut prompt = String::new();
|
||||
|
||||
prompt.push_str("# Player Phase - Implementation\n\n");
|
||||
prompt.push_str("You are the PLAYER in an autocoding session. Your role is to IMPLEMENT the requirements.\n\n");
|
||||
|
||||
prompt.push_str("## Requirements (Source of Truth)\n\n");
|
||||
prompt.push_str(&state.requirements);
|
||||
prompt.push_str("\n\n");
|
||||
|
||||
prompt.push_str(&format!(
|
||||
"## Session Info\n\n\
|
||||
- Turn: {}/{}\n\
|
||||
- Approval Threshold: {:.0}%\n",
|
||||
state.current_turn,
|
||||
state.max_turns,
|
||||
state.approval_threshold * 100.0
|
||||
));
|
||||
|
||||
if let Some(ref feedback) = state.last_coach_feedback {
|
||||
prompt.push_str("\n## Previous Coach Feedback\n\n");
|
||||
prompt.push_str("Address these issues from the last review:\n\n");
|
||||
prompt.push_str(feedback);
|
||||
prompt.push('\n');
|
||||
}
|
||||
|
||||
prompt.push_str("\n## Instructions\n\n");
|
||||
prompt.push_str(
|
||||
"1. Implement the requirements above using available tools\n\
|
||||
2. Focus on making incremental progress\n\
|
||||
3. DO NOT declare success or claim completion\n\
|
||||
4. DO NOT evaluate your own work\n\
|
||||
5. The Coach will verify your implementation\n\n\
|
||||
Begin implementation now.\n",
|
||||
);
|
||||
|
||||
prompt
|
||||
}
|
||||
|
||||
/// Generate the coach (validation) prompt for the current state.
|
||||
///
|
||||
/// The coach verifies the implementation against requirements and ignores player self-assessment.
|
||||
#[must_use]
|
||||
pub fn generate_coach_prompt(state: &DuoState) -> String {
|
||||
let mut prompt = String::new();
|
||||
|
||||
prompt.push_str("# Coach Phase - Validation\n\n");
|
||||
prompt.push_str("You are the COACH in an autocoding session. Your role is to VERIFY the implementation.\n\n");
|
||||
|
||||
prompt.push_str("## Requirements (Source of Truth)\n\n");
|
||||
prompt.push_str(&state.requirements);
|
||||
prompt.push_str("\n\n");
|
||||
|
||||
prompt.push_str(&format!(
|
||||
"## Session Info\n\n\
|
||||
- Turn: {}/{}\n\
|
||||
- Approval Threshold: {:.0}%\n\
|
||||
- Turns Remaining: {}\n",
|
||||
state.current_turn,
|
||||
state.max_turns,
|
||||
state.approval_threshold * 100.0,
|
||||
state.turns_remaining()
|
||||
));
|
||||
|
||||
if !state.quality_scores.is_empty() {
|
||||
let avg = state.average_quality_score().unwrap_or(0.0);
|
||||
prompt.push_str(&format!("- Average Quality: {:.1}%\n", avg * 100.0));
|
||||
}
|
||||
|
||||
prompt.push_str("\n## Instructions\n\n");
|
||||
prompt.push_str(
|
||||
"1. Review the current implementation against the requirements\n\
|
||||
2. Create a COMPLIANCE CHECKLIST:\n\
|
||||
- [ ] or [x] for each requirement item\n\
|
||||
- Note any missing or incorrect implementations\n\
|
||||
3. Calculate a COMPLIANCE SCORE (0.0 to 1.0)\n\
|
||||
4. IGNORE any player self-assessment or claims of completion\n\
|
||||
5. If score >= threshold AND all critical items pass:\n\
|
||||
- Output: COACH APPROVED\n\
|
||||
6. Otherwise, provide specific feedback:\n\
|
||||
- What is missing\n\
|
||||
- What needs to be fixed\n\
|
||||
- Actionable next steps\n\n\
|
||||
Begin validation now.\n",
|
||||
);
|
||||
|
||||
prompt
|
||||
}
|
||||
|
||||
/// Generate a summary of the session for system prompt injection.
|
||||
#[must_use]
|
||||
pub fn session_summary(session: &DuoSession) -> String {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
if let Some(ref state) = session.active_state {
|
||||
lines.push(format!("Active Duo Session: {}", state.summary()));
|
||||
} else {
|
||||
lines.push("No active Duo session.".to_string());
|
||||
}
|
||||
|
||||
if !session.saved_states.is_empty() {
|
||||
lines.push(format!("Saved sessions: {}", session.saved_states.len()));
|
||||
for (id, state) in &session.saved_states {
|
||||
let name = state
|
||||
.session_name
|
||||
.as_deref()
|
||||
.unwrap_or(&id[..8.min(id.len())]);
|
||||
lines.push(format!(" - {}: {} ({})", name, state.status, state.phase));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
// === Tests ===
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_requirements() -> String {
|
||||
"## Requirements\n\
|
||||
- [ ] Create a function `add(a, b)` that returns the sum\n\
|
||||
- [ ] Add unit tests for the function\n\
|
||||
- [ ] Document the function with comments"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_session() {
|
||||
let state = DuoState::create(
|
||||
sample_requirements(),
|
||||
Some("test-session".to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(state.session_name, Some("test-session".to_string()));
|
||||
assert_eq!(state.current_turn, 1);
|
||||
assert_eq!(state.max_turns, 10);
|
||||
assert_eq!(state.phase, DuoPhase::Init);
|
||||
assert_eq!(state.status, DuoStatus::Active);
|
||||
assert!(state.turn_history.is_empty());
|
||||
assert!(state.last_coach_feedback.is_none());
|
||||
assert_eq!(state.approval_threshold, 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_advance_to_coach() {
|
||||
let mut state = DuoState::create(sample_requirements(), None, None, None);
|
||||
|
||||
assert!(
|
||||
state
|
||||
.advance_to_coach("Implemented add function".to_string())
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(state.phase, DuoPhase::Coach);
|
||||
assert_eq!(state.turn_history.len(), 1);
|
||||
assert_eq!(state.turn_history[0].phase, DuoPhase::Player);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_advance_turn_approved() {
|
||||
let mut state = DuoState::create(sample_requirements(), None, None, None);
|
||||
state
|
||||
.advance_to_coach("Implemented everything".to_string())
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
state
|
||||
.advance_turn(
|
||||
"COACH APPROVED - All requirements met".to_string(),
|
||||
true,
|
||||
Some(0.95)
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
assert_eq!(state.phase, DuoPhase::Approved);
|
||||
assert_eq!(state.status, DuoStatus::Approved);
|
||||
assert!(state.is_complete());
|
||||
assert_eq!(state.quality_scores, vec![0.95]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_advance_turn_continue() {
|
||||
let mut state = DuoState::create(sample_requirements(), None, None, None);
|
||||
state
|
||||
.advance_to_coach("Partial implementation".to_string())
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
state
|
||||
.advance_turn("Missing tests".to_string(), false, Some(0.5))
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
assert_eq!(state.phase, DuoPhase::Player);
|
||||
assert_eq!(state.status, DuoStatus::Active);
|
||||
assert_eq!(state.current_turn, 2);
|
||||
assert!(!state.is_complete());
|
||||
assert_eq!(state.last_coach_feedback, Some("Missing tests".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeout() {
|
||||
let mut state = DuoState::create(sample_requirements(), None, Some(2), None);
|
||||
|
||||
// Turn 1
|
||||
state.advance_to_coach("Attempt 1".to_string()).unwrap();
|
||||
state
|
||||
.advance_turn("Not good enough".to_string(), false, Some(0.3))
|
||||
.unwrap();
|
||||
|
||||
// Turn 2 (max)
|
||||
state.advance_to_coach("Attempt 2".to_string()).unwrap();
|
||||
state
|
||||
.advance_turn("Still not good enough".to_string(), false, Some(0.4))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(state.phase, DuoPhase::Timeout);
|
||||
assert_eq!(state.status, DuoStatus::Timeout);
|
||||
assert!(state.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_phase_transition() {
|
||||
let mut state = DuoState::create(sample_requirements(), None, None, None);
|
||||
state.phase = DuoPhase::Approved;
|
||||
|
||||
let result = state.advance_to_coach("Should fail".to_string());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_turns_remaining() {
|
||||
let state = DuoState::create(sample_requirements(), None, Some(10), None);
|
||||
assert_eq!(state.turns_remaining(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_average_quality_score() {
|
||||
let mut state = DuoState::create(sample_requirements(), None, None, None);
|
||||
assert!(state.average_quality_score().is_none());
|
||||
|
||||
state.quality_scores = vec![0.5, 0.7, 0.9];
|
||||
let avg = state.average_quality_score().unwrap();
|
||||
assert!((avg - 0.7).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_container() {
|
||||
let mut session = DuoSession::new();
|
||||
|
||||
// Start first session
|
||||
session.start_session(
|
||||
sample_requirements(),
|
||||
Some("session-1".to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(session.get_active().is_some());
|
||||
|
||||
// Start second session (first gets saved)
|
||||
session.start_session(
|
||||
"Other requirements".to_string(),
|
||||
Some("session-2".to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(session.saved_states.len(), 1);
|
||||
|
||||
// Get active
|
||||
let active = session.get_active().unwrap();
|
||||
assert_eq!(active.session_name, Some("session-2".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_player_prompt() {
|
||||
let state = DuoState::create(sample_requirements(), None, None, None);
|
||||
let prompt = generate_player_prompt(&state);
|
||||
|
||||
assert!(prompt.contains("Player Phase"));
|
||||
assert!(prompt.contains("Requirements (Source of Truth)"));
|
||||
assert!(prompt.contains("Turn: 1/10"));
|
||||
assert!(prompt.contains("DO NOT declare success"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_coach_prompt() {
|
||||
let state = DuoState::create(sample_requirements(), None, None, None);
|
||||
let prompt = generate_coach_prompt(&state);
|
||||
|
||||
assert!(prompt.contains("Coach Phase"));
|
||||
assert!(prompt.contains("COMPLIANCE CHECKLIST"));
|
||||
assert!(prompt.contains("COACH APPROVED"));
|
||||
assert!(prompt.contains("IGNORE any player self-assessment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_session() {
|
||||
let shared = new_shared_duo_session();
|
||||
|
||||
{
|
||||
let mut session = shared.lock().unwrap();
|
||||
session.start_session(sample_requirements(), None, None, None);
|
||||
}
|
||||
|
||||
{
|
||||
let session = shared.lock().unwrap();
|
||||
assert!(session.get_active().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summary() {
|
||||
let state = DuoState::create(sample_requirements(), Some("test".to_string()), None, None);
|
||||
let summary = state.summary();
|
||||
|
||||
assert!(summary.contains("Duo Session: test"));
|
||||
assert!(summary.contains("Phase: init"));
|
||||
assert!(summary.contains("Turn: 1/10"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_summary() {
|
||||
let mut session = DuoSession::new();
|
||||
session.start_session(
|
||||
sample_requirements(),
|
||||
Some("active-session".to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let summary = session_summary(&session);
|
||||
assert!(summary.contains("Active Duo Session"));
|
||||
assert!(summary.contains("active-session"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Shared error taxonomy across client, tools, runtime, and UI.
|
||||
//!
|
||||
//! Not yet wired into consumers; will be adopted incrementally.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::llm_client::LlmError;
|
||||
use crate::tools::spec::ToolError;
|
||||
|
||||
/// Broad category for typed error handling and policy decisions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorCategory {
|
||||
Network,
|
||||
Authentication,
|
||||
Authorization,
|
||||
RateLimit,
|
||||
Timeout,
|
||||
InvalidInput,
|
||||
Parse,
|
||||
Tool,
|
||||
State,
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// Severity hint for UI and logs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorSeverity {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
Critical,
|
||||
}
|
||||
|
||||
/// Unified envelope used when crossing subsystem boundaries.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ErrorEnvelope {
|
||||
pub category: ErrorCategory,
|
||||
pub severity: ErrorSeverity,
|
||||
pub recoverable: bool,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ErrorEnvelope {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
category: ErrorCategory,
|
||||
severity: ErrorSeverity,
|
||||
recoverable: bool,
|
||||
code: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
category,
|
||||
severity,
|
||||
recoverable,
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LlmError> for ErrorEnvelope {
|
||||
fn from(value: LlmError) -> Self {
|
||||
match value {
|
||||
LlmError::RateLimited { message, .. } => Self::new(
|
||||
ErrorCategory::RateLimit,
|
||||
ErrorSeverity::Warning,
|
||||
true,
|
||||
"llm_rate_limited",
|
||||
message,
|
||||
),
|
||||
LlmError::ServerError { status, message } => Self::new(
|
||||
ErrorCategory::Internal,
|
||||
ErrorSeverity::Error,
|
||||
true,
|
||||
format!("llm_server_{status}"),
|
||||
message,
|
||||
),
|
||||
LlmError::NetworkError(message) => Self::new(
|
||||
ErrorCategory::Network,
|
||||
ErrorSeverity::Error,
|
||||
true,
|
||||
"llm_network_error",
|
||||
message,
|
||||
),
|
||||
LlmError::Timeout(duration) => Self::new(
|
||||
ErrorCategory::Timeout,
|
||||
ErrorSeverity::Warning,
|
||||
true,
|
||||
"llm_timeout",
|
||||
format!("Request timed out after {duration:?}"),
|
||||
),
|
||||
LlmError::AuthenticationError(message) => Self::new(
|
||||
ErrorCategory::Authentication,
|
||||
ErrorSeverity::Critical,
|
||||
false,
|
||||
"llm_auth_error",
|
||||
message,
|
||||
),
|
||||
LlmError::InvalidRequest { message, .. } => Self::new(
|
||||
ErrorCategory::InvalidInput,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"llm_invalid_request",
|
||||
message,
|
||||
),
|
||||
LlmError::ModelError(message) => Self::new(
|
||||
ErrorCategory::InvalidInput,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"llm_model_error",
|
||||
message,
|
||||
),
|
||||
LlmError::ContentPolicyError(message) => Self::new(
|
||||
ErrorCategory::Authorization,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"llm_content_policy",
|
||||
message,
|
||||
),
|
||||
LlmError::ParseError(message) => Self::new(
|
||||
ErrorCategory::Parse,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"llm_parse_error",
|
||||
message,
|
||||
),
|
||||
LlmError::ContextLengthError(message) => Self::new(
|
||||
ErrorCategory::InvalidInput,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"llm_context_length",
|
||||
message,
|
||||
),
|
||||
LlmError::Other(message) => Self::new(
|
||||
ErrorCategory::Internal,
|
||||
ErrorSeverity::Error,
|
||||
true,
|
||||
"llm_other",
|
||||
message,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ToolError> for ErrorEnvelope {
|
||||
fn from(value: ToolError) -> Self {
|
||||
match value {
|
||||
ToolError::InvalidInput { message } => Self::new(
|
||||
ErrorCategory::InvalidInput,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"tool_invalid_input",
|
||||
message,
|
||||
),
|
||||
ToolError::MissingField { field } => Self::new(
|
||||
ErrorCategory::InvalidInput,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"tool_missing_field",
|
||||
format!("Missing required field: {field}"),
|
||||
),
|
||||
ToolError::PathEscape { path } => Self::new(
|
||||
ErrorCategory::Authorization,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"tool_path_escape",
|
||||
format!("Path escapes workspace: {}", path.display()),
|
||||
),
|
||||
ToolError::ExecutionFailed { message } => Self::new(
|
||||
ErrorCategory::Tool,
|
||||
ErrorSeverity::Error,
|
||||
true,
|
||||
"tool_execution_failed",
|
||||
message,
|
||||
),
|
||||
ToolError::Timeout { seconds } => Self::new(
|
||||
ErrorCategory::Timeout,
|
||||
ErrorSeverity::Warning,
|
||||
true,
|
||||
"tool_timeout",
|
||||
format!("Tool timed out after {seconds}s"),
|
||||
),
|
||||
ToolError::NotAvailable { message } => Self::new(
|
||||
ErrorCategory::State,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"tool_not_available",
|
||||
message,
|
||||
),
|
||||
ToolError::PermissionDenied { message } => Self::new(
|
||||
ErrorCategory::Authorization,
|
||||
ErrorSeverity::Error,
|
||||
false,
|
||||
"tool_permission_denied",
|
||||
message,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,6 @@ pub enum Feature {
|
||||
ApplyPatch,
|
||||
/// Enable MCP tools.
|
||||
Mcp,
|
||||
/// Enable RLM tools.
|
||||
Rlm,
|
||||
/// Enable Duo tools.
|
||||
Duo,
|
||||
/// Enable execpolicy integration/tooling.
|
||||
ExecPolicy,
|
||||
}
|
||||
@@ -171,18 +167,6 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Rlm,
|
||||
key: "rlm",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Duo,
|
||||
key: "duo",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: true,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ExecPolicy,
|
||||
key: "exec_policy",
|
||||
|
||||
+15
-3
@@ -77,7 +77,7 @@ pub enum HookCondition {
|
||||
},
|
||||
/// Only run in specific modes
|
||||
Mode {
|
||||
/// Mode: "plan", "agent", "yolo", "rlm", "duo"
|
||||
/// Mode: "plan", "agent", "yolo"
|
||||
mode: String,
|
||||
},
|
||||
/// Only run when exit code matches (for `ToolCallAfter`)
|
||||
@@ -319,7 +319,13 @@ impl HookContext {
|
||||
if let Some(ref result) = self.tool_result {
|
||||
// Truncate result to 10KB to avoid environment variable size limits
|
||||
let truncated = if result.len() > 10000 {
|
||||
format!("{}...[truncated]", &result[..10000])
|
||||
let safe_end = result
|
||||
.char_indices()
|
||||
.take_while(|(i, _)| *i < 10000)
|
||||
.last()
|
||||
.map(|(i, c)| i + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
format!("{}...[truncated]", &result[..safe_end])
|
||||
} else {
|
||||
result.clone()
|
||||
};
|
||||
@@ -343,7 +349,13 @@ impl HookContext {
|
||||
if let Some(ref message) = self.message {
|
||||
// Truncate message to prevent env var issues
|
||||
let truncated = if message.len() > 5000 {
|
||||
format!("{}...[truncated]", &message[..5000])
|
||||
let safe_end = message
|
||||
.char_indices()
|
||||
.take_while(|(i, _)| *i < 5000)
|
||||
.last()
|
||||
.map(|(i, c)| i + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
format!("{}...[truncated]", &message[..safe_end])
|
||||
} else {
|
||||
message.clone()
|
||||
};
|
||||
|
||||
+5
-9
@@ -29,6 +29,7 @@ use anyhow::Result;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::time::{Duration, Instant};
|
||||
use uuid::Uuid;
|
||||
|
||||
// === LlmClient Trait ===
|
||||
|
||||
@@ -420,16 +421,11 @@ impl RetryConfig {
|
||||
|
||||
let final_delay = if self.jitter {
|
||||
// Add random jitter to prevent thundering herd problem
|
||||
// Uses a simple deterministic approach when rand is not available
|
||||
let jitter_range = capped_delay * self.jitter_factor;
|
||||
|
||||
// Simple pseudo-random jitter based on current time
|
||||
// This avoids adding the rand crate as a dependency
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0);
|
||||
let random_factor = f64::from(nanos % 1000) / 1000.0; // 0.0 to 0.999
|
||||
// Use UUID v4 entropy for jitter randomness.
|
||||
let bytes = *Uuid::new_v4().as_bytes();
|
||||
let sample = u16::from_le_bytes([bytes[0], bytes[1]]);
|
||||
let random_factor = f64::from(sample) / f64::from(u16::MAX); // 0.0 to 1.0
|
||||
let jitter = jitter_range * (2.0 * random_factor - 1.0); // -range to +range
|
||||
|
||||
(capped_delay + jitter).max(0.0)
|
||||
|
||||
+430
-54
@@ -26,13 +26,14 @@ use dotenvy::dotenv;
|
||||
use tempfile::NamedTempFile;
|
||||
use wait_timeout::ChildExt;
|
||||
|
||||
mod audit;
|
||||
mod client;
|
||||
mod command_safety;
|
||||
mod commands;
|
||||
mod compaction;
|
||||
mod config;
|
||||
mod core;
|
||||
mod duo;
|
||||
mod error_taxonomy;
|
||||
mod eval;
|
||||
mod execpolicy;
|
||||
mod features;
|
||||
@@ -48,18 +49,22 @@ mod project_context;
|
||||
mod project_doc;
|
||||
mod prompts;
|
||||
mod responses_api_proxy;
|
||||
mod rlm;
|
||||
mod runtime_api;
|
||||
mod runtime_threads;
|
||||
mod sandbox;
|
||||
mod session_manager;
|
||||
mod settings;
|
||||
mod skills;
|
||||
mod task_manager;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
mod tools;
|
||||
mod tui;
|
||||
mod ui;
|
||||
mod utils;
|
||||
mod working_set;
|
||||
|
||||
use crate::config::{Config, MAX_SUBAGENTS};
|
||||
use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS};
|
||||
use crate::eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
|
||||
use crate::features::Feature;
|
||||
use crate::llm_client::LlmClient;
|
||||
@@ -161,6 +166,8 @@ enum Commands {
|
||||
},
|
||||
/// Remove the saved API key
|
||||
Logout,
|
||||
/// List available models from the configured API endpoint
|
||||
Models(ModelsArgs),
|
||||
/// Run a non-interactive prompt
|
||||
Exec(ExecArgs),
|
||||
/// Run a code review over a git diff
|
||||
@@ -215,6 +222,9 @@ struct ExecArgs {
|
||||
/// Enable agentic mode with tool access and auto-approvals
|
||||
#[arg(long, default_value_t = false)]
|
||||
auto: bool,
|
||||
/// Emit machine-readable JSON output
|
||||
#[arg(long, default_value_t = false)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
@@ -255,6 +265,13 @@ struct EvalArgs {
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
struct ModelsArgs {
|
||||
/// Print models as pretty JSON
|
||||
#[arg(long, default_value_t = false)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Default, Clone)]
|
||||
struct FeatureToggles {
|
||||
/// Enable a feature (repeatable). Equivalent to `features.<name>=true`.
|
||||
@@ -295,6 +312,9 @@ struct ReviewArgs {
|
||||
/// Maximum diff characters to include
|
||||
#[arg(long, default_value_t = 200_000)]
|
||||
max_chars: usize,
|
||||
/// Emit machine-readable JSON output
|
||||
#[arg(long, default_value_t = false)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone)]
|
||||
@@ -309,6 +329,18 @@ struct ServeArgs {
|
||||
/// Start MCP server over stdio
|
||||
#[arg(long)]
|
||||
mcp: bool,
|
||||
/// Start runtime HTTP/SSE API server
|
||||
#[arg(long)]
|
||||
http: bool,
|
||||
/// Bind host for HTTP server (default localhost)
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
host: String,
|
||||
/// Bind port for HTTP server
|
||||
#[arg(long, default_value_t = 7878)]
|
||||
port: u16,
|
||||
/// Background task worker count (1-8)
|
||||
#[arg(long, default_value_t = 2)]
|
||||
workers: usize,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
@@ -333,6 +365,37 @@ enum McpCommand {
|
||||
#[arg(value_name = "SERVER")]
|
||||
server: Option<String>,
|
||||
},
|
||||
/// Add an MCP server entry
|
||||
Add {
|
||||
/// Server name
|
||||
name: String,
|
||||
/// Command to launch stdio server
|
||||
#[arg(long, conflicts_with = "url")]
|
||||
command: Option<String>,
|
||||
/// URL for streamable HTTP/SSE server
|
||||
#[arg(long, conflicts_with = "command")]
|
||||
url: Option<String>,
|
||||
/// Arguments for command-based servers
|
||||
#[arg(long = "arg")]
|
||||
args: Vec<String>,
|
||||
},
|
||||
/// Remove an MCP server entry
|
||||
Remove {
|
||||
/// Server name
|
||||
name: String,
|
||||
},
|
||||
/// Enable an MCP server
|
||||
Enable {
|
||||
/// Server name
|
||||
name: String,
|
||||
},
|
||||
/// Disable an MCP server
|
||||
Disable {
|
||||
/// Server name
|
||||
name: String,
|
||||
},
|
||||
/// Validate MCP config and required servers
|
||||
Validate,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone)]
|
||||
@@ -424,12 +487,16 @@ async fn main() -> Result<()> {
|
||||
Commands::Init => init_project(),
|
||||
Commands::Login { api_key } => run_login(api_key),
|
||||
Commands::Logout => run_logout(),
|
||||
Commands::Models(args) => {
|
||||
let config = load_config_from_cli(&cli)?;
|
||||
run_models(&config, args).await
|
||||
}
|
||||
Commands::Exec(args) => {
|
||||
let config = load_config_from_cli(&cli)?;
|
||||
let model = args
|
||||
.model
|
||||
.or_else(|| config.default_text_model.clone())
|
||||
.unwrap_or_else(|| "deepseek-reasoner".to_string());
|
||||
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
|
||||
if args.auto || cli.yolo {
|
||||
let workspace = cli.workspace.clone().unwrap_or_else(|| {
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
@@ -447,8 +514,11 @@ async fn main() -> Result<()> {
|
||||
max_subagents,
|
||||
true,
|
||||
auto_mode,
|
||||
args.json,
|
||||
)
|
||||
.await
|
||||
} else if args.json {
|
||||
run_one_shot_json(&config, &model, &args.prompt).await
|
||||
} else {
|
||||
run_one_shot(&config, &model, &args.prompt).await
|
||||
}
|
||||
@@ -473,10 +543,25 @@ async fn main() -> Result<()> {
|
||||
let workspace = cli.workspace.clone().unwrap_or_else(|| {
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
});
|
||||
if args.mcp && args.http {
|
||||
bail!("Choose exactly one server mode: --mcp or --http");
|
||||
}
|
||||
if args.mcp {
|
||||
mcp_server::run_mcp_server(workspace)
|
||||
} else if args.http {
|
||||
let config = load_config_from_cli(&cli)?;
|
||||
runtime_api::run_http_server(
|
||||
config,
|
||||
workspace,
|
||||
runtime_api::RuntimeApiOptions {
|
||||
host: args.host,
|
||||
port: args.port,
|
||||
workers: args.workers.clamp(1, 8),
|
||||
},
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
bail!("No server mode specified. Use --mcp.")
|
||||
bail!("No server mode specified. Use --mcp or --http.")
|
||||
}
|
||||
}
|
||||
Commands::Resume { session_id, last } => {
|
||||
@@ -502,7 +587,7 @@ async fn main() -> Result<()> {
|
||||
let model = config
|
||||
.default_text_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "deepseek-reasoner".to_string());
|
||||
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
|
||||
return run_one_shot(&config, &model, &prompt).await;
|
||||
}
|
||||
|
||||
@@ -608,9 +693,8 @@ fn ensure_parent_dir(path: &Path) -> Result<()> {
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("Failed to create directory for {}", parent.display())
|
||||
})?;
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create directory for {}", parent.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -639,13 +723,18 @@ fn mcp_template_json() -> Result<String> {
|
||||
cfg.servers.insert(
|
||||
"example".to_string(),
|
||||
McpServerConfig {
|
||||
command: "node".to_string(),
|
||||
command: Some("node".to_string()),
|
||||
args: vec!["./path/to/your-mcp-server.js".to_string()],
|
||||
env: std::collections::HashMap::new(),
|
||||
url: None,
|
||||
connect_timeout: None,
|
||||
execute_timeout: None,
|
||||
read_timeout: None,
|
||||
disabled: true,
|
||||
enabled: true,
|
||||
required: false,
|
||||
enabled_tools: Vec::new(),
|
||||
disabled_tools: Vec::new(),
|
||||
},
|
||||
);
|
||||
serde_json::to_string_pretty(&cfg)
|
||||
@@ -701,9 +790,7 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
"DeepSeek Setup"
|
||||
.truecolor(aqua_r, aqua_g, aqua_b)
|
||||
.bold()
|
||||
"DeepSeek Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
|
||||
);
|
||||
println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
|
||||
println!("Workspace: {}", workspace.display());
|
||||
@@ -740,7 +827,10 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
|
||||
println!(" ✓ Overwrote example skill at {}", skill_path.display());
|
||||
}
|
||||
WriteStatus::SkippedExists => {
|
||||
println!(" · Example skill already exists at {}", skill_path.display());
|
||||
println!(
|
||||
" · Example skill already exists at {}",
|
||||
skill_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
if args.local {
|
||||
@@ -795,7 +885,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
dirs::home_dir().map_or_else(|| PathBuf::from(".deepseek"), |h| h.join(".deepseek"));
|
||||
let config_path = config_path_override
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var("DEEPSEEK_CONFIG_PATH").ok().map(PathBuf::from))
|
||||
.or_else(|| {
|
||||
std::env::var("DEEPSEEK_CONFIG_PATH")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
})
|
||||
.unwrap_or_else(|| default_config_dir.join("config.toml"));
|
||||
|
||||
if config_path.exists() {
|
||||
@@ -816,7 +910,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
// Check API keys
|
||||
println!();
|
||||
println!("{}", "API Keys:".bold());
|
||||
let has_api_key = if std::env::var("DEEPSEEK_API_KEY").is_ok() {
|
||||
let has_api_key = if std::env::var("DEEPSEEK_API_KEY")
|
||||
.ok()
|
||||
.filter(|k| !k.trim().is_empty())
|
||||
.is_some()
|
||||
{
|
||||
println!(
|
||||
" {} DEEPSEEK_API_KEY is set",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b)
|
||||
@@ -885,9 +983,15 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!("{}", "MCP Servers:".bold());
|
||||
let features = config.features();
|
||||
if features.enabled(Feature::Mcp) {
|
||||
println!(" {} MCP feature flag enabled", "✓".truecolor(aqua_r, aqua_g, aqua_b));
|
||||
println!(
|
||||
" {} MCP feature flag enabled",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b)
|
||||
);
|
||||
} else {
|
||||
println!(" {} MCP feature flag disabled", "!".truecolor(sky_r, sky_g, sky_b));
|
||||
println!(
|
||||
" {} MCP feature flag disabled",
|
||||
"!".truecolor(sky_r, sky_g, sky_b)
|
||||
);
|
||||
}
|
||||
|
||||
let mcp_config_path = config.mcp_config_path();
|
||||
@@ -932,8 +1036,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!();
|
||||
println!("{}", "Skills:".bold());
|
||||
let global_skills_dir = config.skills_dir();
|
||||
let agents_skills_dir = workspace.join(".agents").join("skills");
|
||||
let local_skills_dir = workspace.join("skills");
|
||||
let selected_skills_dir = if local_skills_dir.exists() {
|
||||
let selected_skills_dir = if agents_skills_dir.exists() {
|
||||
&agents_skills_dir
|
||||
} else if local_skills_dir.exists() {
|
||||
&local_skills_dir
|
||||
} else {
|
||||
&global_skills_dir
|
||||
@@ -960,6 +1067,21 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
);
|
||||
}
|
||||
|
||||
if agents_skills_dir.exists() {
|
||||
println!(
|
||||
" {} .agents skills dir found at {} ({} items)",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
agents_skills_dir.display(),
|
||||
describe_dir(&agents_skills_dir)
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} .agents skills dir not found at {}",
|
||||
"·".dimmed(),
|
||||
agents_skills_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
if global_skills_dir.exists() {
|
||||
println!(
|
||||
" {} global skills dir found at {} ({} items)",
|
||||
@@ -975,8 +1097,12 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
);
|
||||
}
|
||||
|
||||
println!(" {} selected skills dir: {}", "·".dimmed(), selected_skills_dir.display());
|
||||
if !local_skills_dir.exists() && !global_skills_dir.exists() {
|
||||
println!(
|
||||
" {} selected skills dir: {}",
|
||||
"·".dimmed(),
|
||||
selected_skills_dir.display()
|
||||
);
|
||||
if !agents_skills_dir.exists() && !local_skills_dir.exists() && !global_skills_dir.exists() {
|
||||
println!(" Run `deepseek setup --skills` (or add --local for ./skills).");
|
||||
}
|
||||
|
||||
@@ -1041,6 +1167,41 @@ fn run_features_list(config: &Config) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> {
|
||||
use crate::client::DeepSeekClient;
|
||||
|
||||
let client = DeepSeekClient::new(config)?;
|
||||
let mut models = client.list_models().await?;
|
||||
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&models)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if models.is_empty() {
|
||||
println!("No models returned by the API.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let default_model = config
|
||||
.default_text_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
|
||||
|
||||
println!("Available models (default: {default_model})");
|
||||
for model in models {
|
||||
let marker = if model.id == default_model { "*" } else { " " };
|
||||
if let Some(owner) = model.owned_by {
|
||||
println!("{marker} {} ({owner})", model.id);
|
||||
} else {
|
||||
println!("{marker} {}", model.id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test API connectivity by making a minimal request
|
||||
async fn test_api_connectivity() -> Result<String> {
|
||||
use crate::client::DeepSeekClient;
|
||||
@@ -1324,7 +1485,7 @@ async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
|
||||
let model = args
|
||||
.model
|
||||
.or_else(|| config.default_text_model.clone())
|
||||
.unwrap_or_else(|| "deepseek-reasoner".to_string());
|
||||
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
|
||||
|
||||
let system = SystemPrompt::Text(
|
||||
"You are a senior code reviewer. Focus on bugs, risks, behavioral regressions, and missing tests. \
|
||||
@@ -1336,7 +1497,7 @@ Provide findings ordered by severity with file references, then open questions,
|
||||
|
||||
let client = DeepSeekClient::new(config)?;
|
||||
let request = MessageRequest {
|
||||
model,
|
||||
model: model.clone(),
|
||||
messages: vec![Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
@@ -1356,11 +1517,25 @@ Provide findings ordered by severity with file references, then open questions,
|
||||
};
|
||||
|
||||
let response = client.create_message(request).await?;
|
||||
let mut output = String::new();
|
||||
for block in response.content {
|
||||
if let ContentBlock::Text { text, .. } = block {
|
||||
println!("{text}");
|
||||
output.push_str(&text);
|
||||
}
|
||||
}
|
||||
if args.json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"mode": "review",
|
||||
"model": model,
|
||||
"success": true,
|
||||
"content": output
|
||||
}))?
|
||||
);
|
||||
} else {
|
||||
println!("{output}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1461,17 +1636,25 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
|
||||
}
|
||||
println!("MCP servers ({}):", cfg.servers.len());
|
||||
for (name, server) in cfg.servers {
|
||||
let status = if server.disabled {
|
||||
"disabled"
|
||||
} else {
|
||||
let status = if server.enabled && !server.disabled {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
let args = if server.args.is_empty() {
|
||||
"".to_string()
|
||||
} else {
|
||||
format!(" {}", server.args.join(" "))
|
||||
};
|
||||
println!(" - {name} [{status}] {}{}", server.command, args);
|
||||
let cmd_str = if let Some(cmd) = server.command {
|
||||
format!("{cmd}{args}")
|
||||
} else if let Some(url) = server.url {
|
||||
url
|
||||
} else {
|
||||
"unknown".to_string()
|
||||
};
|
||||
let required = if server.required { " required" } else { "" };
|
||||
println!(" - {name} [{status}{required}] {cmd_str}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1530,6 +1713,83 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
McpCommand::Add {
|
||||
name,
|
||||
command,
|
||||
url,
|
||||
args,
|
||||
} => {
|
||||
if command.is_none() && url.is_none() {
|
||||
bail!("Provide either --command or --url for `mcp add`.");
|
||||
}
|
||||
let mut cfg = load_mcp_config(&config_path)?;
|
||||
cfg.servers.insert(
|
||||
name.clone(),
|
||||
McpServerConfig {
|
||||
command,
|
||||
args,
|
||||
env: std::collections::HashMap::new(),
|
||||
url,
|
||||
connect_timeout: None,
|
||||
execute_timeout: None,
|
||||
read_timeout: None,
|
||||
disabled: false,
|
||||
enabled: true,
|
||||
required: false,
|
||||
enabled_tools: Vec::new(),
|
||||
disabled_tools: Vec::new(),
|
||||
},
|
||||
);
|
||||
save_mcp_config(&config_path, &cfg)?;
|
||||
println!("Added MCP server '{name}' in {}", config_path.display());
|
||||
Ok(())
|
||||
}
|
||||
McpCommand::Remove { name } => {
|
||||
let mut cfg = load_mcp_config(&config_path)?;
|
||||
if cfg.servers.remove(&name).is_none() {
|
||||
bail!("MCP server '{name}' not found");
|
||||
}
|
||||
save_mcp_config(&config_path, &cfg)?;
|
||||
println!("Removed MCP server '{name}'");
|
||||
Ok(())
|
||||
}
|
||||
McpCommand::Enable { name } => {
|
||||
let mut cfg = load_mcp_config(&config_path)?;
|
||||
let server = cfg
|
||||
.servers
|
||||
.get_mut(&name)
|
||||
.ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
|
||||
server.enabled = true;
|
||||
server.disabled = false;
|
||||
save_mcp_config(&config_path, &cfg)?;
|
||||
println!("Enabled MCP server '{name}'");
|
||||
Ok(())
|
||||
}
|
||||
McpCommand::Disable { name } => {
|
||||
let mut cfg = load_mcp_config(&config_path)?;
|
||||
let server = cfg
|
||||
.servers
|
||||
.get_mut(&name)
|
||||
.ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
|
||||
server.enabled = false;
|
||||
server.disabled = true;
|
||||
save_mcp_config(&config_path, &cfg)?;
|
||||
println!("Disabled MCP server '{name}'");
|
||||
Ok(())
|
||||
}
|
||||
McpCommand::Validate => {
|
||||
let mut pool = McpPool::from_config_path(&config_path)?;
|
||||
let errors = pool.connect_all().await;
|
||||
if errors.is_empty() {
|
||||
println!("MCP config is valid. All enabled servers connected.");
|
||||
return Ok(());
|
||||
}
|
||||
eprintln!("MCP validation failed:");
|
||||
for (name, err) in errors {
|
||||
eprintln!(" - {name}: {err}");
|
||||
}
|
||||
bail!("one or more MCP servers failed validation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1544,6 +1804,19 @@ fn load_mcp_config(path: &Path) -> Result<McpConfig> {
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
fn save_mcp_config(path: &Path, cfg: &McpConfig) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("Failed to create MCP config directory {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let rendered = serde_json::to_string_pretty(cfg)
|
||||
.map_err(|e| anyhow!("Failed to serialize MCP config: {e}"))?;
|
||||
std::fs::write(path, rendered)
|
||||
.map_err(|e| anyhow!("Failed to write MCP config {}: {}", path.display(), e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_sandbox_command(args: SandboxArgs) -> Result<()> {
|
||||
use crate::sandbox::{CommandSpec, SandboxManager};
|
||||
|
||||
@@ -1708,7 +1981,7 @@ async fn run_interactive(
|
||||
let model = config
|
||||
.default_text_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "deepseek-reasoner".to_string());
|
||||
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
|
||||
let max_subagents = cli.max_subagents.map_or_else(
|
||||
|| config.max_subagents(),
|
||||
|value| value.clamp(1, MAX_SUBAGENTS),
|
||||
@@ -1774,6 +2047,53 @@ async fn run_one_shot(config: &Config, model: &str, prompt: &str) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_one_shot_json(config: &Config, model: &str, prompt: &str) -> Result<()> {
|
||||
use crate::client::DeepSeekClient;
|
||||
use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
|
||||
|
||||
let client = DeepSeekClient::new(config)?;
|
||||
let request = MessageRequest {
|
||||
model: model.to_string(),
|
||||
messages: vec![Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: prompt.to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
}],
|
||||
max_tokens: 4096,
|
||||
system: Some(SystemPrompt::Text(
|
||||
"You are a coding assistant. Give concise, actionable responses.".to_string(),
|
||||
)),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
stream: Some(false),
|
||||
temperature: Some(0.2),
|
||||
top_p: Some(0.9),
|
||||
};
|
||||
|
||||
let response = client.create_message(request).await?;
|
||||
let mut output = String::new();
|
||||
for block in response.content {
|
||||
if let ContentBlock::Text { text, .. } = block {
|
||||
output.push_str(&text);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"mode": "one-shot",
|
||||
"model": model,
|
||||
"success": true,
|
||||
"output": output
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_exec_agent(
|
||||
config: &Config,
|
||||
model: &str,
|
||||
@@ -1782,19 +2102,23 @@ async fn run_exec_agent(
|
||||
max_subagents: usize,
|
||||
auto_approve: bool,
|
||||
trust_mode: bool,
|
||||
json_output: bool,
|
||||
) -> Result<()> {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::compaction::CompactionConfig;
|
||||
use crate::core::engine::{EngineConfig, spawn_engine};
|
||||
use crate::core::events::Event;
|
||||
use crate::core::ops::Op;
|
||||
use crate::duo::DuoSession;
|
||||
use crate::rlm::RlmSession;
|
||||
use crate::models::{compaction_message_threshold_for_model, compaction_threshold_for_model};
|
||||
use crate::tools::plan::new_shared_plan_state;
|
||||
use crate::tools::todo::new_shared_todo_list;
|
||||
use crate::tui::app::AppMode;
|
||||
|
||||
let mut compaction = CompactionConfig::default();
|
||||
compaction.enabled = true;
|
||||
compaction.model = model.to_string();
|
||||
compaction.token_threshold = compaction_threshold_for_model(model);
|
||||
compaction.message_threshold = compaction_message_threshold_for_model(model);
|
||||
|
||||
let engine_config = EngineConfig {
|
||||
model: model.to_string(),
|
||||
workspace: workspace.clone(),
|
||||
@@ -1805,9 +2129,8 @@ async fn run_exec_agent(
|
||||
max_steps: 100,
|
||||
max_subagents,
|
||||
features: config.features(),
|
||||
rlm_session: Arc::new(Mutex::new(RlmSession::default())),
|
||||
duo_session: Arc::new(Mutex::new(DuoSession::new())),
|
||||
compaction: CompactionConfig::default(),
|
||||
compaction,
|
||||
capacity: crate::core::capacity::CapacityControllerConfig::from_app_config(config),
|
||||
todos: new_shared_todo_list(),
|
||||
plan_state: new_shared_plan_state(),
|
||||
};
|
||||
@@ -1829,6 +2152,29 @@ async fn run_exec_agent(
|
||||
))
|
||||
.await?;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ExecToolEntry {
|
||||
name: String,
|
||||
success: bool,
|
||||
output: String,
|
||||
}
|
||||
#[derive(serde::Serialize, Default)]
|
||||
struct ExecSummary {
|
||||
mode: String,
|
||||
model: String,
|
||||
prompt: String,
|
||||
output: String,
|
||||
tools: Vec<ExecToolEntry>,
|
||||
status: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
let mut summary = ExecSummary {
|
||||
mode: "agent".to_string(),
|
||||
model: model.to_string(),
|
||||
prompt: prompt.to_string(),
|
||||
..ExecSummary::default()
|
||||
};
|
||||
|
||||
let mut stdout = io::stdout();
|
||||
let mut ends_with_newline = false;
|
||||
loop {
|
||||
@@ -1843,35 +2189,49 @@ async fn run_exec_agent(
|
||||
|
||||
match event {
|
||||
Event::MessageDelta { content, .. } => {
|
||||
print!("{content}");
|
||||
stdout.flush()?;
|
||||
summary.output.push_str(&content);
|
||||
if !json_output {
|
||||
print!("{content}");
|
||||
stdout.flush()?;
|
||||
}
|
||||
ends_with_newline = content.ends_with('\n');
|
||||
}
|
||||
Event::MessageComplete { .. } => {
|
||||
if !ends_with_newline {
|
||||
if !json_output && !ends_with_newline {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
Event::ToolCallStarted { name, input, .. } => {
|
||||
let summary = summarize_tool_args(&input);
|
||||
if let Some(summary) = summary {
|
||||
eprintln!("tool: {name} ({summary})");
|
||||
} else {
|
||||
eprintln!("tool: {name}");
|
||||
if !json_output {
|
||||
let summary = summarize_tool_args(&input);
|
||||
if let Some(summary) = summary {
|
||||
eprintln!("tool: {name} ({summary})");
|
||||
} else {
|
||||
eprintln!("tool: {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::ToolCallProgress { id, output } => {
|
||||
eprintln!("tool {id}: {}", summarize_tool_output(&output));
|
||||
if !json_output {
|
||||
eprintln!("tool {id}: {}", summarize_tool_output(&output));
|
||||
}
|
||||
}
|
||||
Event::ToolCallComplete { name, result, .. } => match result {
|
||||
Ok(output) => {
|
||||
summary.tools.push(ExecToolEntry {
|
||||
name: name.clone(),
|
||||
success: output.success,
|
||||
output: output.content.clone(),
|
||||
});
|
||||
if name == "exec_shell" && !output.content.trim().is_empty() {
|
||||
eprintln!("tool {name} completed");
|
||||
eprintln!(
|
||||
"--- stdout/stderr ---\n{}\n---------------------",
|
||||
output.content
|
||||
);
|
||||
} else {
|
||||
if !json_output {
|
||||
eprintln!("tool {name} completed");
|
||||
eprintln!(
|
||||
"--- stdout/stderr ---\n{}\n---------------------",
|
||||
output.content
|
||||
);
|
||||
}
|
||||
} else if !json_output {
|
||||
eprintln!(
|
||||
"tool {name} completed: {}",
|
||||
summarize_tool_output(&output.content)
|
||||
@@ -1879,7 +2239,14 @@ async fn run_exec_agent(
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("tool {name} failed: {err}");
|
||||
summary.tools.push(ExecToolEntry {
|
||||
name: name.clone(),
|
||||
success: false,
|
||||
output: err.to_string(),
|
||||
});
|
||||
if !json_output {
|
||||
eprintln!("tool {name} failed: {err}");
|
||||
}
|
||||
}
|
||||
},
|
||||
Event::AgentSpawned { id, prompt } => {
|
||||
@@ -1920,9 +2287,14 @@ async fn run_exec_agent(
|
||||
message,
|
||||
recoverable: _,
|
||||
} => {
|
||||
eprintln!("error: {message}");
|
||||
summary.error = Some(message.clone());
|
||||
if !json_output {
|
||||
eprintln!("error: {message}");
|
||||
}
|
||||
}
|
||||
Event::TurnComplete { .. } => {
|
||||
Event::TurnComplete { status, error, .. } => {
|
||||
summary.status = Some(format!("{status:?}").to_lowercase());
|
||||
summary.error = error;
|
||||
let _ = engine_handle.send(Op::Shutdown).await;
|
||||
break;
|
||||
}
|
||||
@@ -1930,5 +2302,9 @@ async fn run_exec_agent(
|
||||
}
|
||||
}
|
||||
|
||||
if json_output {
|
||||
println!("{}", serde_json::to_string_pretty(&summary)?);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+827
-75
File diff suppressed because it is too large.
Load diff
+87
-8
@@ -2,6 +2,13 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const DEFAULT_CONTEXT_WINDOW_TOKENS: u32 = 128_000;
|
||||
pub const DEFAULT_COMPACTION_TOKEN_THRESHOLD: usize = 50_000;
|
||||
pub const DEFAULT_COMPACTION_MESSAGE_THRESHOLD: usize = 50;
|
||||
const COMPACTION_THRESHOLD_PERCENT: u32 = 80;
|
||||
const COMPACTION_MESSAGE_DIVISOR: u32 = 1200;
|
||||
const MAX_COMPACTION_MESSAGE_THRESHOLD: usize = 150;
|
||||
|
||||
// === Core Message Types ===
|
||||
|
||||
/// Request payload for sending a message to the API.
|
||||
@@ -29,7 +36,7 @@ pub struct MessageRequest {
|
||||
}
|
||||
|
||||
/// System prompt representation (plain text or structured blocks).
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum SystemPrompt {
|
||||
Text(String),
|
||||
@@ -37,7 +44,7 @@ pub enum SystemPrompt {
|
||||
}
|
||||
|
||||
/// A structured system prompt block.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct SystemBlock {
|
||||
#[serde(rename = "type")]
|
||||
pub block_type: String,
|
||||
@@ -47,14 +54,14 @@ pub struct SystemBlock {
|
||||
}
|
||||
|
||||
/// A chat message with role and content blocks.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct Message {
|
||||
pub role: String,
|
||||
pub content: Vec<ContentBlock>,
|
||||
}
|
||||
|
||||
/// A single content block inside a message.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ContentBlock {
|
||||
#[serde(rename = "text")]
|
||||
@@ -79,7 +86,7 @@ pub enum ContentBlock {
|
||||
}
|
||||
|
||||
/// Cache control metadata for tool definitions and blocks.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct CacheControl {
|
||||
#[serde(rename = "type")]
|
||||
pub cache_type: String,
|
||||
@@ -119,11 +126,17 @@ pub struct Usage {
|
||||
#[must_use]
|
||||
pub fn context_window_for_model(model: &str) -> Option<u32> {
|
||||
let lower = model.to_lowercase();
|
||||
if lower.contains("deepseek-chat") || lower.contains("deepseek-reasoner") {
|
||||
return Some(128_000);
|
||||
if lower.contains("deepseek-v3.2") {
|
||||
return Some(DEFAULT_CONTEXT_WINDOW_TOKENS);
|
||||
}
|
||||
if lower.contains("deepseek-chat")
|
||||
|| lower.contains("deepseek-reasoner")
|
||||
|| lower.contains("deepseek-r1")
|
||||
{
|
||||
return Some(DEFAULT_CONTEXT_WINDOW_TOKENS);
|
||||
}
|
||||
if lower.contains("deepseek") {
|
||||
return Some(128_000);
|
||||
return Some(DEFAULT_CONTEXT_WINDOW_TOKENS);
|
||||
}
|
||||
if lower.contains("claude") {
|
||||
return Some(200_000);
|
||||
@@ -131,6 +144,35 @@ pub fn context_window_for_model(model: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Derive a compaction token threshold from model context window.
|
||||
///
|
||||
/// Keeps headroom for tool outputs and assistant completion by defaulting to 80%
|
||||
/// of known context windows.
|
||||
#[must_use]
|
||||
pub fn compaction_threshold_for_model(model: &str) -> usize {
|
||||
let Some(window) = context_window_for_model(model) else {
|
||||
return DEFAULT_COMPACTION_TOKEN_THRESHOLD;
|
||||
};
|
||||
|
||||
let threshold = (u64::from(window) * u64::from(COMPACTION_THRESHOLD_PERCENT)) / 100;
|
||||
usize::try_from(threshold).unwrap_or(DEFAULT_COMPACTION_TOKEN_THRESHOLD)
|
||||
}
|
||||
|
||||
/// Derive a compaction message-count threshold from model context window.
|
||||
#[must_use]
|
||||
pub fn compaction_message_threshold_for_model(model: &str) -> usize {
|
||||
let Some(window) = context_window_for_model(model) else {
|
||||
return DEFAULT_COMPACTION_MESSAGE_THRESHOLD;
|
||||
};
|
||||
|
||||
let scaled = usize::try_from(window / COMPACTION_MESSAGE_DIVISOR)
|
||||
.unwrap_or(DEFAULT_COMPACTION_MESSAGE_THRESHOLD);
|
||||
scaled.clamp(
|
||||
DEFAULT_COMPACTION_MESSAGE_THRESHOLD,
|
||||
MAX_COMPACTION_MESSAGE_THRESHOLD,
|
||||
)
|
||||
}
|
||||
|
||||
// === Streaming Structures ===
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -198,3 +240,40 @@ pub struct MessageDelta {
|
||||
pub stop_reason: Option<String>,
|
||||
pub stop_sequence: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deepseek_models_map_to_128k_context_window() {
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-reasoner"),
|
||||
Some(DEFAULT_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-v3.2"),
|
||||
Some(DEFAULT_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("deepseek-v3.2-0324"),
|
||||
Some(DEFAULT_CONTEXT_WINDOW_TOKENS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_threshold_scales_with_context_window() {
|
||||
assert_eq!(compaction_threshold_for_model("deepseek-reasoner"), 102_400);
|
||||
assert_eq!(compaction_threshold_for_model("unknown-model"), 50_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_message_threshold_scales_with_context_window() {
|
||||
assert_eq!(
|
||||
compaction_message_threshold_for_model("deepseek-reasoner"),
|
||||
106
|
||||
);
|
||||
assert_eq!(compaction_message_threshold_for_model("unknown-model"), 50);
|
||||
assert_eq!(compaction_message_threshold_for_model("claude-3"), 150);
|
||||
}
|
||||
}
|
||||
+31
-9
@@ -12,6 +12,9 @@ pub const DEEPSEEK_INK_RGB: (u8, u8, u8) = (11, 21, 38);
|
||||
pub const DEEPSEEK_SLATE_RGB: (u8, u8, u8) = (18, 28, 46);
|
||||
pub const DEEPSEEK_RED_RGB: (u8, u8, u8) = (226, 80, 96);
|
||||
|
||||
// New semantic colors
|
||||
pub const BORDER_COLOR_RGB: (u8, u8, u8) = (42, 74, 127); // #2A4A7F
|
||||
|
||||
pub const DEEPSEEK_BLUE: Color = Color::Rgb(
|
||||
DEEPSEEK_BLUE_RGB.0,
|
||||
DEEPSEEK_BLUE_RGB.1,
|
||||
@@ -42,22 +45,35 @@ pub const DEEPSEEK_RED: Color =
|
||||
Color::Rgb(DEEPSEEK_RED_RGB.0, DEEPSEEK_RED_RGB.1, DEEPSEEK_RED_RGB.2);
|
||||
|
||||
pub const TEXT_PRIMARY: Color = Color::White;
|
||||
pub const TEXT_MUTED: Color = Color::DarkGray;
|
||||
pub const TEXT_DIM: Color = Color::Gray;
|
||||
pub const TEXT_MUTED: Color = Color::Rgb(192, 192, 192); // #C0C0C0
|
||||
pub const TEXT_DIM: Color = Color::Rgb(160, 160, 160); // #A0A0A0
|
||||
|
||||
// New semantic colors for UI theming
|
||||
pub const BORDER_COLOR: Color =
|
||||
Color::Rgb(BORDER_COLOR_RGB.0, BORDER_COLOR_RGB.1, BORDER_COLOR_RGB.2);
|
||||
#[allow(dead_code)]
|
||||
pub const ACCENT_PRIMARY: Color = DEEPSEEK_BLUE; // #3578E5
|
||||
#[allow(dead_code)]
|
||||
pub const ACCENT_SECONDARY: Color = DEEPSEEK_SKY; // #6AAEF2
|
||||
#[allow(dead_code)]
|
||||
pub const BACKGROUND_LIGHT: Color = Color::Rgb(30, 47, 71); // #1E2F47
|
||||
#[allow(dead_code)]
|
||||
pub const BACKGROUND_DARK: Color = Color::Rgb(13, 26, 48); // #0D1A30
|
||||
#[allow(dead_code)]
|
||||
pub const STATUS_NEUTRAL: Color = Color::Rgb(160, 160, 160); // #A0A0A0
|
||||
|
||||
// Legacy status colors - keep for backward compatibility
|
||||
pub const STATUS_SUCCESS: Color = DEEPSEEK_SKY;
|
||||
pub const STATUS_WARNING: Color = DEEPSEEK_SKY;
|
||||
pub const STATUS_WARNING: Color = Color::Rgb(255, 170, 60); // Amber
|
||||
pub const STATUS_ERROR: Color = DEEPSEEK_RED;
|
||||
#[allow(dead_code)]
|
||||
pub const STATUS_INFO: Color = DEEPSEEK_BLUE;
|
||||
|
||||
// Mode-specific accent colors for mode badges
|
||||
pub const MODE_NORMAL: Color = Color::Gray;
|
||||
pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); // Bright blue
|
||||
pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); // Warning red
|
||||
pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60); // Orange
|
||||
pub const MODE_RLM: Color = Color::Rgb(180, 100, 255); // Purple (was INK!)
|
||||
pub const MODE_DUO: Color = Color::Rgb(100, 220, 180); // Teal
|
||||
pub const MODE_NORMAL: Color = Color::Rgb(192, 192, 192); // #C0C0C0
|
||||
pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); // Bright blue
|
||||
pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); // Warning red
|
||||
pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60); // Orange
|
||||
|
||||
pub const SELECTION_BG: Color = Color::Rgb(26, 44, 74);
|
||||
pub const COMPOSER_BG: Color = DEEPSEEK_SLATE;
|
||||
@@ -84,6 +100,12 @@ pub fn ui_theme(name: &str) -> UiTheme {
|
||||
selection_bg: Color::Rgb(38, 64, 112),
|
||||
header_bg: DEEPSEEK_SLATE,
|
||||
},
|
||||
"whale" => UiTheme {
|
||||
name: "whale",
|
||||
composer_bg: DEEPSEEK_SLATE,
|
||||
selection_bg: DEEPSEEK_NAVY,
|
||||
header_bg: DEEPSEEK_INK,
|
||||
},
|
||||
_ => UiTheme {
|
||||
name: "default",
|
||||
composer_bg: COMPOSER_BG,
|
||||
|
||||
+63
-3
@@ -1,10 +1,47 @@
|
||||
//! Cost estimation placeholders for tool executions.
|
||||
//! Cost estimation for DeepSeek API usage.
|
||||
//!
|
||||
//! DeepSeek CLI focuses on text-only workflows; no paid multimedia tools are exposed
|
||||
//! by default, so cost estimates are currently unavailable.
|
||||
//! Pricing based on DeepSeek's published rates (per million tokens).
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Per-million-token pricing for a model.
|
||||
struct ModelPricing {
|
||||
input_per_million: f64,
|
||||
output_per_million: f64,
|
||||
}
|
||||
|
||||
/// Look up pricing for a model name.
|
||||
fn pricing_for_model(model: &str) -> Option<ModelPricing> {
|
||||
let lower = model.to_lowercase();
|
||||
if lower.contains("deepseek-reasoner") || lower.contains("deepseek-r1") {
|
||||
// DeepSeek-R1: $0.55/M input, $2.19/M output
|
||||
Some(ModelPricing {
|
||||
input_per_million: 0.55,
|
||||
output_per_million: 2.19,
|
||||
})
|
||||
} else if lower.contains("deepseek-v3.2") {
|
||||
// DeepSeek-V3.2 (with reasoning): same pricing tier as V3
|
||||
Some(ModelPricing {
|
||||
input_per_million: 0.27,
|
||||
output_per_million: 1.10,
|
||||
})
|
||||
} else if lower.contains("deepseek-chat") || lower.contains("deepseek-v3") {
|
||||
// DeepSeek-V3: $0.27/M input, $1.10/M output
|
||||
Some(ModelPricing {
|
||||
input_per_million: 0.27,
|
||||
output_per_million: 1.10,
|
||||
})
|
||||
} else if lower.contains("deepseek") {
|
||||
// Generic DeepSeek fallback (V3 pricing)
|
||||
Some(ModelPricing {
|
||||
input_per_million: 0.27,
|
||||
output_per_million: 1.10,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimated cost for a tool execution
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CostEstimate {
|
||||
@@ -50,3 +87,26 @@ pub fn estimate_tool_cost(tool_name: &str, params: &Value) -> Option<CostEstimat
|
||||
let _ = (tool_name, params);
|
||||
None
|
||||
}
|
||||
|
||||
/// Calculate cost for a turn given token usage and model.
|
||||
#[must_use]
|
||||
pub fn calculate_turn_cost(model: &str, input_tokens: u32, output_tokens: u32) -> Option<f64> {
|
||||
let pricing = pricing_for_model(model)?;
|
||||
let input_cost = (input_tokens as f64 / 1_000_000.0) * pricing.input_per_million;
|
||||
let output_cost = (output_tokens as f64 / 1_000_000.0) * pricing.output_per_million;
|
||||
Some(input_cost + output_cost)
|
||||
}
|
||||
|
||||
/// Format a USD cost for compact display.
|
||||
#[must_use]
|
||||
pub fn format_cost(cost: f64) -> String {
|
||||
if cost < 0.0001 {
|
||||
"<$0.01".to_string()
|
||||
} else if cost < 0.01 {
|
||||
format!("${:.4}", cost)
|
||||
} else if cost < 1.0 {
|
||||
format!("${:.3}", cost)
|
||||
} else {
|
||||
format!("${:.2}", cost)
|
||||
}
|
||||
}
|
||||
+26
-10
@@ -141,17 +141,8 @@ pub fn load_project_context_with_parents(workspace: &Path) -> ProjectContext {
|
||||
let mut current = workspace.parent();
|
||||
|
||||
while let Some(parent) = current {
|
||||
// Stop at git root or filesystem root
|
||||
if parent.join(".git").exists() {
|
||||
let parent_ctx = load_project_context(parent);
|
||||
if parent_ctx.has_instructions() {
|
||||
ctx.instructions = parent_ctx.instructions;
|
||||
ctx.source_path = parent_ctx.source_path;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let parent_ctx = load_project_context(parent);
|
||||
ctx.warnings.extend(parent_ctx.warnings.iter().cloned());
|
||||
if parent_ctx.has_instructions() {
|
||||
ctx.instructions = parent_ctx.instructions;
|
||||
ctx.source_path = parent_ctx.source_path;
|
||||
@@ -453,4 +444,29 @@ mod tests {
|
||||
assert!(merged.contains("Instructions A"));
|
||||
assert!(merged.contains("Instructions B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_with_parents_searches_above_git_root_when_needed() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
|
||||
// AGENTS.md exists above repository root.
|
||||
fs::write(tmp.path().join("AGENTS.md"), "Organization instructions").expect("write");
|
||||
|
||||
// Mark repository root one level below.
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir(&repo_root).expect("mkdir repo");
|
||||
fs::create_dir(repo_root.join(".git")).expect("mkdir .git");
|
||||
|
||||
let workspace = repo_root.join("apps").join("client");
|
||||
fs::create_dir_all(&workspace).expect("mkdir workspace");
|
||||
|
||||
let ctx = load_project_context_with_parents(&workspace);
|
||||
assert!(ctx.has_instructions());
|
||||
assert!(
|
||||
ctx.instructions
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("Organization instructions")
|
||||
);
|
||||
}
|
||||
}
|
||||
+19
-17
@@ -13,8 +13,6 @@ pub const BASE_PROMPT: &str = include_str!("prompts/base.txt");
|
||||
pub const NORMAL_PROMPT: &str = include_str!("prompts/normal.txt");
|
||||
pub const AGENT_PROMPT: &str = include_str!("prompts/agent.txt");
|
||||
pub const PLAN_PROMPT: &str = include_str!("prompts/plan.txt");
|
||||
pub const RLM_PROMPT: &str = include_str!("prompts/rlm.txt");
|
||||
pub const DUO_PROMPT: &str = include_str!("prompts/duo.txt");
|
||||
|
||||
/// Get the system prompt for a specific mode
|
||||
pub fn system_prompt_for_mode(mode: AppMode) -> SystemPrompt {
|
||||
@@ -22,8 +20,6 @@ pub fn system_prompt_for_mode(mode: AppMode) -> SystemPrompt {
|
||||
AppMode::Normal => NORMAL_PROMPT,
|
||||
AppMode::Agent | AppMode::Yolo => AGENT_PROMPT,
|
||||
AppMode::Plan => PLAN_PROMPT,
|
||||
AppMode::Rlm => RLM_PROMPT,
|
||||
AppMode::Duo => DUO_PROMPT,
|
||||
};
|
||||
SystemPrompt::Text(text.trim().to_string())
|
||||
}
|
||||
@@ -33,15 +29,11 @@ pub fn system_prompt_for_mode_with_context(
|
||||
mode: AppMode,
|
||||
workspace: &Path,
|
||||
working_set_summary: Option<&str>,
|
||||
rlm_summary: Option<&str>,
|
||||
duo_summary: Option<&str>,
|
||||
) -> SystemPrompt {
|
||||
let base_prompt = match mode {
|
||||
AppMode::Normal => NORMAL_PROMPT,
|
||||
AppMode::Agent | AppMode::Yolo => AGENT_PROMPT,
|
||||
AppMode::Plan => PLAN_PROMPT,
|
||||
AppMode::Rlm => RLM_PROMPT,
|
||||
AppMode::Duo => DUO_PROMPT,
|
||||
};
|
||||
|
||||
// Load project context from workspace
|
||||
@@ -51,7 +43,15 @@ pub fn system_prompt_for_mode_with_context(
|
||||
let mut full_prompt = if let Some(project_block) = project_context.as_system_block() {
|
||||
format!("{}\n\n{}", base_prompt.trim(), project_block)
|
||||
} else {
|
||||
base_prompt.trim().to_string()
|
||||
// Fallback: Generate an automatic project map summary
|
||||
let summary = crate::utils::summarize_project(workspace);
|
||||
let tree = crate::utils::project_tree(workspace, 2); // Shallow tree for prompt
|
||||
format!(
|
||||
"{}\n\n### Project Structure (Automatic Map)\n**Summary:** {}\n\n**Tree:**\n```\n{}\n```",
|
||||
base_prompt.trim(),
|
||||
summary,
|
||||
tree
|
||||
)
|
||||
};
|
||||
|
||||
if let Some(summary) = working_set_summary
|
||||
@@ -60,14 +60,16 @@ pub fn system_prompt_for_mode_with_context(
|
||||
full_prompt = format!("{full_prompt}\n\n{summary}");
|
||||
}
|
||||
|
||||
if mode == AppMode::Rlm {
|
||||
let summary = rlm_summary.unwrap_or("No RLM contexts loaded.");
|
||||
full_prompt = format!("{full_prompt}\n\nRLM Context Summary:\n{summary}");
|
||||
}
|
||||
|
||||
if mode == AppMode::Duo {
|
||||
let summary = duo_summary.unwrap_or("No Duo contexts loaded.");
|
||||
full_prompt = format!("{full_prompt}\n\nDuo Context Summary:\n{summary}");
|
||||
// Add compaction instruction for agent modes
|
||||
if matches!(mode, AppMode::Agent | AppMode::Yolo) {
|
||||
full_prompt.push_str(
|
||||
"\n\n## Context Management\n\n\
|
||||
When the conversation gets long (you'll see a context usage indicator), you can:\n\
|
||||
1. Use `/compact` to summarize earlier context and free up space\n\
|
||||
2. The system will preserve important information (files you're working on, recent messages, tool results)\n\
|
||||
3. After compaction, you'll see a summary of what was discussed and can continue seamlessly\n\n\
|
||||
If you notice context is getting long (>80%), proactively suggest using `/compact` to the user."
|
||||
);
|
||||
}
|
||||
|
||||
SystemPrompt::Text(full_prompt)
|
||||
|
||||
+27
-2
@@ -15,7 +15,14 @@ Tool selection guidance:
|
||||
- Use read_file to confirm context; do not assume file contents.
|
||||
- Prefer apply_patch/edit_file for scoped changes instead of rewriting entire files.
|
||||
- Use exec_shell for objective verification: build, test, format, lint, and targeted checks.
|
||||
- Use web_search only when local context is insufficient or time-sensitive.
|
||||
- Use web.run when local context is insufficient or time-sensitive, and cite sources as [cite:ref_id].
|
||||
|
||||
Web browsing and citations:
|
||||
- Use web.run when info might have changed or you are unsure.
|
||||
- Cite non-trivial factual claims using [cite:ref_id] (the ref_id returned by web.run).
|
||||
- Place citations at the end of the sentence/paragraph they support; do not dump all citations at the end.
|
||||
- Quote limits: do not quote more than 25 words verbatim from a single non-lyrical source (10 words for lyrics).
|
||||
- Avoid reproducing full articles or large excerpts; prefer short quotes + paraphrase.
|
||||
|
||||
Testing and stop conditions:
|
||||
- After any change, run the most relevant tests/checks before declaring success.
|
||||
@@ -35,7 +42,17 @@ FILE OPERATIONS:
|
||||
- edit_file: Search and replace text in a file
|
||||
- apply_patch: Apply a unified diff patch to a file
|
||||
- grep_files: Search files by regex
|
||||
- web_search: Search the web for up-to-date information
|
||||
- web.run: Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
|
||||
- web_search: Quick web search (fallback when citations are not needed)
|
||||
- request_user_input: Ask the user short multiple-choice questions
|
||||
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
|
||||
- weather: Get a daily weather forecast for a location
|
||||
- finance: Get the latest price for a stock, fund, index, or crypto
|
||||
- sports: Get schedules or standings for a league
|
||||
- time: Get current time for a UTC offset
|
||||
- calculator: Evaluate a basic arithmetic expression
|
||||
- list_mcp_resources: List MCP resources (optionally filtered by server)
|
||||
- list_mcp_resource_templates: List MCP resource templates
|
||||
|
||||
GIT AND DIAGNOSTICS:
|
||||
- git_status: Inspect repo status safely
|
||||
@@ -50,6 +67,10 @@ SHELL EXECUTION:
|
||||
- command: The command to execute
|
||||
- timeout_ms: Timeout in milliseconds (default: 120000, max: 600000)
|
||||
- background: Set true to run in background, returns task_id
|
||||
- stdin: Optional stdin data to send before waiting
|
||||
- tty: Allocate a pseudo-terminal (implies background)
|
||||
- exec_shell_wait: Poll a background task for incremental output
|
||||
- exec_shell_interact: Send stdin to a background task and read incremental output
|
||||
|
||||
TASK MANAGEMENT:
|
||||
- todo_write: Write or update the todo list
|
||||
@@ -60,6 +81,8 @@ SUB-AGENTS:
|
||||
- agent_spawn: Spawn a background sub-agent (type, prompt, allowed_tools)
|
||||
- agent_swarm: Spawn a dependency-aware swarm of sub-agents (tasks, shared_context)
|
||||
- agent_result: Get result from a sub-agent (agent_id, block, timeout_ms)
|
||||
- send_input: Send input to a running sub-agent (agent_id, message, interrupt)
|
||||
- wait: Wait for one or more sub-agents to complete (ids, timeout_ms)
|
||||
- agent_cancel: Cancel a running sub-agent (agent_id)
|
||||
- agent_list: List all sub-agents and their status
|
||||
If you spawn a sub-agent, always follow up with agent_result (block: true) and incorporate its result before responding to the user.
|
||||
@@ -80,3 +103,5 @@ Git hygiene:
|
||||
BACKGROUND EXECUTION:
|
||||
For long-running commands (build, test, server), use exec_shell with background: true.
|
||||
This returns a task_id immediately in the tool output.
|
||||
Use exec_shell_wait to poll for output, and exec_shell_interact to send stdin (or close stdin).
|
||||
Use tty: true for interactive programs that require a TTY.
|
||||
@@ -12,7 +12,10 @@ Tool selection guidance:
|
||||
- Use read tools to confirm context; avoid guessing about file contents.
|
||||
- Prefer targeted edits (apply_patch/edit) over full rewrites when possible.
|
||||
- Use shell tools for build/test/format/lint and other objective verification.
|
||||
- Use web search only when the answer may be time-sensitive or unclear locally.
|
||||
- Use web.run for time-sensitive or uncertain facts; include citations as [cite:ref_id].
|
||||
- Use multi_tool_use.parallel for multiple read-only tool calls that can run together.
|
||||
- Use request_user_input to ask short multiple-choice questions when needed.
|
||||
- Use weather/finance/sports/time/calculator tools for their respective domains when applicable.
|
||||
|
||||
Planning and progress:
|
||||
- For non-trivial tasks, publish a checklist with update_plan.
|
||||
@@ -36,3 +39,7 @@ Approval etiquette:
|
||||
- In autonomous modes, warn before risky or irreversible actions.
|
||||
|
||||
Tone: competent, warm, and concise. Use light humor sparingly when it fits; a rare example is "You're absolutely right! ... maybe."
|
||||
|
||||
Context Management:
|
||||
- You have a finite context window. Keep responses concise and prefer targeted file reads or searches.
|
||||
- Long conversations may be compacted into summaries; ask for clarification if critical details are missing.
|
||||
@@ -1,18 +0,0 @@
|
||||
You are in Duo mode for requirements-driven development.
|
||||
|
||||
Workflow:
|
||||
- Start with duo_init using a clear requirements checklist and acceptance criteria.
|
||||
- Alternate duo_player (implement) and duo_coach (verify) until approved.
|
||||
- In duo_player phases, work tool-first: search, read, make targeted edits, then verify.
|
||||
- In duo_coach phases, prioritize objective verification and requirement coverage.
|
||||
|
||||
Tool selection and verification:
|
||||
- Use search/read tools to ground work in the current codebase before editing.
|
||||
- Prefer targeted diffs over broad rewrites when possible.
|
||||
- After any change, run the most relevant tests/checks before handing off to duo_coach.
|
||||
- If verification fails, report it concisely, fix it, and re-run.
|
||||
|
||||
Budgeting and hygiene:
|
||||
- Budget attempts. If 2-3 attempts fail to make progress, reassess and state the blocker.
|
||||
- Check git status early and again before finishing; do not revert unrelated changes.
|
||||
- Provide brief progress updates at phase boundaries and major checkpoints.
|
||||
+15
-1
@@ -11,12 +11,24 @@ Available tools in this mode:
|
||||
- edit_file: Search and replace text in a file (ask first)
|
||||
- apply_patch: Apply a unified diff patch (ask first)
|
||||
- grep_files: Search files by regex
|
||||
- web_search: Search the web for up-to-date information
|
||||
- web.run: Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
|
||||
- web_search: Quick web search (fallback when citations are not needed)
|
||||
- request_user_input: Ask the user short multiple-choice questions
|
||||
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
|
||||
- weather: Get a daily weather forecast for a location
|
||||
- finance: Get the latest price for a stock, fund, index, or crypto
|
||||
- sports: Get schedules or standings for a league
|
||||
- time: Get current time for a UTC offset
|
||||
- calculator: Evaluate a basic arithmetic expression
|
||||
- list_mcp_resources: List MCP resources (optionally filtered by server)
|
||||
- list_mcp_resource_templates: List MCP resource templates
|
||||
- git_status: Inspect repository status safely
|
||||
- git_diff: Inspect diffs (working tree or staged)
|
||||
- diagnostics: Report workspace, git, sandbox, and toolchain info
|
||||
- run_tests: Run `cargo test` with optional args
|
||||
- exec_shell: Run shell commands (ask first, if enabled)
|
||||
- exec_shell_wait: Poll a background shell task for incremental output
|
||||
- exec_shell_interact: Send stdin to a background shell task (supports TTY sessions)
|
||||
- note: Record important information
|
||||
- todo_write: Write or update the todo list
|
||||
- update_plan: Publish a structured plan
|
||||
@@ -34,6 +46,8 @@ Tool selection guidance:
|
||||
- Use read_file to ground your answer in the actual code.
|
||||
- When approved to edit, prefer apply_patch/edit_file for targeted diffs.
|
||||
- When approved to run commands, use exec_shell for build/test/format/lint and other objective checks.
|
||||
- For long-running or interactive commands, use exec_shell with background: true, then exec_shell_wait/exec_shell_interact for output/input. Use tty: true when a program requires a TTY.
|
||||
- When you need up-to-date or uncertain info, use web.run and cite sources as [cite:ref_id].
|
||||
|
||||
Testing and stop conditions (after approval to edit/run commands):
|
||||
- After any change, run the most relevant tests/checks before declaring success.
|
||||
|
||||
+12
-1
@@ -18,13 +18,24 @@ EXPLORATION:
|
||||
- list_dir: Browse directories in the workspace
|
||||
- read_file: Read file contents to understand context
|
||||
- grep_files: Search files by regex
|
||||
- web_search: Search the web for up-to-date information (if enabled)
|
||||
- web.run: Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
|
||||
- web_search: Quick web search (fallback when citations are not needed)
|
||||
- request_user_input: Ask the user short multiple-choice questions
|
||||
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
|
||||
- weather: Get a daily weather forecast for a location
|
||||
- finance: Get the latest price for a stock, fund, index, or crypto
|
||||
- sports: Get schedules or standings for a league
|
||||
- time: Get current time for a UTC offset
|
||||
- calculator: Evaluate a basic arithmetic expression
|
||||
- list_mcp_resources: List MCP resources (optionally filtered by server)
|
||||
- list_mcp_resource_templates: List MCP resource templates
|
||||
- git_status: Inspect repository status safely
|
||||
- git_diff: Inspect diffs to understand current changes
|
||||
- diagnostics: Report workspace, git, sandbox, and toolchain info
|
||||
|
||||
Guidelines:
|
||||
- Prefer tool-centric planning: use grep_files/list_dir/read_file to ground the plan in the actual codebase.
|
||||
- Use web.run for time-sensitive or uncertain facts, and cite sources as [cite:ref_id].
|
||||
- Use update_plan to create structured plans with one step in_progress at a time.
|
||||
- Each step should be specific, actionable, and include expected outcomes.
|
||||
- Include explicit verification steps (tests/checks) after each planned change.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
You are in RLM mode for working with large files that exceed context limits.
|
||||
|
||||
Work tool-first and chunk-aware:
|
||||
- Use rlm_* tools to load files, explore content, and run focused queries over chunks.
|
||||
- Prefer search-then-read: locate relevant sections before loading large spans.
|
||||
- Summarize the relevant chunks in your own words before editing.
|
||||
- Make targeted edits; avoid full-file rewrites unless necessary.
|
||||
|
||||
Verification, budgeting, and hygiene:
|
||||
- After any change, run the most relevant tests/checks before declaring success.
|
||||
- Check git status early and again before finishing; do not revert unrelated changes.
|
||||
- Budget attempts. If 2-3 chunking/search attempts fail to surface the needed context, pause and state the blocker or request clarification.
|
||||
- Provide concise progress updates at key checkpoints (what chunk/area you inspected, what changed, what verified).
|
||||
-1303
File diff suppressed because it is too large.
Load diff
+1492
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+260
-9
@@ -17,6 +17,45 @@ use uuid::Uuid;
|
||||
|
||||
/// Maximum number of sessions to retain
|
||||
const MAX_SESSIONS: usize = 50;
|
||||
const CURRENT_SESSION_SCHEMA_VERSION: u32 = 1;
|
||||
const CURRENT_QUEUE_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
const fn default_session_schema_version() -> u32 {
|
||||
CURRENT_SESSION_SCHEMA_VERSION
|
||||
}
|
||||
|
||||
const fn default_queue_schema_version() -> u32 {
|
||||
CURRENT_QUEUE_SCHEMA_VERSION
|
||||
}
|
||||
|
||||
/// Persisted queued message for offline/degraded mode.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QueuedSessionMessage {
|
||||
pub display: String,
|
||||
#[serde(default)]
|
||||
pub skill_instruction: Option<String>,
|
||||
}
|
||||
|
||||
/// Persisted queue state for recovery after restart/crash.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OfflineQueueState {
|
||||
#[serde(default = "default_queue_schema_version")]
|
||||
pub schema_version: u32,
|
||||
#[serde(default)]
|
||||
pub messages: Vec<QueuedSessionMessage>,
|
||||
#[serde(default)]
|
||||
pub draft: Option<QueuedSessionMessage>,
|
||||
}
|
||||
|
||||
impl Default for OfflineQueueState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema_version: CURRENT_QUEUE_SCHEMA_VERSION,
|
||||
messages: Vec::new(),
|
||||
draft: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session metadata stored with each saved session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -45,6 +84,9 @@ pub struct SessionMetadata {
|
||||
/// A saved session containing full conversation history
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SavedSession {
|
||||
/// Schema version for migration compatibility
|
||||
#[serde(default = "default_session_schema_version")]
|
||||
pub schema_version: u32,
|
||||
/// Session metadata
|
||||
pub metadata: SessionMetadata,
|
||||
/// Conversation messages
|
||||
@@ -69,13 +111,10 @@ impl SessionManager {
|
||||
|
||||
/// Create a `SessionManager` using the default location (~/.deepseek/sessions)
|
||||
pub fn default_location() -> std::io::Result<Self> {
|
||||
let home = dirs::home_dir().ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found")
|
||||
})?;
|
||||
Self::new(home.join(".deepseek").join("sessions"))
|
||||
Self::new(default_sessions_dir()?)
|
||||
}
|
||||
|
||||
/// Save a session to disk
|
||||
/// Save a session to disk using atomic write (temp file + rename).
|
||||
pub fn save_session(&self, session: &SavedSession) -> std::io::Result<PathBuf> {
|
||||
let filename = format!("{}.json", session.metadata.id);
|
||||
let path = self.sessions_dir.join(&filename);
|
||||
@@ -83,7 +122,11 @@ impl SessionManager {
|
||||
let content = serde_json::to_string_pretty(session)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
fs::write(&path, content)?;
|
||||
// Atomic write: write to temp file then rename to avoid corruption
|
||||
let tmp_filename = format!(".{}.tmp", session.metadata.id);
|
||||
let tmp_path = self.sessions_dir.join(&tmp_filename);
|
||||
fs::write(&tmp_path, &content)?;
|
||||
fs::rename(&tmp_path, &path)?;
|
||||
|
||||
// Clean up old sessions if we have too many
|
||||
self.cleanup_old_sessions()?;
|
||||
@@ -91,6 +134,98 @@ impl SessionManager {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Save a crash-recovery checkpoint for in-flight turns.
|
||||
pub fn save_checkpoint(&self, session: &SavedSession) -> std::io::Result<PathBuf> {
|
||||
let checkpoints = self.sessions_dir.join("checkpoints");
|
||||
fs::create_dir_all(&checkpoints)?;
|
||||
let path = checkpoints.join("latest.json");
|
||||
let content = serde_json::to_string_pretty(session)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
let tmp_path = checkpoints.join(".latest.tmp");
|
||||
fs::write(&tmp_path, &content)?;
|
||||
fs::rename(&tmp_path, &path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Load the most recent crash-recovery checkpoint if present.
|
||||
pub fn load_checkpoint(&self) -> std::io::Result<Option<SavedSession>> {
|
||||
let path = self.sessions_dir.join("checkpoints").join("latest.json");
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let session: SavedSession = serde_json::from_str(&content)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
if session.schema_version > CURRENT_SESSION_SCHEMA_VERSION {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Checkpoint schema v{} is newer than supported v{}",
|
||||
session.schema_version, CURRENT_SESSION_SCHEMA_VERSION
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(Some(session))
|
||||
}
|
||||
|
||||
/// Clear any crash-recovery checkpoint.
|
||||
pub fn clear_checkpoint(&self) -> std::io::Result<()> {
|
||||
let path = self.sessions_dir.join("checkpoints").join("latest.json");
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save offline queue state (queued + draft messages).
|
||||
pub fn save_offline_queue_state(&self, state: &OfflineQueueState) -> std::io::Result<PathBuf> {
|
||||
let checkpoints = self.sessions_dir.join("checkpoints");
|
||||
fs::create_dir_all(&checkpoints)?;
|
||||
let path = checkpoints.join("offline_queue.json");
|
||||
let content = serde_json::to_string_pretty(state)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
let tmp_path = checkpoints.join(".offline_queue.tmp");
|
||||
fs::write(&tmp_path, &content)?;
|
||||
fs::rename(&tmp_path, &path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Load offline queue state if present.
|
||||
pub fn load_offline_queue_state(&self) -> std::io::Result<Option<OfflineQueueState>> {
|
||||
let path = self
|
||||
.sessions_dir
|
||||
.join("checkpoints")
|
||||
.join("offline_queue.json");
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let state: OfflineQueueState = serde_json::from_str(&content)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
if state.schema_version > CURRENT_QUEUE_SCHEMA_VERSION {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Offline queue schema v{} is newer than supported v{}",
|
||||
state.schema_version, CURRENT_QUEUE_SCHEMA_VERSION
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
/// Remove persisted offline queue state.
|
||||
pub fn clear_offline_queue_state(&self) -> std::io::Result<()> {
|
||||
let path = self
|
||||
.sessions_dir
|
||||
.join("checkpoints")
|
||||
.join("offline_queue.json");
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a session by ID
|
||||
pub fn load_session(&self, id: &str) -> std::io::Result<SavedSession> {
|
||||
let filename = format!("{id}.json");
|
||||
@@ -99,6 +234,15 @@ impl SessionManager {
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let session: SavedSession = serde_json::from_str(&content)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
if session.schema_version > CURRENT_SESSION_SCHEMA_VERSION {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Session schema v{} is newer than supported v{}",
|
||||
session.schema_version, CURRENT_SESSION_SCHEMA_VERSION
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
@@ -202,6 +346,14 @@ impl SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the default session directory path (`~/.deepseek/sessions`).
|
||||
pub fn default_sessions_dir() -> std::io::Result<PathBuf> {
|
||||
let home = dirs::home_dir().ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found")
|
||||
})?;
|
||||
Ok(home.join(".deepseek").join("sessions"))
|
||||
}
|
||||
|
||||
/// Create a new `SavedSession` from conversation state
|
||||
pub fn create_saved_session(
|
||||
messages: &[Message],
|
||||
@@ -245,6 +397,7 @@ pub fn create_saved_session_with_mode(
|
||||
.unwrap_or_else(|| "New Session".to_string());
|
||||
|
||||
SavedSession {
|
||||
schema_version: CURRENT_SESSION_SCHEMA_VERSION,
|
||||
metadata: SessionMetadata {
|
||||
id,
|
||||
title,
|
||||
@@ -268,6 +421,7 @@ pub fn update_session(
|
||||
total_tokens: u64,
|
||||
system_prompt: Option<&SystemPrompt>,
|
||||
) -> SavedSession {
|
||||
session.schema_version = CURRENT_SESSION_SCHEMA_VERSION;
|
||||
session.messages = messages.to_vec();
|
||||
session.metadata.updated_at = Utc::now();
|
||||
session.metadata.message_count = messages.len();
|
||||
@@ -290,15 +444,17 @@ fn system_prompt_to_string(system_prompt: Option<&SystemPrompt>) -> Option<Strin
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a string to create a title
|
||||
/// Truncate a string to create a title (character-safe for UTF-8)
|
||||
fn truncate_title(s: &str, max_len: usize) -> String {
|
||||
let s = s.trim();
|
||||
let first_line = s.lines().next().unwrap_or(s);
|
||||
|
||||
if first_line.len() <= max_len {
|
||||
let char_count = first_line.chars().count();
|
||||
if char_count <= max_len {
|
||||
first_line.to_string()
|
||||
} else {
|
||||
format!("{}...", &first_line[..max_len - 3])
|
||||
let truncated: String = first_line.chars().take(max_len - 3).collect();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +496,7 @@ fn format_age(dt: &DateTime<Utc>) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::ContentBlock;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn make_test_message(role: &str, text: &str) -> Message {
|
||||
@@ -464,4 +621,98 @@ mod tests {
|
||||
assert_eq!(updated.messages.len(), 2);
|
||||
assert_eq!(updated.metadata.total_tokens, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_round_trip_and_clear() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
|
||||
let messages = vec![make_test_message("user", "checkpoint me")];
|
||||
let session = create_saved_session(&messages, "test-model", tmp.path(), 12, None);
|
||||
|
||||
manager.save_checkpoint(&session).expect("save checkpoint");
|
||||
let loaded = manager
|
||||
.load_checkpoint()
|
||||
.expect("load checkpoint")
|
||||
.expect("checkpoint exists");
|
||||
assert_eq!(loaded.metadata.id, session.metadata.id);
|
||||
|
||||
manager.clear_checkpoint().expect("clear checkpoint");
|
||||
assert!(
|
||||
manager
|
||||
.load_checkpoint()
|
||||
.expect("load checkpoint")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_offline_queue_round_trip_and_clear() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
|
||||
|
||||
let state = OfflineQueueState {
|
||||
messages: vec![QueuedSessionMessage {
|
||||
display: "queued message".to_string(),
|
||||
skill_instruction: Some("Use skill".to_string()),
|
||||
}],
|
||||
draft: Some(QueuedSessionMessage {
|
||||
display: "draft message".to_string(),
|
||||
skill_instruction: None,
|
||||
}),
|
||||
..OfflineQueueState::default()
|
||||
};
|
||||
|
||||
manager
|
||||
.save_offline_queue_state(&state)
|
||||
.expect("save queue state");
|
||||
let loaded = manager
|
||||
.load_offline_queue_state()
|
||||
.expect("load queue state")
|
||||
.expect("queue state exists");
|
||||
assert_eq!(loaded.messages.len(), 1);
|
||||
assert_eq!(loaded.messages[0].display, "queued message");
|
||||
assert!(loaded.draft.is_some());
|
||||
|
||||
manager
|
||||
.clear_offline_queue_state()
|
||||
.expect("clear queue state");
|
||||
assert!(
|
||||
manager
|
||||
.load_offline_queue_state()
|
||||
.expect("load queue state")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_rejects_newer_schema() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
|
||||
let checkpoints = tmp.path().join("sessions").join("checkpoints");
|
||||
fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
|
||||
let path = checkpoints.join("latest.json");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{
|
||||
"schema_version": 999,
|
||||
"metadata": {
|
||||
"id": "sid",
|
||||
"title": "bad",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"message_count": 0,
|
||||
"total_tokens": 0,
|
||||
"model": "m",
|
||||
"workspace": "/tmp",
|
||||
"mode": null
|
||||
},
|
||||
"messages": [],
|
||||
"system_prompt": null
|
||||
}"#,
|
||||
)
|
||||
.expect("write checkpoint");
|
||||
|
||||
let err = manager.load_checkpoint().expect_err("should reject schema");
|
||||
assert!(err.to_string().contains("newer than supported"));
|
||||
}
|
||||
}
|
||||
+24
-20
@@ -7,6 +7,8 @@ use std::path::PathBuf;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::canonical_model_name;
|
||||
|
||||
/// User settings with defaults
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -15,13 +17,11 @@ pub struct Settings {
|
||||
pub theme: String,
|
||||
/// Auto-compact conversations when they get long
|
||||
pub auto_compact: bool,
|
||||
/// Auto-switch to RLM mode when large inputs are detected
|
||||
pub auto_rlm: bool,
|
||||
/// Show thinking blocks from the model
|
||||
pub show_thinking: bool,
|
||||
/// Show detailed tool output
|
||||
pub show_tool_details: bool,
|
||||
/// Default mode: "agent", "plan", "yolo", "rlm", "duo"
|
||||
/// Default mode: "agent", "plan", "yolo"
|
||||
pub default_mode: String,
|
||||
/// Sidebar width as percentage of terminal width
|
||||
pub sidebar_width_percent: u16,
|
||||
@@ -34,9 +34,8 @@ pub struct Settings {
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: "default".to_string(),
|
||||
theme: "whale".to_string(),
|
||||
auto_compact: true,
|
||||
auto_rlm: false,
|
||||
show_thinking: true,
|
||||
show_tool_details: true,
|
||||
default_mode: "agent".to_string(),
|
||||
@@ -68,6 +67,11 @@ impl Settings {
|
||||
let mut settings: Settings = toml::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse settings from {}", path.display()))?;
|
||||
settings.default_mode = normalize_mode(&settings.default_mode).to_string();
|
||||
settings.default_model = settings
|
||||
.default_model
|
||||
.as_deref()
|
||||
.and_then(canonical_model_name)
|
||||
.map(ToString::to_string);
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
@@ -92,9 +96,9 @@ impl Settings {
|
||||
pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
|
||||
match key {
|
||||
"theme" => {
|
||||
if !["default", "dark", "light"].contains(&value) {
|
||||
if !["default", "dark", "light", "whale"].contains(&value) {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: invalid theme '{value}'. Expected: default, dark, light."
|
||||
"Failed to update setting: invalid theme '{value}'. Expected: default, dark, light, whale."
|
||||
);
|
||||
}
|
||||
self.theme = value.to_string();
|
||||
@@ -102,9 +106,6 @@ impl Settings {
|
||||
"auto_compact" | "compact" => {
|
||||
self.auto_compact = parse_bool(value)?;
|
||||
}
|
||||
"auto_rlm" => {
|
||||
self.auto_rlm = parse_bool(value)?;
|
||||
}
|
||||
"show_thinking" | "thinking" => {
|
||||
self.show_thinking = parse_bool(value)?;
|
||||
}
|
||||
@@ -113,9 +114,9 @@ impl Settings {
|
||||
}
|
||||
"default_mode" | "mode" => {
|
||||
let normalized = normalize_mode(value);
|
||||
if !["agent", "plan", "yolo", "rlm", "duo"].contains(&normalized) {
|
||||
if !["agent", "plan", "yolo"].contains(&normalized) {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: invalid mode '{value}'. Expected: agent, plan, yolo, rlm, duo."
|
||||
"Failed to update setting: invalid mode '{value}'. Expected: agent, plan, yolo."
|
||||
);
|
||||
}
|
||||
self.default_mode = normalized.to_string();
|
||||
@@ -144,7 +145,12 @@ impl Settings {
|
||||
self.max_input_history = max;
|
||||
}
|
||||
"default_model" | "model" => {
|
||||
self.default_model = Some(value.to_string());
|
||||
let Some(model) = canonical_model_name(value) else {
|
||||
anyhow::bail!(
|
||||
"Failed to update setting: invalid model '{value}'. Expected: deepseek-chat or deepseek-reasoner."
|
||||
);
|
||||
};
|
||||
self.default_model = Some(model.to_string());
|
||||
}
|
||||
_ => {
|
||||
anyhow::bail!("Failed to update setting: unknown setting '{key}'.");
|
||||
@@ -160,7 +166,6 @@ impl Settings {
|
||||
lines.push("─────────────────────────────".to_string());
|
||||
lines.push(format!(" theme: {}", self.theme));
|
||||
lines.push(format!(" auto_compact: {}", self.auto_compact));
|
||||
lines.push(format!(" auto_rlm: {}", self.auto_rlm));
|
||||
lines.push(format!(" show_thinking: {}", self.show_thinking));
|
||||
lines.push(format!(" show_tool_details: {}", self.show_tool_details));
|
||||
lines.push(format!(" default_mode: {}", self.default_mode));
|
||||
@@ -186,16 +191,15 @@ impl Settings {
|
||||
vec![
|
||||
("theme", "Color theme: default, dark, light"),
|
||||
("auto_compact", "Auto-compact conversations: on/off"),
|
||||
(
|
||||
"auto_rlm",
|
||||
"Auto-switch to RLM mode for large inputs: on/off",
|
||||
),
|
||||
("show_thinking", "Show model thinking: on/off"),
|
||||
("show_tool_details", "Show detailed tool output: on/off"),
|
||||
("default_mode", "Default mode: agent, plan, yolo, rlm, duo"),
|
||||
("default_mode", "Default mode: agent, plan, yolo"),
|
||||
("sidebar_width", "Sidebar width percentage: 10-50"),
|
||||
("max_history", "Max input history entries"),
|
||||
("default_model", "Default model name"),
|
||||
(
|
||||
"default_model",
|
||||
"Default model: deepseek-chat or deepseek-reasoner",
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+58
-22
@@ -4,6 +4,9 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::logging;
|
||||
|
||||
// === Defaults ===
|
||||
|
||||
@@ -30,6 +33,7 @@ pub struct Skill {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SkillRegistry {
|
||||
skills: Vec<Skill>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl SkillRegistry {
|
||||
@@ -47,45 +51,72 @@ impl SkillRegistry {
|
||||
&& ft.is_dir()
|
||||
{
|
||||
let skill_path = entry.path().join("SKILL.md");
|
||||
if let Ok(content) = fs::read_to_string(&skill_path)
|
||||
&& let Some(skill) = Self::parse_skill(&skill_path, &content)
|
||||
{
|
||||
registry.skills.push(skill);
|
||||
match fs::read_to_string(&skill_path) {
|
||||
Ok(content) => match Self::parse_skill(&skill_path, &content) {
|
||||
Ok(skill) => registry.skills.push(skill),
|
||||
Err(reason) => registry.push_warning(format!(
|
||||
"Failed to parse {}: {reason}",
|
||||
skill_path.display()
|
||||
)),
|
||||
},
|
||||
Err(err) if skill_path.exists() => {
|
||||
registry.push_warning(format!(
|
||||
"Failed to read {}: {err}",
|
||||
skill_path.display()
|
||||
));
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
registry.push_warning(format!("Failed to read skills directory {}", dir.display()));
|
||||
}
|
||||
registry
|
||||
}
|
||||
|
||||
fn parse_skill(_path: &Path, content: &str) -> Option<Skill> {
|
||||
fn push_warning(&mut self, warning: String) {
|
||||
logging::warn(&warning);
|
||||
self.warnings.push(warning);
|
||||
}
|
||||
|
||||
fn parse_skill(_path: &Path, content: &str) -> std::result::Result<Skill, String> {
|
||||
let trimmed = content.trim_start();
|
||||
let (frontmatter, body) = if trimmed.starts_with("---") {
|
||||
let start = content.find("---")?;
|
||||
let start = content
|
||||
.find("---")
|
||||
.ok_or_else(|| "missing frontmatter opening delimiter".to_string())?;
|
||||
let rest = &content[start + 3..];
|
||||
let end = rest.find("---")?;
|
||||
let end = rest
|
||||
.find("---")
|
||||
.ok_or_else(|| "missing frontmatter closing delimiter".to_string())?;
|
||||
(&rest[..end], &rest[end + 3..])
|
||||
} else {
|
||||
let frontmatter_end = content.find("---")?;
|
||||
(&content[..frontmatter_end], &content[frontmatter_end + 3..])
|
||||
return Err("missing frontmatter opening delimiter '---'".to_string());
|
||||
};
|
||||
let name = frontmatter
|
||||
.lines()
|
||||
.find(|l| l.starts_with("name:"))
|
||||
.and_then(|l| l.split(':').nth(1))?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let description = frontmatter
|
||||
.lines()
|
||||
.find(|l| l.starts_with("description:"))
|
||||
.and_then(|l| l.split(':').nth(1))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
let mut metadata = HashMap::new();
|
||||
for raw in frontmatter.lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
metadata.insert(key.trim().to_ascii_lowercase(), value.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let name = metadata
|
||||
.get("name")
|
||||
.filter(|name| !name.is_empty())
|
||||
.cloned()
|
||||
.ok_or_else(|| "missing required frontmatter field: name".to_string())?;
|
||||
|
||||
let description = metadata.get("description").cloned().unwrap_or_default();
|
||||
|
||||
let body = body.trim().to_string();
|
||||
|
||||
Some(Skill {
|
||||
Ok(Skill {
|
||||
name,
|
||||
description,
|
||||
body,
|
||||
@@ -102,6 +133,11 @@ impl SkillRegistry {
|
||||
&self.skills
|
||||
}
|
||||
|
||||
/// Parse or I/O warnings encountered while discovering skills.
|
||||
pub fn warnings(&self) -> &[String] {
|
||||
&self.warnings
|
||||
}
|
||||
|
||||
/// Check whether any skills were loaded.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
|
||||
+1560
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
//! Shared test-only helpers.
|
||||
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
/// Acquire the process-wide env-var mutex.
|
||||
///
|
||||
/// If a prior test panicked while holding the lock, recover the guard instead
|
||||
/// of cascading failures across unrelated tests.
|
||||
pub(crate) fn lock_test_env() -> MutexGuard<'static, ()> {
|
||||
match env_lock().lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Calculator tool for evaluating arithmetic expressions.
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
optional_str, required_str,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct CalculatorResponse {
|
||||
value: String,
|
||||
result: String,
|
||||
}
|
||||
|
||||
pub struct CalculatorTool;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for CalculatorTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"calculator"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Evaluate a basic arithmetic expression."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expression": { "type": "string" },
|
||||
"prefix": { "type": "string" },
|
||||
"suffix": { "type": "string" }
|
||||
},
|
||||
"required": ["expression"]
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly]
|
||||
}
|
||||
|
||||
fn supports_parallel(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let expression = required_str(&input, "expression")?;
|
||||
let prefix = optional_str(&input, "prefix").unwrap_or("");
|
||||
let suffix = optional_str(&input, "suffix").unwrap_or("");
|
||||
|
||||
let value = eval_expression(expression)
|
||||
.map_err(|e| ToolError::invalid_input(format!("Invalid expression: {e}")))?;
|
||||
if !value.is_finite() {
|
||||
return Err(ToolError::invalid_input(
|
||||
"Invalid expression: result is not finite".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let rendered = format_value(value);
|
||||
let result = format!("{prefix}{rendered}{suffix}");
|
||||
|
||||
ToolResult::json(&CalculatorResponse {
|
||||
value: rendered,
|
||||
result,
|
||||
})
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_value(value: f64) -> String {
|
||||
if value.fract() == 0.0 {
|
||||
format!("{:.0}", value)
|
||||
} else {
|
||||
let rendered = format!("{value}");
|
||||
rendered
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_expression(expression: &str) -> std::result::Result<f64, String> {
|
||||
let mut parser = ExpressionParser::new(expression);
|
||||
let value = parser.parse_expression()?;
|
||||
parser.skip_whitespace();
|
||||
if !parser.is_eof() {
|
||||
return Err(format!("unexpected token at byte {}", parser.position()));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
struct ExpressionParser<'a> {
|
||||
input: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> ExpressionParser<'a> {
|
||||
fn new(input: &'a str) -> Self {
|
||||
Self {
|
||||
input: input.as_bytes(),
|
||||
pos: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn position(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
|
||||
fn is_eof(&self) -> bool {
|
||||
self.pos >= self.input.len()
|
||||
}
|
||||
|
||||
fn skip_whitespace(&mut self) {
|
||||
while let Some(ch) = self.peek() {
|
||||
if ch.is_ascii_whitespace() {
|
||||
self.pos += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.input.get(self.pos).copied()
|
||||
}
|
||||
|
||||
fn consume(&mut self, ch: u8) -> bool {
|
||||
self.skip_whitespace();
|
||||
if self.peek() == Some(ch) {
|
||||
self.pos += 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_expression(&mut self) -> std::result::Result<f64, String> {
|
||||
let mut value = self.parse_term()?;
|
||||
loop {
|
||||
if self.consume(b'+') {
|
||||
value += self.parse_term()?;
|
||||
} else if self.consume(b'-') {
|
||||
value -= self.parse_term()?;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn parse_term(&mut self) -> std::result::Result<f64, String> {
|
||||
let mut value = self.parse_power()?;
|
||||
loop {
|
||||
if self.consume(b'*') {
|
||||
value *= self.parse_power()?;
|
||||
} else if self.consume(b'/') {
|
||||
let divisor = self.parse_power()?;
|
||||
if divisor == 0.0 {
|
||||
return Err("division by zero".to_string());
|
||||
}
|
||||
value /= divisor;
|
||||
} else if self.consume(b'%') {
|
||||
let divisor = self.parse_power()?;
|
||||
if divisor == 0.0 {
|
||||
return Err("modulo by zero".to_string());
|
||||
}
|
||||
value %= divisor;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn parse_power(&mut self) -> std::result::Result<f64, String> {
|
||||
let value = self.parse_unary()?;
|
||||
if self.consume(b'^') {
|
||||
let exponent = self.parse_power()?;
|
||||
Ok(value.powf(exponent))
|
||||
} else {
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_unary(&mut self) -> std::result::Result<f64, String> {
|
||||
if self.consume(b'+') {
|
||||
self.parse_unary()
|
||||
} else if self.consume(b'-') {
|
||||
Ok(-self.parse_unary()?)
|
||||
} else {
|
||||
self.parse_primary()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_primary(&mut self) -> std::result::Result<f64, String> {
|
||||
self.skip_whitespace();
|
||||
if self.consume(b'(') {
|
||||
let value = self.parse_expression()?;
|
||||
if !self.consume(b')') {
|
||||
return Err("missing closing ')'".to_string());
|
||||
}
|
||||
return Ok(value);
|
||||
}
|
||||
self.parse_number()
|
||||
}
|
||||
|
||||
fn parse_number(&mut self) -> std::result::Result<f64, String> {
|
||||
self.skip_whitespace();
|
||||
let start = self.pos;
|
||||
let mut saw_digit = false;
|
||||
let mut saw_dot = false;
|
||||
let mut saw_exp = false;
|
||||
|
||||
while let Some(ch) = self.peek() {
|
||||
if ch.is_ascii_digit() {
|
||||
saw_digit = true;
|
||||
self.pos += 1;
|
||||
} else if ch == b'.' && !saw_dot && !saw_exp {
|
||||
saw_dot = true;
|
||||
self.pos += 1;
|
||||
} else if (ch == b'e' || ch == b'E') && saw_digit && !saw_exp {
|
||||
saw_exp = true;
|
||||
self.pos += 1;
|
||||
if matches!(self.peek(), Some(b'+') | Some(b'-')) {
|
||||
self.pos += 1;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if start == self.pos || !saw_digit {
|
||||
return Err(format!("expected number at byte {}", start));
|
||||
}
|
||||
|
||||
let number_text = std::str::from_utf8(&self.input[start..self.pos])
|
||||
.map_err(|_| format!("invalid UTF-8 near byte {}", start))?;
|
||||
number_text
|
||||
.parse::<f64>()
|
||||
.map_err(|_| format!("invalid number '{number_text}'"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn evaluates_expression() {
|
||||
let value = eval_expression("2 + 2").expect("expression should parse");
|
||||
assert_eq!(format_value(value), "4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_precedence_and_parentheses() {
|
||||
let value = eval_expression("2 + 3 * (4 - 1)").expect("expression should parse");
|
||||
assert_eq!(format_value(value), "11");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_input() {
|
||||
let err = eval_expression("2 +").expect_err("invalid expression should fail");
|
||||
assert!(err.contains("expected number"));
|
||||
}
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
//! Tools for Duo mode: Player-Coach autocoding workflow.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::duo::{
|
||||
DuoPhase, SharedDuoSession, generate_coach_prompt, generate_player_prompt, session_summary,
|
||||
};
|
||||
use crate::tools::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
optional_str, required_str,
|
||||
};
|
||||
|
||||
/// Initialize an autocoding session with requirements.
|
||||
pub struct DuoInitTool {
|
||||
session: SharedDuoSession,
|
||||
}
|
||||
|
||||
impl DuoInitTool {
|
||||
#[must_use]
|
||||
pub fn new(session: SharedDuoSession) -> Self {
|
||||
Self { session }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for DuoInitTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"duo_init"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Initialize a Duo autocoding session with requirements. Returns session summary."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requirements": {
|
||||
"type": "string",
|
||||
"description": "The requirements document (source of truth). Should be structured as a checklist."
|
||||
},
|
||||
"max_turns": {
|
||||
"type": "integer",
|
||||
"description": "Maximum turns before timeout (default: 10)"
|
||||
},
|
||||
"session_name": {
|
||||
"type": "string",
|
||||
"description": "Optional human-readable session name (e.g., 'auth-feature')"
|
||||
},
|
||||
"approval_threshold": {
|
||||
"type": "number",
|
||||
"description": "Minimum compliance score for approval (0-1, default: 0.9)"
|
||||
}
|
||||
},
|
||||
"required": ["requirements"]
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let requirements = required_str(&input, "requirements")?;
|
||||
let max_turns = input
|
||||
.get("max_turns")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
let session_name = optional_str(&input, "session_name").map(str::to_string);
|
||||
let approval_threshold = input.get("approval_threshold").and_then(|v| v.as_f64());
|
||||
|
||||
let mut session = self
|
||||
.session
|
||||
.lock()
|
||||
.map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?;
|
||||
|
||||
let state = session.start_session(
|
||||
requirements.to_string(),
|
||||
session_name,
|
||||
max_turns,
|
||||
approval_threshold,
|
||||
);
|
||||
|
||||
let summary = state.summary();
|
||||
Ok(ToolResult::success(format!(
|
||||
"Duo session initialized. Ready for player phase.\n\n{}",
|
||||
summary
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the player prompt for implementation.
|
||||
pub struct DuoPlayerTool {
|
||||
session: SharedDuoSession,
|
||||
}
|
||||
|
||||
impl DuoPlayerTool {
|
||||
#[must_use]
|
||||
pub fn new(session: SharedDuoSession) -> Self {
|
||||
Self { session }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for DuoPlayerTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"duo_player"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Generate the player prompt for implementation. Must be in Init or Player phase. Call after implementing to advance to Coach phase."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"implementation_summary": {
|
||||
"type": "string",
|
||||
"description": "Optional summary of implementation work done (recorded in history)"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let implementation_summary = optional_str(&input, "implementation_summary")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "Implementation in progress".to_string());
|
||||
|
||||
let mut session = self
|
||||
.session
|
||||
.lock()
|
||||
.map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?;
|
||||
|
||||
let state = session
|
||||
.get_active_mut()
|
||||
.ok_or_else(|| ToolError::invalid_input("No active session. Call duo_init first."))?;
|
||||
|
||||
// Check we're in a valid phase for player
|
||||
match state.phase {
|
||||
DuoPhase::Init | DuoPhase::Player => {
|
||||
// Generate prompt first
|
||||
let prompt = generate_player_prompt(state);
|
||||
|
||||
// Advance to Coach phase
|
||||
state
|
||||
.advance_to_coach(implementation_summary)
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
|
||||
|
||||
Ok(ToolResult::success(format!(
|
||||
"=== PLAYER PROMPT ===\n\n{}\n\n---\nAdvanced to Coach phase. Use duo_coach for verification.",
|
||||
prompt
|
||||
)))
|
||||
}
|
||||
DuoPhase::Coach => Err(ToolError::invalid_input(
|
||||
"Already in Coach phase. Use duo_coach to get verification prompt.",
|
||||
)),
|
||||
DuoPhase::Approved => Err(ToolError::invalid_input(
|
||||
"Session already approved. Start a new session with duo_init.",
|
||||
)),
|
||||
DuoPhase::Timeout => Err(ToolError::invalid_input(
|
||||
"Session timed out. Start a new session with duo_init.",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the coach prompt for validation.
|
||||
pub struct DuoCoachTool {
|
||||
session: SharedDuoSession,
|
||||
}
|
||||
|
||||
impl DuoCoachTool {
|
||||
#[must_use]
|
||||
pub fn new(session: SharedDuoSession) -> Self {
|
||||
Self { session }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for DuoCoachTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"duo_coach"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Generate the coach prompt for validation. Must be in Coach phase. Does NOT advance state."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_input: Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let session = self
|
||||
.session
|
||||
.lock()
|
||||
.map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?;
|
||||
|
||||
let state = session
|
||||
.get_active()
|
||||
.ok_or_else(|| ToolError::invalid_input("No active session. Call duo_init first."))?;
|
||||
|
||||
if state.phase != DuoPhase::Coach {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Expected Coach phase, but current phase is {}. Use duo_player first.",
|
||||
state.phase
|
||||
)));
|
||||
}
|
||||
|
||||
let prompt = generate_coach_prompt(state);
|
||||
|
||||
Ok(ToolResult::success(format!(
|
||||
"=== COACH PROMPT ===\n\n{}\n\n---\nAfter verification, use duo_advance with feedback and approval status.",
|
||||
prompt
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the session after coach review.
|
||||
pub struct DuoAdvanceTool {
|
||||
session: SharedDuoSession,
|
||||
}
|
||||
|
||||
impl DuoAdvanceTool {
|
||||
#[must_use]
|
||||
pub fn new(session: SharedDuoSession) -> Self {
|
||||
Self { session }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for DuoAdvanceTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"duo_advance"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Advance the session after coach review. Updates turn count and records feedback. Returns new status."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"feedback": {
|
||||
"type": "string",
|
||||
"description": "The coach's feedback text (compliance checklist and actions needed)"
|
||||
},
|
||||
"approved": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the coach approved the implementation (look for 'COACH APPROVED')"
|
||||
},
|
||||
"compliance_score": {
|
||||
"type": "number",
|
||||
"description": "Optional compliance score (0-1) based on checklist items satisfied"
|
||||
}
|
||||
},
|
||||
"required": ["feedback", "approved"]
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let feedback = required_str(&input, "feedback")?;
|
||||
let approved = input
|
||||
.get("approved")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| ToolError::missing_field("approved"))?;
|
||||
let compliance_score = input.get("compliance_score").and_then(|v| v.as_f64());
|
||||
|
||||
let mut session = self
|
||||
.session
|
||||
.lock()
|
||||
.map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?;
|
||||
|
||||
let state = session
|
||||
.get_active_mut()
|
||||
.ok_or_else(|| ToolError::invalid_input("No active session. Call duo_init first."))?;
|
||||
|
||||
if state.phase != DuoPhase::Coach {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Expected Coach phase, but current phase is {}",
|
||||
state.phase
|
||||
)));
|
||||
}
|
||||
|
||||
// Advance the turn
|
||||
state
|
||||
.advance_turn(feedback.to_string(), approved, compliance_score)
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
|
||||
|
||||
// Determine status message based on new phase
|
||||
let status_msg = match state.phase {
|
||||
DuoPhase::Approved => "🎉 APPROVED! All requirements verified.",
|
||||
DuoPhase::Timeout => "⏰ TIMEOUT. Max turns reached without approval.",
|
||||
DuoPhase::Player => "🔄 Continuing to next player turn...",
|
||||
_ => "Session updated.",
|
||||
};
|
||||
|
||||
let summary = state.summary();
|
||||
let mut result = ToolResult::success(format!("{}\n\n{}", status_msg, summary));
|
||||
result.metadata = Some(json!({
|
||||
"phase": state.phase.to_string(),
|
||||
"status": state.status.to_string(),
|
||||
"turn": state.current_turn,
|
||||
"max_turns": state.max_turns,
|
||||
"approved": approved,
|
||||
"compliance_score": compliance_score,
|
||||
"is_complete": state.is_complete(),
|
||||
}));
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the current session status.
|
||||
pub struct DuoStatusTool {
|
||||
session: SharedDuoSession,
|
||||
}
|
||||
|
||||
impl DuoStatusTool {
|
||||
#[must_use]
|
||||
pub fn new(session: SharedDuoSession) -> Self {
|
||||
Self { session }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for DuoStatusTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"duo_status"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Show the current Duo session status including phase, turn count, and requirements."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_input: Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let session = self
|
||||
.session
|
||||
.lock()
|
||||
.map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?;
|
||||
|
||||
Ok(ToolResult::success(session_summary(&session)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::duo::new_shared_duo_session;
|
||||
|
||||
#[test]
|
||||
fn test_duo_init_tool_schema() {
|
||||
let session = new_shared_duo_session();
|
||||
let tool = DuoInitTool::new(session);
|
||||
|
||||
assert_eq!(tool.name(), "duo_init");
|
||||
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
|
||||
|
||||
let schema = tool.input_schema();
|
||||
assert!(schema.get("properties").is_some());
|
||||
assert!(
|
||||
schema["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("requirements"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duo_player_tool_schema() {
|
||||
let session = new_shared_duo_session();
|
||||
let tool = DuoPlayerTool::new(session);
|
||||
|
||||
assert_eq!(tool.name(), "duo_player");
|
||||
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duo_coach_tool_schema() {
|
||||
let session = new_shared_duo_session();
|
||||
let tool = DuoCoachTool::new(session);
|
||||
|
||||
assert_eq!(tool.name(), "duo_coach");
|
||||
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duo_advance_tool_schema() {
|
||||
let session = new_shared_duo_session();
|
||||
let tool = DuoAdvanceTool::new(session);
|
||||
|
||||
assert_eq!(tool.name(), "duo_advance");
|
||||
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
|
||||
|
||||
let schema = tool.input_schema();
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
assert!(required.contains(&json!("feedback")));
|
||||
assert!(required.contains(&json!("approved")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duo_status_tool_schema() {
|
||||
let session = new_shared_duo_session();
|
||||
let tool = DuoStatusTool::new(session);
|
||||
|
||||
assert_eq!(tool.name(), "duo_status");
|
||||
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! Finance tool for stock/crypto pricing.
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT_MS: u64 = 15_000;
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct FinanceRequest {
|
||||
ticker: String,
|
||||
instrument_type: String,
|
||||
market: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct FinanceResult {
|
||||
ticker: String,
|
||||
instrument_type: String,
|
||||
market: String,
|
||||
source: String,
|
||||
price: Option<f64>,
|
||||
currency: Option<String>,
|
||||
as_of: Option<String>,
|
||||
details: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct FinanceResponse {
|
||||
results: Vec<FinanceResult>,
|
||||
}
|
||||
|
||||
pub struct FinanceTool;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for FinanceTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"finance"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Get the latest price for a stock, fund, index, or cryptocurrency."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ticker": { "type": "string" },
|
||||
"type": { "type": "string", "enum": ["equity", "fund", "crypto", "index"] },
|
||||
"market": { "type": "string" },
|
||||
"finance": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ticker": { "type": "string" },
|
||||
"type": { "type": "string" },
|
||||
"market": { "type": "string" }
|
||||
},
|
||||
"required": ["ticker", "type"]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly, ToolCapability::Network]
|
||||
}
|
||||
|
||||
fn supports_parallel(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let requests = parse_finance_requests(&input)?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(TIMEOUT_MS))
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
|
||||
})?;
|
||||
|
||||
let mut results = Vec::with_capacity(requests.len());
|
||||
for req in requests {
|
||||
let instrument_type = req.instrument_type.to_lowercase();
|
||||
let result = if instrument_type == "crypto" {
|
||||
fetch_crypto_price(&client, &req).await?
|
||||
} else {
|
||||
fetch_stooq_price(&client, &req).await?
|
||||
};
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
ToolResult::json(&FinanceResponse { results })
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_finance_requests(input: &Value) -> Result<Vec<FinanceRequest>, ToolError> {
|
||||
if let Some(list) = input.get("finance").and_then(|v| v.as_array()) {
|
||||
let mut requests = Vec::new();
|
||||
for item in list {
|
||||
let ticker = required_str(item, "ticker")?.to_string();
|
||||
let instrument_type = required_str(item, "type")?.to_string();
|
||||
let market = item
|
||||
.get("market")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
requests.push(FinanceRequest {
|
||||
ticker,
|
||||
instrument_type,
|
||||
market,
|
||||
});
|
||||
}
|
||||
if requests.is_empty() {
|
||||
return Err(ToolError::invalid_input("finance list is empty"));
|
||||
}
|
||||
return Ok(requests);
|
||||
}
|
||||
|
||||
let ticker = required_str(input, "ticker")?.to_string();
|
||||
let instrument_type = required_str(input, "type")?.to_string();
|
||||
let market = input
|
||||
.get("market")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(vec![FinanceRequest {
|
||||
ticker,
|
||||
instrument_type,
|
||||
market,
|
||||
}])
|
||||
}
|
||||
|
||||
async fn fetch_crypto_price(
|
||||
client: &reqwest::Client,
|
||||
req: &FinanceRequest,
|
||||
) -> Result<FinanceResult, ToolError> {
|
||||
let search_url = format!(
|
||||
"https://api.coingecko.com/api/v3/search?query={}",
|
||||
url_encode(&req.ticker)
|
||||
);
|
||||
let search_resp = client
|
||||
.get(&search_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("CoinGecko search failed: {e}")))?;
|
||||
let status = search_resp.status();
|
||||
let body = search_resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?;
|
||||
if !status.is_success() {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"CoinGecko search failed: HTTP {}",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
let json: Value = serde_json::from_str(&body)
|
||||
.map_err(|e| ToolError::execution_failed(format!("Invalid CoinGecko JSON: {e}")))?;
|
||||
let coins = json
|
||||
.get("coins")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| ToolError::execution_failed("CoinGecko returned no coins"))?;
|
||||
let ticker_lower = req.ticker.to_lowercase();
|
||||
let selected = coins
|
||||
.iter()
|
||||
.find(|coin| {
|
||||
coin.get("symbol")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.eq_ignore_ascii_case(&ticker_lower))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.or_else(|| coins.first())
|
||||
.ok_or_else(|| ToolError::execution_failed("CoinGecko returned no results"))?;
|
||||
|
||||
let id = selected
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::execution_failed("Missing CoinGecko id"))?;
|
||||
let symbol = selected
|
||||
.get("symbol")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&req.ticker)
|
||||
.to_string();
|
||||
let name = selected
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&req.ticker)
|
||||
.to_string();
|
||||
|
||||
let price_url = format!(
|
||||
"https://api.coingecko.com/api/v3/simple/price?ids={id}&vs_currencies=usd&include_last_updated_at=true"
|
||||
);
|
||||
let price_resp = client
|
||||
.get(&price_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("CoinGecko price failed: {e}")))?;
|
||||
let status = price_resp.status();
|
||||
let body = price_resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?;
|
||||
if !status.is_success() {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"CoinGecko price failed: HTTP {}",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
let json: Value = serde_json::from_str(&body)
|
||||
.map_err(|e| ToolError::execution_failed(format!("Invalid CoinGecko price JSON: {e}")))?;
|
||||
let price = json
|
||||
.get(id)
|
||||
.and_then(|v| v.get("usd"))
|
||||
.and_then(|v| v.as_f64());
|
||||
let last_updated = json
|
||||
.get(id)
|
||||
.and_then(|v| v.get("last_updated_at"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|ts| format!("{ts}"));
|
||||
|
||||
Ok(FinanceResult {
|
||||
ticker: req.ticker.clone(),
|
||||
instrument_type: req.instrument_type.clone(),
|
||||
market: req.market.clone(),
|
||||
source: "coingecko".to_string(),
|
||||
price,
|
||||
currency: Some("USD".to_string()),
|
||||
as_of: last_updated,
|
||||
details: json!({
|
||||
"id": id,
|
||||
"symbol": symbol,
|
||||
"name": name,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_stooq_price(
|
||||
client: &reqwest::Client,
|
||||
req: &FinanceRequest,
|
||||
) -> Result<FinanceResult, ToolError> {
|
||||
let symbol = normalize_stooq_symbol(&req.ticker, &req.market);
|
||||
let url = format!("https://stooq.com/q/l/?s={symbol}&f=sd2t2ohlcv&h&e=csv");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("Stooq request failed: {e}")))?;
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?;
|
||||
if !status.is_success() {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"Stooq failed: HTTP {}",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut lines = body.lines();
|
||||
let _header = lines.next();
|
||||
let data = lines
|
||||
.next()
|
||||
.ok_or_else(|| ToolError::execution_failed("Stooq returned no data"))?;
|
||||
let fields: Vec<&str> = data.split(',').collect();
|
||||
if fields.len() < 8 {
|
||||
return Err(ToolError::execution_failed("Stooq data malformed"));
|
||||
}
|
||||
if fields[1] == "N/D" {
|
||||
return Err(ToolError::execution_failed("Stooq returned no data"));
|
||||
}
|
||||
|
||||
let date = fields[1].to_string();
|
||||
let time = fields[2].to_string();
|
||||
let open = parse_f64(fields[3]);
|
||||
let high = parse_f64(fields[4]);
|
||||
let low = parse_f64(fields[5]);
|
||||
let close = parse_f64(fields[6]);
|
||||
let volume = parse_f64(fields[7]);
|
||||
|
||||
Ok(FinanceResult {
|
||||
ticker: req.ticker.clone(),
|
||||
instrument_type: req.instrument_type.clone(),
|
||||
market: req.market.clone(),
|
||||
source: "stooq".to_string(),
|
||||
price: close,
|
||||
currency: None,
|
||||
as_of: Some(format!("{date} {time}")),
|
||||
details: json!({
|
||||
"symbol": symbol,
|
||||
"open": open,
|
||||
"high": high,
|
||||
"low": low,
|
||||
"close": close,
|
||||
"volume": volume,
|
||||
"date": date,
|
||||
"time": time,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_stooq_symbol(ticker: &str, market: &str) -> String {
|
||||
if ticker.contains('.') {
|
||||
return ticker.to_lowercase();
|
||||
}
|
||||
let suffix = match market.to_lowercase().as_str() {
|
||||
"usa" | "us" => ".us",
|
||||
"uk" | "gb" => ".uk",
|
||||
"jp" | "japan" => ".jp",
|
||||
"de" | "germany" => ".de",
|
||||
"fr" | "france" => ".fr",
|
||||
"ca" | "canada" => ".ca",
|
||||
_ => "",
|
||||
};
|
||||
format!("{}{}", ticker.to_lowercase(), suffix)
|
||||
}
|
||||
|
||||
fn parse_f64(input: &str) -> Option<f64> {
|
||||
input.parse::<f64>().ok()
|
||||
}
|
||||
|
||||
fn url_encode(input: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for ch in input.bytes() {
|
||||
match ch {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
encoded.push(ch as char)
|
||||
}
|
||||
b' ' => encoded.push('+'),
|
||||
_ => encoded.push_str(&format!("%{ch:02X}")),
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
+24
-5
@@ -5,22 +5,30 @@
|
||||
// === Modules ===
|
||||
|
||||
pub mod apply_patch;
|
||||
pub mod calculator;
|
||||
pub mod diagnostics;
|
||||
pub mod duo;
|
||||
pub mod file;
|
||||
pub mod file_search;
|
||||
pub mod finance;
|
||||
pub mod git;
|
||||
pub mod parallel;
|
||||
pub mod plan;
|
||||
pub mod project;
|
||||
pub mod registry;
|
||||
pub mod review;
|
||||
pub mod rlm;
|
||||
pub mod search;
|
||||
pub mod shell;
|
||||
mod shell_output;
|
||||
pub mod spec;
|
||||
pub mod sports;
|
||||
pub mod subagent;
|
||||
pub mod swarm;
|
||||
pub mod test_runner;
|
||||
pub mod time;
|
||||
pub mod todo;
|
||||
pub mod user_input;
|
||||
pub mod weather;
|
||||
pub mod web_run;
|
||||
pub mod web_search;
|
||||
|
||||
// === Re-exports ===
|
||||
@@ -35,7 +43,15 @@ pub use registry::{ToolRegistry, ToolRegistryBuilder};
|
||||
pub use file_search::FileSearchTool;
|
||||
pub use search::GrepFilesTool;
|
||||
|
||||
// Re-export structured data tools
|
||||
pub use calculator::CalculatorTool;
|
||||
pub use finance::FinanceTool;
|
||||
pub use sports::SportsTool;
|
||||
pub use time::TimeTool;
|
||||
pub use weather::WeatherTool;
|
||||
|
||||
// Re-export web search tools
|
||||
pub use web_run::WebRunTool;
|
||||
pub use web_search::WebSearchTool;
|
||||
|
||||
// Re-export patch tools
|
||||
@@ -54,7 +70,7 @@ pub use diagnostics::DiagnosticsTool;
|
||||
pub use git::{GitDiffTool, GitStatusTool};
|
||||
|
||||
// Re-export shell types
|
||||
pub use shell::ExecShellTool;
|
||||
pub use shell::{ExecShellTool, ShellInteractTool, ShellWaitTool};
|
||||
|
||||
// Re-export subagent types
|
||||
pub use subagent::SubAgent;
|
||||
@@ -68,5 +84,8 @@ pub use todo::TodoWriteTool;
|
||||
// Re-export plan types
|
||||
pub use plan::UpdatePlanTool;
|
||||
|
||||
// Re-export RLM tools
|
||||
pub use rlm::{RlmExecTool, RlmLoadTool, RlmQueryTool, RlmStatusTool};
|
||||
// Re-export parallel/multi-tool types
|
||||
pub use parallel::MultiToolUseParallelTool;
|
||||
|
||||
// Re-export user input tool/types
|
||||
pub use user_input::{RequestUserInputTool, UserInputAnswer, UserInputRequest, UserInputResponse};
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Tool wrapper for executing multiple tool calls in parallel.
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub struct MultiToolUseParallelTool;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for MultiToolUseParallelTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"multi_tool_use.parallel"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Execute multiple tool calls in parallel and return their results."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_uses": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recipient_name": { "type": "string" },
|
||||
"parameters": { "type": "object" }
|
||||
},
|
||||
"required": ["recipient_name", "parameters"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["tool_uses"]
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_input: Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
Err(ToolError::execution_failed(
|
||||
"multi_tool_use.parallel must be handled by the engine",
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Project mapping tool for understanding codebase structure.
|
||||
|
||||
use crate::utils::{is_key_file, project_tree, summarize_project};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,
|
||||
};
|
||||
|
||||
pub struct ProjectMapTool;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ProjectMap {
|
||||
tree: String,
|
||||
summary: String,
|
||||
key_files: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for ProjectMapTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"project_map"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Get a high-level map of the project structure, including key files and a tree view."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_depth": {
|
||||
"type": "integer",
|
||||
"description": "Maximum depth for the tree view (default: 3)."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let max_depth = optional_u64(&input, "max_depth", 3) as usize;
|
||||
let map = generate_project_map(&context.workspace, max_depth)?;
|
||||
ToolResult::json(&map).map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_project_map(root: &std::path::Path, max_depth: usize) -> Result<ProjectMap, ToolError> {
|
||||
let tree = project_tree(root, max_depth);
|
||||
let summary = summarize_project(root);
|
||||
|
||||
// For key_files, we can just do a quick scan since summarize_project doesn't return them directly anymore
|
||||
let mut key_files = Vec::new();
|
||||
let mut builder = ignore::WalkBuilder::new(root);
|
||||
builder.hidden(false).follow_links(true).max_depth(Some(2));
|
||||
let walker = builder.build();
|
||||
|
||||
for entry in walker.flatten() {
|
||||
if is_key_file(entry.path()) {
|
||||
if let Ok(rel) = entry.path().strip_prefix(root) {
|
||||
key_files.push(rel.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ProjectMap {
|
||||
tree,
|
||||
summary,
|
||||
key_files,
|
||||
})
|
||||
}
|
||||
+61
-31
@@ -12,9 +12,7 @@ use std::sync::Arc;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::DeepSeekClient;
|
||||
use crate::duo::SharedDuoSession;
|
||||
use crate::models::Tool;
|
||||
use crate::rlm::SharedRlmSession;
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
@@ -280,8 +278,12 @@ impl ToolRegistryBuilder {
|
||||
/// Include shell execution tool.
|
||||
#[must_use]
|
||||
pub fn with_shell_tools(self) -> Self {
|
||||
use super::shell::ExecShellTool;
|
||||
use super::shell::{ExecShellTool, ShellInteractTool, ShellWaitTool};
|
||||
self.with_tool(Arc::new(ExecShellTool))
|
||||
.with_tool(Arc::new(ShellWaitTool::new("exec_shell_wait")))
|
||||
.with_tool(Arc::new(ShellInteractTool::new("exec_shell_interact")))
|
||||
.with_tool(Arc::new(ShellWaitTool::new("exec_wait")))
|
||||
.with_tool(Arc::new(ShellInteractTool::new("exec_interact")))
|
||||
}
|
||||
|
||||
/// Include search tools (`grep_files`).
|
||||
@@ -308,6 +310,13 @@ impl ToolRegistryBuilder {
|
||||
self.with_tool(Arc::new(DiagnosticsTool))
|
||||
}
|
||||
|
||||
/// Include project mapping tools.
|
||||
#[must_use]
|
||||
pub fn with_project_tools(self) -> Self {
|
||||
use super::project::ProjectMapTool;
|
||||
self.with_tool(Arc::new(ProjectMapTool))
|
||||
}
|
||||
|
||||
/// Include cargo test runner tool.
|
||||
#[must_use]
|
||||
pub fn with_test_runner_tool(self) -> Self {
|
||||
@@ -318,8 +327,35 @@ impl ToolRegistryBuilder {
|
||||
/// Include web search tools.
|
||||
#[must_use]
|
||||
pub fn with_web_tools(self) -> Self {
|
||||
use super::web_run::WebRunTool;
|
||||
use super::web_search::WebSearchTool;
|
||||
self.with_tool(Arc::new(WebSearchTool))
|
||||
.with_tool(Arc::new(WebRunTool))
|
||||
}
|
||||
|
||||
/// Include multi-tool parallel wrapper.
|
||||
#[must_use]
|
||||
pub fn with_parallel_tool(self) -> Self {
|
||||
use super::parallel::MultiToolUseParallelTool;
|
||||
self.with_tool(Arc::new(MultiToolUseParallelTool))
|
||||
}
|
||||
|
||||
/// Include request_user_input tool.
|
||||
#[must_use]
|
||||
pub fn with_user_input_tool(self) -> Self {
|
||||
use super::user_input::RequestUserInputTool;
|
||||
self.with_tool(Arc::new(RequestUserInputTool))
|
||||
}
|
||||
|
||||
/// Include structured data tools (weather/finance/sports/time/calculator).
|
||||
#[must_use]
|
||||
pub fn with_structured_data_tools(self) -> Self {
|
||||
use super::{CalculatorTool, FinanceTool, SportsTool, TimeTool, WeatherTool};
|
||||
self.with_tool(Arc::new(WeatherTool))
|
||||
.with_tool(Arc::new(FinanceTool))
|
||||
.with_tool(Arc::new(SportsTool))
|
||||
.with_tool(Arc::new(TimeTool))
|
||||
.with_tool(Arc::new(CalculatorTool))
|
||||
}
|
||||
|
||||
/// Include patch tools (`apply_patch`).
|
||||
@@ -351,9 +387,13 @@ impl ToolRegistryBuilder {
|
||||
.with_note_tool()
|
||||
.with_search_tools()
|
||||
.with_web_tools()
|
||||
.with_user_input_tool()
|
||||
.with_parallel_tool()
|
||||
.with_structured_data_tools()
|
||||
.with_patch_tools()
|
||||
.with_git_tools()
|
||||
.with_diagnostics_tool()
|
||||
.with_project_tools()
|
||||
.with_test_runner_tool();
|
||||
|
||||
if allow_shell {
|
||||
@@ -393,33 +433,6 @@ impl ToolRegistryBuilder {
|
||||
.with_plan_tool(plan_state)
|
||||
}
|
||||
|
||||
/// Include RLM tools for context execution and sub-queries.
|
||||
#[must_use]
|
||||
pub fn with_rlm_tools(
|
||||
self,
|
||||
session: SharedRlmSession,
|
||||
client: Option<DeepSeekClient>,
|
||||
model: String,
|
||||
) -> Self {
|
||||
self.with_tool(Arc::new(super::rlm::RlmExecTool::new(session.clone())))
|
||||
.with_tool(Arc::new(super::rlm::RlmLoadTool::new(session.clone())))
|
||||
.with_tool(Arc::new(super::rlm::RlmStatusTool::new(session.clone())))
|
||||
.with_tool(Arc::new(super::rlm::RlmQueryTool::new(
|
||||
session, client, model,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Include Duo tools for dialectical autocoding.
|
||||
#[must_use]
|
||||
pub fn with_duo_tools(self, session: SharedDuoSession) -> Self {
|
||||
use super::duo::{DuoAdvanceTool, DuoCoachTool, DuoInitTool, DuoPlayerTool, DuoStatusTool};
|
||||
self.with_tool(Arc::new(DuoInitTool::new(session.clone())))
|
||||
.with_tool(Arc::new(DuoPlayerTool::new(session.clone())))
|
||||
.with_tool(Arc::new(DuoCoachTool::new(session.clone())))
|
||||
.with_tool(Arc::new(DuoAdvanceTool::new(session.clone())))
|
||||
.with_tool(Arc::new(DuoStatusTool::new(session)))
|
||||
}
|
||||
|
||||
/// Include sub-agent management tools.
|
||||
#[must_use]
|
||||
pub fn with_subagent_tools(
|
||||
@@ -427,15 +440,32 @@ impl ToolRegistryBuilder {
|
||||
manager: super::subagent::SharedSubAgentManager,
|
||||
runtime: super::subagent::SubAgentRuntime,
|
||||
) -> Self {
|
||||
use super::subagent::{AgentCancelTool, AgentListTool, AgentResultTool, AgentSpawnTool};
|
||||
use super::subagent::{
|
||||
AgentCancelTool, AgentListTool, AgentResultTool, AgentSendInputTool, AgentSpawnTool,
|
||||
AgentWaitTool, DelegateToAgentTool,
|
||||
};
|
||||
use super::swarm::AgentSwarmTool;
|
||||
|
||||
self.with_tool(Arc::new(AgentSpawnTool::new(
|
||||
manager.clone(),
|
||||
runtime.clone(),
|
||||
)))
|
||||
.with_tool(Arc::new(DelegateToAgentTool::new(
|
||||
manager.clone(),
|
||||
runtime.clone(),
|
||||
)))
|
||||
.with_tool(Arc::new(AgentSwarmTool::new(manager.clone(), runtime)))
|
||||
.with_tool(Arc::new(AgentResultTool::new(manager.clone())))
|
||||
.with_tool(Arc::new(AgentSendInputTool::new(
|
||||
manager.clone(),
|
||||
"send_input",
|
||||
)))
|
||||
.with_tool(Arc::new(AgentWaitTool::new(manager.clone(), "wait")))
|
||||
.with_tool(Arc::new(AgentSendInputTool::new(
|
||||
manager.clone(),
|
||||
"agent_send_input",
|
||||
)))
|
||||
.with_tool(Arc::new(AgentWaitTool::new(manager.clone(), "agent_wait")))
|
||||
.with_tool(Arc::new(AgentCancelTool::new(manager.clone())))
|
||||
.with_tool(Arc::new(AgentListTool::new(manager)))
|
||||
}
|
||||
|
||||
-1047
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,600 @@
|
||||
//! Semantic search tool with hybrid lexical + semantic ranking.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use ignore::WalkBuilder;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
optional_bool, optional_u64, required_str,
|
||||
};
|
||||
|
||||
const DEFAULT_TOP_K: usize = 8;
|
||||
const MAX_TOP_K: usize = 64;
|
||||
const DEFAULT_CHUNK_SIZE: usize = 800;
|
||||
const MIN_CHUNK_SIZE: usize = 200;
|
||||
const MAX_CHUNK_SIZE: usize = 4_000;
|
||||
const DEFAULT_MAX_FILES: usize = 300;
|
||||
const MAX_MAX_FILES: usize = 2_000;
|
||||
const MAX_FILE_BYTES: u64 = 1_500_000;
|
||||
const MAX_CANDIDATE_CHUNKS: usize = 4_000;
|
||||
const EXCERPT_CHARS: usize = 280;
|
||||
const EMBED_DIM: usize = 256;
|
||||
const DISABLE_SEMANTIC_ENV: &str = "DEEPSEEK_DISABLE_SEMANTIC_SEARCH";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct SemanticSearchHit {
|
||||
path: String,
|
||||
line: usize,
|
||||
excerpt: String,
|
||||
score: f64,
|
||||
lexical_score: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
semantic_score: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct SemanticSearchResponse {
|
||||
query: String,
|
||||
results: Vec<SemanticSearchHit>,
|
||||
scanned_files: usize,
|
||||
candidate_chunks: usize,
|
||||
fallback_used: bool,
|
||||
backend: String,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ChunkCandidate {
|
||||
path: PathBuf,
|
||||
line: usize,
|
||||
text: String,
|
||||
lexical_score: f64,
|
||||
semantic_score: f64,
|
||||
score: f64,
|
||||
}
|
||||
|
||||
/// Tool for hybrid lexical + semantic code search.
|
||||
pub struct SemanticSearchTool;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for SemanticSearchTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"semantic_search"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Search code/doc chunks using hybrid lexical and semantic ranking."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Natural-language query to search for"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Optional base path (relative to workspace)"
|
||||
},
|
||||
"paths": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of base paths (relative to workspace)"
|
||||
},
|
||||
"top_k": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of ranked results (default: 8)"
|
||||
},
|
||||
"chunk_size": {
|
||||
"type": "integer",
|
||||
"description": "Approximate characters per chunk (default: 800)"
|
||||
},
|
||||
"max_files": {
|
||||
"type": "integer",
|
||||
"description": "Maximum files to scan (default: 300)"
|
||||
},
|
||||
"include_hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Include hidden files/directories (default: false)"
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
fn supports_parallel(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let query = required_str(&input, "query")?.trim();
|
||||
if query.is_empty() {
|
||||
return Err(ToolError::invalid_input("query cannot be empty"));
|
||||
}
|
||||
|
||||
let top_k = usize::try_from(optional_u64(&input, "top_k", DEFAULT_TOP_K as u64))
|
||||
.unwrap_or(DEFAULT_TOP_K)
|
||||
.clamp(1, MAX_TOP_K);
|
||||
let chunk_size = usize::try_from(optional_u64(
|
||||
&input,
|
||||
"chunk_size",
|
||||
DEFAULT_CHUNK_SIZE as u64,
|
||||
))
|
||||
.unwrap_or(DEFAULT_CHUNK_SIZE)
|
||||
.clamp(MIN_CHUNK_SIZE, MAX_CHUNK_SIZE);
|
||||
let max_files = usize::try_from(optional_u64(&input, "max_files", DEFAULT_MAX_FILES as u64))
|
||||
.unwrap_or(DEFAULT_MAX_FILES)
|
||||
.clamp(1, MAX_MAX_FILES);
|
||||
let include_hidden = optional_bool(&input, "include_hidden", false);
|
||||
|
||||
let roots = resolve_roots(context, &input)?;
|
||||
let files = collect_files(&roots, include_hidden, max_files)?;
|
||||
|
||||
let query_tokens = tokenize(query);
|
||||
let query_phrase = normalize_text(query);
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
let mut scanned_files = 0usize;
|
||||
|
||||
for file in files {
|
||||
if candidates.len() >= MAX_CANDIDATE_CHUNKS {
|
||||
break;
|
||||
}
|
||||
let Ok(metadata) = fs::metadata(&file) else {
|
||||
continue;
|
||||
};
|
||||
if metadata.len() > MAX_FILE_BYTES {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(content) = fs::read_to_string(&file) else {
|
||||
continue;
|
||||
};
|
||||
if !looks_like_text(&content) {
|
||||
continue;
|
||||
}
|
||||
scanned_files += 1;
|
||||
|
||||
let chunks = split_into_chunks(&content, chunk_size);
|
||||
if chunks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut best_for_file: Option<ChunkCandidate> = None;
|
||||
for (line, chunk_text) in chunks {
|
||||
if candidates.len() >= MAX_CANDIDATE_CHUNKS {
|
||||
break;
|
||||
}
|
||||
let lexical = lexical_score(&chunk_text, &query_tokens, &query_phrase);
|
||||
let candidate = ChunkCandidate {
|
||||
path: file.clone(),
|
||||
line,
|
||||
text: chunk_text,
|
||||
lexical_score: lexical,
|
||||
semantic_score: 0.0,
|
||||
score: lexical,
|
||||
};
|
||||
|
||||
if lexical > 0.0 {
|
||||
candidates.push(candidate);
|
||||
} else if best_for_file
|
||||
.as_ref()
|
||||
.is_none_or(|best| candidate.text.len() > best.text.len())
|
||||
{
|
||||
best_for_file = Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.iter().all(|c| c.path != file)
|
||||
&& let Some(fallback_chunk) = best_for_file
|
||||
{
|
||||
candidates.push(fallback_chunk);
|
||||
}
|
||||
}
|
||||
|
||||
let mut fallback_used = false;
|
||||
let semantic_enabled = semantic_backend_enabled();
|
||||
let query_embedding = if semantic_enabled {
|
||||
embed(query)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if query_embedding.is_none() {
|
||||
fallback_used = true;
|
||||
}
|
||||
|
||||
if let Some(query_vec) = query_embedding {
|
||||
for candidate in &mut candidates {
|
||||
let semantic = embed(&candidate.text)
|
||||
.map(|chunk_vec| cosine_similarity(&query_vec, &chunk_vec))
|
||||
.unwrap_or(0.0);
|
||||
candidate.semantic_score = semantic;
|
||||
candidate.score = (candidate.lexical_score * 0.35) + (semantic * 0.65);
|
||||
}
|
||||
} else {
|
||||
for candidate in &mut candidates {
|
||||
candidate.semantic_score = 0.0;
|
||||
candidate.score = candidate.lexical_score;
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| a.path.cmp(&b.path))
|
||||
.then_with(|| a.line.cmp(&b.line))
|
||||
});
|
||||
|
||||
let truncated = candidates.len() > top_k;
|
||||
let hits: Vec<SemanticSearchHit> = candidates
|
||||
.into_iter()
|
||||
.take(top_k)
|
||||
.map(|item| SemanticSearchHit {
|
||||
path: item
|
||||
.path
|
||||
.strip_prefix(&context.workspace)
|
||||
.unwrap_or(&item.path)
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
line: item.line,
|
||||
excerpt: compact_excerpt(&item.text, EXCERPT_CHARS),
|
||||
score: round_score(item.score),
|
||||
lexical_score: round_score(item.lexical_score),
|
||||
semantic_score: if fallback_used {
|
||||
None
|
||||
} else {
|
||||
Some(round_score(item.semantic_score))
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
let response = SemanticSearchResponse {
|
||||
query: query.to_string(),
|
||||
scanned_files,
|
||||
candidate_chunks: hits.len(),
|
||||
results: hits,
|
||||
fallback_used,
|
||||
backend: if fallback_used {
|
||||
"lexical-only".to_string()
|
||||
} else {
|
||||
"local-hash-embed".to_string()
|
||||
},
|
||||
truncated,
|
||||
};
|
||||
|
||||
ToolResult::json(&response).map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_roots(context: &ToolContext, input: &Value) -> Result<Vec<PathBuf>, ToolError> {
|
||||
let mut roots = Vec::new();
|
||||
|
||||
if let Some(path) = input.get("path").and_then(|v| v.as_str())
|
||||
&& !path.trim().is_empty()
|
||||
{
|
||||
roots.push(context.resolve_path(path)?);
|
||||
}
|
||||
|
||||
if let Some(paths) = input.get("paths").and_then(|v| v.as_array()) {
|
||||
for value in paths {
|
||||
if let Some(path) = value.as_str() {
|
||||
let trimmed = path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
roots.push(context.resolve_path(trimmed)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if roots.is_empty() {
|
||||
roots.push(context.workspace.clone());
|
||||
}
|
||||
|
||||
let mut deduped = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for root in roots {
|
||||
if seen.insert(root.clone()) {
|
||||
deduped.push(root);
|
||||
}
|
||||
}
|
||||
Ok(deduped)
|
||||
}
|
||||
|
||||
fn collect_files(
|
||||
roots: &[PathBuf],
|
||||
include_hidden: bool,
|
||||
max_files: usize,
|
||||
) -> Result<Vec<PathBuf>, ToolError> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for root in roots {
|
||||
if out.len() >= max_files {
|
||||
break;
|
||||
}
|
||||
|
||||
if root.is_file() {
|
||||
if seen.insert(root.clone()) {
|
||||
out.push(root.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut builder = WalkBuilder::new(root);
|
||||
builder
|
||||
.hidden(!include_hidden)
|
||||
.follow_links(true)
|
||||
.require_git(false);
|
||||
|
||||
for entry in builder.build() {
|
||||
if out.len() >= max_files {
|
||||
break;
|
||||
}
|
||||
let entry = entry
|
||||
.map_err(|e| ToolError::execution_failed(format!("File walk failed: {e}")))?;
|
||||
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path().to_path_buf();
|
||||
if seen.insert(path.clone()) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn split_into_chunks(content: &str, target_chars: usize) -> Vec<(usize, String)> {
|
||||
let mut chunks = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut start_line = 1usize;
|
||||
let mut current_line = 1usize;
|
||||
|
||||
for line in content.lines() {
|
||||
let next_len = current.len() + line.len() + 1;
|
||||
if !current.is_empty() && next_len > target_chars {
|
||||
chunks.push((start_line, current.trim().to_string()));
|
||||
current.clear();
|
||||
start_line = current_line;
|
||||
}
|
||||
|
||||
current.push_str(line);
|
||||
current.push('\n');
|
||||
current_line += 1;
|
||||
}
|
||||
|
||||
if !current.trim().is_empty() {
|
||||
chunks.push((start_line, current.trim().to_string()));
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
|
||||
fn looks_like_text(content: &str) -> bool {
|
||||
!content.contains('\u{0}')
|
||||
}
|
||||
|
||||
fn normalize_text(text: &str) -> String {
|
||||
text.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn tokenize(text: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for ch in text.chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||
current.push(ch.to_ascii_lowercase());
|
||||
} else if !current.is_empty() {
|
||||
tokens.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
let mut counts: HashMap<String, usize> = HashMap::new();
|
||||
for token in tokens {
|
||||
*counts.entry(token).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
counts
|
||||
.into_iter()
|
||||
.filter(|(token, _)| token.len() > 1)
|
||||
.map(|(token, _)| token)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn lexical_score(chunk: &str, query_tokens: &[String], query_phrase: &str) -> f64 {
|
||||
if query_tokens.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let chunk_norm = normalize_text(chunk);
|
||||
let mut hits = 0usize;
|
||||
for token in query_tokens {
|
||||
if chunk_norm.contains(token) {
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let token_score = hits as f64 / query_tokens.len() as f64;
|
||||
let phrase_bonus = if !query_phrase.is_empty() && chunk_norm.contains(query_phrase) {
|
||||
0.2
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
(token_score + phrase_bonus).min(1.0)
|
||||
}
|
||||
|
||||
fn semantic_backend_enabled() -> bool {
|
||||
!std::env::var(DISABLE_SEMANTIC_ENV)
|
||||
.ok()
|
||||
.as_deref()
|
||||
.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
}
|
||||
|
||||
fn embed(text: &str) -> Option<Vec<f64>> {
|
||||
let tokens = tokenize(text);
|
||||
if tokens.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut vec = vec![0.0_f64; EMBED_DIM];
|
||||
for token in tokens {
|
||||
let idx = (stable_hash(&token) as usize) % EMBED_DIM;
|
||||
vec[idx] += 1.0;
|
||||
}
|
||||
|
||||
let norm = vec.iter().map(|v| v * v).sum::<f64>().sqrt();
|
||||
if norm <= f64::EPSILON {
|
||||
return None;
|
||||
}
|
||||
|
||||
for value in &mut vec {
|
||||
*value /= norm;
|
||||
}
|
||||
|
||||
Some(vec)
|
||||
}
|
||||
|
||||
fn stable_hash(text: &str) -> u64 {
|
||||
// FNV-1a 64-bit
|
||||
let mut hash = 0xcbf29ce484222325_u64;
|
||||
for byte in text.as_bytes() {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
|
||||
if a.len() != b.len() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sum = 0.0;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
sum += x * y;
|
||||
}
|
||||
sum.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn compact_excerpt(text: &str, max_chars: usize) -> String {
|
||||
let normalized = text.replace('\n', " ").trim().to_string();
|
||||
if normalized.chars().count() <= max_chars {
|
||||
return normalized;
|
||||
}
|
||||
let head: String = normalized.chars().take(max_chars).collect();
|
||||
format!("{}...", head.trim_end())
|
||||
}
|
||||
|
||||
fn round_score(score: f64) -> f64 {
|
||||
(score * 10_000.0).round() / 10_000.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
use crate::tools::spec::{ToolContext, ToolSpec};
|
||||
|
||||
use super::SemanticSearchTool;
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_search_finds_relevant_chunk() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let root = tmp.path();
|
||||
|
||||
std::fs::create_dir_all(root.join("src")).expect("mkdir");
|
||||
std::fs::write(
|
||||
root.join("src/lib.rs"),
|
||||
"pub fn analyze() {\n // ownership and lifetimes are central\n}\n",
|
||||
)
|
||||
.expect("write");
|
||||
std::fs::write(root.join("README.md"), "terminal color theme settings\n").expect("write");
|
||||
|
||||
let ctx = ToolContext::new(root.to_path_buf());
|
||||
let tool = SemanticSearchTool;
|
||||
let result = tool
|
||||
.execute(json!({"query": "ownership lifetimes", "top_k": 3}), &ctx)
|
||||
.await
|
||||
.expect("execute");
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.content.contains("src/lib.rs"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_search_respects_path_filter() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let root = tmp.path();
|
||||
|
||||
std::fs::create_dir_all(root.join("src")).expect("mkdir");
|
||||
std::fs::create_dir_all(root.join("docs")).expect("mkdir");
|
||||
std::fs::write(root.join("src/lib.rs"), "fn alpha() {}\n").expect("write");
|
||||
std::fs::write(root.join("docs/guide.md"), "alpha beta gamma\n").expect("write");
|
||||
|
||||
let ctx = ToolContext::new(root.to_path_buf());
|
||||
let tool = SemanticSearchTool;
|
||||
let result = tool
|
||||
.execute(
|
||||
json!({"query": "alpha", "paths": ["docs"], "top_k": 3}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect("execute");
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.content.contains("docs/guide.md"));
|
||||
assert!(!result.content.contains("src/lib.rs"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_search_reports_fallback_when_disabled() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("notes.txt"), "offline semantic fallback\n").expect("write");
|
||||
|
||||
// Safety: test-only environment mutation scoped to this test.
|
||||
unsafe {
|
||||
std::env::set_var("DEEPSEEK_DISABLE_SEMANTIC_SEARCH", "1");
|
||||
}
|
||||
|
||||
let ctx = ToolContext::new(root.to_path_buf());
|
||||
let tool = SemanticSearchTool;
|
||||
let result = tool
|
||||
.execute(json!({"query": "offline fallback"}), &ctx)
|
||||
.await
|
||||
.expect("execute");
|
||||
|
||||
// Safety: test-only environment cleanup scoped to this test.
|
||||
unsafe {
|
||||
std::env::remove_var("DEEPSEEK_DISABLE_SEMANTIC_SEARCH");
|
||||
}
|
||||
|
||||
assert!(result.success);
|
||||
assert!(result.content.contains("\"fallback_used\": true"));
|
||||
}
|
||||
}
|
||||
+719
-328
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,199 @@
|
||||
use super::*;
|
||||
|
||||
use crate::tools::spec::ToolContext;
|
||||
use serde_json::{Value, json};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn echo_command(message: &str) -> String {
|
||||
format!("echo {message}")
|
||||
}
|
||||
|
||||
fn sleep_command(seconds: u64) -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let ping_count = seconds.saturating_add(1);
|
||||
let ps_path = r#"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"#;
|
||||
format!(
|
||||
"\"{ps_path}\" -NoProfile -Command \"Start-Sleep -Seconds {seconds}\" || ping 127.0.0.1 -n {ping_count} > NUL"
|
||||
)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
format!("sleep {seconds}")
|
||||
}
|
||||
}
|
||||
|
||||
fn sleep_then_echo_command(seconds: u64, message: &str) -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let ping_count = seconds.saturating_add(1);
|
||||
let ps_path = r#"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"#;
|
||||
format!(
|
||||
"\"{ps_path}\" -NoProfile -Command \"Start-Sleep -Seconds {seconds}; Write-Output {message}\" || (ping 127.0.0.1 -n {ping_count} > NUL && echo {message})"
|
||||
)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
format!("sleep {seconds} && echo {message}")
|
||||
}
|
||||
}
|
||||
|
||||
fn echo_stdin_command() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
"more".to_string()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
"cat".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_execution() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let mut manager = ShellManager::new(tmp.path().to_path_buf());
|
||||
|
||||
let result = manager
|
||||
.execute(&echo_command("hello"), None, 5000, false)
|
||||
.expect("execute");
|
||||
|
||||
assert_eq!(result.status, ShellStatus::Completed);
|
||||
assert!(result.stdout.contains("hello"));
|
||||
assert!(result.task_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_background_execution() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let mut manager = ShellManager::new(tmp.path().to_path_buf());
|
||||
|
||||
let result = manager
|
||||
.execute(&sleep_then_echo_command(1, "done"), None, 5000, true)
|
||||
.expect("execute");
|
||||
|
||||
assert_eq!(result.status, ShellStatus::Running);
|
||||
assert!(result.task_id.is_some());
|
||||
|
||||
let task_id = result
|
||||
.task_id
|
||||
.expect("background execution should return task_id");
|
||||
|
||||
// Wait for completion
|
||||
let final_result = manager
|
||||
.get_output(&task_id, true, 5000)
|
||||
.expect("get_output");
|
||||
|
||||
assert_eq!(final_result.status, ShellStatus::Completed);
|
||||
assert!(final_result.stdout.contains("done"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeout() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let mut manager = ShellManager::new(tmp.path().to_path_buf());
|
||||
|
||||
let result = manager
|
||||
.execute(&sleep_command(10), None, 1000, false)
|
||||
.expect("execute");
|
||||
|
||||
assert_eq!(result.status, ShellStatus::TimedOut);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kill() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let mut manager = ShellManager::new(tmp.path().to_path_buf());
|
||||
|
||||
let result = manager
|
||||
.execute(&sleep_command(60), None, 5000, true)
|
||||
.expect("execute");
|
||||
|
||||
let task_id = result
|
||||
.task_id
|
||||
.expect("background execution should return task_id");
|
||||
|
||||
// Kill it
|
||||
let killed = manager.kill(&task_id).expect("kill");
|
||||
assert_eq!(killed.status, ShellStatus::Killed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_stdin_streams_output() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let mut manager = ShellManager::new(tmp.path().to_path_buf());
|
||||
|
||||
let result = manager
|
||||
.execute_with_options(&echo_stdin_command(), None, 5000, true, None, false, None)
|
||||
.expect("execute");
|
||||
|
||||
let task_id = result
|
||||
.task_id
|
||||
.expect("background execution should return task_id");
|
||||
|
||||
manager
|
||||
.write_stdin(&task_id, "hello\n", true)
|
||||
.expect("write stdin");
|
||||
|
||||
let delta = manager
|
||||
.get_output_delta(&task_id, true, 5000)
|
||||
.expect("get_output_delta");
|
||||
|
||||
assert!(delta.result.stdout.contains("hello"));
|
||||
|
||||
let delta2 = manager
|
||||
.get_output_delta(&task_id, false, 0)
|
||||
.expect("get_output_delta");
|
||||
assert!(delta2.result.stdout.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_truncation() {
|
||||
let long_output = "x".repeat(50_000);
|
||||
let truncated = truncate_output(&long_output);
|
||||
|
||||
assert!(truncated.len() < long_output.len());
|
||||
assert!(truncated.contains("truncated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_with_meta_reports_omission_counts() {
|
||||
let long_output = format!("line1\nline2\n{}", "x".repeat(60_000));
|
||||
let (truncated, meta) = truncate_with_meta(&long_output);
|
||||
|
||||
assert!(meta.truncated);
|
||||
assert!(meta.original_len >= long_output.len());
|
||||
assert!(meta.omitted > 0);
|
||||
assert!(truncated.contains("bytes omitted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summarize_output_strips_truncation_note() {
|
||||
let long_output = "x".repeat(60_000);
|
||||
let truncated = truncate_output(&long_output);
|
||||
let summary = summarize_output(&truncated);
|
||||
assert!(!summary.contains("Output truncated at"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_exec_shell_metadata_includes_summaries() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path());
|
||||
let tool = ExecShellTool;
|
||||
|
||||
let result = tool
|
||||
.execute(json!({"command": echo_command("hello")}), &ctx)
|
||||
.await
|
||||
.expect("execute");
|
||||
assert!(result.success);
|
||||
|
||||
let meta = result.metadata.expect("metadata");
|
||||
let summary = meta
|
||||
.get("summary")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
assert!(summary.contains("hello"));
|
||||
assert!(meta.get("stdout_len").is_some());
|
||||
assert!(meta.get("stdout_truncated").is_some());
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Output truncation and summarization helpers for shell tools.
|
||||
|
||||
/// Maximum output size before truncation (30KB like Claude Code).
|
||||
const MAX_OUTPUT_SIZE: usize = 30_000;
|
||||
/// Limits for summary strings in tool metadata.
|
||||
const SUMMARY_MAX_LINES: usize = 3;
|
||||
const SUMMARY_MAX_CHARS: usize = 240;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub(crate) struct TruncationMeta {
|
||||
pub(crate) original_len: usize,
|
||||
pub(crate) omitted: usize,
|
||||
pub(crate) truncated: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_with_meta(output: &str) -> (String, TruncationMeta) {
|
||||
let original_len = output.len();
|
||||
if original_len <= MAX_OUTPUT_SIZE {
|
||||
return (
|
||||
output.to_string(),
|
||||
TruncationMeta {
|
||||
original_len,
|
||||
omitted: 0,
|
||||
truncated: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let cut_index = char_boundary_at_or_before(output, MAX_OUTPUT_SIZE);
|
||||
let truncated = &output[..cut_index];
|
||||
let omitted = original_len.saturating_sub(cut_index);
|
||||
let note =
|
||||
format!("...\n\n[Output truncated at {MAX_OUTPUT_SIZE} bytes. {omitted} bytes omitted.]");
|
||||
|
||||
(
|
||||
format!("{truncated}{note}"),
|
||||
TruncationMeta {
|
||||
original_len,
|
||||
omitted,
|
||||
truncated: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn char_boundary_at_or_before(text: &str, max_bytes: usize) -> usize {
|
||||
if max_bytes >= text.len() {
|
||||
return text.len();
|
||||
}
|
||||
|
||||
let mut last_end = 0usize;
|
||||
for (idx, ch) in text.char_indices() {
|
||||
let end = idx.saturating_add(ch.len_utf8());
|
||||
if end > max_bytes {
|
||||
break;
|
||||
}
|
||||
last_end = end;
|
||||
}
|
||||
|
||||
last_end.min(text.len())
|
||||
}
|
||||
|
||||
fn strip_truncation_note(text: &str) -> &str {
|
||||
text.split_once("\n\n[Output truncated at")
|
||||
.map_or(text, |(prefix, _)| prefix)
|
||||
}
|
||||
|
||||
fn truncate_chars(text: &str, max_chars: usize) -> String {
|
||||
if text.chars().count() <= max_chars {
|
||||
return text.to_string();
|
||||
}
|
||||
|
||||
let mut end = text.len();
|
||||
for (count, (idx, _)) in text.char_indices().enumerate() {
|
||||
if count == max_chars {
|
||||
end = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
format!("{}...", &text[..end])
|
||||
}
|
||||
|
||||
pub(crate) fn summarize_output(text: &str) -> String {
|
||||
let stripped = strip_truncation_note(text);
|
||||
let summary = stripped
|
||||
.lines()
|
||||
.take(SUMMARY_MAX_LINES)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if summary.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
truncate_chars(&summary, SUMMARY_MAX_CHARS)
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate output to `MAX_OUTPUT_SIZE`.
|
||||
pub(crate) fn truncate_output(output: &str) -> String {
|
||||
truncate_with_meta(output).0
|
||||
}
|
||||
+20
-2
@@ -13,6 +13,8 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::tools::shell::{SharedShellManager, new_shared_shell_manager};
|
||||
|
||||
/// Capabilities that a tool may have or require.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ToolCapability {
|
||||
@@ -177,6 +179,8 @@ pub enum SandboxPolicy {
|
||||
pub struct ToolContext {
|
||||
/// The workspace root directory
|
||||
pub workspace: PathBuf,
|
||||
/// Shared shell manager for background tasks and streaming IO.
|
||||
pub shell_manager: SharedShellManager,
|
||||
/// Whether to allow paths outside workspace
|
||||
pub trust_mode: bool,
|
||||
/// Current sandbox policy
|
||||
@@ -198,10 +202,12 @@ impl ToolContext {
|
||||
#[must_use]
|
||||
pub fn new(workspace: impl Into<PathBuf>) -> Self {
|
||||
let workspace = workspace.into();
|
||||
let shell_manager = new_shared_shell_manager(workspace.clone());
|
||||
let notes_path = workspace.join(".deepseek").join("notes.md");
|
||||
let mcp_config_path = workspace.join(".deepseek").join("mcp.json");
|
||||
Self {
|
||||
workspace,
|
||||
shell_manager,
|
||||
trust_mode: false,
|
||||
sandbox_policy: SandboxPolicy::None,
|
||||
notes_path,
|
||||
@@ -218,8 +224,11 @@ impl ToolContext {
|
||||
notes_path: impl Into<PathBuf>,
|
||||
mcp_config_path: impl Into<PathBuf>,
|
||||
) -> Self {
|
||||
let workspace = workspace.into();
|
||||
let shell_manager = new_shared_shell_manager(workspace.clone());
|
||||
Self {
|
||||
workspace: workspace.into(),
|
||||
workspace,
|
||||
shell_manager,
|
||||
trust_mode,
|
||||
sandbox_policy: SandboxPolicy::None,
|
||||
notes_path: notes_path.into(),
|
||||
@@ -237,8 +246,11 @@ impl ToolContext {
|
||||
mcp_config_path: impl Into<PathBuf>,
|
||||
auto_approve: bool,
|
||||
) -> Self {
|
||||
let workspace = workspace.into();
|
||||
let shell_manager = new_shared_shell_manager(workspace.clone());
|
||||
Self {
|
||||
workspace: workspace.into(),
|
||||
workspace,
|
||||
shell_manager,
|
||||
trust_mode,
|
||||
sandbox_policy: SandboxPolicy::None,
|
||||
notes_path: notes_path.into(),
|
||||
@@ -376,6 +388,12 @@ impl ToolContext {
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the shared shell manager.
|
||||
pub fn with_shell_manager(mut self, shell_manager: SharedShellManager) -> Self {
|
||||
self.shell_manager = shell_manager;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the elevated sandbox policy override.
|
||||
///
|
||||
/// This is used when retrying a tool after a sandbox denial, to run
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
//! Sports tool for schedules and standings (ESPN public APIs).
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
optional_str, optional_u64, required_str,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{NaiveDate, Utc};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT_MS: u64 = 15_000;
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct SportsGameTeam {
|
||||
name: String,
|
||||
abbreviation: String,
|
||||
score: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct SportsGame {
|
||||
id: String,
|
||||
date: String,
|
||||
status: String,
|
||||
home: SportsGameTeam,
|
||||
away: SportsGameTeam,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct SportsScheduleResponse {
|
||||
league: String,
|
||||
games: Vec<SportsGame>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct SportsStandingEntry {
|
||||
name: String,
|
||||
abbreviation: String,
|
||||
stats: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct SportsStandingsResponse {
|
||||
league: String,
|
||||
entries: Vec<SportsStandingEntry>,
|
||||
}
|
||||
|
||||
pub struct SportsTool;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for SportsTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"sports"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Get sports schedules or standings for a league."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fn": { "type": "string", "enum": ["schedule", "standings"] },
|
||||
"league": { "type": "string" },
|
||||
"team": { "type": "string" },
|
||||
"opponent": { "type": "string" },
|
||||
"date_from": { "type": "string", "description": "YYYY-MM-DD" },
|
||||
"date_to": { "type": "string", "description": "YYYY-MM-DD" },
|
||||
"num_games": { "type": "integer" },
|
||||
"locale": { "type": "string" }
|
||||
},
|
||||
"required": ["fn", "league"]
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly, ToolCapability::Network]
|
||||
}
|
||||
|
||||
fn supports_parallel(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let action = required_str(&input, "fn")?.to_lowercase();
|
||||
let league = required_str(&input, "league")?.to_lowercase();
|
||||
let team = optional_str(&input, "team").map(|s| s.to_string());
|
||||
let opponent = optional_str(&input, "opponent").map(|s| s.to_string());
|
||||
let date_from = optional_str(&input, "date_from").map(|s| s.to_string());
|
||||
let date_to = optional_str(&input, "date_to").map(|s| s.to_string());
|
||||
let num_games = optional_u64(&input, "num_games", 20) as usize;
|
||||
|
||||
let (sport, league_code) = map_league(&league)?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(TIMEOUT_MS))
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
|
||||
})?;
|
||||
|
||||
match action.as_str() {
|
||||
"schedule" => {
|
||||
let schedule = fetch_schedule(
|
||||
&client,
|
||||
&sport,
|
||||
&league_code,
|
||||
date_from.as_deref(),
|
||||
date_to.as_deref(),
|
||||
team.as_deref(),
|
||||
opponent.as_deref(),
|
||||
num_games,
|
||||
)
|
||||
.await?;
|
||||
ToolResult::json(&schedule).map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
"standings" => {
|
||||
let standings = fetch_standings(&client, &sport, &league_code).await?;
|
||||
ToolResult::json(&standings).map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
_ => Err(ToolError::invalid_input("fn must be schedule or standings")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_league(league: &str) -> Result<(String, String), ToolError> {
|
||||
match league {
|
||||
"nba" => Ok(("basketball".to_string(), "nba".to_string())),
|
||||
"wnba" => Ok(("basketball".to_string(), "wnba".to_string())),
|
||||
"nfl" => Ok(("football".to_string(), "nfl".to_string())),
|
||||
"nhl" => Ok(("hockey".to_string(), "nhl".to_string())),
|
||||
"mlb" => Ok(("baseball".to_string(), "mlb".to_string())),
|
||||
"epl" => Ok(("soccer".to_string(), "eng.1".to_string())),
|
||||
"ncaamb" => Ok((
|
||||
"basketball".to_string(),
|
||||
"mens-college-basketball".to_string(),
|
||||
)),
|
||||
"ncaawb" => Ok((
|
||||
"basketball".to_string(),
|
||||
"womens-college-basketball".to_string(),
|
||||
)),
|
||||
"ipl" => Ok(("cricket".to_string(), "ipl".to_string())),
|
||||
_ => Err(ToolError::invalid_input("Unsupported league")),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn fetch_schedule(
|
||||
client: &reqwest::Client,
|
||||
sport: &str,
|
||||
league: &str,
|
||||
date_from: Option<&str>,
|
||||
date_to: Option<&str>,
|
||||
team: Option<&str>,
|
||||
opponent: Option<&str>,
|
||||
num_games: usize,
|
||||
) -> Result<SportsScheduleResponse, ToolError> {
|
||||
let date_param = build_date_param(date_from, date_to)?;
|
||||
let url = if let Some(dates) = date_param {
|
||||
format!(
|
||||
"https://site.web.api.espn.com/apis/v2/sports/{sport}/{league}/scoreboard?dates={dates}"
|
||||
)
|
||||
} else {
|
||||
format!("https://site.web.api.espn.com/apis/v2/sports/{sport}/{league}/scoreboard")
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("Schedule request failed: {e}")))?;
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?;
|
||||
if !status.is_success() {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"Schedule failed: HTTP {}",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
|
||||
let json: Value = serde_json::from_str(&body)
|
||||
.map_err(|e| ToolError::execution_failed(format!("Invalid schedule JSON: {e}")))?;
|
||||
let events = json
|
||||
.get("events")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut games = Vec::new();
|
||||
for event in events.iter() {
|
||||
let competitions = match event.get("competitions").and_then(|v| v.as_array()) {
|
||||
Some(list) => list,
|
||||
None => continue,
|
||||
};
|
||||
let competition = match competitions.first() {
|
||||
Some(comp) => comp,
|
||||
None => continue,
|
||||
};
|
||||
let competitors = match competition.get("competitors").and_then(|v| v.as_array()) {
|
||||
Some(list) => list,
|
||||
None => continue,
|
||||
};
|
||||
if competitors.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let (home, away) = split_competitors(competitors);
|
||||
if let (Some(home), Some(away)) = (home, away) {
|
||||
if let Some(team_filter) = team {
|
||||
if !team_matches(&home, team_filter) && !team_matches(&away, team_filter) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(opponent_filter) = opponent {
|
||||
if !team_matches(&home, opponent_filter) && !team_matches(&away, opponent_filter) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let status = competition
|
||||
.get("status")
|
||||
.and_then(|v| v.get("type"))
|
||||
.and_then(|v| v.get("description"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
let game = SportsGame {
|
||||
id: event
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
date: event
|
||||
.get("date")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
status,
|
||||
home,
|
||||
away,
|
||||
};
|
||||
games.push(game);
|
||||
}
|
||||
if games.len() >= num_games {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SportsScheduleResponse {
|
||||
league: league.to_string(),
|
||||
games,
|
||||
})
|
||||
}
|
||||
|
||||
fn split_competitors(competitors: &[Value]) -> (Option<SportsGameTeam>, Option<SportsGameTeam>) {
|
||||
let mut home = None;
|
||||
let mut away = None;
|
||||
for comp in competitors {
|
||||
let team = comp.get("team").and_then(|v| v.as_object());
|
||||
let name = team
|
||||
.and_then(|t| t.get("displayName"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let abbreviation = team
|
||||
.and_then(|t| t.get("abbreviation"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let score = comp
|
||||
.get("score")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let home_away = comp.get("homeAway").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let team_info = SportsGameTeam {
|
||||
name,
|
||||
abbreviation,
|
||||
score,
|
||||
};
|
||||
if home_away == "home" {
|
||||
home = Some(team_info);
|
||||
} else {
|
||||
away = Some(team_info);
|
||||
}
|
||||
}
|
||||
(home, away)
|
||||
}
|
||||
|
||||
fn team_matches(team: &SportsGameTeam, filter: &str) -> bool {
|
||||
let req = filter.to_lowercase();
|
||||
team.abbreviation.to_lowercase() == req || team.name.to_lowercase().contains(&req)
|
||||
}
|
||||
|
||||
async fn fetch_standings(
|
||||
client: &reqwest::Client,
|
||||
sport: &str,
|
||||
league: &str,
|
||||
) -> Result<SportsStandingsResponse, ToolError> {
|
||||
let url = format!("https://site.web.api.espn.com/apis/v2/sports/{sport}/{league}/standings");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("Standings request failed: {e}")))?;
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?;
|
||||
if !status.is_success() {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"Standings failed: HTTP {}",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
let json: Value = serde_json::from_str(&body)
|
||||
.map_err(|e| ToolError::execution_failed(format!("Invalid standings JSON: {e}")))?;
|
||||
|
||||
let entries = collect_standings_entries(&json);
|
||||
let mut output = Vec::new();
|
||||
for entry in entries {
|
||||
let team = entry.get("team").and_then(|v| v.as_object());
|
||||
let name = team
|
||||
.and_then(|t| t.get("displayName"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let abbreviation = team
|
||||
.and_then(|t| t.get("abbreviation"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let stats = entry
|
||||
.get("stats")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|stats| {
|
||||
let mut map = serde_json::Map::new();
|
||||
for stat in stats {
|
||||
if let Some(name) = stat.get("name").and_then(|v| v.as_str()) {
|
||||
if let Some(val) = stat.get("displayValue").or_else(|| stat.get("value")) {
|
||||
map.insert(name.to_string(), val.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(map)
|
||||
})
|
||||
.unwrap_or_else(|| json!({}));
|
||||
output.push(SportsStandingEntry {
|
||||
name,
|
||||
abbreviation,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SportsStandingsResponse {
|
||||
league: league.to_string(),
|
||||
entries: output,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_standings_entries(value: &Value) -> Vec<Value> {
|
||||
if let Some(entries) = value
|
||||
.get("standings")
|
||||
.and_then(|v| v.get("entries"))
|
||||
.and_then(|v| v.as_array())
|
||||
{
|
||||
return entries.clone();
|
||||
}
|
||||
|
||||
if let Some(children) = value.get("children").and_then(|v| v.as_array()) {
|
||||
let mut entries = Vec::new();
|
||||
for child in children {
|
||||
entries.extend(collect_standings_entries(child));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn build_date_param(
|
||||
date_from: Option<&str>,
|
||||
date_to: Option<&str>,
|
||||
) -> Result<Option<String>, ToolError> {
|
||||
let start = match date_from {
|
||||
Some(date) => NaiveDate::parse_from_str(date, "%Y-%m-%d")
|
||||
.map_err(|_| ToolError::invalid_input("date_from must be YYYY-MM-DD"))?,
|
||||
None => Utc::now().date_naive(),
|
||||
};
|
||||
|
||||
if let Some(date_to) = date_to {
|
||||
let end = NaiveDate::parse_from_str(date_to, "%Y-%m-%d")
|
||||
.map_err(|_| ToolError::invalid_input("date_to must be YYYY-MM-DD"))?;
|
||||
if end < start {
|
||||
return Err(ToolError::invalid_input("date_to must be >= date_from"));
|
||||
}
|
||||
let diff = (end - start).num_days();
|
||||
if diff > 7 {
|
||||
return Ok(Some(start.format("%Y%m%d").to_string()));
|
||||
}
|
||||
return Ok(Some(format!(
|
||||
"{}-{}",
|
||||
start.format("%Y%m%d"),
|
||||
end.format("%Y%m%d")
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Some(start.format("%Y%m%d").to_string()))
|
||||
}
|
||||
+364
-13
@@ -4,7 +4,7 @@
|
||||
//! and retrieve results. Sub-agents run with a filtered toolset and
|
||||
//! inherit the workspace configuration from the main session.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -39,10 +39,11 @@ const RESULT_POLL_INTERVAL: Duration = Duration::from_millis(250);
|
||||
// === Types ===
|
||||
|
||||
/// Sub-agent execution types with specialized behavior and tool access.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SubAgentType {
|
||||
/// General purpose - full tool access for multi-step tasks.
|
||||
#[default]
|
||||
General,
|
||||
/// Fast exploration - read-only tools for codebase search.
|
||||
Explore,
|
||||
@@ -92,8 +93,19 @@ impl SubAgentType {
|
||||
"apply_patch",
|
||||
"grep_files",
|
||||
"file_search",
|
||||
"web.run",
|
||||
"web_search",
|
||||
"multi_tool_use.parallel",
|
||||
"weather",
|
||||
"finance",
|
||||
"sports",
|
||||
"time",
|
||||
"calculator",
|
||||
"exec_shell",
|
||||
"exec_shell_wait",
|
||||
"exec_shell_interact",
|
||||
"exec_wait",
|
||||
"exec_interact",
|
||||
"note",
|
||||
"todo_write",
|
||||
"todo_add",
|
||||
@@ -106,15 +118,32 @@ impl SubAgentType {
|
||||
"read_file",
|
||||
"grep_files",
|
||||
"file_search",
|
||||
"web.run",
|
||||
"web_search",
|
||||
"multi_tool_use.parallel",
|
||||
"weather",
|
||||
"finance",
|
||||
"sports",
|
||||
"time",
|
||||
"calculator",
|
||||
"exec_shell",
|
||||
"exec_shell_wait",
|
||||
"exec_shell_interact",
|
||||
"exec_wait",
|
||||
"exec_interact",
|
||||
],
|
||||
Self::Plan => vec![
|
||||
"list_dir",
|
||||
"read_file",
|
||||
"grep_files",
|
||||
"file_search",
|
||||
"web.run",
|
||||
"note",
|
||||
"weather",
|
||||
"finance",
|
||||
"sports",
|
||||
"time",
|
||||
"calculator",
|
||||
"update_plan",
|
||||
"todo_write",
|
||||
"todo_add",
|
||||
@@ -127,12 +156,6 @@ impl SubAgentType {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SubAgentType {
|
||||
fn default() -> Self {
|
||||
Self::General
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of a sub-agent execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum SubAgentStatus {
|
||||
@@ -153,6 +176,12 @@ pub struct SubAgentResult {
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SubAgentInput {
|
||||
text: String,
|
||||
interrupt: bool,
|
||||
}
|
||||
|
||||
/// Runtime configuration for spawning sub-agents.
|
||||
#[derive(Clone)]
|
||||
pub struct SubAgentRuntime {
|
||||
@@ -193,12 +222,18 @@ pub struct SubAgent {
|
||||
pub steps_taken: u32,
|
||||
pub started_at: Instant,
|
||||
pub allowed_tools: Vec<String>,
|
||||
input_tx: Option<mpsc::UnboundedSender<SubAgentInput>>,
|
||||
task_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl SubAgent {
|
||||
/// Create a new sub-agent.
|
||||
fn new(agent_type: SubAgentType, prompt: String, allowed_tools: Vec<String>) -> Self {
|
||||
fn new(
|
||||
agent_type: SubAgentType,
|
||||
prompt: String,
|
||||
allowed_tools: Vec<String>,
|
||||
input_tx: mpsc::UnboundedSender<SubAgentInput>,
|
||||
) -> Self {
|
||||
let id = format!("agent_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
|
||||
Self {
|
||||
@@ -210,6 +245,7 @@ impl SubAgent {
|
||||
steps_taken: 0,
|
||||
started_at: Instant::now(),
|
||||
allowed_tools,
|
||||
input_tx: Some(input_tx),
|
||||
task_handle: None,
|
||||
}
|
||||
}
|
||||
@@ -285,7 +321,8 @@ impl SubAgentManager {
|
||||
}
|
||||
|
||||
let tools = build_allowed_tools(&agent_type, allowed_tools, runtime.allow_shell)?;
|
||||
let mut agent = SubAgent::new(agent_type.clone(), prompt.clone(), tools.clone());
|
||||
let (input_tx, input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(agent_type.clone(), prompt.clone(), tools.clone(), input_tx);
|
||||
let agent_id = agent.id.clone();
|
||||
let started_at = agent.started_at;
|
||||
let max_steps = self.max_steps;
|
||||
@@ -306,6 +343,7 @@ impl SubAgentManager {
|
||||
allowed_tools: tools,
|
||||
started_at,
|
||||
max_steps,
|
||||
input_rx,
|
||||
};
|
||||
let handle = tokio::spawn(run_subagent_task(task));
|
||||
agent.task_handle = Some(handle);
|
||||
@@ -344,6 +382,28 @@ impl SubAgentManager {
|
||||
Ok(agent.snapshot())
|
||||
}
|
||||
|
||||
/// Send input to a running sub-agent.
|
||||
pub fn send_input(&mut self, agent_id: &str, text: String, interrupt: bool) -> Result<()> {
|
||||
let agent = self
|
||||
.agents
|
||||
.get_mut(agent_id)
|
||||
.ok_or_else(|| anyhow!("Agent {agent_id} not found"))?;
|
||||
|
||||
if agent.status != SubAgentStatus::Running {
|
||||
return Err(anyhow!("Agent {agent_id} is not running"));
|
||||
}
|
||||
|
||||
let tx = agent
|
||||
.input_tx
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Agent {agent_id} cannot accept input"))?;
|
||||
|
||||
tx.send(SubAgentInput { text, interrupt })
|
||||
.map_err(|_| anyhow!("Failed to send input to agent {agent_id}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all agents and their status.
|
||||
#[must_use]
|
||||
pub fn list(&self) -> Vec<SubAgentResult> {
|
||||
@@ -664,6 +724,250 @@ impl ToolSpec for AgentListTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool to send input to a running sub-agent.
|
||||
pub struct AgentSendInputTool {
|
||||
manager: SharedSubAgentManager,
|
||||
name: &'static str,
|
||||
}
|
||||
|
||||
impl AgentSendInputTool {
|
||||
/// Create a new send-input tool.
|
||||
#[must_use]
|
||||
pub fn new(manager: SharedSubAgentManager, name: &'static str) -> Self {
|
||||
Self { manager, name }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for AgentSendInputTool {
|
||||
fn name(&self) -> &'static str {
|
||||
self.name
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Send input to a running sub-agent."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "ID returned by agent_spawn"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Alias for agent_id"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Message to deliver to the agent"
|
||||
},
|
||||
"input": {
|
||||
"type": "string",
|
||||
"description": "Alias for message"
|
||||
},
|
||||
"interrupt": {
|
||||
"type": "boolean",
|
||||
"description": "Prioritize this message over pending inputs"
|
||||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let agent_id = input
|
||||
.get("agent_id")
|
||||
.or_else(|| input.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::missing_field("agent_id"))?;
|
||||
let message = input
|
||||
.get("message")
|
||||
.or_else(|| input.get("input"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::missing_field("message"))?;
|
||||
let interrupt = optional_bool(&input, "interrupt", false);
|
||||
|
||||
let mut manager = self.manager.lock().await;
|
||||
manager
|
||||
.send_input(agent_id, message.to_string(), interrupt)
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
|
||||
let snapshot = manager
|
||||
.get_result(agent_id)
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
|
||||
|
||||
ToolResult::json(&snapshot).map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool to wait for sub-agents to complete.
|
||||
pub struct AgentWaitTool {
|
||||
manager: SharedSubAgentManager,
|
||||
name: &'static str,
|
||||
}
|
||||
|
||||
impl AgentWaitTool {
|
||||
/// Create a new wait tool.
|
||||
#[must_use]
|
||||
pub fn new(manager: SharedSubAgentManager, name: &'static str) -> Self {
|
||||
Self { manager, name }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for AgentWaitTool {
|
||||
fn name(&self) -> &'static str {
|
||||
self.name
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Wait for one or more sub-agents to reach a terminal status."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Agent IDs to wait on"
|
||||
},
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "Single agent ID"
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "integer",
|
||||
"description": "Max wait time in milliseconds (default: 30000)"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly]
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let timeout_ms = optional_u64(&input, "timeout_ms", 30_000).clamp(1000, 300_000);
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
if let Some(list) = input.get("ids").and_then(|v| v.as_array()) {
|
||||
ids.extend(
|
||||
list.iter()
|
||||
.filter_map(|item| item.as_str().map(str::to_string)),
|
||||
);
|
||||
}
|
||||
if ids.is_empty() {
|
||||
if let Some(id) = input.get("agent_id").and_then(|v| v.as_str()) {
|
||||
ids.push(id.to_string());
|
||||
}
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Err(ToolError::missing_field("ids"));
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
|
||||
loop {
|
||||
let snapshots = {
|
||||
let manager = self.manager.lock().await;
|
||||
ids.iter()
|
||||
.map(|id| {
|
||||
manager
|
||||
.get_result(id)
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
|
||||
let any_done = snapshots
|
||||
.iter()
|
||||
.any(|snapshot| snapshot.status != SubAgentStatus::Running);
|
||||
if any_done || Instant::now() >= deadline {
|
||||
return ToolResult::json(&snapshots)
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()));
|
||||
}
|
||||
|
||||
tokio::time::sleep(RESULT_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool to delegate a task to a specialized agent (alias for agent_spawn).
|
||||
pub struct DelegateToAgentTool {
|
||||
manager: SharedSubAgentManager,
|
||||
runtime: SubAgentRuntime,
|
||||
}
|
||||
|
||||
impl DelegateToAgentTool {
|
||||
/// Create a new delegation tool.
|
||||
#[must_use]
|
||||
pub fn new(manager: SharedSubAgentManager, runtime: SubAgentRuntime) -> Self {
|
||||
Self { manager, runtime }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for DelegateToAgentTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"delegate_to_agent"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Delegate a task to a specialized sub-agent. This is an alias for agent_spawn."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_name": {
|
||||
"type": "string",
|
||||
"description": "Name or type of the agent: general, explore, plan, review"
|
||||
},
|
||||
"objective": {
|
||||
"type": "string",
|
||||
"description": "The goal or task description for the agent"
|
||||
}
|
||||
},
|
||||
"required": ["objective"]
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![
|
||||
ToolCapability::ExecutesCode,
|
||||
ToolCapability::RequiresApproval,
|
||||
]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Required
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let objective = required_str(&input, "objective")?.to_string();
|
||||
let agent_type_str = input
|
||||
.get("agent_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("general");
|
||||
|
||||
let spawn_input = json!({
|
||||
"prompt": objective,
|
||||
"type": agent_type_str
|
||||
});
|
||||
|
||||
let spawn_tool = AgentSpawnTool::new(self.manager.clone(), self.runtime.clone());
|
||||
spawn_tool.execute(spawn_input, context).await
|
||||
}
|
||||
}
|
||||
|
||||
// === Sub-agent Execution ===
|
||||
|
||||
struct SubAgentTask {
|
||||
@@ -675,6 +979,7 @@ struct SubAgentTask {
|
||||
allowed_tools: Vec<String>,
|
||||
started_at: Instant,
|
||||
max_steps: u32,
|
||||
input_rx: mpsc::UnboundedReceiver<SubAgentInput>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
@@ -687,6 +992,7 @@ async fn run_subagent_task(task: SubAgentTask) {
|
||||
task.allowed_tools,
|
||||
task.started_at,
|
||||
task.max_steps,
|
||||
task.input_rx,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -708,7 +1014,7 @@ async fn run_subagent_task(task: SubAgentTask) {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||
async fn run_subagent(
|
||||
runtime: &SubAgentRuntime,
|
||||
agent_id: String,
|
||||
@@ -717,6 +1023,7 @@ async fn run_subagent(
|
||||
allowed_tools: Vec<String>,
|
||||
started_at: Instant,
|
||||
max_steps: u32,
|
||||
mut input_rx: mpsc::UnboundedReceiver<SubAgentInput>,
|
||||
) -> Result<SubAgentResult> {
|
||||
let system_prompt = agent_type.system_prompt();
|
||||
let tool_registry = SubAgentToolRegistry::new(
|
||||
@@ -738,10 +1045,30 @@ async fn run_subagent(
|
||||
|
||||
let mut steps = 0;
|
||||
let mut final_result: Option<String> = None;
|
||||
let mut pending_inputs: VecDeque<SubAgentInput> = VecDeque::new();
|
||||
|
||||
for _step in 0..max_steps {
|
||||
steps += 1;
|
||||
|
||||
while let Ok(input) = input_rx.try_recv() {
|
||||
if input.interrupt {
|
||||
pending_inputs.clear();
|
||||
}
|
||||
pending_inputs.push_back(input);
|
||||
}
|
||||
|
||||
while let Some(input) = pending_inputs.pop_front() {
|
||||
if !input.text.trim().is_empty() {
|
||||
messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: input.text,
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let request = MessageRequest {
|
||||
model: runtime.model.clone(),
|
||||
messages: messages.clone(),
|
||||
@@ -779,7 +1106,16 @@ async fn run_subagent(
|
||||
});
|
||||
|
||||
if tool_uses.is_empty() {
|
||||
break;
|
||||
while let Ok(input) = input_rx.try_recv() {
|
||||
if input.interrupt {
|
||||
pending_inputs.clear();
|
||||
}
|
||||
pending_inputs.push_back(input);
|
||||
}
|
||||
if pending_inputs.is_empty() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut tool_results: Vec<ContentBlock> = Vec::new();
|
||||
@@ -917,7 +1253,16 @@ fn build_allowed_tools(
|
||||
}
|
||||
|
||||
if !allow_shell {
|
||||
tools.retain(|tool| tool != "exec_shell");
|
||||
tools.retain(|tool| {
|
||||
!matches!(
|
||||
tool.as_str(),
|
||||
"exec_shell"
|
||||
| "exec_shell_wait"
|
||||
| "exec_shell_interact"
|
||||
| "exec_wait"
|
||||
| "exec_interact"
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
Ok(tools)
|
||||
@@ -1045,6 +1390,10 @@ mod tests {
|
||||
fn test_allowed_tools_shell_filter() {
|
||||
let tools = build_allowed_tools(&SubAgentType::General, None, false).unwrap();
|
||||
assert!(!tools.contains(&"exec_shell".to_string()));
|
||||
assert!(!tools.contains(&"exec_shell_wait".to_string()));
|
||||
assert!(!tools.contains(&"exec_shell_interact".to_string()));
|
||||
assert!(!tools.contains(&"exec_wait".to_string()));
|
||||
assert!(!tools.contains(&"exec_interact".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1056,10 +1405,12 @@ mod tests {
|
||||
#[test]
|
||||
fn test_running_count_respects_limit() {
|
||||
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
|
||||
let (input_tx, _input_rx) = mpsc::unbounded_channel();
|
||||
let mut agent = SubAgent::new(
|
||||
SubAgentType::Explore,
|
||||
"prompt".to_string(),
|
||||
vec!["read_file".to_string()],
|
||||
input_tx,
|
||||
);
|
||||
agent.status = SubAgentStatus::Running;
|
||||
manager.agents.insert(agent.id.clone(), agent);
|
||||
|
||||
+1
-4
@@ -634,10 +634,7 @@ fn validate_swarm_tasks(tasks: &[SwarmTaskSpec]) -> Result<(), ToolError> {
|
||||
)));
|
||||
}
|
||||
if matches!(task.agent_type, Some(SubAgentType::Custom)) {
|
||||
let tools = task
|
||||
.allowed_tools
|
||||
.as_deref()
|
||||
.unwrap_or(&[]);
|
||||
let tools = task.allowed_tools.as_deref().unwrap_or(&[]);
|
||||
if tools.is_empty() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"task '{id}' requires allowed_tools for custom type"
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Time tool for returning the current time at a given UTC offset.
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, FixedOffset, Utc};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct TimeRequest {
|
||||
utc_offset: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct TimeResult {
|
||||
utc_offset: String,
|
||||
datetime: String,
|
||||
date: String,
|
||||
time: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct TimeResponse {
|
||||
results: Vec<TimeResult>,
|
||||
}
|
||||
|
||||
pub struct TimeTool;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for TimeTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"time"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Get the current time for a given UTC offset."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"utc_offset": {
|
||||
"type": "string",
|
||||
"description": "UTC offset like +03:00 or -07:00"
|
||||
},
|
||||
"time": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"utc_offset": { "type": "string" }
|
||||
},
|
||||
"required": ["utc_offset"]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly]
|
||||
}
|
||||
|
||||
fn supports_parallel(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let requests = parse_time_requests(&input)?;
|
||||
let mut results = Vec::with_capacity(requests.len());
|
||||
|
||||
for req in requests {
|
||||
let offset = parse_offset(&req.utc_offset)?;
|
||||
let now: DateTime<FixedOffset> = Utc::now().with_timezone(&offset);
|
||||
results.push(TimeResult {
|
||||
utc_offset: req.utc_offset,
|
||||
datetime: now.to_rfc3339(),
|
||||
date: now.format("%Y-%m-%d").to_string(),
|
||||
time: now.format("%H:%M:%S").to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
ToolResult::json(&TimeResponse { results })
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_time_requests(input: &Value) -> Result<Vec<TimeRequest>, ToolError> {
|
||||
if let Some(list) = input.get("time").and_then(|v| v.as_array()) {
|
||||
let mut requests = Vec::new();
|
||||
for item in list {
|
||||
let offset = required_str(item, "utc_offset")?.to_string();
|
||||
requests.push(TimeRequest { utc_offset: offset });
|
||||
}
|
||||
if requests.is_empty() {
|
||||
return Err(ToolError::invalid_input("time list is empty"));
|
||||
}
|
||||
return Ok(requests);
|
||||
}
|
||||
|
||||
let offset = required_str(input, "utc_offset")?.to_string();
|
||||
Ok(vec![TimeRequest { utc_offset: offset }])
|
||||
}
|
||||
|
||||
fn parse_offset(raw: &str) -> Result<FixedOffset, ToolError> {
|
||||
let raw = raw.trim();
|
||||
if raw.len() != 6 {
|
||||
return Err(ToolError::invalid_input(
|
||||
"utc_offset must be formatted like +03:00",
|
||||
));
|
||||
}
|
||||
let sign = match &raw[0..1] {
|
||||
"+" => 1,
|
||||
"-" => -1,
|
||||
_ => {
|
||||
return Err(ToolError::invalid_input(
|
||||
"utc_offset must start with + or -",
|
||||
));
|
||||
}
|
||||
};
|
||||
let hours: i32 = raw[1..3]
|
||||
.parse()
|
||||
.map_err(|_| ToolError::invalid_input("Invalid utc_offset hours"))?;
|
||||
let minutes: i32 = raw[4..6]
|
||||
.parse()
|
||||
.map_err(|_| ToolError::invalid_input("Invalid utc_offset minutes"))?;
|
||||
if &raw[3..4] != ":" {
|
||||
return Err(ToolError::invalid_input(
|
||||
"utc_offset must include ':' separator",
|
||||
));
|
||||
}
|
||||
let total_seconds = sign * (hours * 3600 + minutes * 60);
|
||||
FixedOffset::east_opt(total_seconds)
|
||||
.ok_or_else(|| ToolError::invalid_input("Invalid utc_offset range"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_offset() {
|
||||
let offset = parse_offset("+03:00").expect("offset");
|
||||
assert_eq!(offset.local_minus_utc(), 3 * 3600);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Tool and types for requesting user input via the TUI.
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserInputOption {
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserInputQuestion {
|
||||
pub header: String,
|
||||
pub id: String,
|
||||
pub question: String,
|
||||
pub options: Vec<UserInputOption>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserInputRequest {
|
||||
pub questions: Vec<UserInputQuestion>,
|
||||
}
|
||||
|
||||
impl UserInputRequest {
|
||||
pub fn from_value(value: &Value) -> Result<Self, ToolError> {
|
||||
let request: UserInputRequest = serde_json::from_value(value.clone()).map_err(|e| {
|
||||
ToolError::invalid_input(format!("Invalid request_user_input payload: {e}"))
|
||||
})?;
|
||||
request.validate()?;
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), ToolError> {
|
||||
if self.questions.is_empty() {
|
||||
return Err(ToolError::invalid_input(
|
||||
"request_user_input.questions must be non-empty",
|
||||
));
|
||||
}
|
||||
if self.questions.len() > 3 {
|
||||
return Err(ToolError::invalid_input(
|
||||
"request_user_input.questions must contain 1 to 3 items",
|
||||
));
|
||||
}
|
||||
for q in &self.questions {
|
||||
if q.header.trim().is_empty() {
|
||||
return Err(ToolError::invalid_input(
|
||||
"request_user_input.questions.header cannot be empty",
|
||||
));
|
||||
}
|
||||
if q.id.trim().is_empty() {
|
||||
return Err(ToolError::invalid_input(
|
||||
"request_user_input.questions.id cannot be empty",
|
||||
));
|
||||
}
|
||||
if q.question.trim().is_empty() {
|
||||
return Err(ToolError::invalid_input(
|
||||
"request_user_input.questions.question cannot be empty",
|
||||
));
|
||||
}
|
||||
if q.options.len() < 2 || q.options.len() > 3 {
|
||||
return Err(ToolError::invalid_input(
|
||||
"request_user_input.questions.options must contain 2 or 3 items",
|
||||
));
|
||||
}
|
||||
for opt in &q.options {
|
||||
if opt.label.trim().is_empty() {
|
||||
return Err(ToolError::invalid_input(
|
||||
"request_user_input option label cannot be empty",
|
||||
));
|
||||
}
|
||||
if opt.description.trim().is_empty() {
|
||||
return Err(ToolError::invalid_input(
|
||||
"request_user_input option description cannot be empty",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserInputAnswer {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserInputResponse {
|
||||
pub answers: Vec<UserInputAnswer>,
|
||||
}
|
||||
|
||||
pub struct RequestUserInputTool;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for RequestUserInputTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"request_user_input"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Ask the user 1-3 short questions and return their selections."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"questions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"header": { "type": "string" },
|
||||
"id": { "type": "string" },
|
||||
"question": { "type": "string" },
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": { "type": "string" },
|
||||
"description": { "type": "string" }
|
||||
},
|
||||
"required": ["label", "description"]
|
||||
},
|
||||
"minItems": 2,
|
||||
"maxItems": 3
|
||||
}
|
||||
},
|
||||
"required": ["header", "id", "question", "options"]
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 3
|
||||
}
|
||||
},
|
||||
"required": ["questions"]
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_input: Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
Err(ToolError::execution_failed(
|
||||
"request_user_input must be handled by the engine",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_request_shape() {
|
||||
let request = UserInputRequest {
|
||||
questions: vec![UserInputQuestion {
|
||||
header: "Pick".to_string(),
|
||||
id: "choice".to_string(),
|
||||
question: "Which option?".to_string(),
|
||||
options: vec![
|
||||
UserInputOption {
|
||||
label: "A".to_string(),
|
||||
description: "Option A".to_string(),
|
||||
},
|
||||
UserInputOption {
|
||||
label: "B".to_string(),
|
||||
description: "Option B".to_string(),
|
||||
},
|
||||
],
|
||||
}],
|
||||
};
|
||||
assert!(request.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_many_questions() {
|
||||
let request = UserInputRequest {
|
||||
questions: vec![
|
||||
UserInputQuestion {
|
||||
header: "Q1".to_string(),
|
||||
id: "q1".to_string(),
|
||||
question: "?".to_string(),
|
||||
options: vec![
|
||||
UserInputOption {
|
||||
label: "A".to_string(),
|
||||
description: "A".to_string(),
|
||||
},
|
||||
UserInputOption {
|
||||
label: "B".to_string(),
|
||||
description: "B".to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
UserInputQuestion {
|
||||
header: "Q2".to_string(),
|
||||
id: "q2".to_string(),
|
||||
question: "?".to_string(),
|
||||
options: vec![
|
||||
UserInputOption {
|
||||
label: "A".to_string(),
|
||||
description: "A".to_string(),
|
||||
},
|
||||
UserInputOption {
|
||||
label: "B".to_string(),
|
||||
description: "B".to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
UserInputQuestion {
|
||||
header: "Q3".to_string(),
|
||||
id: "q3".to_string(),
|
||||
question: "?".to_string(),
|
||||
options: vec![
|
||||
UserInputOption {
|
||||
label: "A".to_string(),
|
||||
description: "A".to_string(),
|
||||
},
|
||||
UserInputOption {
|
||||
label: "B".to_string(),
|
||||
description: "B".to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
UserInputQuestion {
|
||||
header: "Q4".to_string(),
|
||||
id: "q4".to_string(),
|
||||
question: "?".to_string(),
|
||||
options: vec![
|
||||
UserInputOption {
|
||||
label: "A".to_string(),
|
||||
description: "A".to_string(),
|
||||
},
|
||||
UserInputOption {
|
||||
label: "B".to_string(),
|
||||
description: "B".to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert!(request.validate().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Weather tool backed by Open-Meteo (no API key required).
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
optional_str, optional_u64, required_str,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{NaiveDate, Utc};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_DAYS: u64 = 7;
|
||||
const MAX_DAYS: u64 = 14;
|
||||
const TIMEOUT_MS: u64 = 15_000;
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct WeatherRequest {
|
||||
location: String,
|
||||
start: String,
|
||||
duration: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct WeatherDay {
|
||||
date: String,
|
||||
temp_max_c: f64,
|
||||
temp_min_c: f64,
|
||||
temp_max_f: f64,
|
||||
temp_min_f: f64,
|
||||
precip_mm: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct WeatherResult {
|
||||
location: String,
|
||||
resolved_name: String,
|
||||
latitude: f64,
|
||||
longitude: f64,
|
||||
timezone: String,
|
||||
source: String,
|
||||
days: Vec<WeatherDay>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct WeatherResponse {
|
||||
results: Vec<WeatherResult>,
|
||||
}
|
||||
|
||||
pub struct WeatherTool;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for WeatherTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"weather"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Get a daily weather forecast for a location."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": { "type": "string" },
|
||||
"start": { "type": "string", "description": "YYYY-MM-DD" },
|
||||
"duration": { "type": "integer", "description": "Number of days" },
|
||||
"weather": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": { "type": "string" },
|
||||
"start": { "type": "string" },
|
||||
"duration": { "type": "integer" }
|
||||
},
|
||||
"required": ["location"]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly, ToolCapability::Network]
|
||||
}
|
||||
|
||||
fn supports_parallel(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let requests = parse_weather_requests(&input)?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(TIMEOUT_MS))
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
|
||||
})?;
|
||||
|
||||
let mut results = Vec::with_capacity(requests.len());
|
||||
for req in requests {
|
||||
let geo = geocode_location(&client, &req.location).await?;
|
||||
let forecast = fetch_forecast(&client, &geo, &req.start, req.duration).await?;
|
||||
results.push(WeatherResult {
|
||||
location: req.location,
|
||||
resolved_name: geo.name,
|
||||
latitude: geo.latitude,
|
||||
longitude: geo.longitude,
|
||||
timezone: forecast.timezone,
|
||||
source: "open-meteo".to_string(),
|
||||
days: forecast.days,
|
||||
});
|
||||
}
|
||||
|
||||
ToolResult::json(&WeatherResponse { results })
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_weather_requests(input: &Value) -> Result<Vec<WeatherRequest>, ToolError> {
|
||||
if let Some(list) = input.get("weather").and_then(|v| v.as_array()) {
|
||||
let mut requests = Vec::new();
|
||||
for item in list {
|
||||
let location = required_str(item, "location")?.to_string();
|
||||
let start = optional_str(item, "start")
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| today_string());
|
||||
let duration = optional_u64(item, "duration", DEFAULT_DAYS).clamp(1, MAX_DAYS);
|
||||
requests.push(WeatherRequest {
|
||||
location,
|
||||
start,
|
||||
duration,
|
||||
});
|
||||
}
|
||||
if requests.is_empty() {
|
||||
return Err(ToolError::invalid_input("weather list is empty"));
|
||||
}
|
||||
return Ok(requests);
|
||||
}
|
||||
|
||||
let location = required_str(input, "location")?.to_string();
|
||||
let start = optional_str(input, "start")
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| today_string());
|
||||
let duration = optional_u64(input, "duration", DEFAULT_DAYS).clamp(1, MAX_DAYS);
|
||||
|
||||
Ok(vec![WeatherRequest {
|
||||
location,
|
||||
start,
|
||||
duration,
|
||||
}])
|
||||
}
|
||||
|
||||
fn today_string() -> String {
|
||||
Utc::now().date_naive().format("%Y-%m-%d").to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct GeoResult {
|
||||
name: String,
|
||||
latitude: f64,
|
||||
longitude: f64,
|
||||
}
|
||||
|
||||
async fn geocode_location(
|
||||
client: &reqwest::Client,
|
||||
location: &str,
|
||||
) -> Result<GeoResult, ToolError> {
|
||||
let encoded = url_encode(location);
|
||||
let url = format!(
|
||||
"https://geocoding-api.open-meteo.com/v1/search?name={encoded}&count=1&language=en&format=json"
|
||||
);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("Geocoding request failed: {e}")))?;
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?;
|
||||
if !status.is_success() {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"Geocoding failed: HTTP {}",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
let json: Value = serde_json::from_str(&body)
|
||||
.map_err(|e| ToolError::execution_failed(format!("Invalid geocoding JSON: {e}")))?;
|
||||
let results = json
|
||||
.get("results")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| ToolError::execution_failed("No geocoding results"))?;
|
||||
let first = results
|
||||
.first()
|
||||
.ok_or_else(|| ToolError::execution_failed("No geocoding results"))?;
|
||||
let name = first
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(location)
|
||||
.to_string();
|
||||
let latitude = first
|
||||
.get("latitude")
|
||||
.and_then(|v| v.as_f64())
|
||||
.ok_or_else(|| ToolError::execution_failed("Missing latitude"))?;
|
||||
let longitude = first
|
||||
.get("longitude")
|
||||
.and_then(|v| v.as_f64())
|
||||
.ok_or_else(|| ToolError::execution_failed("Missing longitude"))?;
|
||||
Ok(GeoResult {
|
||||
name,
|
||||
latitude,
|
||||
longitude,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ForecastResult {
|
||||
timezone: String,
|
||||
days: Vec<WeatherDay>,
|
||||
}
|
||||
|
||||
async fn fetch_forecast(
|
||||
client: &reqwest::Client,
|
||||
geo: &GeoResult,
|
||||
start: &str,
|
||||
duration: u64,
|
||||
) -> Result<ForecastResult, ToolError> {
|
||||
let start_date = NaiveDate::parse_from_str(start, "%Y-%m-%d")
|
||||
.map_err(|_| ToolError::invalid_input("start must be YYYY-MM-DD"))?;
|
||||
let end_date = start_date
|
||||
.checked_add_signed(chrono::Duration::days((duration as i64).saturating_sub(1)))
|
||||
.ok_or_else(|| ToolError::invalid_input("Invalid duration"))?;
|
||||
let url = format!(
|
||||
"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto&start_date={start}&end_date={end}",
|
||||
lat = geo.latitude,
|
||||
lon = geo.longitude,
|
||||
start = start_date.format("%Y-%m-%d"),
|
||||
end = end_date.format("%Y-%m-%d"),
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("Forecast request failed: {e}")))?;
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?;
|
||||
if !status.is_success() {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"Forecast failed: HTTP {}",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
|
||||
let json: Value = serde_json::from_str(&body)
|
||||
.map_err(|e| ToolError::execution_failed(format!("Invalid forecast JSON: {e}")))?;
|
||||
let timezone = json
|
||||
.get("timezone")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("UTC")
|
||||
.to_string();
|
||||
let daily = json
|
||||
.get("daily")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| ToolError::execution_failed("Missing daily forecast"))?;
|
||||
|
||||
let dates = daily
|
||||
.get("time")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| ToolError::execution_failed("Missing daily time"))?;
|
||||
let maxes = daily
|
||||
.get("temperature_2m_max")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| ToolError::execution_failed("Missing temperature_2m_max"))?;
|
||||
let mins = daily
|
||||
.get("temperature_2m_min")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| ToolError::execution_failed("Missing temperature_2m_min"))?;
|
||||
let precips = daily
|
||||
.get("precipitation_sum")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| ToolError::execution_failed("Missing precipitation_sum"))?;
|
||||
|
||||
let mut days = Vec::new();
|
||||
for idx in 0..dates.len() {
|
||||
let date = dates
|
||||
.get(idx)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let max_c = maxes.get(idx).and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let min_c = mins.get(idx).and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let precip = precips.get(idx).and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
days.push(WeatherDay {
|
||||
date,
|
||||
temp_max_c: max_c,
|
||||
temp_min_c: min_c,
|
||||
temp_max_f: c_to_f(max_c),
|
||||
temp_min_f: c_to_f(min_c),
|
||||
precip_mm: precip,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ForecastResult { timezone, days })
|
||||
}
|
||||
|
||||
fn c_to_f(c: f64) -> f64 {
|
||||
c * 9.0 / 5.0 + 32.0
|
||||
}
|
||||
|
||||
fn url_encode(input: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for ch in input.bytes() {
|
||||
match ch {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
encoded.push(ch as char)
|
||||
}
|
||||
b' ' => encoded.push('+'),
|
||||
_ => encoded.push_str(&format!("%{ch:02X}")),
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+297
-64
@@ -10,11 +10,11 @@ use thiserror::Error;
|
||||
|
||||
use crate::compaction::CompactionConfig;
|
||||
use crate::config::{Config, has_api_key, save_api_key};
|
||||
use crate::duo::{SharedDuoSession, new_shared_duo_session};
|
||||
use crate::hooks::{HookContext, HookEvent, HookExecutor, HookResult};
|
||||
use crate::models::{Message, SystemPrompt};
|
||||
use crate::models::{
|
||||
Message, SystemPrompt, compaction_message_threshold_for_model, compaction_threshold_for_model,
|
||||
};
|
||||
use crate::palette::{self, UiTheme};
|
||||
use crate::rlm::{RlmSession, SharedRlmSession};
|
||||
use crate::settings::Settings;
|
||||
use crate::tools::plan::{SharedPlanState, new_shared_plan_state};
|
||||
use crate::tools::subagent::SubAgentResult;
|
||||
@@ -27,7 +27,24 @@ use crate::tui::scrolling::{MouseScrollState, TranscriptScroll};
|
||||
use crate::tui::selection::TranscriptSelection;
|
||||
use crate::tui::transcript::TranscriptViewCache;
|
||||
use crate::tui::views::ViewStack;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Format a nice welcome banner.
|
||||
fn format_welcome_banner(model: &str, workspace: &PathBuf, yolo: bool) -> String {
|
||||
let mode_line = if yolo {
|
||||
"\nYOLO mode — shell + trust + auto-approve enabled\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
format!(
|
||||
"Tips: Tab to switch modes, F1 or /help for commands, Esc to cancel\n\
|
||||
{mode_line}\
|
||||
Directory: {}\n\
|
||||
Model: {}",
|
||||
workspace.display(),
|
||||
model
|
||||
)
|
||||
}
|
||||
|
||||
// === Types ===
|
||||
|
||||
@@ -48,8 +65,6 @@ pub enum AppMode {
|
||||
Agent,
|
||||
Yolo,
|
||||
Plan,
|
||||
Rlm,
|
||||
Duo,
|
||||
}
|
||||
|
||||
fn char_count(text: &str) -> usize {
|
||||
@@ -89,6 +104,8 @@ fn sanitize_api_key_text(text: &str) -> String {
|
||||
text.chars().filter(|c| !c.is_control()).collect()
|
||||
}
|
||||
|
||||
const MAX_SUBMITTED_INPUT_CHARS: usize = 16_000;
|
||||
|
||||
impl AppMode {
|
||||
/// Short label used in the UI footer.
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -97,8 +114,6 @@ impl AppMode {
|
||||
AppMode::Agent => "AGENT",
|
||||
AppMode::Yolo => "YOLO",
|
||||
AppMode::Plan => "PLAN",
|
||||
AppMode::Rlm => "RLM",
|
||||
AppMode::Duo => "DUO",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,8 +125,6 @@ impl AppMode {
|
||||
AppMode::Agent => "Agent mode - autonomous task execution with tools",
|
||||
AppMode::Yolo => "YOLO mode - full tool access without approvals",
|
||||
AppMode::Plan => "Plan mode - design before implementing",
|
||||
AppMode::Rlm => "RLM mode - recursive language model sandbox",
|
||||
AppMode::Duo => "Duo mode - dialectical autocoding with player-coach loop",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,12 +176,13 @@ pub struct App {
|
||||
pub transcript_cache: TranscriptViewCache,
|
||||
pub transcript_selection: TranscriptSelection,
|
||||
pub last_transcript_area: Option<Rect>,
|
||||
pub last_scrollbar_area: Option<Rect>,
|
||||
pub last_transcript_top: usize,
|
||||
pub last_transcript_visible: usize,
|
||||
pub last_transcript_total: usize,
|
||||
pub last_transcript_padding_top: usize,
|
||||
pub is_loading: bool,
|
||||
/// Degraded connectivity mode; new user inputs are queued for later retry.
|
||||
pub offline_mode: bool,
|
||||
pub status_message: Option<String>,
|
||||
pub model: String,
|
||||
pub workspace: PathBuf,
|
||||
@@ -179,9 +193,9 @@ pub struct App {
|
||||
pub input_history: Vec<String>,
|
||||
pub history_index: Option<usize>,
|
||||
pub auto_compact: bool,
|
||||
pub auto_rlm: bool,
|
||||
pub show_thinking: bool,
|
||||
pub show_tool_details: bool,
|
||||
pub sidebar_width_percent: u16,
|
||||
#[allow(dead_code)]
|
||||
pub compact_threshold: usize,
|
||||
pub max_input_history: usize,
|
||||
@@ -221,12 +235,6 @@ pub struct App {
|
||||
pub plan_prompt_pending: bool,
|
||||
/// Whether update_plan was called during the current turn
|
||||
pub plan_tool_used_in_turn: bool,
|
||||
/// RLM sandbox session state
|
||||
pub rlm_session: SharedRlmSession,
|
||||
/// Duo mode session state (player-coach autocoding loop)
|
||||
pub duo_session: SharedDuoSession,
|
||||
/// Whether RLM REPL input mode is active.
|
||||
pub rlm_repl_active: bool,
|
||||
/// Todo list for `TodoWriteTool`
|
||||
#[allow(dead_code)] // For future engine integration
|
||||
pub todos: SharedTodoList,
|
||||
@@ -238,6 +246,8 @@ pub struct App {
|
||||
pub active_skill: Option<String>,
|
||||
/// Tool call cells by tool id
|
||||
pub tool_cells: HashMap<String, usize>,
|
||||
/// Full tool input/output keyed by history cell index.
|
||||
pub tool_details_by_cell: HashMap<usize, ToolDetailRecord>,
|
||||
/// Active exploring cell index
|
||||
pub exploring_cell: Option<usize>,
|
||||
/// Mapping of exploring tool ids to (cell index, entry index)
|
||||
@@ -262,19 +272,45 @@ pub struct App {
|
||||
pub queued_draft: Option<QueuedMessage>,
|
||||
/// Start time for current turn
|
||||
pub turn_started_at: Option<Instant>,
|
||||
/// Current runtime turn id (if known).
|
||||
pub runtime_turn_id: Option<String>,
|
||||
/// Current runtime turn status (if known).
|
||||
pub runtime_turn_status: Option<String>,
|
||||
/// Last prompt token usage
|
||||
pub last_prompt_tokens: Option<u32>,
|
||||
/// Last completion token usage
|
||||
pub last_completion_tokens: Option<u32>,
|
||||
/// Cached background tasks for sidebar rendering.
|
||||
pub task_panel: Vec<TaskPanelEntry>,
|
||||
/// Whether the UI needs to be redrawn.
|
||||
pub needs_redraw: bool,
|
||||
}
|
||||
|
||||
/// Message queued while the engine is busy.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct QueuedMessage {
|
||||
pub display: String,
|
||||
pub skill_instruction: Option<String>,
|
||||
}
|
||||
|
||||
/// Detailed tool payload attached to a history cell.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolDetailRecord {
|
||||
pub tool_id: String,
|
||||
pub tool_name: String,
|
||||
pub input: Value,
|
||||
pub output: Option<String>,
|
||||
}
|
||||
|
||||
/// Lightweight task view for sidebar rendering.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskPanelEntry {
|
||||
pub id: String,
|
||||
pub status: String,
|
||||
pub prompt_summary: String,
|
||||
pub duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl QueuedMessage {
|
||||
pub fn new(display: String, skill_instruction: Option<String>) -> Self {
|
||||
Self {
|
||||
@@ -293,14 +329,6 @@ impl QueuedMessage {
|
||||
self.display.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn content_with_query(&self, query: &str) -> String {
|
||||
if let Some(skill_instruction) = self.skill_instruction.as_ref() {
|
||||
format!("{skill_instruction}\n\n---\n\nUser request: {query}")
|
||||
} else {
|
||||
query.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Errors ===
|
||||
@@ -343,20 +371,19 @@ impl App {
|
||||
let needs_onboarding = !skip_onboarding && (!was_onboarded || needs_api_key);
|
||||
let settings = Settings::load().unwrap_or_else(|_| Settings::default());
|
||||
let auto_compact = settings.auto_compact;
|
||||
let auto_rlm = settings.auto_rlm;
|
||||
let show_thinking = settings.show_thinking;
|
||||
let show_tool_details = settings.show_tool_details;
|
||||
let sidebar_width_percent = settings.sidebar_width_percent;
|
||||
let max_input_history = settings.max_input_history;
|
||||
let ui_theme = palette::ui_theme(&settings.theme);
|
||||
let model = settings.default_model.clone().unwrap_or_else(|| model);
|
||||
let compact_threshold = compaction_threshold_for_model(&model);
|
||||
|
||||
// Start in YOLO mode if --yolo flag was passed
|
||||
let preferred_mode = match settings.default_mode.as_str() {
|
||||
"plan" => AppMode::Plan,
|
||||
"agent" | "normal" => AppMode::Agent,
|
||||
"yolo" => AppMode::Yolo,
|
||||
"rlm" => AppMode::Rlm,
|
||||
"duo" => AppMode::Duo,
|
||||
_ => AppMode::Agent,
|
||||
};
|
||||
let initial_mode = if yolo {
|
||||
@@ -370,18 +397,8 @@ impl App {
|
||||
let history = if needs_onboarding {
|
||||
Vec::new() // No welcome message during onboarding
|
||||
} else {
|
||||
let mode_msg = if yolo {
|
||||
" | YOLO MODE (shell + trust + auto-approve)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
vec![HistoryCell::System {
|
||||
content: format!(
|
||||
"Welcome to DeepSeek! Model: {} | Workspace: {}{}",
|
||||
model,
|
||||
workspace.display(),
|
||||
mode_msg
|
||||
),
|
||||
content: format_welcome_banner(&model, &workspace, yolo),
|
||||
}]
|
||||
};
|
||||
|
||||
@@ -394,8 +411,11 @@ impl App {
|
||||
|
||||
let history_len = history.len() as u64;
|
||||
|
||||
let agents_skills_dir = workspace.join(".agents").join("skills");
|
||||
let local_skills_dir = workspace.join("skills");
|
||||
let skills_dir = if local_skills_dir.exists() {
|
||||
let skills_dir = if agents_skills_dir.exists() {
|
||||
agents_skills_dir
|
||||
} else if local_skills_dir.exists() {
|
||||
local_skills_dir
|
||||
} else {
|
||||
global_skills_dir
|
||||
@@ -415,12 +435,12 @@ impl App {
|
||||
transcript_cache: TranscriptViewCache::new(),
|
||||
transcript_selection: TranscriptSelection::default(),
|
||||
last_transcript_area: None,
|
||||
last_scrollbar_area: None,
|
||||
last_transcript_top: 0,
|
||||
last_transcript_visible: 0,
|
||||
last_transcript_total: 0,
|
||||
last_transcript_padding_top: 0,
|
||||
is_loading: false,
|
||||
offline_mode: false,
|
||||
status_message: None,
|
||||
model,
|
||||
workspace,
|
||||
@@ -430,10 +450,10 @@ impl App {
|
||||
input_history: Vec::new(),
|
||||
history_index: None,
|
||||
auto_compact,
|
||||
auto_rlm,
|
||||
show_thinking,
|
||||
show_tool_details,
|
||||
compact_threshold: 50000,
|
||||
sidebar_width_percent,
|
||||
compact_threshold,
|
||||
max_input_history,
|
||||
total_tokens: 0,
|
||||
total_conversation_tokens: 0,
|
||||
@@ -469,14 +489,12 @@ impl App {
|
||||
plan_state,
|
||||
plan_prompt_pending: false,
|
||||
plan_tool_used_in_turn: false,
|
||||
rlm_session: Arc::new(Mutex::new(RlmSession::default())),
|
||||
duo_session: new_shared_duo_session(),
|
||||
rlm_repl_active: false,
|
||||
todos: new_shared_todo_list(),
|
||||
tool_log: Vec::new(),
|
||||
session_cost: 0.0,
|
||||
active_skill: None,
|
||||
tool_cells: HashMap::new(),
|
||||
tool_details_by_cell: HashMap::new(),
|
||||
exploring_cell: None,
|
||||
exploring_entries: HashMap::new(),
|
||||
ignored_tool_calls: HashSet::new(),
|
||||
@@ -489,8 +507,12 @@ impl App {
|
||||
queued_messages: VecDeque::new(),
|
||||
queued_draft: None,
|
||||
turn_started_at: None,
|
||||
runtime_turn_id: None,
|
||||
runtime_turn_status: None,
|
||||
last_prompt_tokens: None,
|
||||
last_completion_tokens: None,
|
||||
task_panel: Vec::new(),
|
||||
needs_redraw: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,11 +539,7 @@ impl App {
|
||||
self.status_message = Some(format!("Failed to mark onboarding: {err}"));
|
||||
}
|
||||
self.add_message(HistoryCell::System {
|
||||
content: format!(
|
||||
"Welcome to DeepSeek CLI! Model: {} | Workspace: {}",
|
||||
self.model,
|
||||
self.workspace.display()
|
||||
),
|
||||
content: format_welcome_banner(&self.model, &self.workspace, self.yolo),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -537,7 +555,6 @@ impl App {
|
||||
} else {
|
||||
ApprovalMode::Suggest
|
||||
};
|
||||
self.rlm_repl_active = false;
|
||||
if mode != AppMode::Plan {
|
||||
self.plan_prompt_pending = false;
|
||||
self.plan_tool_used_in_turn = false;
|
||||
@@ -550,16 +567,15 @@ impl App {
|
||||
.with_workspace(self.workspace.clone())
|
||||
.with_model(&self.model);
|
||||
let _ = self.hooks.execute(HookEvent::ModeChange, &context);
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// Cycle through modes: Plan → Agent → YOLO → RLM → Duo → Plan
|
||||
/// Cycle through modes: Plan -> Agent -> YOLO -> Plan
|
||||
pub fn cycle_mode(&mut self) {
|
||||
let next = match self.mode {
|
||||
AppMode::Plan => AppMode::Agent,
|
||||
AppMode::Agent => AppMode::Yolo,
|
||||
AppMode::Yolo => AppMode::Rlm,
|
||||
AppMode::Rlm => AppMode::Duo,
|
||||
AppMode::Duo | AppMode::Normal => AppMode::Plan,
|
||||
AppMode::Yolo | AppMode::Normal => AppMode::Plan,
|
||||
};
|
||||
self.set_mode(next);
|
||||
}
|
||||
@@ -591,6 +607,7 @@ impl App {
|
||||
|
||||
pub fn mark_history_updated(&mut self) {
|
||||
self.history_version = self.history_version.wrapping_add(1);
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub fn transcript_render_options(&self) -> TranscriptRenderOptions {
|
||||
@@ -620,7 +637,6 @@ impl App {
|
||||
|
||||
// Clear stale layout info
|
||||
self.last_transcript_area = None;
|
||||
self.last_scrollbar_area = None;
|
||||
self.last_transcript_top = 0;
|
||||
self.last_transcript_visible = 0;
|
||||
self.last_transcript_total = 0;
|
||||
@@ -642,6 +658,7 @@ impl App {
|
||||
let byte_index = byte_index_at_char(&self.input, cursor);
|
||||
self.input.insert_str(byte_index, text);
|
||||
self.cursor_position = cursor + char_count(text);
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub fn insert_paste_text(&mut self, text: &str) {
|
||||
@@ -724,16 +741,19 @@ impl App {
|
||||
pub fn scroll_up(&mut self, amount: usize) {
|
||||
let delta = i32::try_from(amount).unwrap_or(i32::MAX);
|
||||
self.pending_scroll_delta = self.pending_scroll_delta.saturating_sub(delta);
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub fn scroll_down(&mut self, amount: usize) {
|
||||
let delta = i32::try_from(amount).unwrap_or(i32::MAX);
|
||||
self.pending_scroll_delta = self.pending_scroll_delta.saturating_add(delta);
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub fn scroll_to_bottom(&mut self) {
|
||||
self.transcript_scroll = TranscriptScroll::ToBottom;
|
||||
self.pending_scroll_delta = 0;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub fn insert_char(&mut self, c: char) {
|
||||
@@ -741,6 +761,7 @@ impl App {
|
||||
let byte_index = byte_index_at_char(&self.input, cursor);
|
||||
self.input.insert(byte_index, c);
|
||||
self.cursor_position = cursor + 1;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub fn delete_char(&mut self) {
|
||||
@@ -751,6 +772,7 @@ impl App {
|
||||
let removed = remove_char_at(&mut self.input, target);
|
||||
if removed {
|
||||
self.cursor_position = target;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -763,30 +785,36 @@ impl App {
|
||||
if !removed {
|
||||
self.cursor_position = char_count(&self.input);
|
||||
}
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub fn move_cursor_left(&mut self) {
|
||||
self.cursor_position = self.cursor_position.saturating_sub(1);
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub fn move_cursor_right(&mut self) {
|
||||
if self.cursor_position < char_count(&self.input) {
|
||||
self.cursor_position += 1;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_cursor_start(&mut self) {
|
||||
self.cursor_position = 0;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub fn move_cursor_end(&mut self) {
|
||||
self.cursor_position = char_count(&self.input);
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub fn clear_input(&mut self) {
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
self.paste_burst.clear_after_explicit_paste();
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
pub fn submit_input(&mut self) -> Option<String> {
|
||||
@@ -794,7 +822,14 @@ impl App {
|
||||
self.paste_burst.clear_after_explicit_paste();
|
||||
return None;
|
||||
}
|
||||
let input = self.input.clone();
|
||||
let mut input = self.input.clone();
|
||||
if char_count(&input) > MAX_SUBMITTED_INPUT_CHARS {
|
||||
input = input.chars().take(MAX_SUBMITTED_INPUT_CHARS).collect();
|
||||
self.status_message = Some(format!(
|
||||
"Input truncated to {} characters for safety",
|
||||
MAX_SUBMITTED_INPUT_CHARS
|
||||
));
|
||||
}
|
||||
if !input.starts_with('/') {
|
||||
self.input_history.push(input.clone());
|
||||
if self.max_input_history == 0 {
|
||||
@@ -877,16 +912,32 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_todos(&mut self) {
|
||||
let mut plan = self.plan_state.blocking_lock();
|
||||
*plan = crate::tools::plan::PlanState::default();
|
||||
pub fn clear_todos(&mut self) -> bool {
|
||||
if let Ok(mut plan) = self.plan_state.try_lock() {
|
||||
*plan = crate::tools::plan::PlanState::default();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn update_model_compaction_budget(&mut self) {
|
||||
self.compact_threshold = compaction_threshold_for_model(&self.model);
|
||||
}
|
||||
|
||||
pub fn compaction_config(&self) -> CompactionConfig {
|
||||
let mut compaction = CompactionConfig::default();
|
||||
compaction.enabled = self.auto_compact;
|
||||
compaction.token_threshold = self.compact_threshold;
|
||||
compaction.message_threshold = compaction_message_threshold_for_model(&self.model);
|
||||
compaction.model = self.model.clone();
|
||||
compaction
|
||||
}
|
||||
}
|
||||
|
||||
// === Actions ===
|
||||
|
||||
/// Actions emitted by the UI event loop.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AppAction {
|
||||
Quit,
|
||||
#[allow(dead_code)] // For explicit /save command
|
||||
@@ -901,13 +952,26 @@ pub enum AppAction {
|
||||
},
|
||||
SendMessage(String),
|
||||
ListSubAgents,
|
||||
FetchModels,
|
||||
UpdateCompaction(CompactionConfig),
|
||||
CompactContext,
|
||||
TaskAdd {
|
||||
prompt: String,
|
||||
},
|
||||
TaskList,
|
||||
TaskShow {
|
||||
id: String,
|
||||
},
|
||||
TaskCancel {
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs};
|
||||
|
||||
fn test_options(yolo: bool) -> TuiOptions {
|
||||
TuiOptions {
|
||||
@@ -933,4 +997,173 @@ mod tests {
|
||||
let app = App::new(test_options(true), &Config::default());
|
||||
assert!(app.trust_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_input_truncates_oversized_payloads() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
app.input = "x".repeat(MAX_SUBMITTED_INPUT_CHARS + 128);
|
||||
app.cursor_position = app.input.chars().count();
|
||||
|
||||
let submitted = app.submit_input().expect("expected submitted input");
|
||||
assert_eq!(submitted.chars().count(), MAX_SUBMITTED_INPUT_CHARS);
|
||||
assert!(
|
||||
app.status_message
|
||||
.as_ref()
|
||||
.is_some_and(|msg| msg.contains("Input truncated"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_todos_resets_plan_state() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
|
||||
{
|
||||
let mut plan = app
|
||||
.plan_state
|
||||
.try_lock()
|
||||
.expect("plan lock should be available");
|
||||
plan.update(UpdatePlanArgs {
|
||||
explanation: Some("test plan".to_string()),
|
||||
plan: vec![PlanItemArg {
|
||||
step: "step 1".to_string(),
|
||||
status: StepStatus::InProgress,
|
||||
}],
|
||||
});
|
||||
assert!(!plan.is_empty());
|
||||
}
|
||||
|
||||
assert!(app.clear_todos());
|
||||
|
||||
let plan = app
|
||||
.plan_state
|
||||
.try_lock()
|
||||
.expect("plan lock should be available");
|
||||
assert!(plan.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cycle_mode_transitions() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
// Default mode should be Agent based on settings
|
||||
let initial_mode = app.mode;
|
||||
app.cycle_mode();
|
||||
// Mode should have changed
|
||||
assert_ne!(app.mode, initial_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_input() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
app.input = "test input".to_string();
|
||||
app.cursor_position = app.input.len();
|
||||
app.clear_input();
|
||||
assert!(app.input.is_empty());
|
||||
assert_eq!(app.cursor_position, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_queue_message() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
app.queue_message(QueuedMessage::new("test message".to_string(), None));
|
||||
assert_eq!(app.queued_message_count(), 1);
|
||||
assert!(app.queued_messages.front().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_queued_message() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
app.queue_message(QueuedMessage::new("first".to_string(), None));
|
||||
app.queue_message(QueuedMessage::new("second".to_string(), None));
|
||||
|
||||
// Remove first (index 0)
|
||||
let removed = app.remove_queued_message(0);
|
||||
assert!(removed.is_some());
|
||||
assert_eq!(app.queued_message_count(), 1);
|
||||
|
||||
// Remove second (now at index 0)
|
||||
let removed = app.remove_queued_message(0);
|
||||
assert!(removed.is_some());
|
||||
assert_eq!(app.queued_message_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_queued_message_invalid_index() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
app.queue_message(QueuedMessage::new("test".to_string(), None));
|
||||
|
||||
// Try to remove non-existent index
|
||||
let removed = app.remove_queued_message(100);
|
||||
assert!(removed.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_mode_updates_state() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
let initial_mode = app.mode;
|
||||
app.set_mode(AppMode::Yolo);
|
||||
assert_eq!(app.mode, AppMode::Yolo);
|
||||
assert_ne!(app.mode, initial_mode);
|
||||
// Yolo mode should enable trust and shell
|
||||
assert!(app.trust_mode);
|
||||
assert!(app.allow_shell);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mark_history_updated() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
let initial_version = app.history_version;
|
||||
app.mark_history_updated();
|
||||
assert!(app.history_version > initial_version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_operations() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
// Just verify scroll methods can be called without panic
|
||||
app.scroll_up(5);
|
||||
app.scroll_down(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_message() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
let initial_len = app.history.len();
|
||||
app.add_message(HistoryCell::User {
|
||||
content: "test".to_string(),
|
||||
});
|
||||
assert_eq!(app.history.len(), initial_len + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compaction_config() {
|
||||
let app = App::new(test_options(false), &Config::default());
|
||||
let config = app.compaction_config();
|
||||
// Config should be valid (just checking it returns something)
|
||||
let _ = config.enabled;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_model_compaction_budget() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
let initial_threshold = app.compact_threshold;
|
||||
app.model = "deepseek-reasoner".to_string();
|
||||
app.update_model_compaction_budget();
|
||||
// Threshold may have changed based on model
|
||||
// deepseek-reasoner has 128k context, so threshold should be higher
|
||||
assert!(app.compact_threshold >= initial_threshold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_input_history_navigation() {
|
||||
let mut app = App::new(test_options(false), &Config::default());
|
||||
app.input_history.push("first".to_string());
|
||||
app.input_history.push("second".to_string());
|
||||
|
||||
// Navigate up
|
||||
app.history_up();
|
||||
assert!(app.history_index.is_some());
|
||||
|
||||
// Navigate down
|
||||
app.history_down();
|
||||
}
|
||||
}
|
||||
+541
-1
@@ -98,7 +98,7 @@ impl ApprovalRequest {
|
||||
pub fn get_tool_category(name: &str) -> ToolCategory {
|
||||
if matches!(name, "write_file" | "edit_file" | "apply_patch") {
|
||||
ToolCategory::FileWrite
|
||||
} else if name == "exec_shell" {
|
||||
} else if name == "exec_shell" || name.starts_with("mcp_") || name.starts_with("list_mcp_") {
|
||||
ToolCategory::Shell
|
||||
} else {
|
||||
// Default to safe (includes read/list/todo/note/update_plan and unknown tools)
|
||||
@@ -151,6 +151,15 @@ impl ApprovalView {
|
||||
})
|
||||
}
|
||||
|
||||
fn emit_params_pager(&self) -> ViewAction {
|
||||
let content = serde_json::to_string_pretty(&self.request.params)
|
||||
.unwrap_or_else(|_| self.request.params.to_string());
|
||||
ViewAction::Emit(ViewEvent::OpenTextPager {
|
||||
title: format!("Tool Params: {}", self.request.tool_name),
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_timed_out(&self) -> bool {
|
||||
match self.timeout {
|
||||
Some(timeout) => self.requested_at.elapsed() >= timeout,
|
||||
@@ -178,6 +187,7 @@ impl ModalView for ApprovalView {
|
||||
KeyCode::Char('y') => self.emit_decision(ReviewDecision::Approved, false),
|
||||
KeyCode::Char('a') => self.emit_decision(ReviewDecision::ApprovedForSession, false),
|
||||
KeyCode::Char('n') => self.emit_decision(ReviewDecision::Denied, false),
|
||||
KeyCode::Char('v') | KeyCode::Char('V') => self.emit_params_pager(),
|
||||
KeyCode::Esc => self.emit_decision(ReviewDecision::Abort, false),
|
||||
_ => ViewAction::None,
|
||||
}
|
||||
@@ -432,3 +442,533 @@ impl ModalView for ElevationView {
|
||||
elevation_widget.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use serde_json::json;
|
||||
|
||||
fn create_key_event(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent {
|
||||
code,
|
||||
modifiers: KeyModifiers::empty(),
|
||||
kind: crossterm::event::KeyEventKind::Press,
|
||||
state: crossterm::event::KeyEventState::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Tool Category Tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_get_tool_category_safe_tools() {
|
||||
// Read-only operations should be Safe
|
||||
assert_eq!(get_tool_category("read_file"), ToolCategory::Safe);
|
||||
assert_eq!(get_tool_category("list_dir"), ToolCategory::Safe);
|
||||
assert_eq!(get_tool_category("todo_write"), ToolCategory::Safe);
|
||||
assert_eq!(get_tool_category("todo_read"), ToolCategory::Safe);
|
||||
assert_eq!(get_tool_category("note"), ToolCategory::Safe);
|
||||
assert_eq!(get_tool_category("update_plan"), ToolCategory::Safe);
|
||||
assert_eq!(get_tool_category("unknown_tool"), ToolCategory::Safe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_tool_category_file_write_tools() {
|
||||
// File modification tools should be FileWrite
|
||||
assert_eq!(get_tool_category("write_file"), ToolCategory::FileWrite);
|
||||
assert_eq!(get_tool_category("edit_file"), ToolCategory::FileWrite);
|
||||
assert_eq!(get_tool_category("apply_patch"), ToolCategory::FileWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_tool_category_shell_tools() {
|
||||
// Shell execution tools should be Shell
|
||||
assert_eq!(get_tool_category("exec_shell"), ToolCategory::Shell);
|
||||
assert_eq!(get_tool_category("mcp_tool"), ToolCategory::Shell);
|
||||
assert_eq!(get_tool_category("list_mcp_tools"), ToolCategory::Shell);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ApprovalRequest Tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_approval_request_new() {
|
||||
let params = json!({"path": "src/main.rs", "content": "test"});
|
||||
let request = ApprovalRequest::new("test-id", "write_file", ¶ms);
|
||||
|
||||
assert_eq!(request.id, "test-id");
|
||||
assert_eq!(request.tool_name, "write_file");
|
||||
assert_eq!(request.category, ToolCategory::FileWrite);
|
||||
assert_eq!(request.params, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_request_params_display_truncates() {
|
||||
// Create params with a very long string
|
||||
let long_content = "x".repeat(300);
|
||||
let params = json!({"path": "src/main.rs", "content": long_content});
|
||||
let request = ApprovalRequest::new("test-id", "write_file", ¶ms);
|
||||
|
||||
let display = request.params_display();
|
||||
// Should be truncated to around 200 chars
|
||||
assert!(display.len() < 250);
|
||||
assert!(display.contains("src/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_request_params_display_short() {
|
||||
let params = json!({"path": "src/main.rs"});
|
||||
let request = ApprovalRequest::new("test-id", "read_file", ¶ms);
|
||||
|
||||
let display = request.params_display();
|
||||
assert!(display.contains("src/main.rs"));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ApprovalView Tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_approval_view_initial_state() {
|
||||
let params = json!({"path": "src/main.rs"});
|
||||
let request = ApprovalRequest::new("test-id", "read_file", ¶ms);
|
||||
let view = ApprovalView::new(request.clone());
|
||||
|
||||
assert_eq!(view.selected, 0);
|
||||
assert!(view.timeout.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_view_navigation() {
|
||||
let params = json!({"path": "src/main.rs"});
|
||||
let request = ApprovalRequest::new("test-id", "read_file", ¶ms);
|
||||
let mut view = ApprovalView::new(request);
|
||||
|
||||
// Initially at 0
|
||||
assert_eq!(view.selected, 0);
|
||||
|
||||
// Navigate down
|
||||
view.select_next();
|
||||
assert_eq!(view.selected, 1);
|
||||
|
||||
view.select_next();
|
||||
assert_eq!(view.selected, 2);
|
||||
|
||||
view.select_next();
|
||||
assert_eq!(view.selected, 3);
|
||||
|
||||
// Should clamp at 3
|
||||
view.select_next();
|
||||
assert_eq!(view.selected, 3);
|
||||
|
||||
// Navigate up
|
||||
view.select_prev();
|
||||
assert_eq!(view.selected, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_view_keybindings_decisions() {
|
||||
let params = json!({"path": "src/main.rs"});
|
||||
let request = ApprovalRequest::new("test-id", "read_file", ¶ms);
|
||||
let mut view = ApprovalView::new(request.clone());
|
||||
|
||||
// Test 'y' -> Approved
|
||||
let action = view.handle_key(create_key_event(KeyCode::Char('y')));
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
|
||||
decision: ReviewDecision::Approved,
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
// Test 'n' -> Denied
|
||||
let mut view = ApprovalView::new(request.clone());
|
||||
let action = view.handle_key(create_key_event(KeyCode::Char('n')));
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
|
||||
decision: ReviewDecision::Denied,
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
// Test 'a' -> ApprovedForSession
|
||||
let mut view = ApprovalView::new(request.clone());
|
||||
let action = view.handle_key(create_key_event(KeyCode::Char('a')));
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
|
||||
decision: ReviewDecision::ApprovedForSession,
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
// Test Esc -> Abort
|
||||
let mut view = ApprovalView::new(request);
|
||||
let action = view.handle_key(create_key_event(KeyCode::Esc));
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
|
||||
decision: ReviewDecision::Abort,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_view_enter_uses_selected_option() {
|
||||
let params = json!({"path": "src/main.rs"});
|
||||
let request = ApprovalRequest::new("test-id", "read_file", ¶ms);
|
||||
let mut view = ApprovalView::new(request);
|
||||
|
||||
// Navigate to index 2 (Denied)
|
||||
view.select_next();
|
||||
view.select_next();
|
||||
assert_eq!(view.selected, 2);
|
||||
|
||||
// Press Enter - should use current selection
|
||||
let action = view.handle_key(create_key_event(KeyCode::Enter));
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
|
||||
decision: ReviewDecision::Denied,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_view_navigation_keys() {
|
||||
let params = json!({"path": "src/main.rs"});
|
||||
let request = ApprovalRequest::new("test-id", "read_file", ¶ms);
|
||||
let mut view = ApprovalView::new(request);
|
||||
|
||||
// Test Up arrow
|
||||
view.handle_key(create_key_event(KeyCode::Up));
|
||||
assert_eq!(view.selected, 0); // Should clamp at 0
|
||||
|
||||
// Test Down arrow
|
||||
view.handle_key(create_key_event(KeyCode::Down));
|
||||
assert_eq!(view.selected, 1);
|
||||
|
||||
// Test 'j' for down
|
||||
view.handle_key(create_key_event(KeyCode::Char('j')));
|
||||
assert_eq!(view.selected, 2);
|
||||
|
||||
// Test 'k' for up
|
||||
view.handle_key(create_key_event(KeyCode::Char('k')));
|
||||
assert_eq!(view.selected, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_view_view_params() {
|
||||
let params = json!({"path": "src/main.rs", "content": "test"});
|
||||
let request = ApprovalRequest::new("test-id", "read_file", ¶ms);
|
||||
let mut view = ApprovalView::new(request.clone());
|
||||
|
||||
// Test 'v' to view params
|
||||
let action = view.handle_key(create_key_event(KeyCode::Char('v')));
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::Emit(ViewEvent::OpenTextPager { .. })
|
||||
));
|
||||
|
||||
// Test 'V' (uppercase) also works
|
||||
let mut view = ApprovalView::new(request.clone());
|
||||
let action = view.handle_key(create_key_event(KeyCode::Char('V')));
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::Emit(ViewEvent::OpenTextPager { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approval_view_current_decision_mapping() {
|
||||
let params = json!({"path": "src/main.rs"});
|
||||
let request = ApprovalRequest::new("test-id", "read_file", ¶ms);
|
||||
let mut view = ApprovalView::new(request);
|
||||
|
||||
// Index 0 -> Approved
|
||||
view.selected = 0;
|
||||
assert_eq!(view.current_decision(), ReviewDecision::Approved);
|
||||
|
||||
// Index 1 -> ApprovedForSession
|
||||
view.selected = 1;
|
||||
assert_eq!(view.current_decision(), ReviewDecision::ApprovedForSession);
|
||||
|
||||
// Index 2 -> Denied
|
||||
view.selected = 2;
|
||||
assert_eq!(view.current_decision(), ReviewDecision::Denied);
|
||||
|
||||
// Index 3 -> Abort
|
||||
view.selected = 3;
|
||||
assert_eq!(view.current_decision(), ReviewDecision::Abort);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ElevationView Tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_elevation_view_initial_state() {
|
||||
let request =
|
||||
ElevationRequest::for_shell("test-id", "cargo build", "network blocked", true, false);
|
||||
let view = ElevationView::new(request);
|
||||
|
||||
assert_eq!(view.selected, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_elevation_view_keybindings() {
|
||||
let request =
|
||||
ElevationRequest::for_shell("test-id", "cargo test", "write blocked", false, true);
|
||||
let mut view = ElevationView::new(request);
|
||||
|
||||
// Test 'n' -> WithNetwork
|
||||
let action = view.handle_key(create_key_event(KeyCode::Char('n')));
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
|
||||
option: ElevationOption::WithNetwork,
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
// Test 'w' -> WithWriteAccess
|
||||
let request =
|
||||
ElevationRequest::for_shell("test-id", "cargo build", "write blocked", false, true);
|
||||
let mut view = ElevationView::new(request);
|
||||
let action = view.handle_key(create_key_event(KeyCode::Char('w')));
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
|
||||
option: ElevationOption::WithWriteAccess(_),
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
// Test 'f' -> FullAccess
|
||||
let request =
|
||||
ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
|
||||
let mut view = ElevationView::new(request);
|
||||
let action = view.handle_key(create_key_event(KeyCode::Char('f')));
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
|
||||
option: ElevationOption::FullAccess,
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
// Test Esc -> Abort
|
||||
let request =
|
||||
ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
|
||||
let mut view = ElevationView::new(request);
|
||||
let action = view.handle_key(create_key_event(KeyCode::Esc));
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
|
||||
option: ElevationOption::Abort,
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
// Test 'a' -> Abort (alternative)
|
||||
let request =
|
||||
ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
|
||||
let mut view = ElevationView::new(request);
|
||||
let action = view.handle_key(create_key_event(KeyCode::Char('a')));
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
|
||||
option: ElevationOption::Abort,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_elevation_view_navigation() {
|
||||
let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, false);
|
||||
let mut view = ElevationView::new(request);
|
||||
|
||||
// Initially at 0
|
||||
assert_eq!(view.selected, 0);
|
||||
|
||||
// Navigate down
|
||||
view.handle_key(create_key_event(KeyCode::Down));
|
||||
assert_eq!(view.selected, 1);
|
||||
|
||||
// Navigate up
|
||||
view.handle_key(create_key_event(KeyCode::Up));
|
||||
assert_eq!(view.selected, 0);
|
||||
|
||||
// Test 'j' and 'k'
|
||||
view.handle_key(create_key_event(KeyCode::Char('j')));
|
||||
assert_eq!(view.selected, 1);
|
||||
|
||||
view.handle_key(create_key_event(KeyCode::Char('k')));
|
||||
assert_eq!(view.selected, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_elevation_view_enter_uses_selected_option() {
|
||||
let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, false);
|
||||
let mut view = ElevationView::new(request);
|
||||
|
||||
// Navigate to index 1
|
||||
view.handle_key(create_key_event(KeyCode::Down));
|
||||
assert_eq!(view.selected, 1);
|
||||
|
||||
// Press Enter
|
||||
let action = view.handle_key(create_key_event(KeyCode::Enter));
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
|
||||
option: ElevationOption::FullAccess,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ElevationOption Tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_elevation_option_labels() {
|
||||
assert_eq!(ElevationOption::WithNetwork.label(), "Allow network access");
|
||||
assert_eq!(
|
||||
ElevationOption::FullAccess.label(),
|
||||
"Full access (no sandbox)"
|
||||
);
|
||||
assert!(
|
||||
ElevationOption::WithWriteAccess(vec![])
|
||||
.label()
|
||||
.contains("write")
|
||||
);
|
||||
assert_eq!(ElevationOption::Abort.label(), "Abort");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_elevation_option_descriptions() {
|
||||
assert!(
|
||||
ElevationOption::WithNetwork
|
||||
.description()
|
||||
.contains("network")
|
||||
);
|
||||
assert!(
|
||||
ElevationOption::FullAccess
|
||||
.description()
|
||||
.contains("dangerous")
|
||||
);
|
||||
assert!(ElevationOption::Abort.description().contains("Cancel"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_elevation_option_to_policy() {
|
||||
let cwd = PathBuf::from("/tmp/test");
|
||||
|
||||
let policy = ElevationOption::WithNetwork.to_policy(&cwd);
|
||||
assert!(matches!(
|
||||
policy,
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
network_access: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
let policy = ElevationOption::FullAccess.to_policy(&cwd);
|
||||
assert!(matches!(policy, SandboxPolicy::DangerFullAccess));
|
||||
|
||||
let paths = vec![PathBuf::from("/tmp/test/src")];
|
||||
let policy = ElevationOption::WithWriteAccess(paths).to_policy(&cwd);
|
||||
assert!(matches!(policy, SandboxPolicy::WorkspaceWrite { .. }));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ElevationRequest Tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_elevation_request_for_shell_with_network_block() {
|
||||
let request = ElevationRequest::for_shell(
|
||||
"test-id",
|
||||
"curl example.com",
|
||||
"network blocked",
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(request.tool_id, "test-id");
|
||||
assert_eq!(request.tool_name, "exec_shell");
|
||||
assert!(request.command.is_some());
|
||||
assert!(request.denial_reason.contains("network"));
|
||||
assert!(
|
||||
request
|
||||
.options
|
||||
.iter()
|
||||
.any(|o| matches!(o, ElevationOption::WithNetwork))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_elevation_request_for_shell_with_write_block() {
|
||||
let request =
|
||||
ElevationRequest::for_shell("test-id", "rm -rf /tmp", "write blocked", false, true);
|
||||
|
||||
assert_eq!(request.tool_id, "test-id");
|
||||
assert!(
|
||||
request
|
||||
.options
|
||||
.iter()
|
||||
.any(|o| matches!(o, ElevationOption::WithWriteAccess(_)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_elevation_request_generic() {
|
||||
let request = ElevationRequest::generic("test-id", "some_tool", "permission denied");
|
||||
|
||||
assert_eq!(request.tool_id, "test-id");
|
||||
assert_eq!(request.tool_name, "some_tool");
|
||||
assert!(request.command.is_none());
|
||||
assert!(
|
||||
request
|
||||
.options
|
||||
.iter()
|
||||
.any(|o| matches!(o, ElevationOption::WithNetwork))
|
||||
);
|
||||
assert!(
|
||||
request
|
||||
.options
|
||||
.iter()
|
||||
.any(|o| matches!(o, ElevationOption::FullAccess))
|
||||
);
|
||||
assert!(
|
||||
request
|
||||
.options
|
||||
.iter()
|
||||
.any(|o| matches!(o, ElevationOption::Abort))
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ApprovalMode Tests
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_approval_mode_labels() {
|
||||
assert_eq!(ApprovalMode::Auto.label(), "AUTO");
|
||||
assert_eq!(ApprovalMode::Suggest.label(), "SUGGEST");
|
||||
assert_eq!(ApprovalMode::Never.label(), "NEVER");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Command palette modal for quick command/skill insertion.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
prelude::Stylize,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::commands;
|
||||
use crate::palette;
|
||||
use crate::skills::SkillRegistry;
|
||||
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandPaletteEntry {
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
pub struct CommandPaletteView {
|
||||
entries: Vec<CommandPaletteEntry>,
|
||||
filtered: Vec<usize>,
|
||||
query: String,
|
||||
selected: usize,
|
||||
}
|
||||
|
||||
pub fn build_entries(skills_dir: &Path) -> Vec<CommandPaletteEntry> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for command in commands::COMMANDS {
|
||||
let requires_args = command.usage.contains('<');
|
||||
let command_text = if requires_args {
|
||||
format!("/{} ", command.name)
|
||||
} else {
|
||||
format!("/{}", command.name)
|
||||
};
|
||||
entries.push(CommandPaletteEntry {
|
||||
label: format!("/{}", command.name),
|
||||
description: command.description.to_string(),
|
||||
command: command_text,
|
||||
});
|
||||
}
|
||||
|
||||
let skills = SkillRegistry::discover(skills_dir);
|
||||
for skill in skills.list() {
|
||||
entries.push(CommandPaletteEntry {
|
||||
label: format!("skill:{}", skill.name),
|
||||
description: skill.description.clone(),
|
||||
command: format!("/skill {}", skill.name),
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
entries
|
||||
}
|
||||
|
||||
impl CommandPaletteView {
|
||||
pub fn new(entries: Vec<CommandPaletteEntry>) -> Self {
|
||||
let mut view = Self {
|
||||
entries,
|
||||
filtered: Vec::new(),
|
||||
query: String::new(),
|
||||
selected: 0,
|
||||
};
|
||||
view.refilter();
|
||||
view
|
||||
}
|
||||
|
||||
fn refilter(&mut self) {
|
||||
let query = self.query.trim().to_ascii_lowercase();
|
||||
self.filtered = self
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, entry)| {
|
||||
if query.is_empty()
|
||||
|| entry.label.to_ascii_lowercase().contains(&query)
|
||||
|| entry.description.to_ascii_lowercase().contains(&query)
|
||||
|| entry.command.to_ascii_lowercase().contains(&query)
|
||||
{
|
||||
Some(idx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if self.selected >= self.filtered.len() {
|
||||
self.selected = 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn move_selection(&mut self, delta: isize) {
|
||||
if self.filtered.is_empty() {
|
||||
self.selected = 0;
|
||||
return;
|
||||
}
|
||||
let len = self.filtered.len() as isize;
|
||||
let next = (self.selected as isize + delta).clamp(0, len - 1) as usize;
|
||||
self.selected = next;
|
||||
}
|
||||
|
||||
fn selected_entry(&self) -> Option<&CommandPaletteEntry> {
|
||||
self.filtered
|
||||
.get(self.selected)
|
||||
.and_then(|idx| self.entries.get(*idx))
|
||||
}
|
||||
}
|
||||
|
||||
impl ModalView for CommandPaletteView {
|
||||
fn kind(&self) -> ModalKind {
|
||||
ModalKind::CommandPalette
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => ViewAction::Close,
|
||||
KeyCode::Enter => {
|
||||
if let Some(entry) = self.selected_entry() {
|
||||
ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
|
||||
command: entry.command.clone(),
|
||||
})
|
||||
} else {
|
||||
ViewAction::None
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.move_selection(-1);
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.move_selection(1);
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
self.move_selection(-8);
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
self.move_selection(8);
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.query.pop();
|
||||
self.refilter();
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Char(c)
|
||||
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
|
||||
{
|
||||
self.query.push(c);
|
||||
self.refilter();
|
||||
ViewAction::None
|
||||
}
|
||||
_ => ViewAction::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let popup_width = 90.min(area.width.saturating_sub(4));
|
||||
let popup_height = 22.min(area.height.saturating_sub(4));
|
||||
let popup_area = Rect {
|
||||
x: (area.width.saturating_sub(popup_width)) / 2,
|
||||
y: (area.height.saturating_sub(popup_height)) / 2,
|
||||
width: popup_width,
|
||||
height: popup_height,
|
||||
};
|
||||
|
||||
Clear.render(popup_area, buf);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let query_label = if self.query.is_empty() {
|
||||
"Type to filter…".to_string()
|
||||
} else {
|
||||
format!("Filter: {}", self.query)
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
query_label,
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
let visible = popup_height.saturating_sub(5) as usize;
|
||||
if self.filtered.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"No matches.",
|
||||
Style::default().fg(palette::TEXT_MUTED).italic(),
|
||||
)));
|
||||
} else {
|
||||
let start = self.selected.saturating_sub(visible.saturating_sub(1));
|
||||
let end = (start + visible).min(self.filtered.len());
|
||||
for (slot, idx) in self.filtered[start..end].iter().enumerate() {
|
||||
let absolute = start + slot;
|
||||
let is_selected = absolute == self.selected;
|
||||
let entry = &self.entries[*idx];
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.bg(palette::SELECTION_BG)
|
||||
} else {
|
||||
Style::default().fg(palette::TEXT_PRIMARY)
|
||||
};
|
||||
|
||||
let mut line = format!("{:<24}", entry.label);
|
||||
let desc = if entry.description.width() > 56 {
|
||||
let mut shortened = String::new();
|
||||
for ch in entry.description.chars() {
|
||||
if shortened.width() >= 53 {
|
||||
break;
|
||||
}
|
||||
shortened.push(ch);
|
||||
}
|
||||
format!("{shortened}...")
|
||||
} else {
|
||||
entry.description.clone()
|
||||
};
|
||||
line.push_str(" ");
|
||||
line.push_str(&desc);
|
||||
lines.push(Line::from(Span::styled(line, style)));
|
||||
}
|
||||
}
|
||||
|
||||
let block = Block::default()
|
||||
.title(" Command Palette ")
|
||||
.title_bottom(Line::from(vec![
|
||||
Span::styled(" Enter insert ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::styled("Esc close", Style::default().fg(palette::TEXT_MUTED)),
|
||||
]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR));
|
||||
|
||||
Paragraph::new(lines)
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(popup_area, buf);
|
||||
}
|
||||
}
|
||||
+102
-46
@@ -19,6 +19,8 @@ use crate::tui::markdown_render;
|
||||
const TOOL_COMMAND_LINE_LIMIT: usize = 5;
|
||||
const TOOL_OUTPUT_LINE_LIMIT: usize = 12;
|
||||
const TOOL_TEXT_LIMIT: usize = 240;
|
||||
const TOOL_RUNNING_SYMBOLS: [&str; 3] = ["◌", "◍", "◉"];
|
||||
const TOOL_STATUS_SYMBOL_MS: u64 = 900;
|
||||
|
||||
// === History Cells ===
|
||||
|
||||
@@ -28,7 +30,7 @@ pub enum HistoryCell {
|
||||
User { content: String },
|
||||
Assistant { content: String, streaming: bool },
|
||||
System { content: String },
|
||||
ThinkingSummary { summary: String },
|
||||
Thinking { content: String, streaming: bool },
|
||||
Tool(ToolCell),
|
||||
}
|
||||
|
||||
@@ -52,24 +54,14 @@ impl HistoryCell {
|
||||
pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
match self {
|
||||
HistoryCell::User { content } => render_message("You", content, user_style(), width),
|
||||
HistoryCell::Assistant { content, streaming } => {
|
||||
let mut lines = render_message("DeepSeek", content, assistant_style(), width);
|
||||
if *streaming {
|
||||
// Add blinking cursor to last line
|
||||
if let Some(last) = lines.last_mut() {
|
||||
last.spans.push(Span::styled(
|
||||
"▋",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY),
|
||||
));
|
||||
}
|
||||
}
|
||||
lines
|
||||
HistoryCell::Assistant { content, .. } => {
|
||||
render_message("Answer", content, assistant_style(), width)
|
||||
}
|
||||
HistoryCell::System { content } => {
|
||||
render_message("System", content, system_style(), width)
|
||||
}
|
||||
HistoryCell::ThinkingSummary { summary } => {
|
||||
render_message("Thinking", summary, thinking_style(), width)
|
||||
HistoryCell::Thinking { content, streaming } => {
|
||||
render_thinking(content, width, *streaming)
|
||||
}
|
||||
HistoryCell::Tool(cell) => cell.lines(width),
|
||||
}
|
||||
@@ -81,7 +73,7 @@ impl HistoryCell {
|
||||
options: TranscriptRenderOptions,
|
||||
) -> Vec<Line<'static>> {
|
||||
match self {
|
||||
HistoryCell::ThinkingSummary { .. } if !options.show_thinking => Vec::new(),
|
||||
HistoryCell::Thinking { .. } if !options.show_thinking => Vec::new(),
|
||||
HistoryCell::Tool(cell) if !options.show_tool_details => {
|
||||
let mut lines = cell.lines(width);
|
||||
if lines.len() > 2 {
|
||||
@@ -114,37 +106,66 @@ impl HistoryCell {
|
||||
#[must_use]
|
||||
pub fn history_cells_from_message(msg: &Message) -> Vec<HistoryCell> {
|
||||
let mut cells = Vec::new();
|
||||
let mut text_blocks = Vec::new();
|
||||
let mut thinking_blocks = Vec::new();
|
||||
|
||||
for block in &msg.content {
|
||||
match block {
|
||||
ContentBlock::Text { text, .. } => text_blocks.push(text.clone()),
|
||||
ContentBlock::Thinking { thinking } => thinking_blocks.push(thinking.clone()),
|
||||
ContentBlock::Text { text, .. } => match msg.role.as_str() {
|
||||
"user" => {
|
||||
if let Some(HistoryCell::User { content }) = cells.last_mut() {
|
||||
if !content.is_empty() {
|
||||
content.push('\n');
|
||||
}
|
||||
content.push_str(text);
|
||||
} else {
|
||||
cells.push(HistoryCell::User {
|
||||
content: text.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
"assistant" => {
|
||||
if let Some(HistoryCell::Assistant { content, .. }) = cells.last_mut() {
|
||||
if !content.is_empty() {
|
||||
content.push('\n');
|
||||
}
|
||||
content.push_str(text);
|
||||
} else {
|
||||
cells.push(HistoryCell::Assistant {
|
||||
content: text.clone(),
|
||||
streaming: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
"system" => {
|
||||
if let Some(HistoryCell::System { content }) = cells.last_mut() {
|
||||
if !content.is_empty() {
|
||||
content.push('\n');
|
||||
}
|
||||
content.push_str(text);
|
||||
} else {
|
||||
cells.push(HistoryCell::System {
|
||||
content: text.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
ContentBlock::Thinking { thinking } => {
|
||||
if let Some(HistoryCell::Thinking { content, .. }) = cells.last_mut() {
|
||||
if !content.is_empty() {
|
||||
content.push('\n');
|
||||
}
|
||||
content.push_str(thinking);
|
||||
} else {
|
||||
cells.push(HistoryCell::Thinking {
|
||||
content: thinking.clone(),
|
||||
streaming: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !text_blocks.is_empty() {
|
||||
let content = text_blocks.join("\n");
|
||||
match msg.role.as_str() {
|
||||
"user" => cells.push(HistoryCell::User { content }),
|
||||
"assistant" => cells.push(HistoryCell::Assistant {
|
||||
content,
|
||||
streaming: false,
|
||||
}),
|
||||
"system" => cells.push(HistoryCell::System { content }),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !thinking_blocks.is_empty() {
|
||||
let reasoning = thinking_blocks.join("\n");
|
||||
if let Some(summary) = extract_reasoning_summary(&reasoning) {
|
||||
cells.push(HistoryCell::ThinkingSummary { summary });
|
||||
}
|
||||
}
|
||||
|
||||
cells
|
||||
}
|
||||
|
||||
@@ -958,6 +979,7 @@ pub fn output_is_image(output: &str) -> bool {
|
||||
.any(|ext| lower.contains(ext))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[must_use]
|
||||
pub fn extract_reasoning_summary(text: &str) -> Option<String> {
|
||||
let mut lines = text.lines().peekable();
|
||||
@@ -999,6 +1021,38 @@ pub fn extract_reasoning_summary(text: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_thinking(content: &str, width: u16, streaming: bool) -> Vec<Line<'static>> {
|
||||
let style = thinking_style();
|
||||
let prefix = "┆ ";
|
||||
let label = if streaming {
|
||||
"[THINKING LIVE]"
|
||||
} else {
|
||||
"[THINKING]"
|
||||
};
|
||||
|
||||
let content_width = usize::from(width.saturating_sub(2).max(1));
|
||||
let rendered = markdown_render::render_markdown(content, content_width as u16, style);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
lines.push(Line::from(Span::styled(
|
||||
label,
|
||||
Style::default()
|
||||
.fg(palette::STATUS_WARNING)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
|
||||
for line in rendered {
|
||||
let mut spans = vec![Span::styled(prefix, style)];
|
||||
spans.extend(line.spans);
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
lines.push(Line::from(vec![Span::styled(prefix, style)]));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn render_message(prefix: &str, content: &str, style: Style, width: u16) -> Vec<Line<'static>> {
|
||||
let prefix_width = UnicodeWidthStr::width(prefix);
|
||||
let prefix_width_u16 = u16::try_from(prefix_width.saturating_add(2)).unwrap_or(u16::MAX);
|
||||
@@ -1206,11 +1260,13 @@ fn status_symbol(started_at: Option<Instant>, status: ToolStatus) -> String {
|
||||
match status {
|
||||
ToolStatus::Running => {
|
||||
let elapsed_ms = started_at.map_or(0, |t| t.elapsed().as_millis());
|
||||
if (elapsed_ms / 400).is_multiple_of(2) {
|
||||
"*".to_string()
|
||||
let cycle = u128::from(TOOL_STATUS_SYMBOL_MS);
|
||||
let idx = if cycle == 0 {
|
||||
0
|
||||
} else {
|
||||
".".to_string()
|
||||
}
|
||||
(elapsed_ms / cycle) % (TOOL_RUNNING_SYMBOLS.len() as u128)
|
||||
};
|
||||
TOOL_RUNNING_SYMBOLS[usize::try_from(idx).unwrap_or_default()].to_string()
|
||||
}
|
||||
ToolStatus::Success => "o".to_string(),
|
||||
ToolStatus::Failed => "x".to_string(),
|
||||
@@ -1245,8 +1301,8 @@ fn system_style() -> Style {
|
||||
|
||||
fn thinking_style() -> Style {
|
||||
Style::default()
|
||||
.fg(palette::TEXT_MUTED)
|
||||
.add_modifier(Modifier::ITALIC | Modifier::DIM)
|
||||
.fg(palette::TEXT_PRIMARY)
|
||||
.add_modifier(Modifier::ITALIC)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
pub mod app;
|
||||
pub mod approval;
|
||||
pub mod clipboard;
|
||||
pub mod command_palette;
|
||||
pub mod diff_render;
|
||||
pub mod event_broker;
|
||||
pub mod history;
|
||||
@@ -18,6 +19,8 @@ pub mod session_picker;
|
||||
pub mod streaming;
|
||||
pub mod transcript;
|
||||
pub mod ui;
|
||||
mod ui_text;
|
||||
pub mod user_input;
|
||||
pub mod views;
|
||||
pub mod widgets;
|
||||
|
||||
|
||||
@@ -55,9 +55,7 @@ pub fn tips_lines() -> Vec<ratatui::text::Line<'static>> {
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::raw(
|
||||
" - Tab cycles modes (Plan → Agent → YOLO → RLM → Duo)",
|
||||
)),
|
||||
Line::from(Span::raw(" - Tab cycles modes (Plan → Agent → YOLO)")),
|
||||
Line::from(Span::raw(" - Ctrl+R opens the session picker")),
|
||||
Line::from(Span::raw(" - l opens the pager for the last message")),
|
||||
Line::from(Span::raw(" - Ctrl+C cancels or exits")),
|
||||
|
||||
@@ -130,6 +130,20 @@ impl PasteBurst {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the remaining delay before a pending char/paste buffer must flush.
|
||||
///
|
||||
/// This lets the UI event loop avoid sleeping past the flush deadline.
|
||||
#[must_use]
|
||||
pub fn next_flush_delay(&self, now: Instant) -> Option<Duration> {
|
||||
let last = self.last_plain_char_time?;
|
||||
let timeout = if self.is_active_internal() {
|
||||
PASTE_BURST_ACTIVE_IDLE_TIMEOUT
|
||||
} else {
|
||||
PASTE_BURST_CHAR_INTERVAL
|
||||
};
|
||||
Some(timeout.saturating_sub(now.duration_since(last)))
|
||||
}
|
||||
|
||||
pub fn append_newline_if_active(&mut self, now: Instant) -> bool {
|
||||
if self.is_active() {
|
||||
self.buffer.push('\n');
|
||||
@@ -295,4 +309,20 @@ mod tests {
|
||||
assert_eq!(burst.flush_before_modified_input(), Some("a".to_string()));
|
||||
assert!(!burst.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_flush_delay_counts_down_to_zero() {
|
||||
let mut burst = PasteBurst::default();
|
||||
let t0 = Instant::now();
|
||||
let _ = burst.on_plain_char('a', t0);
|
||||
|
||||
let almost_due = t0 + Duration::from_millis(7);
|
||||
let remaining = burst
|
||||
.next_flush_delay(almost_due)
|
||||
.expect("delay should exist");
|
||||
assert!(remaining <= Duration::from_millis(1));
|
||||
|
||||
let due = t0 + Duration::from_millis(20);
|
||||
assert_eq!(burst.next_flush_delay(due), Some(Duration::ZERO));
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,10 @@ impl SessionPickerView {
|
||||
self.sessions.retain(|s| s.id != session.id);
|
||||
self.apply_sort_and_filter();
|
||||
self.refresh_preview();
|
||||
self.status = Some(format!("Deleted session {}", &session.id[..8]));
|
||||
self.status = Some(format!(
|
||||
"Deleted session {}",
|
||||
&session.id[..8.min(session.id.len())]
|
||||
));
|
||||
Some(ViewEvent::SessionDeleted {
|
||||
session_id: session.id,
|
||||
title: session.title,
|
||||
@@ -405,7 +408,7 @@ fn format_session_line(session: &SessionMetadata) -> String {
|
||||
.to_ascii_lowercase();
|
||||
format!(
|
||||
"{} | {} | {} msgs | {} | {}",
|
||||
&session.id[..8],
|
||||
&session.id[..8.min(session.id.len())],
|
||||
title,
|
||||
session.message_count,
|
||||
mode,
|
||||
|
||||
+1720
-1259
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,267 @@
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn selection_point_from_position_ignores_top_padding() {
|
||||
let area = Rect {
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 30,
|
||||
height: 5,
|
||||
};
|
||||
|
||||
// Content is bottom-aligned: 2 transcript lines in a 5-row viewport.
|
||||
let padding_top = 3;
|
||||
let transcript_top = 0;
|
||||
let transcript_total = 2;
|
||||
|
||||
// Click in padding area -> no selection
|
||||
assert!(
|
||||
selection_point_from_position(
|
||||
area,
|
||||
area.x + 1,
|
||||
area.y,
|
||||
transcript_top,
|
||||
transcript_total,
|
||||
padding_top,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
|
||||
// First transcript line is at row `padding_top`
|
||||
let p0 = selection_point_from_position(
|
||||
area,
|
||||
area.x + 2,
|
||||
area.y + u16::try_from(padding_top).expect("padding should fit"),
|
||||
transcript_top,
|
||||
transcript_total,
|
||||
padding_top,
|
||||
)
|
||||
.expect("point");
|
||||
assert_eq!(p0.line_index, 0);
|
||||
assert_eq!(p0.column, 2);
|
||||
|
||||
// Second transcript line is one row below
|
||||
let p1 = selection_point_from_position(
|
||||
area,
|
||||
area.x,
|
||||
area.y + u16::try_from(padding_top + 1).expect("padding should fit"),
|
||||
transcript_top,
|
||||
transcript_total,
|
||||
padding_top,
|
||||
)
|
||||
.expect("point");
|
||||
assert_eq!(p1.line_index, 1);
|
||||
assert_eq!(p1.column, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_plan_choice_accepts_numbers() {
|
||||
assert_eq!(parse_plan_choice("1"), Some(PlanChoice::ImplementAgent));
|
||||
assert_eq!(parse_plan_choice("2"), Some(PlanChoice::ImplementYolo));
|
||||
assert_eq!(parse_plan_choice("3"), Some(PlanChoice::RevisePlan));
|
||||
assert_eq!(parse_plan_choice("4"), Some(PlanChoice::ExitPlan));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_plan_choice_accepts_aliases() {
|
||||
assert_eq!(parse_plan_choice("agent"), Some(PlanChoice::ImplementAgent));
|
||||
assert_eq!(parse_plan_choice("yolo"), Some(PlanChoice::ImplementYolo));
|
||||
assert_eq!(parse_plan_choice("revise"), Some(PlanChoice::RevisePlan));
|
||||
assert_eq!(parse_plan_choice("exit"), Some(PlanChoice::ExitPlan));
|
||||
assert_eq!(parse_plan_choice("unknown"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_scroll_percent_is_clamped_and_relative() {
|
||||
assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));
|
||||
assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));
|
||||
assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));
|
||||
assert_eq!(transcript_scroll_percent(0, 20, 20), None);
|
||||
}
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-reasoner".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: false,
|
||||
skip_onboarding: false,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_token_count_compact_formats_units() {
|
||||
assert_eq!(format_token_count_compact(999), "999");
|
||||
assert_eq!(format_token_count_compact(1_200), "1.2k");
|
||||
assert_eq!(format_token_count_compact(1_000_000), "1.0M");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_context_budget_caps_overflow_display() {
|
||||
assert_eq!(format_context_budget(5_000, 128_000), "5.0k/128.0k");
|
||||
assert_eq!(format_context_budget(250_000, 128_000), ">128.0k/128.0k");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {
|
||||
let mut app = create_test_app();
|
||||
app.last_prompt_tokens = Some(320_000);
|
||||
app.api_messages = vec![Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "hello".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
}];
|
||||
|
||||
let (used, max, percent) =
|
||||
context_usage_snapshot(&app).expect("context usage should be available");
|
||||
assert_eq!(max, 128_000);
|
||||
assert!(used > 0);
|
||||
assert!(used <= i64::from(max));
|
||||
assert!(percent < 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_auto_compact_before_send_respects_threshold_and_setting() {
|
||||
let mut app = create_test_app();
|
||||
app.last_prompt_tokens = Some(123_000);
|
||||
app.auto_compact = true;
|
||||
assert!(should_auto_compact_before_send(&app));
|
||||
|
||||
app.auto_compact = false;
|
||||
assert!(!should_auto_compact_before_send(&app));
|
||||
|
||||
app.auto_compact = true;
|
||||
app.last_prompt_tokens = Some(10_000);
|
||||
assert!(!should_auto_compact_before_send(&app));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Streaming Cancel Behavior Tests
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_esc_cancels_streaming_sets_is_loading_false() {
|
||||
let mut app = create_test_app();
|
||||
app.is_loading = true;
|
||||
app.mode = AppMode::Agent;
|
||||
|
||||
// Simulate what happens in ui.rs when Esc is pressed during loading:
|
||||
// engine_handle.cancel() is called (can't test directly - private)
|
||||
// Then these state changes occur:
|
||||
app.is_loading = false;
|
||||
app.status_message = Some("Request cancelled".to_string());
|
||||
|
||||
assert!(!app.is_loading);
|
||||
assert_eq!(app.status_message, Some("Request cancelled".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_esc_with_input_clears_input_when_not_loading() {
|
||||
let mut app = create_test_app();
|
||||
app.is_loading = false;
|
||||
app.input = "some draft input".to_string();
|
||||
app.cursor_position = app.input.chars().count();
|
||||
|
||||
// Simulate Esc key press when not loading but input not empty
|
||||
app.clear_input();
|
||||
|
||||
assert!(app.input.is_empty());
|
||||
assert_eq!(app.cursor_position, 0);
|
||||
assert!(!app.is_loading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_esc_switches_to_normal_mode_when_idle() {
|
||||
let mut app = create_test_app();
|
||||
app.is_loading = false;
|
||||
app.input.clear();
|
||||
app.cursor_position = 0;
|
||||
app.mode = AppMode::Agent;
|
||||
|
||||
// Simulate Esc key press when not loading and input empty
|
||||
app.set_mode(AppMode::Normal);
|
||||
|
||||
assert_eq!(app.mode, AppMode::Normal);
|
||||
assert!(!app.is_loading);
|
||||
assert!(app.input.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ctrl_c_cancels_streaming_sets_status() {
|
||||
let mut app = create_test_app();
|
||||
app.is_loading = true;
|
||||
|
||||
// Simulate Ctrl+C during loading state
|
||||
// engine_handle.cancel() is called (can't test directly - private)
|
||||
app.is_loading = false;
|
||||
app.status_message = Some("Request cancelled".to_string());
|
||||
|
||||
assert!(!app.is_loading);
|
||||
assert_eq!(app.status_message, Some("Request cancelled".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ctrl_c_exits_when_not_loading() {
|
||||
let mut app = create_test_app();
|
||||
app.is_loading = false;
|
||||
|
||||
// Ctrl+C when not loading should trigger shutdown
|
||||
// We can't test the actual shutdown, but verify the state is correct
|
||||
// for the shutdown path to be taken
|
||||
assert!(!app.is_loading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ctrl_d_exits_when_input_empty() {
|
||||
let mut app = create_test_app();
|
||||
app.input.clear();
|
||||
|
||||
// Ctrl+D when input empty should trigger shutdown
|
||||
assert!(app.input.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ctrl_d_does_nothing_when_input_not_empty() {
|
||||
let mut app = create_test_app();
|
||||
app.input = "some input".to_string();
|
||||
|
||||
// Ctrl+D when input not empty should not trigger shutdown
|
||||
assert!(!app.input.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_esc_priority_order_loading_then_input_then_mode() {
|
||||
// Test 1: Loading state takes priority
|
||||
let mut app = create_test_app();
|
||||
app.is_loading = true;
|
||||
app.input = "draft".to_string();
|
||||
app.mode = AppMode::Yolo;
|
||||
// Should cancel request (not clear input or change mode)
|
||||
assert!(app.is_loading);
|
||||
|
||||
// Test 2: Input not empty takes priority when not loading
|
||||
app.is_loading = false;
|
||||
assert!(!app.input.is_empty());
|
||||
// Should clear input (not change mode)
|
||||
|
||||
// Test 3: Change mode when not loading and input empty
|
||||
app.input.clear();
|
||||
app.mode = AppMode::Yolo;
|
||||
assert!(app.input.is_empty());
|
||||
assert_eq!(app.mode, AppMode::Yolo);
|
||||
// Should change to Normal mode
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Shared text helpers for TUI selection and clipboard workflows.
|
||||
|
||||
use ratatui::text::Line;
|
||||
|
||||
use crate::tui::history::HistoryCell;
|
||||
|
||||
pub(super) fn history_cell_to_text(cell: &HistoryCell, width: u16) -> String {
|
||||
cell.lines(width)
|
||||
.into_iter()
|
||||
.map(line_to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn line_to_string(line: Line<'static>) -> String {
|
||||
line.spans
|
||||
.into_iter()
|
||||
.map(|span| span.content.to_string())
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
pub(super) fn line_to_plain(line: &Line<'static>) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
pub(super) fn slice_text(text: &str, start: usize, end: usize) -> String {
|
||||
let mut out = String::new();
|
||||
let mut idx = 0usize;
|
||||
for ch in text.chars() {
|
||||
if idx >= start && idx < end {
|
||||
out.push(ch);
|
||||
}
|
||||
idx += 1;
|
||||
if idx >= end {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Modal for request_user_input tool prompts.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::layout::{Alignment, Rect};
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
|
||||
use crate::palette;
|
||||
use crate::tools::user_input::{
|
||||
UserInputAnswer, UserInputQuestion, UserInputRequest, UserInputResponse,
|
||||
};
|
||||
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum InputMode {
|
||||
Selecting,
|
||||
OtherInput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserInputView {
|
||||
tool_id: String,
|
||||
request: UserInputRequest,
|
||||
question_index: usize,
|
||||
selected: usize,
|
||||
mode: InputMode,
|
||||
other_input: String,
|
||||
answers: Vec<UserInputAnswer>,
|
||||
}
|
||||
|
||||
impl UserInputView {
|
||||
pub fn new(tool_id: impl Into<String>, request: UserInputRequest) -> Self {
|
||||
Self {
|
||||
tool_id: tool_id.into(),
|
||||
request,
|
||||
question_index: 0,
|
||||
selected: 0,
|
||||
mode: InputMode::Selecting,
|
||||
other_input: String::new(),
|
||||
answers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_question(&self) -> &UserInputQuestion {
|
||||
&self.request.questions[self.question_index]
|
||||
}
|
||||
|
||||
fn option_count(&self) -> usize {
|
||||
self.current_question().options.len() + 1
|
||||
}
|
||||
|
||||
fn is_other_selected(&self) -> bool {
|
||||
self.selected + 1 == self.option_count()
|
||||
}
|
||||
|
||||
fn advance_question(&mut self, answer: UserInputAnswer) -> ViewAction {
|
||||
self.answers.push(answer);
|
||||
if self.question_index + 1 >= self.request.questions.len() {
|
||||
let response = UserInputResponse {
|
||||
answers: self.answers.clone(),
|
||||
};
|
||||
return ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted {
|
||||
tool_id: self.tool_id.clone(),
|
||||
response,
|
||||
});
|
||||
}
|
||||
self.question_index += 1;
|
||||
self.selected = 0;
|
||||
self.mode = InputMode::Selecting;
|
||||
self.other_input.clear();
|
||||
ViewAction::None
|
||||
}
|
||||
|
||||
fn handle_selecting_key(&mut self, key: KeyEvent) -> ViewAction {
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.selected = self.selected.saturating_sub(1);
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.selected = (self.selected + 1).min(self.option_count().saturating_sub(1));
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if self.is_other_selected() {
|
||||
self.mode = InputMode::OtherInput;
|
||||
self.other_input.clear();
|
||||
ViewAction::None
|
||||
} else {
|
||||
let question = self.current_question();
|
||||
let option = &question.options[self.selected];
|
||||
let answer = UserInputAnswer {
|
||||
id: question.id.clone(),
|
||||
label: option.label.clone(),
|
||||
value: option.label.clone(),
|
||||
};
|
||||
self.advance_question(answer)
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::UserInputCancelled {
|
||||
tool_id: self.tool_id.clone(),
|
||||
}),
|
||||
_ => ViewAction::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_other_input_key(&mut self, key: KeyEvent) -> ViewAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.mode = InputMode::Selecting;
|
||||
self.other_input.clear();
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let question = self.current_question();
|
||||
let answer = UserInputAnswer {
|
||||
id: question.id.clone(),
|
||||
label: "Other".to_string(),
|
||||
value: self.other_input.trim().to_string(),
|
||||
};
|
||||
self.advance_question(answer)
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.other_input.pop();
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Char(ch) => {
|
||||
if !ch.is_control() {
|
||||
self.other_input.push(ch);
|
||||
}
|
||||
ViewAction::None
|
||||
}
|
||||
_ => ViewAction::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModalView for UserInputView {
|
||||
fn kind(&self) -> ModalKind {
|
||||
ModalKind::UserInput
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
|
||||
match self.mode {
|
||||
InputMode::Selecting => self.handle_selecting_key(key),
|
||||
InputMode::OtherInput => self.handle_other_input_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let question = self.current_question();
|
||||
let total = self.request.questions.len();
|
||||
let header = format!(
|
||||
" {} ({}/{}) ",
|
||||
question.header,
|
||||
self.question_index + 1,
|
||||
total
|
||||
);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
question.question.clone(),
|
||||
Style::default().fg(palette::TEXT_PRIMARY).bold(),
|
||||
)]));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
for (idx, option) in question.options.iter().enumerate() {
|
||||
let selected = self.selected == idx;
|
||||
let prefix = if selected { ">" } else { " " };
|
||||
let style = if selected {
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold()
|
||||
} else {
|
||||
Style::default().fg(palette::TEXT_PRIMARY)
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(format!("{prefix} ")),
|
||||
Span::styled(option.label.clone(), style),
|
||||
Span::raw(" - "),
|
||||
Span::styled(
|
||||
option.description.clone(),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
let other_index = question.options.len();
|
||||
let other_selected = self.selected == other_index;
|
||||
let other_style = if other_selected {
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold()
|
||||
} else {
|
||||
Style::default().fg(palette::TEXT_PRIMARY)
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(if other_selected { "> " } else { " " }),
|
||||
Span::styled("Other", other_style),
|
||||
Span::raw(" - "),
|
||||
Span::styled(
|
||||
"Provide a custom response",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
),
|
||||
]));
|
||||
|
||||
if self.mode == InputMode::OtherInput {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Other:", Style::default().fg(palette::TEXT_PRIMARY)),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
if self.other_input.is_empty() {
|
||||
"(type your response)".to_string()
|
||||
} else {
|
||||
self.other_input.clone()
|
||||
},
|
||||
Style::default().fg(palette::DEEPSEEK_BLUE),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
let hint = if self.mode == InputMode::OtherInput {
|
||||
"Enter=submit, Esc=back"
|
||||
} else {
|
||||
"Up/Down=select, Enter=confirm, Esc=cancel"
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
hint,
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
|
||||
let paragraph = Paragraph::new(lines)
|
||||
.alignment(Alignment::Left)
|
||||
.wrap(Wrap { trim: true })
|
||||
.block(
|
||||
Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
header,
|
||||
Style::default().fg(palette::DEEPSEEK_BLUE).bold(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::DEEPSEEK_SKY)),
|
||||
);
|
||||
|
||||
let popup_area = centered_rect(80, 60, area);
|
||||
paragraph.render(popup_area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||
let popup_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
Constraint::Percentage(percent_y),
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
])
|
||||
.split(r);
|
||||
let horizontal = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
Constraint::Percentage(percent_x),
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
])
|
||||
.split(popup_layout[1]);
|
||||
horizontal[1]
|
||||
}
|
||||
+44
-15
@@ -3,6 +3,7 @@ use ratatui::{buffer::Buffer, layout::Rect};
|
||||
use std::fmt;
|
||||
|
||||
use crate::palette;
|
||||
use crate::tools::UserInputResponse;
|
||||
use crate::tools::subagent::{SubAgentResult, SubAgentStatus, SubAgentType};
|
||||
use crate::tui::approval::{ElevationOption, ReviewDecision};
|
||||
|
||||
@@ -10,6 +11,8 @@ use crate::tui::approval::{ElevationOption, ReviewDecision};
|
||||
pub enum ModalKind {
|
||||
Approval,
|
||||
Elevation,
|
||||
UserInput,
|
||||
CommandPalette,
|
||||
Help,
|
||||
SubAgents,
|
||||
Pager,
|
||||
@@ -18,6 +21,13 @@ pub enum ModalKind {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ViewEvent {
|
||||
CommandPaletteSelected {
|
||||
command: String,
|
||||
},
|
||||
OpenTextPager {
|
||||
title: String,
|
||||
content: String,
|
||||
},
|
||||
ApprovalDecision {
|
||||
tool_id: String,
|
||||
tool_name: String,
|
||||
@@ -29,6 +39,13 @@ pub enum ViewEvent {
|
||||
tool_name: String,
|
||||
option: ElevationOption,
|
||||
},
|
||||
UserInputSubmitted {
|
||||
tool_id: String,
|
||||
response: UserInputResponse,
|
||||
},
|
||||
UserInputCancelled {
|
||||
tool_id: String,
|
||||
},
|
||||
SubAgentsRefresh,
|
||||
SessionSelected {
|
||||
session_id: String,
|
||||
@@ -235,14 +252,16 @@ impl ModalView for HelpView {
|
||||
Line::from(" Esc - Cancel request / clear input"),
|
||||
Line::from(" Ctrl+C - Cancel request or exit application"),
|
||||
Line::from(" Ctrl+D - Exit when input is empty"),
|
||||
Line::from(" Ctrl+K - Open command palette"),
|
||||
Line::from(" l - Open pager for last message (when input empty)"),
|
||||
Line::from(" v - Open tool details (when input empty)"),
|
||||
Line::from(" Enter (selection) - Open pager for selected text"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Modes ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Tab - Cycle through modes"),
|
||||
Line::from(" Tab - Complete /command or cycle modes"),
|
||||
Line::from(" Ctrl+X - Toggle between Agent and Normal modes"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
@@ -275,7 +294,7 @@ impl ModalView for HelpView {
|
||||
"Modes:",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Tab cycles modes: Plan → Agent → YOLO → RLM → Duo"),
|
||||
Line::from(" Tab cycles modes: Plan → Agent → YOLO"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"Commands:",
|
||||
@@ -290,25 +309,35 @@ impl ModalView for HelpView {
|
||||
)));
|
||||
}
|
||||
|
||||
help_lines.push(Line::from(""));
|
||||
help_lines.push(Line::from(vec![Span::styled(
|
||||
"RLM / Aleph:",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]));
|
||||
help_lines.push(Line::from(" /rlm or /aleph - Enter external memory mode"));
|
||||
help_lines.push(Line::from(
|
||||
" /load @path - Load a file into RLM context",
|
||||
));
|
||||
help_lines.push(Line::from(" /repl - Toggle expression mode"));
|
||||
help_lines.push(Line::from(" /status - Show contexts and usage"));
|
||||
|
||||
help_lines.push(Line::from(""));
|
||||
help_lines.push(Line::from(vec![Span::styled(
|
||||
"Tools:",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]));
|
||||
help_lines.push(Line::from(
|
||||
" web_search - Search the web (DuckDuckGo; MCP optional)",
|
||||
" web.run - Browse the web (search/open/click/find/screenshot)",
|
||||
));
|
||||
help_lines.push(Line::from(
|
||||
" web_search - Quick web search (DuckDuckGo; MCP optional)",
|
||||
));
|
||||
help_lines.push(Line::from(
|
||||
" request_user_input - Ask the user to choose from short prompts",
|
||||
));
|
||||
help_lines.push(Line::from(
|
||||
" multi_tool_use.parallel - Execute multiple tools in parallel",
|
||||
));
|
||||
help_lines.push(Line::from(" weather - Daily forecast for a location"));
|
||||
help_lines.push(Line::from(" finance - Stock/crypto price lookup"));
|
||||
help_lines.push(Line::from(" sports - League schedules/standings"));
|
||||
help_lines.push(Line::from(" time - Current time for UTC offsets"));
|
||||
help_lines.push(Line::from(
|
||||
" calculator - Evaluate arithmetic expressions",
|
||||
));
|
||||
help_lines.push(Line::from(
|
||||
" list_mcp_resources - List MCP resources (optionally by server)",
|
||||
));
|
||||
help_lines.push(Line::from(
|
||||
" list_mcp_resource_templates - List MCP resource templates",
|
||||
));
|
||||
help_lines.push(Line::from(" mcp_* - Tools exposed by MCP servers"));
|
||||
help_lines.push(Line::from(""));
|
||||
|
||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user