mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 17:10:18 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5640a00b03 | ||
|
|
9bbe82e7f4 | ||
|
|
cbe2a30ea4 | ||
|
|
a5c02c0eb4 | ||
|
|
3204f556af | ||
|
|
12aeca7171 | ||
|
|
19d168b577 |
No files matched your search
@@ -37,6 +37,8 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Run tests
|
||||
run: cargo test --all-features
|
||||
- name: Run Offline Eval Harness
|
||||
run: cargo run --all-features -- eval
|
||||
|
||||
build:
|
||||
name: Build
|
||||
|
||||
+38
-1
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.2] - 2026-01-28
|
||||
|
||||
### Fixed
|
||||
- Preserve tool-call + tool-result pairing during compaction to avoid invalid tool message sequences
|
||||
- Drop orphan tool results in request builder as a safety net to prevent API 400s
|
||||
|
||||
## [0.3.1] - 2026-01-27
|
||||
|
||||
### Added
|
||||
- `deepseek setup` to bootstrap MCP config and skills directories
|
||||
- `deepseek mcp init` to generate a template `mcp.json` at the configured path
|
||||
|
||||
### Changed
|
||||
- `deepseek doctor` now follows the resolved config path and config-derived MCP/skills locations
|
||||
|
||||
### Fixed
|
||||
- Doctor no longer reports missing MCP/skills when paths are overridden via config or env
|
||||
|
||||
## [0.3.0] - 2026-01-27
|
||||
|
||||
### Added
|
||||
- Repo-aware working set tracking with prompt injection for active paths
|
||||
- Working set signals now pin relevant messages during auto-compaction
|
||||
- Offline eval harness (`deepseek eval`) with CI coverage in the test job
|
||||
- Shell tool now emits stdout/stderr summaries and truncation metadata
|
||||
- Dependency-aware `agent_swarm` tool for orchestrating multiple sub-agents
|
||||
- Expanded sub-agent tool access (apply_patch, web_search, file_search)
|
||||
|
||||
### Changed
|
||||
- Auto-compaction now accounts for pinned budget and preserves working-set context
|
||||
- Apply patch tool validates patch shape, reports per-file summaries, and improves hunk mismatch diagnostics
|
||||
- Eval harness shell step now uses a Windows-safe default command
|
||||
- Increased `max_subagents` clamp to `1..=20`
|
||||
|
||||
## [0.2.2] - 2026-01-22
|
||||
|
||||
### Fixed
|
||||
@@ -111,7 +145,10 @@ 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.2.2...HEAD
|
||||
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.2...HEAD
|
||||
[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.0]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.2.0
|
||||
|
||||
Generated
+1
-1
@@ -646,7 +646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui"
|
||||
version = "0.2.2"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deepseek-tui"
|
||||
version = "0.2.2"
|
||||
version = "0.3.2"
|
||||
edition = "2024"
|
||||
description = "Unofficial DeepSeek CLI - Just run 'deepseek' to start chatting"
|
||||
license = "MIT"
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# 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
|
||||
|
||||
@@ -16,7 +16,7 @@ Unofficial terminal UI (TUI) + CLI for the [DeepSeek platform](https://platform.
|
||||
- **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, manage, and cancel background agents for parallel work
|
||||
- **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`
|
||||
@@ -39,6 +39,9 @@ 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
|
||||
```
|
||||
@@ -77,7 +80,7 @@ On first run, the TUI can prompt for your API key and save it to `~/.deepseek/co
|
||||
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‑5)
|
||||
max_subagents = 3 # optional (1‑20)
|
||||
```
|
||||
|
||||
Useful environment variables:
|
||||
@@ -88,6 +91,9 @@ Useful environment variables:
|
||||
- `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
|
||||
@@ -131,6 +137,7 @@ DeepSeek CLI exposes a comprehensive set of tools to the model across 5 categori
|
||||
|
||||
#### 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
|
||||
@@ -140,7 +147,7 @@ DeepSeek CLI exposes a comprehensive set of tools to the model across 5 categori
|
||||
- **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`). Use `/skills` and `/skill <name>`.
|
||||
- **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)
|
||||
@@ -239,11 +246,18 @@ Set `DEEPSEEK_BASE_URL` to `https://api.deepseeki.com` (China).
|
||||
### Session issues
|
||||
Run `deepseek sessions` and try `deepseek --resume latest`.
|
||||
|
||||
### 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.
|
||||
|
||||
### MCP tools missing
|
||||
Validate `~/.deepseek/mcp.json` (or `DEEPSEEK_MCP_CONFIG`) and restart.
|
||||
Run `deepseek mcp init` (or `deepseek setup --mcp`), then restart. `deepseek doctor`
|
||||
now checks the MCP path resolved from your config/env overrides.
|
||||
|
||||
### Sandbox errors (macOS)
|
||||
Ensure `/usr/bin/sandbox-exec` exists (comes with macOS). For other platforms, sandboxing is limited.
|
||||
Run `deepseek doctor` to confirm sandbox availability. On macOS, ensure
|
||||
`/usr/bin/sandbox-exec` exists. For other platforms, sandboxing is limited.
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
@@ -271,4 +285,4 @@ MIT
|
||||
|
||||
---
|
||||
|
||||
DeepSeek is a trademark of DeepSeek Inc. This is an unofficial project.
|
||||
DeepSeek is a trademark of DeepSeek Inc. This is an unofficial project.
|
||||
@@ -37,6 +37,7 @@ notes_path = "~/.deepseek/notes.txt"
|
||||
# Security
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
allow_shell = false
|
||||
max_subagents = 5 # optional (1-20)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# TUI
|
||||
|
||||
+11
-3
@@ -15,6 +15,9 @@ Overrides:
|
||||
|
||||
If both are set, `--config` wins. Environment variable overrides are applied after the file is loaded.
|
||||
|
||||
To bootstrap MCP and skills directories at their resolved paths, run `deepseek setup`.
|
||||
To only scaffold MCP, run `deepseek mcp init`.
|
||||
|
||||
## Profiles
|
||||
|
||||
You can define multiple profiles in the same file:
|
||||
@@ -46,7 +49,7 @@ These override config values:
|
||||
- `DEEPSEEK_NOTES_PATH`
|
||||
- `DEEPSEEK_MEMORY_PATH`
|
||||
- `DEEPSEEK_ALLOW_SHELL` (`1`/`true` enables)
|
||||
- `DEEPSEEK_MAX_SUBAGENTS` (clamped to `1..=5`)
|
||||
- `DEEPSEEK_MAX_SUBAGENTS` (clamped to `1..=20`)
|
||||
|
||||
## Settings File (Persistent UI Preferences)
|
||||
|
||||
@@ -76,7 +79,7 @@ Common settings keys:
|
||||
- `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`.
|
||||
- `max_subagents` (int, optional): defaults to `5` and is clamped to `1..=5`.
|
||||
- `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`).
|
||||
- `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.
|
||||
@@ -124,4 +127,9 @@ Use `deepseek features list` to inspect known flags and their effective state.
|
||||
|
||||
## Notes On `deepseek doctor`
|
||||
|
||||
`deepseek doctor` checks default locations under `~/.deepseek/` (including `config.toml` and `mcp.json`). If you override paths via `--config` or `DEEPSEEK_MCP_CONFIG`, the doctor output may not reflect those overrides.
|
||||
`deepseek doctor` now follows the same config resolution rules as the rest of the CLI.
|
||||
That means `--config` / `DEEPSEEK_CONFIG_PATH` are respected, and MCP/skills checks
|
||||
use the resolved `mcp_config_path` / `skills_dir` (including env overrides).
|
||||
|
||||
To bootstrap missing MCP/skills paths, run `deepseek setup --all`. You can also
|
||||
run `deepseek setup --skills --local` to create a workspace-local `./skills` dir.
|
||||
+14
-3
@@ -2,6 +2,16 @@
|
||||
|
||||
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.
|
||||
|
||||
## Bootstrap MCP Config
|
||||
|
||||
Create a starter MCP config at your resolved MCP path:
|
||||
|
||||
```bash
|
||||
deepseek mcp init
|
||||
```
|
||||
|
||||
`deepseek setup --mcp` performs the same MCP bootstrap alongside skills setup.
|
||||
|
||||
## Config File Location
|
||||
|
||||
Default path:
|
||||
@@ -13,6 +23,8 @@ Overrides:
|
||||
- Config: `mcp_config_path = "/path/to/mcp.json"`
|
||||
- Env: `DEEPSEEK_MCP_CONFIG=/path/to/mcp.json`
|
||||
|
||||
`deepseek mcp init` (and `deepseek setup --mcp`) writes to this resolved path.
|
||||
|
||||
After editing the file, restart the TUI.
|
||||
|
||||
## Tool Naming
|
||||
@@ -61,7 +73,6 @@ MCP tools currently execute without TUI approval prompts. Only configure MCP ser
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Run `deepseek doctor` to confirm whether the default `~/.deepseek/mcp.json` exists.
|
||||
- If you override `mcp_config_path` / `DEEPSEEK_MCP_CONFIG`, note that `deepseek doctor` still checks `~/.deepseek/mcp.json`.
|
||||
- Run `deepseek doctor` to confirm the MCP config path it resolved and whether it exists.
|
||||
- If the MCP config is missing, run `deepseek mcp init --force` to regenerate it.
|
||||
- If tools don’t appear, verify the server command works from your shell and that the server supports MCP `tools/list`.
|
||||
|
||||
+1
-2
@@ -54,8 +54,7 @@ Run `deepseek --help` for the canonical list. Common flags:
|
||||
- `--yolo`: start in YOLO mode
|
||||
- `-r, --resume <ID|PREFIX|latest>`: resume a saved session
|
||||
- `-c, --continue`: resume the most recent session
|
||||
- `--max-subagents <N>`: clamp to `1..=5`
|
||||
- `--max-subagents <N>`: clamp to `1..=20`
|
||||
- `--profile <NAME>`: select config profile
|
||||
- `--config <PATH>`: config file path
|
||||
- `-v, --verbose`: verbose logging
|
||||
|
||||
+73
-4
@@ -3,6 +3,7 @@
|
||||
//! Uses the OpenAI Responses API when available, falling back to Chat Completions
|
||||
//! if the Responses endpoint is unsupported by the target base URL.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
@@ -427,6 +428,7 @@ fn build_chat_messages(
|
||||
) -> Vec<Value> {
|
||||
let mut out = Vec::new();
|
||||
let include_reasoning = requires_reasoning_content(model);
|
||||
let mut pending_tool_calls: HashSet<String> = HashSet::new();
|
||||
|
||||
if let Some(instructions) = system_to_instructions(system.cloned()) {
|
||||
if !instructions.trim().is_empty() {
|
||||
@@ -442,7 +444,8 @@ fn build_chat_messages(
|
||||
let mut text_parts = Vec::new();
|
||||
let mut thinking_parts = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
let mut tool_results = Vec::new();
|
||||
let mut tool_call_ids = Vec::new();
|
||||
let mut tool_results: Vec<(String, Value)> = Vec::new();
|
||||
|
||||
for block in &message.content {
|
||||
match block {
|
||||
@@ -458,16 +461,17 @@ fn build_chat_messages(
|
||||
"arguments": args,
|
||||
}
|
||||
}));
|
||||
tool_call_ids.push(id.clone());
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
} => {
|
||||
tool_results.push(json!({
|
||||
tool_results.push((tool_use_id.clone(), json!({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_use_id,
|
||||
"content": content,
|
||||
}));
|
||||
})));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -483,6 +487,9 @@ fn build_chat_messages(
|
||||
}
|
||||
if !tool_calls.is_empty() {
|
||||
msg["tool_calls"] = json!(tool_calls);
|
||||
pending_tool_calls = tool_call_ids.into_iter().collect();
|
||||
} else {
|
||||
pending_tool_calls.clear();
|
||||
}
|
||||
out.push(msg);
|
||||
} else if role == "user" {
|
||||
@@ -496,7 +503,21 @@ fn build_chat_messages(
|
||||
}
|
||||
|
||||
if !tool_results.is_empty() {
|
||||
out.extend(tool_results);
|
||||
if pending_tool_calls.is_empty() {
|
||||
logging::warn("Dropping tool results without matching tool_calls".to_string());
|
||||
} else {
|
||||
for (tool_id, tool_msg) in tool_results {
|
||||
if pending_tool_calls.remove(&tool_id) {
|
||||
out.push(tool_msg);
|
||||
} else {
|
||||
logging::warn(format!(
|
||||
"Dropping tool result for unknown tool_call_id: {tool_id}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if role != "assistant" {
|
||||
pending_tool_calls.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,6 +799,7 @@ where
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn chat_messages_include_reasoning_content_for_reasoner() {
|
||||
@@ -819,4 +841,51 @@ mod tests {
|
||||
.expect("assistant message");
|
||||
assert!(assistant.get("reasoning_content").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_messages_drop_orphan_tool_results() {
|
||||
let messages = vec![Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
content: "ok".to_string(),
|
||||
}],
|
||||
}];
|
||||
|
||||
let out = build_chat_messages(None, &messages, "deepseek-chat");
|
||||
assert!(!out.iter().any(|value| {
|
||||
value.get("role").and_then(Value::as_str) == Some("tool")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_messages_include_tool_results_when_call_present() {
|
||||
let messages = vec![
|
||||
Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "list_dir".to_string(),
|
||||
input: json!({}),
|
||||
}],
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
content: "ok".to_string(),
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
let out = build_chat_messages(None, &messages, "deepseek-chat");
|
||||
assert!(out.iter().any(|value| {
|
||||
value.get("role").and_then(Value::as_str) == Some("tool")
|
||||
}));
|
||||
let assistant = out
|
||||
.iter()
|
||||
.find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
|
||||
.expect("assistant message");
|
||||
assert!(assistant.get("tool_calls").is_some());
|
||||
}
|
||||
}
|
||||
@@ -40,9 +40,8 @@ pub fn clear(app: &mut App) -> CommandResult {
|
||||
app.transcript_selection.clear();
|
||||
app.total_conversation_tokens = 0;
|
||||
app.clear_todos();
|
||||
if let Ok(mut plan) = app.plan_state.lock() {
|
||||
*plan = PlanState::default();
|
||||
}
|
||||
let mut plan = app.plan_state.blocking_lock();
|
||||
*plan = PlanState::default();
|
||||
app.tool_log.clear();
|
||||
CommandResult::message("Conversation cleared")
|
||||
}
|
||||
|
||||
+54
-1
@@ -321,7 +321,8 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"save" => session::save(app, arg),
|
||||
"sessions" | "resume" => session::sessions(app),
|
||||
"load" => {
|
||||
if app.mode == AppMode::Rlm {
|
||||
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)
|
||||
@@ -383,3 +384,55 @@ pub fn commands_matching(prefix: &str) -> Vec<&'static CommandInfo> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
+615
-37
@@ -3,7 +3,11 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use anyhow::Result;
|
||||
use regex::Regex;
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::fmt::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::client::DeepSeekClient;
|
||||
@@ -34,35 +38,427 @@ impl Default for CompactionConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn estimate_tokens(messages: &[Message]) -> usize {
|
||||
// Rough estimate: ~4 chars per token
|
||||
messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
m.content
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
ContentBlock::Text { text, .. } => text.len() / 4,
|
||||
ContentBlock::Thinking { thinking } => thinking.len() / 4,
|
||||
ContentBlock::ToolUse { input, .. } => serde_json::to_string(input)
|
||||
.map(|s| s.len() / 4)
|
||||
.unwrap_or(100),
|
||||
ContentBlock::ToolResult { content, .. } => content.len() / 4,
|
||||
})
|
||||
.sum::<usize>()
|
||||
})
|
||||
.sum()
|
||||
const KEEP_RECENT_MESSAGES: usize = 4;
|
||||
const RECENT_WORKING_SET_WINDOW: usize = 12;
|
||||
const MAX_WORKING_SET_PATHS: usize = 24;
|
||||
const MIN_SUMMARIZE_MESSAGES: usize = 6;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct CompactionPlan {
|
||||
pinned_indices: BTreeSet<usize>,
|
||||
summarize_indices: Vec<usize>,
|
||||
working_set_paths: HashSet<String>,
|
||||
}
|
||||
|
||||
pub fn should_compact(messages: &[Message], config: &CompactionConfig) -> bool {
|
||||
fn path_regex() -> &'static Regex {
|
||||
static PATH_RE: OnceLock<Regex> = OnceLock::new();
|
||||
PATH_RE.get_or_init(|| {
|
||||
Regex::new(
|
||||
r"(?x)
|
||||
(?:
|
||||
(?P<root>
|
||||
Cargo\.toml|
|
||||
Cargo\.lock|
|
||||
README\.md|
|
||||
CHANGELOG\.md|
|
||||
AGENTS\.md|
|
||||
config\.example\.toml
|
||||
)
|
||||
)
|
||||
|
|
||||
(?P<path>
|
||||
(?:[A-Za-z0-9._-]+/)+
|
||||
[A-Za-z0-9._-]+
|
||||
\.(?:rs|toml|md|json|ya?ml|txt|lock)
|
||||
)
|
||||
",
|
||||
)
|
||||
.expect("path regex is valid")
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_path_candidate(candidate: &str, workspace: Option<&Path>) -> Option<String> {
|
||||
if candidate.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cleaned = candidate.replace('\\', "/");
|
||||
let mut path = PathBuf::from(cleaned);
|
||||
|
||||
if path.is_absolute() {
|
||||
let ws = workspace?;
|
||||
if let Ok(stripped) = path.strip_prefix(ws) {
|
||||
path = stripped.to_path_buf();
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let rel = path.to_string_lossy().trim_start_matches("./").to_string();
|
||||
if rel.is_empty() || rel.contains("..") {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(ws) = workspace {
|
||||
let repo_path = ws.join(&rel);
|
||||
if repo_path.exists() || looks_repo_relative(&rel) {
|
||||
return Some(rel);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
if looks_repo_relative(&rel) {
|
||||
return Some(rel);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn looks_repo_relative(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"Cargo.toml"
|
||||
| "Cargo.lock"
|
||||
| "README.md"
|
||||
| "CHANGELOG.md"
|
||||
| "AGENTS.md"
|
||||
| "config.example.toml"
|
||||
) || path.starts_with("src/")
|
||||
|| path.starts_with("tests/")
|
||||
|| path.starts_with("docs/")
|
||||
|| path.starts_with("examples/")
|
||||
|| path.starts_with("benches/")
|
||||
|| path.starts_with("crates/")
|
||||
|| path.starts_with(".github/")
|
||||
|| (path.contains('/') && path.rsplit('.').next().is_some())
|
||||
}
|
||||
|
||||
fn extract_paths_from_text(text: &str, workspace: Option<&Path>) -> Vec<String> {
|
||||
path_regex()
|
||||
.captures_iter(text)
|
||||
.filter_map(|caps| {
|
||||
let candidate = caps
|
||||
.name("path")
|
||||
.or_else(|| caps.name("root"))
|
||||
.map(|m| m.as_str())?;
|
||||
normalize_path_candidate(candidate, workspace)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extract_paths_from_tool_input(
|
||||
input: &serde_json::Value,
|
||||
workspace: Option<&Path>,
|
||||
) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let Some(obj) = input.as_object() else {
|
||||
return out;
|
||||
};
|
||||
|
||||
for key in ["path", "file", "target", "cwd"] {
|
||||
if let Some(val) = obj.get(key).and_then(serde_json::Value::as_str)
|
||||
&& let Some(path) = normalize_path_candidate(val, workspace)
|
||||
{
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
for key in ["paths", "files", "targets"] {
|
||||
if let Some(vals) = obj.get(key).and_then(serde_json::Value::as_array) {
|
||||
for val in vals {
|
||||
if let Some(s) = val.as_str()
|
||||
&& let Some(path) = normalize_path_candidate(s, workspace)
|
||||
{
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn message_text(msg: &Message) -> String {
|
||||
let mut text = String::new();
|
||||
for block in &msg.content {
|
||||
match block {
|
||||
ContentBlock::Text { text: t, .. } => {
|
||||
let _ = writeln!(text, "{t}");
|
||||
}
|
||||
ContentBlock::Thinking { .. } => {}
|
||||
ContentBlock::ToolUse { name, input, .. } => {
|
||||
let _ = writeln!(text, "[tool_use:{name}] {input}");
|
||||
}
|
||||
ContentBlock::ToolResult { content, .. } => {
|
||||
let _ = writeln!(text, "{content}");
|
||||
}
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn extract_paths_from_message(message: &Message, workspace: Option<&Path>) -> Vec<String> {
|
||||
let mut paths = Vec::new();
|
||||
for block in &message.content {
|
||||
let candidates = match block {
|
||||
ContentBlock::Text { text, .. } => extract_paths_from_text(text, workspace),
|
||||
ContentBlock::ToolResult { content, .. } => extract_paths_from_text(content, workspace),
|
||||
ContentBlock::ToolUse { input, .. } => extract_paths_from_tool_input(input, workspace),
|
||||
ContentBlock::Thinking { .. } => Vec::new(),
|
||||
};
|
||||
paths.extend(candidates);
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
fn derive_working_set_paths(
|
||||
messages: &[Message],
|
||||
workspace: Option<&Path>,
|
||||
seed_indices: &[usize],
|
||||
) -> HashSet<String> {
|
||||
let mut paths: Vec<String> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
|
||||
let mut seeds: Vec<usize> = seed_indices
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|idx| *idx < messages.len())
|
||||
.collect();
|
||||
seeds.sort_unstable_by(|a, b| b.cmp(a));
|
||||
|
||||
for idx in seeds {
|
||||
for candidate in extract_paths_from_message(&messages[idx], workspace) {
|
||||
if seen.insert(candidate.clone()) {
|
||||
paths.push(candidate);
|
||||
if paths.len() >= MAX_WORKING_SET_PATHS {
|
||||
return paths.into_iter().collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for msg in messages.iter().rev().take(RECENT_WORKING_SET_WINDOW) {
|
||||
for candidate in extract_paths_from_message(msg, workspace) {
|
||||
if seen.insert(candidate.clone()) {
|
||||
paths.push(candidate);
|
||||
if paths.len() >= MAX_WORKING_SET_PATHS {
|
||||
return paths.into_iter().collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paths.into_iter().collect()
|
||||
}
|
||||
|
||||
fn should_pin_message(text: &str, working_set_paths: &HashSet<String>) -> bool {
|
||||
let lower = text.to_lowercase();
|
||||
|
||||
let mentions_working_set = working_set_paths.iter().any(|p| text.contains(p));
|
||||
if mentions_working_set {
|
||||
return true;
|
||||
}
|
||||
|
||||
let error_markers = [
|
||||
"error:",
|
||||
"error ",
|
||||
"failed",
|
||||
"panic",
|
||||
"traceback",
|
||||
"stack trace",
|
||||
"assertion failed",
|
||||
"test failed",
|
||||
];
|
||||
if error_markers.iter().any(|m| lower.contains(m)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let patch_markers = [
|
||||
"diff --git",
|
||||
"+++ b/",
|
||||
"--- a/",
|
||||
"*** begin patch",
|
||||
"*** update file:",
|
||||
"*** add file:",
|
||||
"*** delete file:",
|
||||
"```diff",
|
||||
"apply_patch",
|
||||
];
|
||||
patch_markers.iter().any(|m| lower.contains(m))
|
||||
}
|
||||
|
||||
fn plan_compaction(
|
||||
messages: &[Message],
|
||||
workspace: Option<&Path>,
|
||||
keep_recent: usize,
|
||||
external_pins: Option<&[usize]>,
|
||||
external_working_set_paths: Option<&[String]>,
|
||||
) -> CompactionPlan {
|
||||
let mut pinned_indices: BTreeSet<usize> = BTreeSet::new();
|
||||
let len = messages.len();
|
||||
if len == 0 {
|
||||
return CompactionPlan::default();
|
||||
}
|
||||
|
||||
// Always pin the tail of the conversation to preserve immediate context.
|
||||
let recent_start = len.saturating_sub(keep_recent);
|
||||
pinned_indices.extend(recent_start..len);
|
||||
|
||||
// Derive a repo-aware working set from recent messages/tool calls and
|
||||
// merge it with any externally provided working-set paths.
|
||||
let seed_indices = external_pins.unwrap_or(&[]);
|
||||
let mut working_set_paths = derive_working_set_paths(messages, workspace, seed_indices);
|
||||
if let Some(paths) = external_working_set_paths {
|
||||
for path in paths {
|
||||
if let Some(normalized) = normalize_path_candidate(path, workspace) {
|
||||
let _ = working_set_paths.insert(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (idx, msg) in messages.iter().enumerate() {
|
||||
if pinned_indices.contains(&idx) {
|
||||
continue;
|
||||
}
|
||||
let text = message_text(msg);
|
||||
if should_pin_message(&text, &working_set_paths) {
|
||||
pinned_indices.insert(idx);
|
||||
}
|
||||
}
|
||||
|
||||
// External pins are authoritative and should be preserved even if they
|
||||
// were not detected by the heuristics above.
|
||||
if let Some(pins) = external_pins {
|
||||
pinned_indices.extend(pins.iter().copied().filter(|idx| *idx < len));
|
||||
}
|
||||
|
||||
// Ensure tool result messages are not kept without their corresponding tool call.
|
||||
enforce_tool_call_pairs(messages, &mut pinned_indices);
|
||||
|
||||
let summarize_indices = (0..len)
|
||||
.filter(|idx| !pinned_indices.contains(idx))
|
||||
.collect();
|
||||
|
||||
CompactionPlan {
|
||||
pinned_indices,
|
||||
summarize_indices,
|
||||
working_set_paths,
|
||||
}
|
||||
}
|
||||
|
||||
fn enforce_tool_call_pairs(messages: &[Message], pinned_indices: &mut BTreeSet<usize>) {
|
||||
if pinned_indices.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut tool_call_indices: HashMap<String, usize> = HashMap::new();
|
||||
for (idx, msg) in messages.iter().enumerate() {
|
||||
for block in &msg.content {
|
||||
if let ContentBlock::ToolUse { id, .. } = block {
|
||||
tool_call_indices.insert(id.clone(), idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut to_add = Vec::new();
|
||||
let mut to_remove = Vec::new();
|
||||
|
||||
for &idx in pinned_indices.iter() {
|
||||
let msg = &messages[idx];
|
||||
let mut tool_ids = Vec::new();
|
||||
for block in &msg.content {
|
||||
if let ContentBlock::ToolResult { tool_use_id, .. } = block {
|
||||
tool_ids.push(tool_use_id.clone());
|
||||
}
|
||||
}
|
||||
if tool_ids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut found_any = false;
|
||||
for tool_id in tool_ids {
|
||||
if let Some(call_idx) = tool_call_indices.get(&tool_id).copied() {
|
||||
to_add.push(call_idx);
|
||||
found_any = true;
|
||||
}
|
||||
}
|
||||
if !found_any {
|
||||
to_remove.push(idx);
|
||||
}
|
||||
}
|
||||
|
||||
for idx in to_add {
|
||||
pinned_indices.insert(idx);
|
||||
}
|
||||
for idx in to_remove {
|
||||
pinned_indices.remove(&idx);
|
||||
}
|
||||
}
|
||||
|
||||
fn estimate_tokens_for_message(message: &Message) -> usize {
|
||||
message
|
||||
.content
|
||||
.iter()
|
||||
.map(|c| match c {
|
||||
ContentBlock::Text { text, .. } => text.len() / 4,
|
||||
ContentBlock::Thinking { thinking } => thinking.len() / 4,
|
||||
ContentBlock::ToolUse { input, .. } => serde_json::to_string(input)
|
||||
.map(|s| s.len() / 4)
|
||||
.unwrap_or(100),
|
||||
ContentBlock::ToolResult { content, .. } => content.len() / 4,
|
||||
})
|
||||
.sum::<usize>()
|
||||
}
|
||||
|
||||
pub fn estimate_tokens(messages: &[Message]) -> usize {
|
||||
// Rough estimate: ~4 chars per token
|
||||
messages.iter().map(estimate_tokens_for_message).sum()
|
||||
}
|
||||
|
||||
pub fn should_compact(
|
||||
messages: &[Message],
|
||||
config: &CompactionConfig,
|
||||
workspace: Option<&Path>,
|
||||
external_pins: Option<&[usize]>,
|
||||
external_working_set_paths: Option<&[String]>,
|
||||
) -> bool {
|
||||
if !config.enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
let token_estimate = estimate_tokens(messages);
|
||||
let message_count = messages.len();
|
||||
let plan = plan_compaction(
|
||||
messages,
|
||||
workspace,
|
||||
KEEP_RECENT_MESSAGES,
|
||||
external_pins,
|
||||
external_working_set_paths,
|
||||
);
|
||||
let pinned_tokens: usize = plan
|
||||
.pinned_indices
|
||||
.iter()
|
||||
.map(|&idx| estimate_tokens_for_message(&messages[idx]))
|
||||
.sum();
|
||||
let pinned_count = plan.pinned_indices.len();
|
||||
|
||||
token_estimate > config.token_threshold || message_count > config.message_threshold
|
||||
let token_estimate: usize = plan
|
||||
.summarize_indices
|
||||
.iter()
|
||||
.map(|&idx| estimate_tokens_for_message(&messages[idx]))
|
||||
.sum();
|
||||
let message_count = plan.summarize_indices.len();
|
||||
|
||||
// Pinned messages consume part of the budget, so compact earlier when needed.
|
||||
let effective_token_threshold = config.token_threshold.saturating_sub(pinned_tokens);
|
||||
let effective_message_threshold = config.message_threshold.saturating_sub(pinned_count);
|
||||
|
||||
let enough_unpinned = message_count >= MIN_SUMMARIZE_MESSAGES
|
||||
|| effective_token_threshold == 0
|
||||
|| effective_message_threshold == 0;
|
||||
if !enough_unpinned {
|
||||
return false;
|
||||
}
|
||||
|
||||
token_estimate > effective_token_threshold || message_count > effective_message_threshold
|
||||
}
|
||||
|
||||
fn truncate_chars(text: &str, max_chars: usize) -> &str {
|
||||
@@ -115,6 +511,9 @@ pub async fn compact_messages_safe(
|
||||
client: &DeepSeekClient,
|
||||
messages: &[Message],
|
||||
config: &CompactionConfig,
|
||||
workspace: Option<&Path>,
|
||||
external_pins: Option<&[usize]>,
|
||||
external_working_set_paths: Option<&[String]>,
|
||||
) -> Result<CompactionResult> {
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
const BASE_DELAY_MS: u64 = 1000;
|
||||
@@ -128,7 +527,16 @@ pub async fn compact_messages_safe(
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
|
||||
match compact_messages(client, messages, config).await {
|
||||
match compact_messages(
|
||||
client,
|
||||
messages,
|
||||
config,
|
||||
workspace,
|
||||
external_pins,
|
||||
external_working_set_paths,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((msgs, prompt)) => {
|
||||
return Ok(CompactionResult {
|
||||
messages: msgs,
|
||||
@@ -154,28 +562,39 @@ pub async fn compact_messages(
|
||||
client: &DeepSeekClient,
|
||||
messages: &[Message],
|
||||
config: &CompactionConfig,
|
||||
workspace: Option<&Path>,
|
||||
external_pins: Option<&[usize]>,
|
||||
external_working_set_paths: Option<&[String]>,
|
||||
) -> Result<(Vec<Message>, Option<SystemPrompt>)> {
|
||||
if messages.is_empty() {
|
||||
return Ok((Vec::new(), None));
|
||||
}
|
||||
|
||||
// Keep the last few messages as-is
|
||||
let keep_recent = 4;
|
||||
let (to_summarize, recent) = if messages.len() <= keep_recent {
|
||||
let plan = plan_compaction(
|
||||
messages,
|
||||
workspace,
|
||||
KEEP_RECENT_MESSAGES,
|
||||
external_pins,
|
||||
external_working_set_paths,
|
||||
);
|
||||
if plan.summarize_indices.is_empty() {
|
||||
return Ok((messages.to_vec(), None));
|
||||
} else {
|
||||
let split_point = messages.len() - keep_recent;
|
||||
(&messages[..split_point], &messages[split_point..])
|
||||
};
|
||||
}
|
||||
|
||||
// Create a summary of older messages
|
||||
let summary = create_summary(client, to_summarize, &config.model).await?;
|
||||
let to_summarize: Vec<Message> = plan
|
||||
.summarize_indices
|
||||
.iter()
|
||||
.map(|&idx| messages[idx].clone())
|
||||
.collect();
|
||||
|
||||
// Create a summary of the unpinned portion of the conversation
|
||||
let summary = create_summary(client, &to_summarize, &config.model).await?;
|
||||
|
||||
// Build new message list with summary as system block
|
||||
let summary_block = SystemBlock {
|
||||
block_type: "text".to_string(),
|
||||
text: format!(
|
||||
"## Conversation Summary\n\nThe following is a summary of the earlier conversation:\n\n{summary}\n\n---\nRecent messages follow:"
|
||||
"## Conversation Summary\n\nThe following summarizes earlier context that was not pinned to the working set:\n\n{summary}\n\n---\nPinned messages follow:"
|
||||
),
|
||||
cache_control: if config.cache_summary {
|
||||
Some(CacheControl {
|
||||
@@ -186,8 +605,14 @@ pub async fn compact_messages(
|
||||
},
|
||||
};
|
||||
|
||||
let pinned_messages = messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, msg)| plan.pinned_indices.contains(&idx).then_some(msg.clone()))
|
||||
.collect();
|
||||
|
||||
Ok((
|
||||
recent.to_vec(),
|
||||
pinned_messages,
|
||||
Some(SystemPrompt::Blocks(vec![summary_block])),
|
||||
))
|
||||
}
|
||||
@@ -315,6 +740,17 @@ pub fn merge_system_prompts(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn msg(role: &str, text: &str) -> Message {
|
||||
Message {
|
||||
role: role.to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: text.to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_chars_respects_unicode_boundaries() {
|
||||
@@ -388,7 +824,7 @@ mod tests {
|
||||
}],
|
||||
})
|
||||
.collect();
|
||||
assert!(!should_compact(&messages, &config));
|
||||
assert!(!should_compact(&messages, &config, None, None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -410,7 +846,7 @@ mod tests {
|
||||
}],
|
||||
})
|
||||
.collect();
|
||||
assert!(!should_compact(&few_messages, &config));
|
||||
assert!(!should_compact(&few_messages, &config, None, None, None));
|
||||
|
||||
// Over threshold
|
||||
let many_messages: Vec<Message> = (0..10)
|
||||
@@ -422,6 +858,148 @@ mod tests {
|
||||
}],
|
||||
})
|
||||
.collect();
|
||||
assert!(should_compact(&many_messages, &config));
|
||||
assert!(should_compact(&many_messages, &config, None, None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_compaction_pins_recent_and_working_set_paths() {
|
||||
let messages = vec![
|
||||
msg("user", "General discussion"),
|
||||
msg("assistant", "Unrelated note"),
|
||||
msg("user", "Earlier we touched src/core/engine.rs"),
|
||||
msg("assistant", "More unrelated chatter"),
|
||||
msg("user", "Let's keep working on src/core/engine.rs"),
|
||||
msg("assistant", "Tool output mentions src/core/engine.rs too"),
|
||||
msg("assistant", "Recent reasoning"),
|
||||
msg("user", "Final recent instruction"),
|
||||
];
|
||||
|
||||
let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, None, None);
|
||||
|
||||
assert!(plan.pinned_indices.contains(&2));
|
||||
for idx in 4..messages.len() {
|
||||
assert!(plan.pinned_indices.contains(&idx));
|
||||
}
|
||||
assert!(plan.summarize_indices.contains(&0));
|
||||
assert!(plan.summarize_indices.contains(&1));
|
||||
assert!(plan.summarize_indices.contains(&3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_compaction_respects_external_pins() {
|
||||
let messages = vec![
|
||||
msg("user", "noise 0"),
|
||||
msg("assistant", "noise 1"),
|
||||
msg("user", "noise 2"),
|
||||
msg("assistant", "noise 3"),
|
||||
msg("user", "recent 4"),
|
||||
msg("assistant", "recent 5"),
|
||||
msg("assistant", "recent 6"),
|
||||
msg("user", "recent 7"),
|
||||
];
|
||||
|
||||
let pins = vec![1usize];
|
||||
let plan = plan_compaction(&messages, None, KEEP_RECENT_MESSAGES, Some(&pins), None);
|
||||
|
||||
assert!(plan.pinned_indices.contains(&1));
|
||||
assert!(!plan.summarize_indices.contains(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_compaction_uses_external_working_set_paths() {
|
||||
let mut messages = vec![msg("user", "edit src/core/engine.rs now")];
|
||||
messages.extend((1..20).map(|i| msg("assistant", &format!("noise {i}"))));
|
||||
|
||||
let working_set_paths = vec!["src/core/engine.rs".to_string()];
|
||||
let plan = plan_compaction(
|
||||
&messages,
|
||||
None,
|
||||
KEEP_RECENT_MESSAGES,
|
||||
None,
|
||||
Some(&working_set_paths),
|
||||
);
|
||||
|
||||
assert!(plan.pinned_indices.contains(&0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_compaction_pins_tool_calls_for_tool_results() {
|
||||
let messages = vec![
|
||||
msg("user", "noise"),
|
||||
Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: json!({"path": "src/main.rs"}),
|
||||
}],
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
content: "ok src/main.rs".to_string(),
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
let plan = plan_compaction(&messages, None, 1, None, None);
|
||||
assert!(plan.pinned_indices.contains(&2));
|
||||
assert!(plan.pinned_indices.contains(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_compact_ignores_fully_pinned_context() {
|
||||
let config = CompactionConfig {
|
||||
enabled: true,
|
||||
token_threshold: 10,
|
||||
message_threshold: 2,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let messages: Vec<Message> = (0..12)
|
||||
.map(|_| msg("user", "Work on src/compaction.rs right now"))
|
||||
.collect();
|
||||
|
||||
assert!(!should_compact(&messages, &config, None, None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_compact_counts_only_unpinned_messages() {
|
||||
let config = CompactionConfig {
|
||||
enabled: true,
|
||||
token_threshold: 1_000_000,
|
||||
message_threshold: 5,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut messages: Vec<Message> = (0..7)
|
||||
.map(|i| msg("user", &format!("noise message {i}")))
|
||||
.collect();
|
||||
messages.push(msg("user", "Focus on src/core/engine.rs"));
|
||||
messages.extend((0..4).map(|i| msg("assistant", &format!("recent {i}"))));
|
||||
|
||||
assert!(should_compact(&messages, &config, None, None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_compact_when_pins_consume_budget() {
|
||||
let config = CompactionConfig {
|
||||
enabled: true,
|
||||
token_threshold: 50,
|
||||
message_threshold: 50,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut messages = vec![msg("user", "noise 0"), msg("assistant", "noise 1")];
|
||||
messages.extend((0..4).map(|_| {
|
||||
msg(
|
||||
"assistant",
|
||||
&format!("{} src/core/engine.rs", "x".repeat(400)),
|
||||
)
|
||||
}));
|
||||
|
||||
// Pinned recent messages exceed the token budget, so unpinned noise should trigger compaction.
|
||||
assert!(should_compact(&messages, &config, None, None, None));
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -11,6 +11,9 @@ use serde::Deserialize;
|
||||
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;
|
||||
|
||||
// === Types ===
|
||||
|
||||
/// Raw retry configuration loaded from config files.
|
||||
@@ -209,7 +212,9 @@ impl Config {
|
||||
/// Return the maximum number of concurrent sub-agents.
|
||||
#[must_use]
|
||||
pub fn max_subagents(&self) -> usize {
|
||||
self.max_subagents.unwrap_or(5).clamp(1, 5)
|
||||
self.max_subagents
|
||||
.unwrap_or(DEFAULT_MAX_SUBAGENTS)
|
||||
.clamp(1, MAX_SUBAGENTS)
|
||||
}
|
||||
|
||||
/// Get hooks configuration, returning default if not configured.
|
||||
@@ -321,7 +326,7 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
if let Ok(value) = std::env::var("DEEPSEEK_MAX_SUBAGENTS")
|
||||
&& let Ok(parsed) = value.parse::<usize>()
|
||||
{
|
||||
config.max_subagents = Some(parsed.clamp(1, 5));
|
||||
config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+385
-179
@@ -24,6 +24,7 @@ use crate::compaction::{
|
||||
CompactionConfig, compact_messages_safe, merge_system_prompts, should_compact,
|
||||
};
|
||||
use crate::config::Config;
|
||||
use crate::config::DEFAULT_MAX_SUBAGENTS;
|
||||
use crate::duo::{DuoSession, SharedDuoSession, session_summary as duo_session_summary};
|
||||
use crate::features::{Feature, Features};
|
||||
use crate::llm_client::LlmClient;
|
||||
@@ -33,12 +34,12 @@ use crate::models::{
|
||||
};
|
||||
use crate::prompts;
|
||||
use crate::rlm::{RlmSession, SharedRlmSession, session_summary as rlm_session_summary};
|
||||
use crate::tools::plan::{PlanState, SharedPlanState};
|
||||
use crate::tools::plan::{SharedPlanState, new_shared_plan_state};
|
||||
use crate::tools::spec::{ApprovalRequirement, ToolError, ToolResult};
|
||||
use crate::tools::subagent::{
|
||||
SharedSubAgentManager, SubAgentRuntime, SubAgentType, new_shared_subagent_manager,
|
||||
};
|
||||
use crate::tools::todo::{SharedTodoList, TodoList};
|
||||
use crate::tools::todo::{SharedTodoList, new_shared_todo_list};
|
||||
use crate::tools::{ToolContext, ToolRegistryBuilder};
|
||||
use crate::tui::app::AppMode;
|
||||
|
||||
@@ -93,13 +94,13 @@ impl Default for EngineConfig {
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
max_steps: 100,
|
||||
max_subagents: 5,
|
||||
max_subagents: DEFAULT_MAX_SUBAGENTS,
|
||||
features: Features::with_defaults(),
|
||||
rlm_session: Arc::new(Mutex::new(RlmSession::default())),
|
||||
duo_session: Arc::new(Mutex::new(DuoSession::new())),
|
||||
compaction: CompactionConfig::default(),
|
||||
todos: Arc::new(Mutex::new(TodoList::new())),
|
||||
plan_state: Arc::new(Mutex::new(PlanState::default())),
|
||||
todos: new_shared_todo_list(),
|
||||
plan_state: new_shared_plan_state(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,6 +237,19 @@ struct ToolExecOutcome {
|
||||
result: Result<ToolResult, ToolError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ToolExecutionPlan {
|
||||
index: usize,
|
||||
id: String,
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
interactive: bool,
|
||||
approval_required: bool,
|
||||
approval_description: String,
|
||||
supports_parallel: bool,
|
||||
read_only: bool,
|
||||
}
|
||||
|
||||
// Hold the lock guard for the duration of a tool execution.
|
||||
enum ToolExecGuard<'a> {
|
||||
Read(tokio::sync::RwLockReadGuard<'a, ()>),
|
||||
@@ -357,6 +371,38 @@ fn extract_balanced_segment(text: &str, open: char, close: char) -> Option<Strin
|
||||
end.map(|end_idx| text[start..end_idx].to_string())
|
||||
}
|
||||
|
||||
fn should_parallelize_tool_batch(plans: &[ToolExecutionPlan]) -> bool {
|
||||
!plans.is_empty()
|
||||
&& plans.iter().all(|plan| {
|
||||
plan.read_only && plan.supports_parallel && !plan.approval_required && !plan.interactive
|
||||
})
|
||||
}
|
||||
|
||||
fn format_tool_error(err: &ToolError, tool_name: &str) -> String {
|
||||
match err {
|
||||
ToolError::InvalidInput { message } => {
|
||||
format!("Invalid input for tool '{tool_name}': {message}")
|
||||
}
|
||||
ToolError::MissingField { field } => {
|
||||
format!("Tool '{tool_name}' is missing required field '{field}'")
|
||||
}
|
||||
ToolError::PathEscape { path } => format!(
|
||||
"Path escapes workspace: {}. Use a workspace-relative path or enable trust mode.",
|
||||
path.display()
|
||||
),
|
||||
ToolError::ExecutionFailed { message } => message.clone(),
|
||||
ToolError::Timeout { seconds } => format!(
|
||||
"Tool '{tool_name}' timed out after {seconds}s. Try a narrower scope or a longer timeout."
|
||||
),
|
||||
ToolError::NotAvailable { message } => format!(
|
||||
"Tool '{tool_name}' is not available: {message}. Check mode, feature flags, or tool name."
|
||||
),
|
||||
ToolError::PermissionDenied { message } => format!(
|
||||
"Tool '{tool_name}' was denied: {message}. Adjust approval mode or request permission."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
/// Create a new engine with the given configuration
|
||||
pub fn new(config: EngineConfig, api_config: &Config) -> (Self, EngineHandle) {
|
||||
@@ -382,9 +428,11 @@ impl Engine {
|
||||
);
|
||||
|
||||
// Set up system prompt with project context (default to agent mode)
|
||||
let working_set_summary = session.working_set.summary_block(&config.workspace);
|
||||
let system_prompt = prompts::system_prompt_for_mode_with_context(
|
||||
AppMode::Agent,
|
||||
&config.workspace,
|
||||
working_set_summary.as_deref(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
@@ -472,19 +520,16 @@ impl Engine {
|
||||
Some(self.tx_event.clone()),
|
||||
);
|
||||
|
||||
let result = self
|
||||
.subagent_manager
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("Failed to lock sub-agent manager"))
|
||||
.and_then(|mut manager| {
|
||||
manager.spawn_background(
|
||||
Arc::clone(&self.subagent_manager),
|
||||
runtime,
|
||||
SubAgentType::General,
|
||||
prompt.clone(),
|
||||
None,
|
||||
)
|
||||
});
|
||||
let result = {
|
||||
let mut manager = self.subagent_manager.lock().await;
|
||||
manager.spawn_background(
|
||||
Arc::clone(&self.subagent_manager),
|
||||
runtime,
|
||||
SubAgentType::General,
|
||||
prompt.clone(),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(snapshot) => {
|
||||
@@ -508,26 +553,11 @@ impl Engine {
|
||||
}
|
||||
}
|
||||
Op::ListSubAgents => {
|
||||
let result = self
|
||||
.subagent_manager
|
||||
.lock()
|
||||
.map(|manager| manager.list())
|
||||
.map_err(|_| anyhow::anyhow!("Failed to lock sub-agent manager"));
|
||||
|
||||
match result {
|
||||
Ok(agents) => {
|
||||
let _ = self.tx_event.send(Event::AgentList { agents }).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::error(
|
||||
format!("Failed to list sub-agents: {err}"),
|
||||
true,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
let agents = {
|
||||
let manager = self.subagent_manager.lock().await;
|
||||
manager.list()
|
||||
};
|
||||
let _ = self.tx_event.send(Event::AgentList { agents }).await;
|
||||
}
|
||||
Op::ChangeMode { mode } => {
|
||||
let _ = self
|
||||
@@ -575,6 +605,7 @@ impl Engine {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.session.rebuild_working_set();
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status("Session context synced".to_string()))
|
||||
@@ -613,6 +644,10 @@ impl Engine {
|
||||
return;
|
||||
}
|
||||
|
||||
self.session
|
||||
.working_set
|
||||
.observe_user_message(&content, &self.session.workspace);
|
||||
|
||||
// Add user message to session
|
||||
let user_msg = Message {
|
||||
role: "user".to_string(),
|
||||
@@ -652,9 +687,14 @@ impl Engine {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let working_set_summary = self
|
||||
.session
|
||||
.working_set
|
||||
.summary_block(&self.config.workspace);
|
||||
self.session.system_prompt = Some(prompts::system_prompt_for_mode_with_context(
|
||||
mode,
|
||||
&self.config.workspace,
|
||||
working_set_summary.as_deref(),
|
||||
rlm_summary.as_deref(),
|
||||
duo_summary.as_deref(),
|
||||
));
|
||||
@@ -668,6 +708,8 @@ impl Engine {
|
||||
ToolRegistryBuilder::new()
|
||||
.with_read_only_file_tools()
|
||||
.with_search_tools()
|
||||
.with_git_tools()
|
||||
.with_diagnostics_tool()
|
||||
.with_todo_tool(todo_list.clone())
|
||||
.with_plan_tool(plan_state.clone())
|
||||
} else {
|
||||
@@ -675,6 +717,9 @@ impl Engine {
|
||||
.with_file_tools()
|
||||
.with_note_tool()
|
||||
.with_search_tools()
|
||||
.with_git_tools()
|
||||
.with_diagnostics_tool()
|
||||
.with_test_runner_tool()
|
||||
.with_todo_tool(todo_list.clone())
|
||||
.with_plan_tool(plan_state.clone())
|
||||
};
|
||||
@@ -932,6 +977,8 @@ impl Engine {
|
||||
.clone()
|
||||
.expect("DeepSeek client should be configured");
|
||||
|
||||
let mut consecutive_tool_error_steps = 0u32;
|
||||
|
||||
loop {
|
||||
if self.cancel_token.is_cancelled() {
|
||||
let _ = self.tx_event.send(Event::status("Request cancelled")).await;
|
||||
@@ -946,8 +993,20 @@ impl Engine {
|
||||
break;
|
||||
}
|
||||
|
||||
let compaction_pins = self
|
||||
.session
|
||||
.working_set
|
||||
.pinned_message_indices(&self.session.messages, &self.session.workspace);
|
||||
let compaction_paths = self.session.working_set.top_paths(24);
|
||||
|
||||
if self.config.compaction.enabled
|
||||
&& should_compact(&self.session.messages, &self.config.compaction)
|
||||
&& should_compact(
|
||||
&self.session.messages,
|
||||
&self.config.compaction,
|
||||
Some(&self.session.workspace),
|
||||
Some(&compaction_pins),
|
||||
Some(&compaction_paths),
|
||||
)
|
||||
{
|
||||
let _ = self
|
||||
.tx_event
|
||||
@@ -957,6 +1016,9 @@ impl Engine {
|
||||
&client,
|
||||
&self.session.messages,
|
||||
&self.config.compaction,
|
||||
Some(&self.session.workspace),
|
||||
Some(&compaction_pins),
|
||||
Some(&compaction_paths),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1303,18 +1365,16 @@ impl Engine {
|
||||
None
|
||||
};
|
||||
|
||||
let mut tool_tasks = FuturesUnordered::new();
|
||||
let mut outcomes: Vec<Option<ToolExecOutcome>> = Vec::with_capacity(tool_uses.len());
|
||||
outcomes.resize_with(tool_uses.len(), || None);
|
||||
|
||||
let mut plans: Vec<ToolExecutionPlan> = Vec::with_capacity(tool_uses.len());
|
||||
for (index, tool) in tool_uses.iter().enumerate() {
|
||||
let tool_id = tool.id.clone();
|
||||
let tool_name = tool.name.clone();
|
||||
let tool_input = tool.input.clone();
|
||||
crate::logging::info(format!(
|
||||
"Executing tool '{}' with input: {:?}",
|
||||
"Planning tool '{}' with input: {:?}",
|
||||
tool_name, tool_input
|
||||
));
|
||||
|
||||
let interactive = tool_name == "exec_shell"
|
||||
&& tool_input
|
||||
.get("interactive")
|
||||
@@ -1323,166 +1383,216 @@ impl Engine {
|
||||
|
||||
let mut approval_required = false;
|
||||
let mut approval_description = "Tool execution requires approval".to_string();
|
||||
let mut supports_parallel = McpPool::is_mcp_tool(&tool_name);
|
||||
if let Some(registry) = tool_registry
|
||||
&& let Some(spec) = registry.get(&tool_name)
|
||||
{
|
||||
approval_required = spec.approval_requirement() != ApprovalRequirement::Auto;
|
||||
approval_description = spec.description().to_string();
|
||||
supports_parallel = spec.supports_parallel();
|
||||
}
|
||||
let mut supports_parallel = false;
|
||||
let mut read_only = false;
|
||||
|
||||
// Handle approval flow: returns (result_override, context_override)
|
||||
let (result_override, context_override): (
|
||||
Option<Result<ToolResult, ToolError>>,
|
||||
Option<crate::tools::ToolContext>,
|
||||
) = if approval_required {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::ApprovalRequired {
|
||||
id: tool_id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
description: approval_description,
|
||||
})
|
||||
.await;
|
||||
|
||||
match self.await_tool_approval(&tool_id).await {
|
||||
Ok(ApprovalResult::Approved) => (None, None),
|
||||
Ok(ApprovalResult::Denied) => (
|
||||
Some(Err(ToolError::permission_denied(format!(
|
||||
"Tool '{tool_name}' denied by user"
|
||||
)))),
|
||||
None,
|
||||
),
|
||||
Ok(ApprovalResult::RetryWithPolicy(policy)) => {
|
||||
// Create a context with the elevated sandbox policy
|
||||
let elevated_context = tool_registry
|
||||
.map(|r| r.context().clone().with_elevated_sandbox_policy(policy));
|
||||
(None, elevated_context)
|
||||
}
|
||||
Err(err) => (Some(Err(err)), None),
|
||||
if !McpPool::is_mcp_tool(&tool_name) {
|
||||
if let Some(registry) = tool_registry
|
||||
&& let Some(spec) = registry.get(&tool_name)
|
||||
{
|
||||
approval_required =
|
||||
spec.approval_requirement() != ApprovalRequirement::Auto;
|
||||
approval_description = spec.description().to_string();
|
||||
supports_parallel = spec.supports_parallel();
|
||||
read_only = spec.is_read_only();
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let registry = tool_registry;
|
||||
let lock = tool_exec_lock.clone();
|
||||
let mcp_pool = mcp_pool.clone();
|
||||
let tx_event = self.tx_event.clone();
|
||||
|
||||
if let Some(result_override) = result_override {
|
||||
let started_at = Instant::now();
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::ToolCallComplete {
|
||||
id: tool_id.clone(),
|
||||
name: tool_name.clone(),
|
||||
result: result_override.clone(),
|
||||
})
|
||||
.await;
|
||||
outcomes[index] = Some(ToolExecOutcome {
|
||||
index,
|
||||
id: tool_id,
|
||||
name: tool_name,
|
||||
input: tool_input,
|
||||
started_at,
|
||||
result: result_override,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if approval_required {
|
||||
let started_at = Instant::now();
|
||||
let result = Self::execute_tool_with_lock(
|
||||
lock,
|
||||
supports_parallel,
|
||||
interactive,
|
||||
self.tx_event.clone(),
|
||||
tool_name.clone(),
|
||||
tool_input.clone(),
|
||||
registry,
|
||||
mcp_pool.clone(),
|
||||
context_override,
|
||||
)
|
||||
.await;
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::ToolCallComplete {
|
||||
id: tool_id.clone(),
|
||||
name: tool_name.clone(),
|
||||
result: result.clone(),
|
||||
})
|
||||
.await;
|
||||
outcomes[index] = Some(ToolExecOutcome {
|
||||
index,
|
||||
id: tool_id,
|
||||
name: tool_name,
|
||||
input: tool_input,
|
||||
started_at,
|
||||
result,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let started_at = Instant::now();
|
||||
tool_tasks.push(async move {
|
||||
let result = Engine::execute_tool_with_lock(
|
||||
lock,
|
||||
supports_parallel,
|
||||
interactive,
|
||||
tx_event.clone(),
|
||||
tool_name.clone(),
|
||||
tool_input.clone(),
|
||||
registry,
|
||||
mcp_pool,
|
||||
None, // No context override for non-approval-required tools
|
||||
)
|
||||
.await;
|
||||
|
||||
let _ = tx_event
|
||||
.send(Event::ToolCallComplete {
|
||||
id: tool_id.clone(),
|
||||
name: tool_name.clone(),
|
||||
result: result.clone(),
|
||||
})
|
||||
.await;
|
||||
|
||||
ToolExecOutcome {
|
||||
index,
|
||||
id: tool_id,
|
||||
name: tool_name,
|
||||
input: tool_input,
|
||||
started_at,
|
||||
result,
|
||||
}
|
||||
plans.push(ToolExecutionPlan {
|
||||
index,
|
||||
id: tool_id,
|
||||
name: tool_name,
|
||||
input: tool_input,
|
||||
interactive,
|
||||
approval_required,
|
||||
approval_description,
|
||||
supports_parallel,
|
||||
read_only,
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(outcome) = tool_tasks.next().await {
|
||||
let index = outcome.index;
|
||||
outcomes[index] = Some(outcome);
|
||||
let parallel_allowed = should_parallelize_tool_batch(&plans);
|
||||
if parallel_allowed && plans.len() > 1 {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(format!(
|
||||
"Executing {} read-only tools in parallel",
|
||||
plans.len()
|
||||
)))
|
||||
.await;
|
||||
} else if plans.len() > 1 {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(
|
||||
"Executing tools sequentially (writes, approvals, or non-parallel tools detected)",
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut outcomes: Vec<Option<ToolExecOutcome>> = Vec::with_capacity(plans.len());
|
||||
outcomes.resize_with(plans.len(), || None);
|
||||
|
||||
if parallel_allowed {
|
||||
let mut tool_tasks = FuturesUnordered::new();
|
||||
for plan in plans {
|
||||
let registry = tool_registry;
|
||||
let lock = tool_exec_lock.clone();
|
||||
let mcp_pool = mcp_pool.clone();
|
||||
let tx_event = self.tx_event.clone();
|
||||
let started_at = Instant::now();
|
||||
|
||||
tool_tasks.push(async move {
|
||||
let result = Engine::execute_tool_with_lock(
|
||||
lock,
|
||||
plan.supports_parallel,
|
||||
plan.interactive,
|
||||
tx_event.clone(),
|
||||
plan.name.clone(),
|
||||
plan.input.clone(),
|
||||
registry,
|
||||
mcp_pool,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let _ = tx_event
|
||||
.send(Event::ToolCallComplete {
|
||||
id: plan.id.clone(),
|
||||
name: plan.name.clone(),
|
||||
result: result.clone(),
|
||||
})
|
||||
.await;
|
||||
|
||||
ToolExecOutcome {
|
||||
index: plan.index,
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
input: plan.input,
|
||||
started_at,
|
||||
result,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
while let Some(outcome) = tool_tasks.next().await {
|
||||
let index = outcome.index;
|
||||
outcomes[index] = Some(outcome);
|
||||
}
|
||||
} else {
|
||||
for plan in plans {
|
||||
let tool_id = plan.id.clone();
|
||||
let tool_name = plan.name.clone();
|
||||
let tool_input = plan.input.clone();
|
||||
|
||||
// Handle approval flow: returns (result_override, context_override)
|
||||
let (result_override, context_override): (
|
||||
Option<Result<ToolResult, ToolError>>,
|
||||
Option<crate::tools::ToolContext>,
|
||||
) = if plan.approval_required {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::ApprovalRequired {
|
||||
id: tool_id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
description: plan.approval_description.clone(),
|
||||
})
|
||||
.await;
|
||||
|
||||
match self.await_tool_approval(&tool_id).await {
|
||||
Ok(ApprovalResult::Approved) => (None, None),
|
||||
Ok(ApprovalResult::Denied) => (
|
||||
Some(Err(ToolError::permission_denied(format!(
|
||||
"Tool '{tool_name}' denied by user"
|
||||
)))),
|
||||
None,
|
||||
),
|
||||
Ok(ApprovalResult::RetryWithPolicy(policy)) => {
|
||||
let elevated_context = tool_registry.map(|r| {
|
||||
r.context().clone().with_elevated_sandbox_policy(policy)
|
||||
});
|
||||
(None, elevated_context)
|
||||
}
|
||||
Err(err) => (Some(Err(err)), None),
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let started_at = Instant::now();
|
||||
let result = if let Some(result_override) = result_override {
|
||||
result_override
|
||||
} else {
|
||||
Self::execute_tool_with_lock(
|
||||
tool_exec_lock.clone(),
|
||||
plan.supports_parallel,
|
||||
plan.interactive,
|
||||
self.tx_event.clone(),
|
||||
tool_name.clone(),
|
||||
tool_input.clone(),
|
||||
tool_registry,
|
||||
mcp_pool.clone(),
|
||||
context_override,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::ToolCallComplete {
|
||||
id: tool_id.clone(),
|
||||
name: tool_name.clone(),
|
||||
result: result.clone(),
|
||||
})
|
||||
.await;
|
||||
|
||||
outcomes[plan.index] = Some(ToolExecOutcome {
|
||||
index: plan.index,
|
||||
id: tool_id,
|
||||
name: tool_name,
|
||||
input: tool_input,
|
||||
started_at,
|
||||
result,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut step_error_count = 0usize;
|
||||
|
||||
for outcome in outcomes.into_iter().flatten() {
|
||||
let duration = outcome.started_at.elapsed();
|
||||
let tool_input = outcome.input.clone();
|
||||
let tool_name_for_ws = outcome.name.clone();
|
||||
let mut tool_call =
|
||||
TurnToolCall::new(outcome.id.clone(), outcome.name.clone(), outcome.input);
|
||||
|
||||
match outcome.result {
|
||||
Ok(output) => {
|
||||
tool_call.set_result(output.content.clone(), duration);
|
||||
let output_content = output.content;
|
||||
tool_call.set_result(output_content.clone(), duration);
|
||||
self.session.working_set.observe_tool_call(
|
||||
&tool_name_for_ws,
|
||||
&tool_input,
|
||||
Some(&output_content),
|
||||
&self.session.workspace,
|
||||
);
|
||||
self.session.add_message(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: outcome.id,
|
||||
content: output.content,
|
||||
content: output_content,
|
||||
}],
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let error = e.to_string();
|
||||
step_error_count += 1;
|
||||
let error = format_tool_error(&e, &outcome.name);
|
||||
tool_call.set_error(error.clone(), duration);
|
||||
self.session.working_set.observe_tool_call(
|
||||
&tool_name_for_ws,
|
||||
&tool_input,
|
||||
Some(&error),
|
||||
&self.session.workspace,
|
||||
);
|
||||
self.session.add_message(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
@@ -1496,6 +1606,22 @@ impl Engine {
|
||||
turn.record_tool_call(tool_call);
|
||||
}
|
||||
|
||||
if step_error_count > 0 {
|
||||
consecutive_tool_error_steps = consecutive_tool_error_steps.saturating_add(1);
|
||||
} else {
|
||||
consecutive_tool_error_steps = 0;
|
||||
}
|
||||
|
||||
if consecutive_tool_error_steps >= 3 {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status(
|
||||
"Stopping after repeated tool failures. Try a narrower scope or adjust approvals.",
|
||||
))
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
|
||||
turn.next_step();
|
||||
}
|
||||
}
|
||||
@@ -1521,3 +1647,83 @@ pub fn spawn_engine(config: EngineConfig, api_config: &Config) -> EngineHandle {
|
||||
|
||||
handle
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use crate::models::{Message, SystemPrompt, Usage};
|
||||
use crate::project_context::{ProjectContext, load_project_context_with_parents};
|
||||
use crate::working_set::WorkingSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Session state for the engine.
|
||||
@@ -41,6 +42,9 @@ pub struct Session {
|
||||
|
||||
/// Project context loaded from AGENTS.md, etc.
|
||||
pub project_context: Option<ProjectContext>,
|
||||
|
||||
/// Repo-aware working set for context management.
|
||||
pub working_set: WorkingSet,
|
||||
}
|
||||
|
||||
/// Cumulative usage statistics for a session.
|
||||
@@ -96,6 +100,7 @@ impl Session {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
working_set: WorkingSet::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +116,12 @@ impl Session {
|
||||
self.messages.push(message);
|
||||
}
|
||||
|
||||
/// Rebuild the working set from current messages (best effort).
|
||||
pub fn rebuild_working_set(&mut self) {
|
||||
self.working_set
|
||||
.rebuild_from_messages(&self.messages, &self.workspace);
|
||||
}
|
||||
|
||||
/// Clear the conversation history
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
|
||||
+636
@@ -0,0 +1,636 @@
|
||||
//! Offline evaluation harness for exercising representative tool loops.
|
||||
//!
|
||||
//! This module is intentionally self-contained so it can be wired into a CLI
|
||||
//! command later without calling the network or any LLM endpoints.
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use ignore::WalkBuilder;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Representative tool steps covered by the evaluation harness.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
||||
pub enum ScenarioStepKind {
|
||||
List,
|
||||
Read,
|
||||
Search,
|
||||
Edit,
|
||||
ApplyPatch,
|
||||
ExecShell,
|
||||
}
|
||||
|
||||
impl ScenarioStepKind {
|
||||
/// Tool name associated with this step.
|
||||
pub fn tool_name(self) -> &'static str {
|
||||
match self {
|
||||
ScenarioStepKind::List => "list_dir",
|
||||
ScenarioStepKind::Read => "read_file",
|
||||
ScenarioStepKind::Search => "search",
|
||||
ScenarioStepKind::Edit => "edit_file",
|
||||
ScenarioStepKind::ApplyPatch => "apply_patch",
|
||||
ScenarioStepKind::ExecShell => "exec_shell",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a step kind from CLI-friendly strings.
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_lowercase().as_str() {
|
||||
"list" | "list_dir" => Some(Self::List),
|
||||
"read" | "read_file" => Some(Self::Read),
|
||||
"search" | "grep" | "grep_files" => Some(Self::Search),
|
||||
"edit" | "edit_file" => Some(Self::Edit),
|
||||
"patch" | "apply_patch" => Some(Self::ApplyPatch),
|
||||
"shell" | "exec_shell" | "exec" => Some(Self::ExecShell),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregate statistics for a single tool kind.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct ToolStats {
|
||||
pub invocations: usize,
|
||||
pub errors: usize,
|
||||
pub total_duration: Duration,
|
||||
}
|
||||
|
||||
/// Top-level metrics produced by an evaluation run.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct EvalMetrics {
|
||||
pub success: bool,
|
||||
pub tool_errors: usize,
|
||||
pub steps: usize,
|
||||
pub duration: Duration,
|
||||
pub per_tool: BTreeMap<ScenarioStepKind, ToolStats>,
|
||||
}
|
||||
|
||||
/// One tool invocation recorded by the harness.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct EvalStep {
|
||||
pub kind: ScenarioStepKind,
|
||||
pub tool_name: &'static str,
|
||||
pub success: bool,
|
||||
pub duration: Duration,
|
||||
pub error: Option<String>,
|
||||
pub output: Option<String>,
|
||||
}
|
||||
|
||||
/// Summary of the generated temporary workspace.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct WorkspaceSummary {
|
||||
pub root: PathBuf,
|
||||
pub file_count: usize,
|
||||
pub files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Configuration for the offline evaluation harness.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvalHarnessConfig {
|
||||
/// Human-readable scenario name for reporting.
|
||||
pub scenario_name: String,
|
||||
/// If set, the harness will intentionally fail this step to test metrics.
|
||||
pub fail_step: Option<ScenarioStepKind>,
|
||||
/// Shell command executed during the `exec_shell` step.
|
||||
pub shell_command: String,
|
||||
/// Token that must appear in shell output for validation.
|
||||
pub shell_expect_token: String,
|
||||
/// Maximum characters stored for step output summaries.
|
||||
pub max_output_chars: usize,
|
||||
}
|
||||
|
||||
impl Default for EvalHarnessConfig {
|
||||
fn default() -> Self {
|
||||
let shell_command = if cfg!(windows) {
|
||||
"echo eval-harness".to_string()
|
||||
} else {
|
||||
"printf eval-harness".to_string()
|
||||
};
|
||||
Self {
|
||||
scenario_name: "offline-tool-loop".to_string(),
|
||||
fail_step: None,
|
||||
shell_command,
|
||||
shell_expect_token: "eval-harness".to_string(),
|
||||
max_output_chars: 240,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Offline harness that exercises representative tool loops in a temp workspace.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvalHarness {
|
||||
config: EvalHarnessConfig,
|
||||
}
|
||||
|
||||
impl EvalHarness {
|
||||
/// Create a new harness with the provided configuration.
|
||||
pub fn new(config: EvalHarnessConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Execute the offline evaluation scenario and return detailed results.
|
||||
pub fn run(&self) -> Result<EvalRun> {
|
||||
let started_at = Instant::now();
|
||||
let workspace = tempfile::Builder::new()
|
||||
.prefix("deepseek-eval-")
|
||||
.tempdir()
|
||||
.context("failed to create evaluation workspace")?;
|
||||
|
||||
let seed = seed_workspace(workspace.path())?;
|
||||
|
||||
let mut steps = Vec::new();
|
||||
let mut per_tool: BTreeMap<ScenarioStepKind, ToolStats> = BTreeMap::new();
|
||||
|
||||
let list_output = self.run_step(ScenarioStepKind::List, &mut steps, &mut per_tool, || {
|
||||
let entries = list_dir(workspace.path())?;
|
||||
Ok(entries.join(", "))
|
||||
});
|
||||
|
||||
let _read_output = self.run_step(ScenarioStepKind::Read, &mut steps, &mut per_tool, || {
|
||||
let path = if self.config.fail_step == Some(ScenarioStepKind::Read) {
|
||||
workspace.path().join("missing.txt")
|
||||
} else {
|
||||
seed.notes_path.clone()
|
||||
};
|
||||
read_file(&path)
|
||||
});
|
||||
|
||||
let search_output =
|
||||
self.run_step(ScenarioStepKind::Search, &mut steps, &mut per_tool, || {
|
||||
let root = if self.config.fail_step == Some(ScenarioStepKind::Search) {
|
||||
workspace.path().join("missing-dir")
|
||||
} else {
|
||||
workspace.path().to_path_buf()
|
||||
};
|
||||
let result = search_files(&root, "offline")?;
|
||||
Ok(format!("matches={}", result.matches.len()))
|
||||
});
|
||||
|
||||
let edit_output = self.run_step(ScenarioStepKind::Edit, &mut steps, &mut per_tool, || {
|
||||
let path = if self.config.fail_step == Some(ScenarioStepKind::Edit) {
|
||||
workspace.path().join("missing.txt")
|
||||
} else {
|
||||
seed.notes_path.clone()
|
||||
};
|
||||
edit_file_append(&path, "edited = true")?;
|
||||
Ok("appended line".to_string())
|
||||
});
|
||||
|
||||
let patch_output = self.run_step(
|
||||
ScenarioStepKind::ApplyPatch,
|
||||
&mut steps,
|
||||
&mut per_tool,
|
||||
|| {
|
||||
let patch = if self.config.fail_step == Some(ScenarioStepKind::ApplyPatch) {
|
||||
"*** Begin Patch\n*** Update File: notes.txt\n@@\n-THIS LINE DOES NOT EXIST\n+broken\n*** End Patch\n"
|
||||
.to_string()
|
||||
} else {
|
||||
"*** Begin Patch\n*** Update File: notes.txt\n@@\n status = \"draft\"\n-todo: offline metrics\n+todo: offline metrics (patched)\n*** End Patch\n"
|
||||
.to_string()
|
||||
};
|
||||
apply_patch(workspace.path(), &patch)?;
|
||||
Ok("patch applied".to_string())
|
||||
},
|
||||
);
|
||||
|
||||
let shell_output = self.run_step(
|
||||
ScenarioStepKind::ExecShell,
|
||||
&mut steps,
|
||||
&mut per_tool,
|
||||
|| {
|
||||
let command = if self.config.fail_step == Some(ScenarioStepKind::ExecShell) {
|
||||
"command_that_does_not_exist".to_string()
|
||||
} else {
|
||||
self.config.shell_command.clone()
|
||||
};
|
||||
exec_shell(workspace.path(), &command)
|
||||
},
|
||||
);
|
||||
|
||||
let duration = started_at.elapsed();
|
||||
|
||||
let workspace_summary = summarize_workspace(workspace.path(), list_output.as_deref())?;
|
||||
|
||||
let validation_success = validate_outputs(
|
||||
workspace.path(),
|
||||
&self.config.shell_expect_token,
|
||||
search_output.as_deref(),
|
||||
edit_output.as_deref(),
|
||||
patch_output.as_deref(),
|
||||
shell_output.as_deref(),
|
||||
);
|
||||
|
||||
let tool_errors = steps.iter().filter(|s| !s.success).count();
|
||||
let success = tool_errors == 0 && validation_success;
|
||||
|
||||
let metrics = EvalMetrics {
|
||||
success,
|
||||
tool_errors,
|
||||
steps: steps.len(),
|
||||
duration,
|
||||
per_tool,
|
||||
};
|
||||
|
||||
Ok(EvalRun {
|
||||
scenario_name: self.config.scenario_name.clone(),
|
||||
workspace,
|
||||
workspace_summary,
|
||||
metrics,
|
||||
steps,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_step<T, F>(
|
||||
&self,
|
||||
kind: ScenarioStepKind,
|
||||
steps: &mut Vec<EvalStep>,
|
||||
per_tool: &mut BTreeMap<ScenarioStepKind, ToolStats>,
|
||||
f: F,
|
||||
) -> Option<T>
|
||||
where
|
||||
F: FnOnce() -> Result<T>,
|
||||
T: ToString,
|
||||
{
|
||||
let started_at = Instant::now();
|
||||
let result = f();
|
||||
let duration = started_at.elapsed();
|
||||
|
||||
let stats = per_tool.entry(kind).or_default();
|
||||
stats.invocations += 1;
|
||||
stats.total_duration += duration;
|
||||
|
||||
match result {
|
||||
Ok(value) => {
|
||||
let output = truncate_output(&value.to_string(), self.config.max_output_chars);
|
||||
steps.push(EvalStep {
|
||||
kind,
|
||||
tool_name: kind.tool_name(),
|
||||
success: true,
|
||||
duration,
|
||||
error: None,
|
||||
output: Some(output),
|
||||
});
|
||||
Some(value)
|
||||
}
|
||||
Err(err) => {
|
||||
stats.errors += 1;
|
||||
steps.push(EvalStep {
|
||||
kind,
|
||||
tool_name: kind.tool_name(),
|
||||
success: false,
|
||||
duration,
|
||||
error: Some(err.to_string()),
|
||||
output: None,
|
||||
});
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EvalHarness {
|
||||
fn default() -> Self {
|
||||
Self::new(EvalHarnessConfig::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of running the evaluation harness.
|
||||
#[derive(Debug)]
|
||||
pub struct EvalRun {
|
||||
pub scenario_name: String,
|
||||
workspace: TempDir,
|
||||
pub workspace_summary: WorkspaceSummary,
|
||||
pub metrics: EvalMetrics,
|
||||
pub steps: Vec<EvalStep>,
|
||||
}
|
||||
|
||||
impl EvalRun {
|
||||
/// Get the root of the temporary workspace.
|
||||
pub fn workspace_root(&self) -> &Path {
|
||||
self.workspace.path()
|
||||
}
|
||||
|
||||
/// Convert the run into a serializable report for CLI output.
|
||||
pub fn to_report(&self) -> EvalReport {
|
||||
EvalReport {
|
||||
scenario_name: self.scenario_name.clone(),
|
||||
workspace_root: self.workspace_root().to_path_buf(),
|
||||
workspace_summary: self.workspace_summary.clone(),
|
||||
metrics: self.metrics.clone(),
|
||||
steps: self.steps.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializable report derived from an `EvalRun`.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct EvalReport {
|
||||
pub scenario_name: String,
|
||||
pub workspace_root: PathBuf,
|
||||
pub workspace_summary: WorkspaceSummary,
|
||||
pub metrics: EvalMetrics,
|
||||
pub steps: Vec<EvalStep>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SeedWorkspace {
|
||||
notes_path: PathBuf,
|
||||
}
|
||||
|
||||
fn seed_workspace(root: &Path) -> Result<SeedWorkspace> {
|
||||
let src_dir = root.join("src");
|
||||
fs::create_dir_all(&src_dir)
|
||||
.with_context(|| format!("failed to create seed directory: {}", src_dir.display()))?;
|
||||
|
||||
let readme_path = root.join("README.md");
|
||||
fs::write(
|
||||
&readme_path,
|
||||
"# Eval Harness Workspace\n\nThis workspace is offline.\n",
|
||||
)
|
||||
.with_context(|| format!("failed to write {}", readme_path.display()))?;
|
||||
|
||||
let notes_path = root.join("notes.txt");
|
||||
fs::write(
|
||||
¬es_path,
|
||||
"# Eval Harness\nstatus = \"draft\"\ntodo: offline metrics\n",
|
||||
)
|
||||
.with_context(|| format!("failed to write {}", notes_path.display()))?;
|
||||
|
||||
let lib_path = src_dir.join("lib.rs");
|
||||
fs::write(
|
||||
&lib_path,
|
||||
"pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n",
|
||||
)
|
||||
.with_context(|| format!("failed to write {}", lib_path.display()))?;
|
||||
|
||||
Ok(SeedWorkspace { notes_path })
|
||||
}
|
||||
|
||||
fn summarize_workspace(root: &Path, list_output: Option<&str>) -> Result<WorkspaceSummary> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
let walker = WalkBuilder::new(root)
|
||||
.hidden(false)
|
||||
.git_ignore(false)
|
||||
.git_global(false)
|
||||
.git_exclude(false)
|
||||
.build();
|
||||
|
||||
for entry in walker {
|
||||
let entry = entry.with_context(|| format!("failed to walk {}", root.display()))?;
|
||||
if entry.file_type().is_some_and(|t| t.is_file()) {
|
||||
files.push(entry.into_path());
|
||||
}
|
||||
}
|
||||
|
||||
if files.is_empty()
|
||||
&& let Some(output) = list_output
|
||||
&& !output.trim().is_empty()
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"workspace appears empty after list_dir: {}",
|
||||
output.trim()
|
||||
));
|
||||
}
|
||||
|
||||
files.sort();
|
||||
|
||||
Ok(WorkspaceSummary {
|
||||
root: root.to_path_buf(),
|
||||
file_count: files.len(),
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_outputs(
|
||||
root: &Path,
|
||||
shell_expect_token: &str,
|
||||
search_output: Option<&str>,
|
||||
edit_output: Option<&str>,
|
||||
patch_output: Option<&str>,
|
||||
shell_output: Option<&str>,
|
||||
) -> bool {
|
||||
let notes_path = root.join("notes.txt");
|
||||
let notes = match fs::read_to_string(¬es_path) {
|
||||
Ok(content) => content,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let search_ok = search_output.is_some_and(|s| s.contains("matches="));
|
||||
let edit_ok = edit_output.is_some_and(|s| !s.is_empty()) && notes.contains("edited = true");
|
||||
let patch_ok = patch_output.is_some_and(|s| !s.is_empty())
|
||||
&& notes.contains("todo: offline metrics (patched)");
|
||||
let shell_ok = shell_output
|
||||
.map(str::trim)
|
||||
.is_some_and(|s| s.contains(shell_expect_token));
|
||||
|
||||
search_ok && edit_ok && patch_ok && shell_ok
|
||||
}
|
||||
|
||||
fn list_dir(path: &Path) -> Result<Vec<String>> {
|
||||
let mut entries = Vec::new();
|
||||
let dir = fs::read_dir(path)
|
||||
.with_context(|| format!("failed to read directory: {}", path.display()))?;
|
||||
|
||||
for entry in dir {
|
||||
let entry = entry.with_context(|| format!("failed to list {}", path.display()))?;
|
||||
entries.push(entry.file_name().to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
entries.sort();
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn read_file(path: &Path) -> Result<String> {
|
||||
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SearchMatch {
|
||||
path: PathBuf,
|
||||
line: usize,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct SearchResult {
|
||||
matches: Vec<SearchMatch>,
|
||||
}
|
||||
|
||||
fn search_files(root: &Path, pattern: &str) -> Result<SearchResult> {
|
||||
if !root.exists() {
|
||||
return Err(anyhow!("search root does not exist: {}", root.display()));
|
||||
}
|
||||
|
||||
let regex = Regex::new(pattern).context("failed to compile search regex")?;
|
||||
let mut matches = Vec::new();
|
||||
|
||||
let walker = WalkBuilder::new(root)
|
||||
.hidden(false)
|
||||
.git_ignore(false)
|
||||
.git_global(false)
|
||||
.git_exclude(false)
|
||||
.build();
|
||||
|
||||
for entry in walker {
|
||||
let entry = entry.with_context(|| format!("failed to walk {}", root.display()))?;
|
||||
if !entry.file_type().is_some_and(|t| t.is_file()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = entry.path();
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for (idx, line) in content.lines().enumerate() {
|
||||
if regex.is_match(line) {
|
||||
matches.push(SearchMatch {
|
||||
path: path.to_path_buf(),
|
||||
line: idx + 1,
|
||||
content: line.to_string(),
|
||||
});
|
||||
}
|
||||
if matches.len() >= 64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if matches.len() >= 64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SearchResult { matches })
|
||||
}
|
||||
|
||||
fn edit_file_append(path: &Path, line: &str) -> Result<()> {
|
||||
let mut content = read_file(path)?;
|
||||
if !content.ends_with('\n') {
|
||||
content.push('\n');
|
||||
}
|
||||
content.push_str(line);
|
||||
content.push('\n');
|
||||
fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn apply_patch(root: &Path, patch: &str) -> Result<()> {
|
||||
let mut lines = patch.lines();
|
||||
|
||||
let begin = lines.next().unwrap_or_default();
|
||||
if begin != "*** Begin Patch" {
|
||||
return Err(anyhow!("patch missing *** Begin Patch header"));
|
||||
}
|
||||
|
||||
let header = lines.next().unwrap_or_default();
|
||||
let file_rel = header
|
||||
.strip_prefix("*** Update File: ")
|
||||
.ok_or_else(|| anyhow!("only *** Update File patches are supported"))?;
|
||||
if file_rel.contains("..") {
|
||||
return Err(anyhow!("patch path must be workspace-relative"));
|
||||
}
|
||||
|
||||
let file_path = root.join(file_rel);
|
||||
let original = read_file(&file_path)?;
|
||||
let had_trailing_newline = original.ends_with('\n');
|
||||
let mut file_lines: Vec<String> = original.lines().map(|l| l.to_string()).collect();
|
||||
|
||||
let mut cursor = 0usize;
|
||||
for raw_line in lines {
|
||||
if raw_line == "*** End Patch" {
|
||||
break;
|
||||
}
|
||||
if raw_line.starts_with("*** ") {
|
||||
return Err(anyhow!("unexpected patch directive: {raw_line}"));
|
||||
}
|
||||
if raw_line.starts_with("@@") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (kind, rest) = raw_line.split_at(1);
|
||||
let content = rest.to_string();
|
||||
|
||||
match kind {
|
||||
" " => {
|
||||
let Some(found) = file_lines[cursor..]
|
||||
.iter()
|
||||
.position(|line| line == &content)
|
||||
.map(|offset| cursor + offset)
|
||||
else {
|
||||
return Err(anyhow!(
|
||||
"patch context not found in {}: {}",
|
||||
file_path.display(),
|
||||
content
|
||||
));
|
||||
};
|
||||
cursor = found + 1;
|
||||
}
|
||||
"-" => {
|
||||
if cursor >= file_lines.len() || file_lines[cursor] != content {
|
||||
return Err(anyhow!(
|
||||
"patch removal mismatch in {}: expected '{}'",
|
||||
file_path.display(),
|
||||
content
|
||||
));
|
||||
}
|
||||
file_lines.remove(cursor);
|
||||
}
|
||||
"+" => {
|
||||
file_lines.insert(cursor, content);
|
||||
cursor += 1;
|
||||
}
|
||||
_ => return Err(anyhow!("unsupported patch line: {raw_line}")),
|
||||
}
|
||||
}
|
||||
|
||||
let mut updated = file_lines.join("\n");
|
||||
if had_trailing_newline {
|
||||
updated.push('\n');
|
||||
}
|
||||
|
||||
fs::write(&file_path, updated)
|
||||
.with_context(|| format!("failed to write patched file {}", file_path.display()))
|
||||
}
|
||||
|
||||
fn exec_shell(root: &Path, command: &str) -> Result<String> {
|
||||
#[cfg(windows)]
|
||||
let output = Command::new("cmd")
|
||||
.args(["/C", command])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.with_context(|| format!("failed to execute shell command: {command}"))?;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
let output = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(command)
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.with_context(|| format!("failed to execute shell command: {command}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow!(
|
||||
"shell command failed (status={}): {}",
|
||||
output.status,
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
Ok(stdout.trim().to_string())
|
||||
}
|
||||
|
||||
fn truncate_output(value: &str, max_chars: usize) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
let truncated: String = value.chars().take(max_chars).collect();
|
||||
format!("{}...", truncated)
|
||||
}
|
||||
+452
-77
@@ -19,7 +19,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use clap::{Args, CommandFactory, Parser, Subcommand};
|
||||
use clap_complete::{Shell, generate};
|
||||
use dotenvy::dotenv;
|
||||
@@ -33,6 +33,7 @@ mod compaction;
|
||||
mod config;
|
||||
mod core;
|
||||
mod duo;
|
||||
mod eval;
|
||||
mod execpolicy;
|
||||
mod features;
|
||||
mod hooks;
|
||||
@@ -56,10 +57,13 @@ mod tools;
|
||||
mod tui;
|
||||
mod ui;
|
||||
mod utils;
|
||||
mod working_set;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config::{Config, MAX_SUBAGENTS};
|
||||
use crate::eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
|
||||
use crate::features::Feature;
|
||||
use crate::llm_client::LlmClient;
|
||||
use crate::mcp::{McpConfig, McpPool};
|
||||
use crate::mcp::{McpConfig, McpPool, McpServerConfig};
|
||||
use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
|
||||
use crate::session_manager::{SessionManager, create_saved_session};
|
||||
use crate::tui::history::{summarize_tool_args, summarize_tool_output};
|
||||
@@ -88,7 +92,7 @@ struct Cli {
|
||||
#[arg(long)]
|
||||
yolo: bool,
|
||||
|
||||
/// Maximum number of concurrent sub-agents (1-5)
|
||||
/// Maximum number of concurrent sub-agents (1-20)
|
||||
#[arg(long)]
|
||||
max_subagents: Option<usize>,
|
||||
|
||||
@@ -130,6 +134,8 @@ struct Cli {
|
||||
enum Commands {
|
||||
/// Run system diagnostics and check configuration
|
||||
Doctor,
|
||||
/// Bootstrap MCP config and/or skills directories
|
||||
Setup(SetupArgs),
|
||||
/// Generate shell completions
|
||||
Completions {
|
||||
/// Shell to generate completions for
|
||||
@@ -161,6 +167,8 @@ enum Commands {
|
||||
Review(ReviewArgs),
|
||||
/// Apply a patch file (or stdin) to the working tree
|
||||
Apply(ApplyArgs),
|
||||
/// Run the offline evaluation harness (no network/LLM calls)
|
||||
Eval(EvalArgs),
|
||||
/// Manage MCP servers
|
||||
Mcp {
|
||||
#[command(subcommand)]
|
||||
@@ -209,6 +217,44 @@ struct ExecArgs {
|
||||
auto: bool,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone, Default)]
|
||||
struct SetupArgs {
|
||||
/// Initialize MCP configuration at the configured path
|
||||
#[arg(long, default_value_t = false)]
|
||||
mcp: bool,
|
||||
/// Initialize skills directory and an example skill
|
||||
#[arg(long, default_value_t = false)]
|
||||
skills: bool,
|
||||
/// Initialize both MCP config and skills (default when no flags provided)
|
||||
#[arg(long, default_value_t = false)]
|
||||
all: bool,
|
||||
/// Create a local workspace skills directory (./skills)
|
||||
#[arg(long, default_value_t = false)]
|
||||
local: bool,
|
||||
/// Overwrite existing template files
|
||||
#[arg(long, default_value_t = false)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Clone)]
|
||||
struct EvalArgs {
|
||||
/// Intentionally fail a specific step (list, read, search, edit, patch, shell)
|
||||
#[arg(long, value_name = "STEP")]
|
||||
fail_step: Option<String>,
|
||||
/// Shell command to run during the exec step
|
||||
#[arg(long, default_value = "printf eval-harness")]
|
||||
shell_command: String,
|
||||
/// Token that must appear in shell output for validation
|
||||
#[arg(long, default_value = "eval-harness")]
|
||||
shell_expect_token: String,
|
||||
/// Maximum characters stored per step output summary
|
||||
#[arg(long, default_value_t = 240)]
|
||||
max_output_chars: usize,
|
||||
/// Emit machine-readable JSON output
|
||||
#[arg(long, default_value_t = false)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug, Default, Clone)]
|
||||
struct FeatureToggles {
|
||||
/// Enable a feature (repeatable). Equivalent to `features.<name>=true`.
|
||||
@@ -269,6 +315,12 @@ struct ServeArgs {
|
||||
enum McpCommand {
|
||||
/// List configured MCP servers
|
||||
List,
|
||||
/// Create a template MCP config at the configured path
|
||||
Init {
|
||||
/// Overwrite an existing MCP config file
|
||||
#[arg(long, default_value_t = false)]
|
||||
force: bool,
|
||||
},
|
||||
/// Connect to MCP servers and report status
|
||||
Connect {
|
||||
/// Optional server name to connect to
|
||||
@@ -354,9 +406,16 @@ async fn main() -> Result<()> {
|
||||
if let Some(command) = cli.command.clone() {
|
||||
return match command {
|
||||
Commands::Doctor => {
|
||||
run_doctor().await;
|
||||
let config = load_config_from_cli(&cli)?;
|
||||
let workspace = resolve_workspace(&cli);
|
||||
run_doctor(&config, &workspace, cli.config.as_deref()).await;
|
||||
Ok(())
|
||||
}
|
||||
Commands::Setup(args) => {
|
||||
let config = load_config_from_cli(&cli)?;
|
||||
let workspace = resolve_workspace(&cli);
|
||||
run_setup(&config, &workspace, args)
|
||||
}
|
||||
Commands::Completions { shell } => {
|
||||
generate_completions(shell);
|
||||
Ok(())
|
||||
@@ -375,9 +434,10 @@ async fn main() -> Result<()> {
|
||||
let workspace = cli.workspace.clone().unwrap_or_else(|| {
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
});
|
||||
let max_subagents = cli
|
||||
.max_subagents
|
||||
.map_or_else(|| config.max_subagents(), |value| value.clamp(1, 5));
|
||||
let max_subagents = cli.max_subagents.map_or_else(
|
||||
|| config.max_subagents(),
|
||||
|value| value.clamp(1, MAX_SUBAGENTS),
|
||||
);
|
||||
let auto_mode = args.auto || cli.yolo;
|
||||
run_exec_agent(
|
||||
&config,
|
||||
@@ -398,6 +458,7 @@ async fn main() -> Result<()> {
|
||||
run_review(&config, args).await
|
||||
}
|
||||
Commands::Apply(args) => run_apply(args),
|
||||
Commands::Eval(args) => run_eval(args),
|
||||
Commands::Mcp { command } => {
|
||||
let config = load_config_from_cli(&cli)?;
|
||||
run_mcp_command(&config, command).await
|
||||
@@ -468,8 +529,243 @@ fn generate_completions(shell: Shell) {
|
||||
generate(shell, &mut cmd, name, &mut io::stdout());
|
||||
}
|
||||
|
||||
/// Run the offline evaluation harness (no network/LLM calls).
|
||||
fn run_eval(args: EvalArgs) -> Result<()> {
|
||||
let fail_step = match args.fail_step.as_deref() {
|
||||
Some(value) => ScenarioStepKind::parse(value)
|
||||
.map(Some)
|
||||
.ok_or_else(|| anyhow!("invalid --fail-step '{value}'"))?,
|
||||
None => None,
|
||||
};
|
||||
|
||||
let config = EvalHarnessConfig {
|
||||
fail_step,
|
||||
shell_command: args.shell_command,
|
||||
shell_expect_token: args.shell_expect_token,
|
||||
max_output_chars: args.max_output_chars,
|
||||
..EvalHarnessConfig::default()
|
||||
};
|
||||
|
||||
let harness = EvalHarness::new(config);
|
||||
let run = harness.run().context("evaluation harness failed")?;
|
||||
let report = run.to_report();
|
||||
|
||||
if args.json {
|
||||
let json = serde_json::to_string_pretty(&report)?;
|
||||
println!("{json}");
|
||||
} else {
|
||||
println!("Offline Eval Harness");
|
||||
println!("scenario: {}", report.scenario_name);
|
||||
println!("workspace: {}", report.workspace_root.display());
|
||||
println!("success: {}", report.metrics.success);
|
||||
println!("steps: {}", report.metrics.steps);
|
||||
println!("tool_errors: {}", report.metrics.tool_errors);
|
||||
println!("duration_ms: {}", report.metrics.duration.as_millis());
|
||||
|
||||
if !report.metrics.per_tool.is_empty() {
|
||||
println!("per_tool:");
|
||||
for (kind, stats) in &report.metrics.per_tool {
|
||||
println!(
|
||||
" {} invocations={} errors={} duration_ms={}",
|
||||
kind.tool_name(),
|
||||
stats.invocations,
|
||||
stats.errors,
|
||||
stats.total_duration.as_millis()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let failed_steps: Vec<_> = report.steps.iter().filter(|s| !s.success).collect();
|
||||
if !failed_steps.is_empty() {
|
||||
println!("failed_steps:");
|
||||
for step in failed_steps {
|
||||
let error = step.error.as_deref().unwrap_or("unknown error");
|
||||
println!(
|
||||
" {} tool={} error={}",
|
||||
step.kind.tool_name(),
|
||||
step.tool_name,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if report.metrics.success {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("offline evaluation harness reported failure")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum WriteStatus {
|
||||
Created,
|
||||
Overwritten,
|
||||
SkippedExists,
|
||||
}
|
||||
|
||||
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())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_template_file(path: &Path, contents: &str, force: bool) -> Result<WriteStatus> {
|
||||
ensure_parent_dir(path)?;
|
||||
|
||||
if path.exists() && !force {
|
||||
return Ok(WriteStatus::SkippedExists);
|
||||
}
|
||||
|
||||
let status = if path.exists() {
|
||||
WriteStatus::Overwritten
|
||||
} else {
|
||||
WriteStatus::Created
|
||||
};
|
||||
|
||||
std::fs::write(path, contents)
|
||||
.with_context(|| format!("Failed to write template at {}", path.display()))?;
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
fn mcp_template_json() -> Result<String> {
|
||||
let mut cfg = McpConfig::default();
|
||||
cfg.servers.insert(
|
||||
"example".to_string(),
|
||||
McpServerConfig {
|
||||
command: "node".to_string(),
|
||||
args: vec!["./path/to/your-mcp-server.js".to_string()],
|
||||
env: std::collections::HashMap::new(),
|
||||
connect_timeout: None,
|
||||
execute_timeout: None,
|
||||
read_timeout: None,
|
||||
disabled: true,
|
||||
},
|
||||
);
|
||||
serde_json::to_string_pretty(&cfg)
|
||||
.map_err(|e| anyhow!("Failed to render MCP template JSON: {e}"))
|
||||
}
|
||||
|
||||
fn init_mcp_config(path: &Path, force: bool) -> Result<WriteStatus> {
|
||||
let template = mcp_template_json()?;
|
||||
write_template_file(path, &template, force)
|
||||
}
|
||||
|
||||
fn skills_template(name: &str) -> String {
|
||||
format!(
|
||||
"\
|
||||
---\n\
|
||||
name: {name}\n\
|
||||
description: Quick repo diagnostics and setup guidance\n\
|
||||
allowed-tools: diagnostics, list_dir, read_file, grep_files, git_status, git_diff\n\
|
||||
---\n\n\
|
||||
When this skill is active:\n\
|
||||
1. Run the diagnostics tool to report workspace and sandbox status.\n\
|
||||
2. Skim key project files (README.md, Cargo.toml, AGENTS.md) before editing.\n\
|
||||
3. Prefer small, validated changes and summarize what you verified.\n\
|
||||
"
|
||||
)
|
||||
}
|
||||
|
||||
fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus)> {
|
||||
std::fs::create_dir_all(skills_dir)
|
||||
.with_context(|| format!("Failed to create skills dir {}", skills_dir.display()))?;
|
||||
|
||||
let skill_name = "getting-started";
|
||||
let skill_path = skills_dir.join(skill_name).join("SKILL.md");
|
||||
ensure_parent_dir(&skill_path)?;
|
||||
|
||||
let status = write_template_file(&skill_path, &skills_template(skill_name), force)?;
|
||||
Ok((skill_path, status))
|
||||
}
|
||||
|
||||
fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
|
||||
use crate::palette;
|
||||
use colored::Colorize;
|
||||
|
||||
let (aqua_r, aqua_g, aqua_b) = palette::DEEPSEEK_SKY_RGB;
|
||||
let (sky_r, sky_g, sky_b) = palette::DEEPSEEK_SKY_RGB;
|
||||
|
||||
let mut run_mcp = args.mcp || args.all;
|
||||
let mut run_skills = args.skills || args.all;
|
||||
if !run_mcp && !run_skills {
|
||||
run_mcp = true;
|
||||
run_skills = true;
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
"DeepSeek Setup"
|
||||
.truecolor(aqua_r, aqua_g, aqua_b)
|
||||
.bold()
|
||||
);
|
||||
println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
|
||||
println!("Workspace: {}", workspace.display());
|
||||
|
||||
if run_mcp {
|
||||
let mcp_path = config.mcp_config_path();
|
||||
let status = init_mcp_config(&mcp_path, args.force)?;
|
||||
match status {
|
||||
WriteStatus::Created => {
|
||||
println!(" ✓ Created MCP config at {}", mcp_path.display());
|
||||
}
|
||||
WriteStatus::Overwritten => {
|
||||
println!(" ✓ Overwrote MCP config at {}", mcp_path.display());
|
||||
}
|
||||
WriteStatus::SkippedExists => {
|
||||
println!(" · MCP config already exists at {}", mcp_path.display());
|
||||
}
|
||||
}
|
||||
println!(" Next: edit the file, then run `deepseek mcp list` or `deepseek mcp tools`.");
|
||||
}
|
||||
|
||||
if run_skills {
|
||||
let skills_dir = if args.local {
|
||||
workspace.join("skills")
|
||||
} else {
|
||||
config.skills_dir()
|
||||
};
|
||||
let (skill_path, status) = init_skills_dir(&skills_dir, args.force)?;
|
||||
match status {
|
||||
WriteStatus::Created => {
|
||||
println!(" ✓ Created example skill at {}", skill_path.display());
|
||||
}
|
||||
WriteStatus::Overwritten => {
|
||||
println!(" ✓ Overwrote example skill at {}", skill_path.display());
|
||||
}
|
||||
WriteStatus::SkippedExists => {
|
||||
println!(" · Example skill already exists at {}", skill_path.display());
|
||||
}
|
||||
}
|
||||
if args.local {
|
||||
println!(
|
||||
" Local skills dir enabled for this workspace: {}",
|
||||
skills_dir.display()
|
||||
);
|
||||
} else {
|
||||
println!(" Skills dir: {}", skills_dir.display());
|
||||
}
|
||||
println!(" Next: run the TUI and use `/skills` then `/skill getting-started`.");
|
||||
}
|
||||
|
||||
let sandbox = crate::sandbox::get_platform_sandbox();
|
||||
if let Some(kind) = sandbox {
|
||||
println!(" ✓ Sandbox available: {kind}");
|
||||
} else {
|
||||
println!(" · Sandbox not available on this platform (best-effort only).");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run system diagnostics
|
||||
async fn run_doctor() {
|
||||
async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Option<&Path>) {
|
||||
use crate::palette;
|
||||
use colored::Colorize;
|
||||
|
||||
@@ -493,24 +789,29 @@ async fn run_doctor() {
|
||||
println!(" rust: {}", rustc_version());
|
||||
println!();
|
||||
|
||||
// Check configuration
|
||||
// Configuration summary
|
||||
println!("{}", "Configuration:".bold());
|
||||
let config_dir =
|
||||
let default_config_dir =
|
||||
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))
|
||||
.unwrap_or_else(|| default_config_dir.join("config.toml"));
|
||||
|
||||
let config_file = config_dir.join("config.toml");
|
||||
if config_file.exists() {
|
||||
if config_path.exists() {
|
||||
println!(
|
||||
" {} config.toml found at {}",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
config_file.display()
|
||||
config_path.display()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} config.toml not found (will use defaults)",
|
||||
"!".truecolor(sky_r, sky_g, sky_b)
|
||||
" {} config.toml not found at {} (using defaults/env)",
|
||||
"!".truecolor(sky_r, sky_g, sky_b),
|
||||
config_path.display()
|
||||
);
|
||||
}
|
||||
println!(" workspace: {}", workspace.display());
|
||||
|
||||
// Check API keys
|
||||
println!();
|
||||
@@ -521,25 +822,19 @@ async fn run_doctor() {
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b)
|
||||
);
|
||||
true
|
||||
} else if config.deepseek_api_key().is_ok() {
|
||||
println!(
|
||||
" {} DeepSeek API key found in effective config",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b)
|
||||
);
|
||||
true
|
||||
} else {
|
||||
let key_in_config = Config::load(None, None)
|
||||
.ok()
|
||||
.and_then(|c| c.deepseek_api_key().ok())
|
||||
.is_some();
|
||||
if key_in_config {
|
||||
println!(
|
||||
" {} DeepSeek API key found in config",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b)
|
||||
);
|
||||
true
|
||||
} else {
|
||||
println!(
|
||||
" {} DeepSeek API key not configured",
|
||||
"✗".truecolor(red_r, red_g, red_b)
|
||||
);
|
||||
println!(" Run 'deepseek' to configure interactively, or set DEEPSEEK_API_KEY");
|
||||
false
|
||||
}
|
||||
println!(
|
||||
" {} DeepSeek API key not configured",
|
||||
"✗".truecolor(red_r, red_g, red_b)
|
||||
);
|
||||
println!(" Run 'deepseek' to configure interactively, or set DEEPSEEK_API_KEY");
|
||||
false
|
||||
};
|
||||
|
||||
// API connectivity test
|
||||
@@ -547,7 +842,6 @@ async fn run_doctor() {
|
||||
println!("{}", "API Connectivity:".bold());
|
||||
if has_api_key {
|
||||
print!(" {} Testing connection to DeepSeek API...", "·".dimmed());
|
||||
// Flush to show progress immediately
|
||||
use std::io::Write;
|
||||
std::io::stdout().flush().ok();
|
||||
|
||||
@@ -565,7 +859,6 @@ async fn run_doctor() {
|
||||
"\r {} API connection failed",
|
||||
"✗".truecolor(red_r, red_g, red_b)
|
||||
);
|
||||
// Provide helpful diagnostics based on error type
|
||||
if error_msg.contains("401") || error_msg.contains("Unauthorized") {
|
||||
println!(" Invalid API key. Check your DEEPSEEK_API_KEY or config.toml");
|
||||
} else if error_msg.contains("403") || error_msg.contains("Forbidden") {
|
||||
@@ -587,68 +880,124 @@ async fn run_doctor() {
|
||||
println!(" {} Skipped (no API key configured)", "·".dimmed());
|
||||
}
|
||||
|
||||
// Check MCP configuration
|
||||
// MCP configuration
|
||||
println!();
|
||||
println!("{}", "MCP Servers:".bold());
|
||||
let mcp_config = config_dir.join("mcp.json");
|
||||
if mcp_config.exists() {
|
||||
println!(" {} mcp.json found", "✓".truecolor(aqua_r, aqua_g, aqua_b));
|
||||
if let Ok(content) = std::fs::read_to_string(&mcp_config)
|
||||
&& let Ok(config) = serde_json::from_str::<crate::mcp::McpConfig>(&content)
|
||||
{
|
||||
if config.servers.is_empty() {
|
||||
let features = config.features();
|
||||
if features.enabled(Feature::Mcp) {
|
||||
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));
|
||||
}
|
||||
|
||||
let mcp_config_path = config.mcp_config_path();
|
||||
if mcp_config_path.exists() {
|
||||
println!(
|
||||
" {} MCP config found at {}",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
mcp_config_path.display()
|
||||
);
|
||||
match load_mcp_config(&mcp_config_path) {
|
||||
Ok(cfg) if cfg.servers.is_empty() => {
|
||||
println!(" {} 0 server(s) configured", "·".dimmed());
|
||||
} else {
|
||||
}
|
||||
Ok(cfg) => {
|
||||
println!(
|
||||
" {} {} server(s) configured",
|
||||
"·".dimmed(),
|
||||
config.servers.len()
|
||||
cfg.servers.len()
|
||||
);
|
||||
for name in config.servers.keys() {
|
||||
for name in cfg.servers.keys() {
|
||||
println!(" - {name}");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
println!(
|
||||
" {} MCP config parse error: {}",
|
||||
"✗".truecolor(red_r, red_g, red_b),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" {} mcp.json not found (no MCP servers)", "·".dimmed());
|
||||
println!(
|
||||
" {} MCP config not found at {}",
|
||||
"·".dimmed(),
|
||||
mcp_config_path.display()
|
||||
);
|
||||
println!(" Run `deepseek mcp init` or `deepseek setup --mcp`.");
|
||||
}
|
||||
|
||||
// Check skills directory
|
||||
// Skills configuration
|
||||
println!();
|
||||
println!("{}", "Skills:".bold());
|
||||
let skills_dir = config_dir.join("skills");
|
||||
if skills_dir.exists() {
|
||||
let skill_count = std::fs::read_dir(skills_dir)
|
||||
let global_skills_dir = config.skills_dir();
|
||||
let local_skills_dir = workspace.join("skills");
|
||||
let selected_skills_dir = if local_skills_dir.exists() {
|
||||
&local_skills_dir
|
||||
} else {
|
||||
&global_skills_dir
|
||||
};
|
||||
|
||||
let describe_dir = |dir: &Path| -> usize {
|
||||
std::fs::read_dir(dir)
|
||||
.map(|entries| entries.filter_map(std::result::Result::ok).count())
|
||||
.unwrap_or(0);
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
if local_skills_dir.exists() {
|
||||
println!(
|
||||
" {} skills directory found ({} items)",
|
||||
" {} local skills dir found at {} ({} items)",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
skill_count
|
||||
local_skills_dir.display(),
|
||||
describe_dir(&local_skills_dir)
|
||||
);
|
||||
} else {
|
||||
println!(" {} skills directory not found", "·".dimmed());
|
||||
println!(
|
||||
" {} local skills dir not found at {}",
|
||||
"·".dimmed(),
|
||||
local_skills_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Platform-specific checks
|
||||
if global_skills_dir.exists() {
|
||||
println!(
|
||||
" {} global skills dir found at {} ({} items)",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
global_skills_dir.display(),
|
||||
describe_dir(&global_skills_dir)
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} global skills dir not found at {}",
|
||||
"·".dimmed(),
|
||||
global_skills_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
println!(" {} selected skills dir: {}", "·".dimmed(), selected_skills_dir.display());
|
||||
if !local_skills_dir.exists() && !global_skills_dir.exists() {
|
||||
println!(" Run `deepseek setup --skills` (or add --local for ./skills).");
|
||||
}
|
||||
|
||||
// Platform and sandbox checks
|
||||
println!();
|
||||
println!("{}", "Platform:".bold());
|
||||
println!(" OS: {}", std::env::consts::OS);
|
||||
println!(" Arch: {}", std::env::consts::ARCH);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if std::path::Path::new("/usr/bin/sandbox-exec").exists() {
|
||||
println!(
|
||||
" {} macOS sandbox available",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b)
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} macOS sandbox not available",
|
||||
"!".truecolor(sky_r, sky_g, sky_b)
|
||||
);
|
||||
}
|
||||
let sandbox = crate::sandbox::get_platform_sandbox();
|
||||
if let Some(kind) = sandbox {
|
||||
println!(
|
||||
" {} sandbox available: {}",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
kind
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} sandbox not available (commands run best-effort)",
|
||||
"!".truecolor(sky_r, sky_g, sky_b)
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
@@ -852,6 +1201,12 @@ fn init_project() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_workspace(cli: &Cli) -> PathBuf {
|
||||
cli.workspace
|
||||
.clone()
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
|
||||
}
|
||||
|
||||
fn load_config_from_cli(cli: &Cli) -> Result<Config> {
|
||||
let profile = cli
|
||||
.profile
|
||||
@@ -1079,6 +1434,25 @@ fn read_patch_from_stdin() -> Result<String> {
|
||||
async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
|
||||
let config_path = config.mcp_config_path();
|
||||
match command {
|
||||
McpCommand::Init { force } => {
|
||||
let status = init_mcp_config(&config_path, force)?;
|
||||
match status {
|
||||
WriteStatus::Created => {
|
||||
println!("Created MCP config at {}", config_path.display());
|
||||
}
|
||||
WriteStatus::Overwritten => {
|
||||
println!("Overwrote MCP config at {}", config_path.display());
|
||||
}
|
||||
WriteStatus::SkippedExists => {
|
||||
println!(
|
||||
"MCP config already exists at {} (use --force to overwrite)",
|
||||
config_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
println!("Edit the file, then run `deepseek mcp list` or `deepseek mcp tools`.");
|
||||
Ok(())
|
||||
}
|
||||
McpCommand::List => {
|
||||
let cfg = load_mcp_config(&config_path)?;
|
||||
if cfg.servers.is_empty() {
|
||||
@@ -1335,9 +1709,10 @@ async fn run_interactive(
|
||||
.default_text_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "deepseek-reasoner".to_string());
|
||||
let max_subagents = cli
|
||||
.max_subagents
|
||||
.map_or_else(|| config.max_subagents(), |value| value.clamp(1, 5));
|
||||
let max_subagents = cli.max_subagents.map_or_else(
|
||||
|| config.max_subagents(),
|
||||
|value| value.clamp(1, MAX_SUBAGENTS),
|
||||
);
|
||||
let use_alt_screen = should_use_alt_screen(cli, config);
|
||||
|
||||
tui::run_tui(
|
||||
@@ -1416,8 +1791,8 @@ async fn run_exec_agent(
|
||||
use crate::core::ops::Op;
|
||||
use crate::duo::DuoSession;
|
||||
use crate::rlm::RlmSession;
|
||||
use crate::tools::plan::PlanState;
|
||||
use crate::tools::todo::TodoList;
|
||||
use crate::tools::plan::new_shared_plan_state;
|
||||
use crate::tools::todo::new_shared_todo_list;
|
||||
use crate::tui::app::AppMode;
|
||||
|
||||
let engine_config = EngineConfig {
|
||||
@@ -1433,8 +1808,8 @@ async fn run_exec_agent(
|
||||
rlm_session: Arc::new(Mutex::new(RlmSession::default())),
|
||||
duo_session: Arc::new(Mutex::new(DuoSession::new())),
|
||||
compaction: CompactionConfig::default(),
|
||||
todos: Arc::new(Mutex::new(TodoList::new())),
|
||||
plan_state: Arc::new(Mutex::new(PlanState::default())),
|
||||
todos: new_shared_todo_list(),
|
||||
plan_state: new_shared_plan_state(),
|
||||
};
|
||||
|
||||
let engine_handle = spawn_engine(engine_config, config);
|
||||
|
||||
@@ -6,6 +6,7 @@ pub const DEEPSEEK_BLUE_RGB: (u8, u8, u8) = (53, 120, 229); // #3578E5
|
||||
pub const DEEPSEEK_SKY_RGB: (u8, u8, u8) = (106, 174, 242);
|
||||
#[allow(dead_code)]
|
||||
pub const DEEPSEEK_AQUA_RGB: (u8, u8, u8) = (54, 187, 212);
|
||||
#[allow(dead_code)]
|
||||
pub const DEEPSEEK_NAVY_RGB: (u8, u8, u8) = (24, 63, 138);
|
||||
pub const DEEPSEEK_INK_RGB: (u8, u8, u8) = (11, 21, 38);
|
||||
pub const DEEPSEEK_SLATE_RGB: (u8, u8, u8) = (18, 28, 46);
|
||||
@@ -24,6 +25,7 @@ pub const DEEPSEEK_AQUA: Color = Color::Rgb(
|
||||
DEEPSEEK_AQUA_RGB.1,
|
||||
DEEPSEEK_AQUA_RGB.2,
|
||||
);
|
||||
#[allow(dead_code)]
|
||||
pub const DEEPSEEK_NAVY: Color = Color::Rgb(
|
||||
DEEPSEEK_NAVY_RGB.0,
|
||||
DEEPSEEK_NAVY_RGB.1,
|
||||
@@ -49,6 +51,14 @@ 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 SELECTION_BG: Color = Color::Rgb(26, 44, 74);
|
||||
pub const COMPOSER_BG: Color = DEEPSEEK_SLATE;
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ pub fn system_prompt_for_mode(mode: AppMode) -> SystemPrompt {
|
||||
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 {
|
||||
@@ -53,6 +54,12 @@ pub fn system_prompt_for_mode_with_context(
|
||||
base_prompt.trim().to_string()
|
||||
};
|
||||
|
||||
if let Some(summary) = working_set_summary
|
||||
&& !summary.trim().is_empty()
|
||||
{
|
||||
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}");
|
||||
|
||||
+43
-9
@@ -3,12 +3,28 @@ You are DeepSeek CLI, an agentic coding assistant with full tool access.
|
||||
IMPORTANT: You are ALREADY running inside the DeepSeek CLI TUI. You have direct access to all tools below - do NOT try to run or launch the CLI binary. Your tools execute directly in the current session.
|
||||
|
||||
When given a task:
|
||||
1. Break it into subtasks and track them with todo tools.
|
||||
2. Work through each subtask systematically.
|
||||
3. Report progress as you go.
|
||||
4. Verify your work before marking complete.
|
||||
5. Do not stop until the full task is done.
|
||||
6. Avoid destructive actions (deletes, irreversible changes) unless the user explicitly requests them; suggest YOLO for high-risk changes.
|
||||
1. Understand the goal, constraints, and acceptance criteria first.
|
||||
2. Break work into small, testable steps and track them with todo tools.
|
||||
3. Read and search first, then make targeted edits, then verify with tools.
|
||||
4. Report concise progress updates at meaningful checkpoints.
|
||||
5. Do not stop until the full task is done or you are clearly blocked.
|
||||
6. Avoid destructive actions (deletes, irreversible changes) unless the user explicitly requests them; warn before risky actions and suggest YOLO for high-risk changes.
|
||||
|
||||
Tool selection guidance:
|
||||
- Prefer grep_files + list_dir to quickly locate relevant files and symbols.
|
||||
- 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.
|
||||
|
||||
Testing and stop conditions:
|
||||
- After any change, run the most relevant tests/checks before declaring success.
|
||||
- Start narrow (targeted tests) and expand to broader checks when appropriate.
|
||||
- If a check fails, report it concisely, fix it, and re-run.
|
||||
- Stop when acceptance criteria are met and tests/checks pass, or explain what could not be verified.
|
||||
|
||||
Step budgeting:
|
||||
- Budget attempts. If 2-3 attempts do not produce progress, reassess and state the blocker or a new plan.
|
||||
|
||||
Available tools:
|
||||
|
||||
@@ -21,6 +37,14 @@ FILE OPERATIONS:
|
||||
- grep_files: Search files by regex
|
||||
- web_search: Search the web for up-to-date information
|
||||
|
||||
GIT AND DIAGNOSTICS:
|
||||
- git_status: Inspect repo status safely
|
||||
- git_diff: Inspect working tree or staged diffs
|
||||
- diagnostics: Report workspace, git, sandbox, and toolchain info
|
||||
|
||||
TESTING:
|
||||
- run_tests: Run `cargo test` with optional args
|
||||
|
||||
SHELL EXECUTION:
|
||||
- exec_shell: Run shell commands (supports background execution)
|
||||
- command: The command to execute
|
||||
@@ -34,14 +58,24 @@ TASK MANAGEMENT:
|
||||
|
||||
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)
|
||||
- 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.
|
||||
If you use agent_swarm, incorporate its aggregated results (or follow up with agent_result for any running agents) before responding.
|
||||
|
||||
For complex work, call update_plan to publish a checklist.
|
||||
Keep exactly one plan step in_progress at a time.
|
||||
Use todo tools for granular progress when helpful.
|
||||
Planning and progress:
|
||||
- For complex or multi-file work, call update_plan to publish a checklist.
|
||||
- Keep exactly one plan step in_progress at a time.
|
||||
- Use todo tools for granular progress when helpful.
|
||||
- Prefer short progress notes over long narration.
|
||||
|
||||
Git hygiene:
|
||||
- Run git status early (to see the workspace state) and again before finishing.
|
||||
- Do not revert or overwrite unrelated user changes.
|
||||
- Avoid destructive git commands unless explicitly requested.
|
||||
- Do not commit unless the user asks.
|
||||
|
||||
BACKGROUND EXECUTION:
|
||||
For long-running commands (build, test, server), use exec_shell with background: true.
|
||||
|
||||
+32
-8
@@ -1,14 +1,38 @@
|
||||
You are DeepSeek CLI, an agentic coding assistant.
|
||||
|
||||
When given a task:
|
||||
1. Break it into subtasks and track them.
|
||||
2. Work through each subtask systematically.
|
||||
3. Report progress as you go.
|
||||
4. Verify your work before marking complete.
|
||||
5. Do not stop until the full task is done.
|
||||
1. Understand the goal, constraints, and acceptance criteria first.
|
||||
2. Break the work into small, testable steps and track them.
|
||||
3. Choose tools deliberately; read before you write, then verify.
|
||||
4. Report short progress updates at meaningful checkpoints.
|
||||
5. Do not stop until the full task is done or you are clearly blocked.
|
||||
|
||||
Use tools when needed. For complex work, call update_plan to publish a checklist.
|
||||
Keep exactly one plan step in_progress at a time.
|
||||
Use todo tools for granular progress when helpful.
|
||||
Tool selection guidance:
|
||||
- Prefer fast search tools (grep/rg) to locate relevant files and symbols.
|
||||
- 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.
|
||||
|
||||
Planning and progress:
|
||||
- For non-trivial tasks, publish a checklist with update_plan.
|
||||
- Keep exactly one plan step in_progress at a time.
|
||||
- Use todo tools for granular progress when helpful.
|
||||
- Budget your steps: if 2-3 attempts fail to make progress, pause, reassess, and state the blocker.
|
||||
|
||||
Testing and stop conditions:
|
||||
- After any change, run the most relevant tests/checks before declaring success.
|
||||
- If tests fail, report the failure concisely, fix it, and re-run.
|
||||
- Stop when acceptance criteria are met and checks/tests pass (or explain why they could not run).
|
||||
|
||||
Git hygiene:
|
||||
- Check git status early and again before finishing.
|
||||
- Do not revert or overwrite unrelated user changes.
|
||||
- Avoid destructive git commands unless explicitly requested.
|
||||
- Do not commit unless the user asks.
|
||||
|
||||
Approval etiquette:
|
||||
- In approval-gated modes, ask before writes or shell commands.
|
||||
- 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."
|
||||
+16
-1
@@ -1,3 +1,18 @@
|
||||
You are in Duo mode for requirements-driven development.
|
||||
|
||||
Use duo_init with a requirements checklist, then alternate duo_player (implement) and duo_coach (verify) until approved.
|
||||
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.
|
||||
+26
-5
@@ -12,14 +12,35 @@ Available tools in this mode:
|
||||
- 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
|
||||
- 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)
|
||||
- note: Record important information
|
||||
- todo_write: Write or update the todo list
|
||||
- update_plan: Publish a structured plan
|
||||
|
||||
Guidelines:
|
||||
1. Answer questions clearly and concisely
|
||||
2. Provide code examples when helpful
|
||||
3. You CAN read files and explore the codebase
|
||||
4. Ask for explicit approval before any file writes, patches, or shell commands
|
||||
5. If the user wants fully autonomous changes, suggest pressing Tab to switch to Agent or YOLO mode
|
||||
1. Understand the goal and constraints before proposing changes.
|
||||
2. Prefer tool-centric reasoning: search, read, then act.
|
||||
3. Answer clearly and concisely; provide code examples when helpful.
|
||||
4. You CAN read files and explore the codebase without approval.
|
||||
5. Ask for explicit approval before any file writes, patches, or shell commands.
|
||||
6. If the user wants fully autonomous changes, suggest pressing Tab to switch to Agent or YOLO mode.
|
||||
|
||||
Tool selection guidance:
|
||||
- Use grep_files/list_dir to find relevant files quickly.
|
||||
- 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.
|
||||
|
||||
Testing and stop conditions (after approval to edit/run commands):
|
||||
- After any change, run the most relevant tests/checks before declaring success.
|
||||
- If a check fails, report it concisely, fix it, and re-run.
|
||||
- Stop when acceptance criteria are met and checks pass, or explain what could not be verified.
|
||||
|
||||
Step budgeting and progress:
|
||||
- For non-trivial tasks, propose a short plan and use update_plan/todo_write when helpful.
|
||||
- Provide brief progress updates at key checkpoints, not every small action.
|
||||
- If 2-3 attempts fail, pause and ask a focused clarifying question.
|
||||
+15
-12
@@ -3,10 +3,10 @@ You are DeepSeek CLI in PLAN mode. Design before implementing.
|
||||
This mode is read-only: you can analyze and plan, but you cannot edit files or run shell commands.
|
||||
|
||||
In this mode, focus on:
|
||||
1. Understanding requirements fully before proposing solutions
|
||||
2. Breaking down complex tasks into clear, actionable steps
|
||||
3. Identifying potential issues and edge cases upfront
|
||||
4. Creating a detailed plan using update_plan before implementation
|
||||
1. Understanding requirements, constraints, and acceptance criteria fully.
|
||||
2. Breaking complex tasks into clear, actionable, testable steps.
|
||||
3. Identifying potential issues, regressions, and edge cases upfront.
|
||||
4. Creating a detailed plan using update_plan before implementation.
|
||||
|
||||
Available tools:
|
||||
|
||||
@@ -19,13 +19,16 @@ EXPLORATION:
|
||||
- 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)
|
||||
- git_status: Inspect repository status safely
|
||||
- git_diff: Inspect diffs to understand current changes
|
||||
- diagnostics: Report workspace, git, sandbox, and toolchain info
|
||||
|
||||
Guidelines:
|
||||
- Focus on planning before making changes
|
||||
- Use update_plan to create structured plans
|
||||
- Each step should be specific and actionable
|
||||
- Include acceptance criteria where possible
|
||||
- Identify dependencies between steps
|
||||
- Call out risks, edge cases, and verification steps
|
||||
- Ask clarifying questions if requirements are unclear
|
||||
- After the plan is ready, summarize briefly and wait for user direction
|
||||
- Prefer tool-centric planning: use grep_files/list_dir/read_file to ground the plan in the actual codebase.
|
||||
- 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.
|
||||
- Include git hygiene in the plan: check git status early and before finishing; avoid reverting unrelated changes.
|
||||
- Identify dependencies, risks, edge cases, and rollback/mitigation ideas.
|
||||
- Budget steps: if key facts are missing after 2-3 exploration attempts, ask a focused clarifying question.
|
||||
- Provide concise progress notes, then wait for user direction once the plan is ready.
|
||||
+11
-1
@@ -1,3 +1,13 @@
|
||||
You are in RLM mode for working with large files that exceed context limits.
|
||||
|
||||
Use rlm_* tools to load files, explore content, and run focused queries over chunks.
|
||||
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).
|
||||
+481
-60
@@ -3,6 +3,7 @@
|
||||
//! This tool provides precise file modifications using unified diff format,
|
||||
//! supporting multi-hunk patches and fuzzy matching.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -18,6 +19,10 @@ use super::spec::{
|
||||
|
||||
/// Maximum lines of context for fuzzy matching (increased for better tolerance)
|
||||
const MAX_FUZZ: usize = 50;
|
||||
/// Limit how much context we print in error messages.
|
||||
const HUNK_PREVIEW_LINES: usize = 4;
|
||||
const SNIPPET_RADIUS: usize = 2;
|
||||
const FILE_LIST_LIMIT: usize = 6;
|
||||
|
||||
// === Types ===
|
||||
|
||||
@@ -30,9 +35,27 @@ pub struct PatchResult {
|
||||
pub hunks_applied: usize,
|
||||
pub hunks_total: usize,
|
||||
pub fuzz_used: usize,
|
||||
#[serde(default)]
|
||||
pub hunks_with_fuzz: usize,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub touched_files: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub file_summaries: Vec<FileSummary>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Per-file summary for patch application output.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileSummary {
|
||||
pub path: String,
|
||||
pub hunks: usize,
|
||||
pub hunks_applied: usize,
|
||||
pub fuzz_used: usize,
|
||||
pub hunks_with_fuzz: usize,
|
||||
pub created: bool,
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
/// A single hunk in a unified diff
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Hunk {
|
||||
@@ -76,6 +99,34 @@ struct PatchStats {
|
||||
hunks_applied: usize,
|
||||
hunks_total: usize,
|
||||
fuzz_used: usize,
|
||||
hunks_with_fuzz: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct PatchStatsExt {
|
||||
stats: PatchStats,
|
||||
touched_files: Vec<String>,
|
||||
file_summaries: Vec<FileSummary>,
|
||||
header_path_mismatch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct PatchShape {
|
||||
has_hunks: bool,
|
||||
header_files: Vec<String>,
|
||||
}
|
||||
|
||||
impl PatchShape {
|
||||
fn file_count(&self) -> usize {
|
||||
self.header_files.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
struct HunkApplyStats {
|
||||
hunks_applied: usize,
|
||||
fuzz_used: usize,
|
||||
hunks_with_fuzz: usize,
|
||||
}
|
||||
|
||||
// === Errors ===
|
||||
@@ -160,21 +211,19 @@ impl ToolSpec for ApplyPatchTool {
|
||||
let create_if_missing = optional_bool(&input, "create_if_missing", false);
|
||||
|
||||
if let Some(changes_value) = input.get("changes") {
|
||||
let pending = build_pending_writes_from_changes(changes_value, context)?;
|
||||
let stats = PatchStats {
|
||||
files_total: pending.len(),
|
||||
files_applied: pending.len(),
|
||||
..PatchStats::default()
|
||||
};
|
||||
let (pending, stats) = build_pending_writes_from_changes(changes_value, context)?;
|
||||
apply_pending_writes(&pending)?;
|
||||
let result = PatchResult {
|
||||
success: true,
|
||||
files_applied: stats.files_applied,
|
||||
files_total: stats.files_total,
|
||||
hunks_applied: stats.hunks_applied,
|
||||
hunks_total: stats.hunks_total,
|
||||
fuzz_used: stats.fuzz_used,
|
||||
message: format!("Applied {} file change(s)", stats.files_applied),
|
||||
files_applied: stats.stats.files_applied,
|
||||
files_total: stats.stats.files_total,
|
||||
hunks_applied: stats.stats.hunks_applied,
|
||||
hunks_total: stats.stats.hunks_total,
|
||||
fuzz_used: stats.stats.fuzz_used,
|
||||
hunks_with_fuzz: stats.stats.hunks_with_fuzz,
|
||||
touched_files: stats.touched_files.clone(),
|
||||
file_summaries: stats.file_summaries.clone(),
|
||||
message: build_summary_message(&stats),
|
||||
};
|
||||
return ToolResult::json(&result)
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()));
|
||||
@@ -182,10 +231,15 @@ impl ToolSpec for ApplyPatchTool {
|
||||
|
||||
let patch_text = required_str(&input, "patch")?;
|
||||
let path_override = optional_str(&input, "path");
|
||||
let patch_shape = inspect_patch_shape(patch_text);
|
||||
validate_patch_shape(&patch_shape, path_override)?;
|
||||
let mismatch_note = path_override.and_then(|path| diff_header_mismatch(path, &patch_shape));
|
||||
let file_patches = if let Some(path) = path_override {
|
||||
let hunks = parse_unified_diff(patch_text)?;
|
||||
if hunks.is_empty() {
|
||||
return Err(ToolError::invalid_input("No valid hunks found in patch"));
|
||||
return Err(ToolError::invalid_input(
|
||||
"Patch did not contain any hunks (`@@ ... @@`). Provide a unified diff hunk.",
|
||||
));
|
||||
}
|
||||
vec![FilePatch {
|
||||
path: path.to_string(),
|
||||
@@ -197,25 +251,28 @@ impl ToolSpec for ApplyPatchTool {
|
||||
let file_patches = parse_unified_diff_files(patch_text, create_if_missing)?;
|
||||
if file_patches.is_empty() {
|
||||
return Err(ToolError::invalid_input(
|
||||
"No valid file patches found in unified diff",
|
||||
"No valid file patches found. Ensure the patch includes `---`/`+++` headers or provide `path`.",
|
||||
));
|
||||
}
|
||||
file_patches
|
||||
};
|
||||
|
||||
let (pending, stats) = build_pending_writes_from_patches(file_patches, context, fuzz)?;
|
||||
let (pending, mut stats) = build_pending_writes_from_patches(file_patches, context, fuzz)?;
|
||||
if stats.header_path_mismatch.is_none() {
|
||||
stats.header_path_mismatch = mismatch_note;
|
||||
}
|
||||
apply_pending_writes(&pending)?;
|
||||
let result = PatchResult {
|
||||
success: true,
|
||||
files_applied: stats.files_applied,
|
||||
files_total: stats.files_total,
|
||||
hunks_applied: stats.hunks_applied,
|
||||
hunks_total: stats.hunks_total,
|
||||
fuzz_used: stats.fuzz_used,
|
||||
message: format!(
|
||||
"Applied {}/{} hunks across {} file(s) (fuzz: {})",
|
||||
stats.hunks_applied, stats.hunks_total, stats.files_applied, stats.fuzz_used
|
||||
),
|
||||
files_applied: stats.stats.files_applied,
|
||||
files_total: stats.stats.files_total,
|
||||
hunks_applied: stats.stats.hunks_applied,
|
||||
hunks_total: stats.stats.hunks_total,
|
||||
fuzz_used: stats.stats.fuzz_used,
|
||||
hunks_with_fuzz: stats.stats.hunks_with_fuzz,
|
||||
touched_files: stats.touched_files.clone(),
|
||||
file_summaries: stats.file_summaries.clone(),
|
||||
message: build_summary_message(&stats),
|
||||
};
|
||||
|
||||
ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
@@ -273,6 +330,7 @@ fn parse_unified_diff_files(
|
||||
let new_path = Some(stripped.trim().to_string());
|
||||
let (path, delete_after, create_flag) =
|
||||
resolve_diff_paths(old_path.as_deref(), new_path.as_deref(), create_if_missing)?;
|
||||
old_path = None;
|
||||
if let Some(file) = current.take() {
|
||||
files.push(file);
|
||||
}
|
||||
@@ -287,8 +345,13 @@ fn parse_unified_diff_files(
|
||||
|
||||
if line.starts_with("@@") {
|
||||
let Some(file) = current.as_mut() else {
|
||||
if let Some(path) = old_path.as_deref() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Patch hunk encountered after `--- {path}` but before a matching `+++` header. Each file section must include both headers."
|
||||
)));
|
||||
}
|
||||
return Err(ToolError::invalid_input(
|
||||
"Patch hunk encountered before file header",
|
||||
"Patch hunk encountered before any file header. Add `---`/`+++` headers or provide `path`.",
|
||||
));
|
||||
};
|
||||
let hunk = parse_hunk_header(line, &mut lines)?;
|
||||
@@ -345,7 +408,7 @@ where
|
||||
let parts: Vec<&str> = header.split_whitespace().collect();
|
||||
if parts.len() < 3 {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Invalid hunk header: {header}"
|
||||
"Invalid hunk header: {header}. Expected `@@ -start,count +start,count @@`."
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -408,31 +471,155 @@ where
|
||||
/// Parse a range like "10,5" or "10" into (start, count)
|
||||
fn parse_range(range: &str) -> Result<(usize, usize), ToolError> {
|
||||
let parts: Vec<&str> = range.split(',').collect();
|
||||
let start = parts[0]
|
||||
.parse::<usize>()
|
||||
.map_err(|_| ToolError::invalid_input(format!("Invalid line number: {}", parts[0])))?;
|
||||
let start = parts[0].parse::<usize>().map_err(|_| {
|
||||
ToolError::invalid_input(format!(
|
||||
"Invalid line number `{}` in hunk header. Use positive integers like `12` or `12,3`.",
|
||||
parts[0]
|
||||
))
|
||||
})?;
|
||||
let count = if parts.len() > 1 {
|
||||
parts[1]
|
||||
.parse::<usize>()
|
||||
.map_err(|_| ToolError::invalid_input(format!("Invalid count: {}", parts[1])))?
|
||||
parts[1].parse::<usize>().map_err(|_| {
|
||||
ToolError::invalid_input(format!(
|
||||
"Invalid line count `{}` in hunk header. Use positive integers like `3`.",
|
||||
parts[1]
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
1
|
||||
};
|
||||
Ok((start, count))
|
||||
}
|
||||
|
||||
fn inspect_patch_shape(patch: &str) -> PatchShape {
|
||||
let mut shape = PatchShape::default();
|
||||
let mut seen = HashSet::new();
|
||||
let mut old_path: Option<String> = None;
|
||||
|
||||
for line in patch.lines() {
|
||||
if line.starts_with("@@") {
|
||||
shape.has_hunks = true;
|
||||
}
|
||||
|
||||
if let Some(stripped) = line.strip_prefix("--- ") {
|
||||
old_path = normalize_diff_path(stripped);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(stripped) = line.strip_prefix("+++ ") {
|
||||
let new_path = normalize_diff_path(stripped);
|
||||
let resolved = new_path.or(old_path.clone());
|
||||
if let Some(path) = resolved {
|
||||
if seen.insert(path.clone()) {
|
||||
shape.header_files.push(path);
|
||||
}
|
||||
}
|
||||
old_path = None;
|
||||
}
|
||||
}
|
||||
|
||||
shape
|
||||
}
|
||||
|
||||
fn validate_patch_shape(shape: &PatchShape, path_override: Option<&str>) -> Result<(), ToolError> {
|
||||
if !shape.has_hunks {
|
||||
return Err(ToolError::invalid_input(
|
||||
"Patch must include at least one hunk header (`@@ -start,count +start,count @@`).",
|
||||
));
|
||||
}
|
||||
|
||||
match path_override {
|
||||
Some(_) if shape.file_count() > 1 => Err(ToolError::invalid_input(format!(
|
||||
"Patch references multiple files ({}) but `path` was provided. Remove `path` to apply a multi-file patch, or provide a single-file patch.",
|
||||
format_file_list(&shape.header_files),
|
||||
))),
|
||||
None if shape.file_count() == 0 => Err(ToolError::invalid_input(
|
||||
"Patch contains hunks but no file headers (`---`/`+++`). Provide `path` or add headers.",
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn diff_header_mismatch(path_override: &str, shape: &PatchShape) -> Option<String> {
|
||||
if shape.file_count() != 1 {
|
||||
return None;
|
||||
}
|
||||
let header_path = &shape.header_files[0];
|
||||
let override_norm = normalize_diff_path(path_override).unwrap_or_else(|| path_override.into());
|
||||
if &override_norm == header_path {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"Note: patch headers reference `{header_path}` but `path` overrides to `{override_norm}`."
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_summary_message(stats: &PatchStatsExt) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if stats.stats.hunks_total > 0 {
|
||||
parts.push(format!(
|
||||
"Applied {}/{} hunks across {} file(s).",
|
||||
stats.stats.hunks_applied, stats.stats.hunks_total, stats.stats.files_applied
|
||||
));
|
||||
} else {
|
||||
parts.push(format!(
|
||||
"Applied {} file change(s).",
|
||||
stats.stats.files_applied
|
||||
));
|
||||
}
|
||||
|
||||
if !stats.touched_files.is_empty() {
|
||||
parts.push(format!(
|
||||
"Files: {}.",
|
||||
format_file_list(&stats.touched_files)
|
||||
));
|
||||
}
|
||||
|
||||
if stats.stats.fuzz_used > 0 {
|
||||
parts.push(format!(
|
||||
"Fuzz used on {} hunk(s) (total fuzz: {}).",
|
||||
stats.stats.hunks_with_fuzz, stats.stats.fuzz_used
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(note) = stats.header_path_mismatch.as_deref() {
|
||||
parts.push(note.to_string());
|
||||
}
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
fn format_file_list(files: &[String]) -> String {
|
||||
if files.is_empty() {
|
||||
return "<none>".to_string();
|
||||
}
|
||||
let mut shown: Vec<String> = files.iter().take(FILE_LIST_LIMIT).cloned().collect();
|
||||
let remaining = files.len().saturating_sub(shown.len());
|
||||
if remaining > 0 {
|
||||
shown.push(format!("... (+{remaining} more)"));
|
||||
}
|
||||
shown.join(", ")
|
||||
}
|
||||
|
||||
fn push_unique(target: &mut Vec<String>, value: String) {
|
||||
if !target.iter().any(|existing| existing == &value) {
|
||||
target.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_pending_writes_from_changes(
|
||||
changes_value: &Value,
|
||||
context: &ToolContext,
|
||||
) -> Result<Vec<PendingWrite>, ToolError> {
|
||||
let changes = changes_value
|
||||
.as_array()
|
||||
.ok_or_else(|| ToolError::invalid_input("changes must be an array of {path, content}"))?;
|
||||
) -> Result<(Vec<PendingWrite>, PatchStatsExt), ToolError> {
|
||||
let changes = changes_value.as_array().ok_or_else(|| {
|
||||
ToolError::invalid_input("`changes` must be an array of objects like {path, content}")
|
||||
})?;
|
||||
if changes.is_empty() {
|
||||
return Err(ToolError::invalid_input("changes cannot be empty"));
|
||||
return Err(ToolError::invalid_input("`changes` cannot be empty"));
|
||||
}
|
||||
|
||||
let mut pending = Vec::new();
|
||||
let mut stats = PatchStatsExt::default();
|
||||
for change in changes {
|
||||
let path = change
|
||||
.get("path")
|
||||
@@ -449,30 +636,44 @@ fn build_pending_writes_from_changes(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let created = original.is_none();
|
||||
|
||||
pending.push(PendingWrite {
|
||||
path: resolved,
|
||||
content: Some(content.to_string()),
|
||||
original,
|
||||
});
|
||||
|
||||
stats.stats.files_total += 1;
|
||||
stats.stats.files_applied += 1;
|
||||
push_unique(&mut stats.touched_files, path.to_string());
|
||||
stats.file_summaries.push(FileSummary {
|
||||
path: path.to_string(),
|
||||
hunks: 0,
|
||||
hunks_applied: 0,
|
||||
fuzz_used: 0,
|
||||
hunks_with_fuzz: 0,
|
||||
created,
|
||||
deleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(pending)
|
||||
Ok((pending, stats))
|
||||
}
|
||||
|
||||
fn build_pending_writes_from_patches(
|
||||
file_patches: Vec<FilePatch>,
|
||||
context: &ToolContext,
|
||||
fuzz: usize,
|
||||
) -> Result<(Vec<PendingWrite>, PatchStats), ToolError> {
|
||||
) -> Result<(Vec<PendingWrite>, PatchStatsExt), ToolError> {
|
||||
let mut pending = Vec::new();
|
||||
let mut stats = PatchStats::default();
|
||||
stats.files_total = file_patches.len();
|
||||
let mut stats = PatchStatsExt::default();
|
||||
stats.stats.files_total = file_patches.len();
|
||||
|
||||
for file_patch in file_patches {
|
||||
if file_patch.hunks.is_empty() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Patch for {} has no hunks",
|
||||
"Patch section for `{}` has no hunks (`@@ ... @@`).",
|
||||
file_patch.path
|
||||
)));
|
||||
}
|
||||
@@ -486,15 +687,17 @@ fn build_pending_writes_from_patches(
|
||||
|
||||
if original.is_none() && !file_patch.create_if_missing {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"File {} does not exist. Set create_if_missing=true for new files.",
|
||||
resolved.display()
|
||||
"File `{}` does not exist at `{}`. Set create_if_missing=true for new files or include headers for file creation.",
|
||||
file_patch.path,
|
||||
resolved.display(),
|
||||
)));
|
||||
}
|
||||
|
||||
if file_patch.delete_after && original.is_none() {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"File {} does not exist to delete.",
|
||||
resolved.display()
|
||||
"File `{}` does not exist at `{}` to delete.",
|
||||
file_patch.path,
|
||||
resolved.display(),
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -505,11 +708,23 @@ fn build_pending_writes_from_patches(
|
||||
base_content.lines().map(String::from).collect()
|
||||
};
|
||||
|
||||
let (applied, fuzz_used) = apply_hunks_to_lines(&mut lines, &file_patch.hunks, fuzz)?;
|
||||
stats.hunks_applied += applied;
|
||||
stats.hunks_total += file_patch.hunks.len();
|
||||
stats.fuzz_used += fuzz_used;
|
||||
stats.files_applied += 1;
|
||||
let apply_stats =
|
||||
apply_hunks_to_lines(&mut lines, &file_patch.hunks, fuzz, &file_patch.path)?;
|
||||
stats.stats.hunks_applied += apply_stats.hunks_applied;
|
||||
stats.stats.hunks_total += file_patch.hunks.len();
|
||||
stats.stats.fuzz_used += apply_stats.fuzz_used;
|
||||
stats.stats.hunks_with_fuzz += apply_stats.hunks_with_fuzz;
|
||||
stats.stats.files_applied += 1;
|
||||
push_unique(&mut stats.touched_files, file_patch.path.clone());
|
||||
stats.file_summaries.push(FileSummary {
|
||||
path: file_patch.path.clone(),
|
||||
hunks: file_patch.hunks.len(),
|
||||
hunks_applied: apply_stats.hunks_applied,
|
||||
fuzz_used: apply_stats.fuzz_used,
|
||||
hunks_with_fuzz: apply_stats.hunks_with_fuzz,
|
||||
created: original.is_none() && !file_patch.delete_after,
|
||||
deleted: file_patch.delete_after,
|
||||
});
|
||||
|
||||
if file_patch.delete_after {
|
||||
pending.push(PendingWrite {
|
||||
@@ -593,31 +808,98 @@ fn read_file_content(path: &PathBuf) -> Result<String, ToolError> {
|
||||
})
|
||||
}
|
||||
|
||||
fn preview_expected_lines(hunk: &Hunk, limit: usize) -> Vec<String> {
|
||||
let mut preview = Vec::new();
|
||||
for line in hunk.lines.iter().filter_map(|line| match line {
|
||||
HunkLine::Context(s) => Some((" ", s)),
|
||||
HunkLine::Remove(s) => Some(("-", s)),
|
||||
HunkLine::Add(_) => None,
|
||||
}) {
|
||||
if preview.len() >= limit {
|
||||
break;
|
||||
}
|
||||
preview.push(format!(" {}{}", line.0, line.1));
|
||||
}
|
||||
if preview.is_empty() {
|
||||
preview.push(" <no context lines in hunk>".to_string());
|
||||
}
|
||||
preview
|
||||
}
|
||||
|
||||
fn snippet_around(lines: &[String], line_1_based: usize, radius: usize) -> Vec<String> {
|
||||
if lines.is_empty() {
|
||||
return vec![" <empty file>".to_string()];
|
||||
}
|
||||
|
||||
let center = line_1_based
|
||||
.saturating_sub(1)
|
||||
.min(lines.len().saturating_sub(1));
|
||||
let start = center.saturating_sub(radius);
|
||||
let end = (center + radius).min(lines.len().saturating_sub(1));
|
||||
|
||||
lines[start..=end]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, line)| {
|
||||
let line_no = start + idx + 1;
|
||||
format!(" {line_no:>4}: {line}")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn format_hunk_no_match_error(
|
||||
lines: &[String],
|
||||
hunk: &Hunk,
|
||||
err: &ApplyHunkError,
|
||||
max_fuzz: usize,
|
||||
) -> String {
|
||||
match err {
|
||||
ApplyHunkError::NoMatch {
|
||||
expected_line,
|
||||
adjusted_line,
|
||||
offset,
|
||||
} => {
|
||||
let expected_preview = preview_expected_lines(hunk, HUNK_PREVIEW_LINES).join("\n");
|
||||
let file_preview = snippet_around(lines, *adjusted_line, SNIPPET_RADIUS).join("\n");
|
||||
format!(
|
||||
"could not find matching context near line {expected_line} (searched around line {adjusted_line} with offset {offset:+} and fuzz up to {max_fuzz}). Expected context preview:\n{expected_preview}\nFile snippet near line {adjusted_line}:\n{file_preview}\nHints: ensure the patch matches the current file contents, increase `fuzz`, or regenerate the patch."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_hunks_to_lines(
|
||||
lines: &mut Vec<String>,
|
||||
hunks: &[Hunk],
|
||||
fuzz: usize,
|
||||
) -> Result<(usize, usize), ToolError> {
|
||||
let mut total_fuzz = 0;
|
||||
let mut hunks_applied = 0;
|
||||
file_label: &str,
|
||||
) -> Result<HunkApplyStats, ToolError> {
|
||||
let mut stats = HunkApplyStats::default();
|
||||
let mut cumulative_offset: isize = 0;
|
||||
|
||||
for hunk in hunks {
|
||||
for (idx, hunk) in hunks.iter().enumerate() {
|
||||
match apply_hunk(lines, hunk, fuzz, &mut cumulative_offset) {
|
||||
Ok(fuzz_used) => {
|
||||
total_fuzz += fuzz_used;
|
||||
hunks_applied += 1;
|
||||
stats.fuzz_used += fuzz_used;
|
||||
stats.hunks_applied += 1;
|
||||
if fuzz_used > 0 {
|
||||
stats.hunks_with_fuzz += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let detail = format_hunk_no_match_error(lines, hunk, &e, fuzz);
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"Failed to apply hunk at line {}: {}",
|
||||
hunk.old_start, e
|
||||
"Failed to apply hunk {}/{} for `{}`: {}",
|
||||
idx + 1,
|
||||
hunks.len(),
|
||||
file_label,
|
||||
detail
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((hunks_applied, total_fuzz))
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// Apply a hunk to the file content with fuzzy matching
|
||||
@@ -721,6 +1003,10 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn parse_patch_result(result: ToolResult) -> PatchResult {
|
||||
serde_json::from_str(&result.content).expect("patch result json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_range() {
|
||||
assert_eq!(parse_range("10,5").unwrap(), (10, 5));
|
||||
@@ -851,6 +1137,9 @@ mod tests {
|
||||
.expect("execute");
|
||||
|
||||
assert!(result.success);
|
||||
let patch_result = parse_patch_result(result);
|
||||
assert_eq!(patch_result.touched_files, vec!["test.txt"]);
|
||||
assert_eq!(patch_result.hunks_applied, 1);
|
||||
|
||||
// Verify the patch was applied
|
||||
let content = fs::read_to_string(tmp.path().join("test.txt")).expect("read");
|
||||
@@ -878,6 +1167,8 @@ mod tests {
|
||||
.expect("execute");
|
||||
|
||||
assert!(result.success);
|
||||
let patch_result = parse_patch_result(result);
|
||||
assert_eq!(patch_result.touched_files, vec!["test.txt"]);
|
||||
|
||||
let content = fs::read_to_string(tmp.path().join("test.txt")).expect("read");
|
||||
assert!(content.contains("line2"));
|
||||
@@ -904,6 +1195,9 @@ mod tests {
|
||||
.expect("execute");
|
||||
|
||||
assert!(result.success);
|
||||
let patch_result = parse_patch_result(result);
|
||||
assert_eq!(patch_result.touched_files, vec!["new_file.txt"]);
|
||||
assert!(patch_result.file_summaries.first().unwrap().created);
|
||||
assert!(tmp.path().join("new_file.txt").exists());
|
||||
}
|
||||
|
||||
@@ -929,6 +1223,11 @@ mod tests {
|
||||
.expect("execute");
|
||||
|
||||
assert!(result.success);
|
||||
let patch_result = parse_patch_result(result);
|
||||
let mut touched = patch_result.touched_files.clone();
|
||||
touched.sort();
|
||||
assert_eq!(touched, vec!["one.txt", "two.txt"]);
|
||||
assert_eq!(patch_result.hunks_total, 0);
|
||||
assert_eq!(
|
||||
fs::read_to_string(tmp.path().join("one.txt")).unwrap(),
|
||||
"new\n"
|
||||
@@ -970,12 +1269,134 @@ diff --git a/b.txt b/b.txt
|
||||
.expect("execute");
|
||||
|
||||
assert!(result.success);
|
||||
let patch_result = parse_patch_result(result);
|
||||
let mut touched = patch_result.touched_files.clone();
|
||||
touched.sort();
|
||||
assert_eq!(touched, vec!["a.txt", "b.txt"]);
|
||||
assert_eq!(patch_result.files_applied, 2);
|
||||
let a = fs::read_to_string(tmp.path().join("a.txt")).unwrap();
|
||||
let b = fs::read_to_string(tmp.path().join("b.txt")).unwrap();
|
||||
assert!(a.contains("line2-mod"));
|
||||
assert!(b.contains("beta2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_patch_requires_headers_without_path() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path().to_path_buf());
|
||||
let tool = ApplyPatchTool;
|
||||
|
||||
let patch = r"@@ -1,1 +1,1 @@
|
||||
-old
|
||||
+new
|
||||
";
|
||||
|
||||
let err = tool
|
||||
.execute(json!({"patch": patch}), &ctx)
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
ToolError::InvalidInput { message } => {
|
||||
assert!(message.contains("no file headers"));
|
||||
assert!(message.contains("Provide `path`"));
|
||||
}
|
||||
other => panic!("expected invalid input, got: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_path_override_rejects_multi_file_diff() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path().to_path_buf());
|
||||
let tool = ApplyPatchTool;
|
||||
|
||||
let patch = r"diff --git a/a.txt b/a.txt
|
||||
--- a/a.txt
|
||||
+++ b/a.txt
|
||||
@@ -1,1 +1,1 @@
|
||||
-one
|
||||
+one-mod
|
||||
diff --git a/b.txt b/b.txt
|
||||
--- a/b.txt
|
||||
+++ b/b.txt
|
||||
@@ -1,1 +1,1 @@
|
||||
-two
|
||||
+two-mod
|
||||
";
|
||||
|
||||
let err = tool
|
||||
.execute(json!({"path": "a.txt", "patch": patch}), &ctx)
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
ToolError::InvalidInput { message } => {
|
||||
assert!(message.contains("multiple files"));
|
||||
assert!(message.contains("a.txt"));
|
||||
assert!(message.contains("b.txt"));
|
||||
}
|
||||
other => panic!("expected invalid input, got: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_patch_summary_reports_fuzz() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path().to_path_buf());
|
||||
let tool = ApplyPatchTool;
|
||||
|
||||
fs::write(tmp.path().join("test.txt"), "line0\nline1\nline2\nline3\n").expect("write");
|
||||
|
||||
let patch = r"@@ -1,2 +1,2 @@
|
||||
-line1
|
||||
+modified
|
||||
line2
|
||||
";
|
||||
|
||||
let result = tool
|
||||
.execute(json!({"path": "test.txt", "patch": patch, "fuzz": 3}), &ctx)
|
||||
.await
|
||||
.expect("execute");
|
||||
assert!(result.success);
|
||||
let patch_result = parse_patch_result(result);
|
||||
assert_eq!(patch_result.hunks_with_fuzz, 1);
|
||||
assert!(patch_result.fuzz_used > 0);
|
||||
assert!(patch_result.message.contains("Fuzz used"));
|
||||
let summary = patch_result.file_summaries.first().unwrap();
|
||||
assert_eq!(summary.hunks_with_fuzz, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_path_override_header_mismatch_note() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path().to_path_buf());
|
||||
let tool = ApplyPatchTool;
|
||||
|
||||
fs::write(tmp.path().join("override.txt"), "old\n").expect("write");
|
||||
|
||||
let patch = r"--- a/other.txt
|
||||
+++ b/other.txt
|
||||
@@ -1,1 +1,1 @@
|
||||
-old
|
||||
+new
|
||||
";
|
||||
|
||||
let result = tool
|
||||
.execute(json!({"path": "override.txt", "patch": patch}), &ctx)
|
||||
.await
|
||||
.expect("execute");
|
||||
let patch_result = parse_patch_result(result);
|
||||
assert!(
|
||||
patch_result
|
||||
.message
|
||||
.contains("headers reference `other.txt`")
|
||||
);
|
||||
assert!(
|
||||
patch_result
|
||||
.message
|
||||
.contains("path` overrides to `override.txt`")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_tool_properties() {
|
||||
let tool = ApplyPatchTool;
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Workspace diagnostics tool: `diagnostics`.
|
||||
//!
|
||||
//! This tool gathers lightweight, best-effort environment information without
|
||||
//! failing hard when optional commands are unavailable.
|
||||
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
};
|
||||
|
||||
/// Tool for collecting workspace and toolchain diagnostics.
|
||||
pub struct DiagnosticsTool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct DiagnosticsOutput {
|
||||
workspace_root: String,
|
||||
current_dir: Option<String>,
|
||||
current_dir_error: Option<String>,
|
||||
git_repo: bool,
|
||||
git_branch: Option<String>,
|
||||
git_error: Option<String>,
|
||||
sandbox_available: bool,
|
||||
sandbox_type: Option<String>,
|
||||
rustc_version: Option<String>,
|
||||
cargo_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct GitProbe {
|
||||
detected: bool,
|
||||
branch: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for DiagnosticsTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"diagnostics"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Report workspace info, git detection, sandbox availability, and Rust toolchain versions."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly]
|
||||
}
|
||||
|
||||
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 workspace_root = context.workspace.display().to_string();
|
||||
|
||||
let (current_dir, current_dir_error) = match env::current_dir() {
|
||||
Ok(dir) => (Some(dir.display().to_string()), None),
|
||||
Err(err) => (None, Some(err.to_string())),
|
||||
};
|
||||
|
||||
let git = probe_git(&context.workspace);
|
||||
let sandbox_type = crate::sandbox::get_platform_sandbox().map(|s| s.to_string());
|
||||
let sandbox_available = sandbox_type.is_some();
|
||||
|
||||
let diagnostics = DiagnosticsOutput {
|
||||
workspace_root,
|
||||
current_dir,
|
||||
current_dir_error,
|
||||
git_repo: git.detected,
|
||||
git_branch: git.branch,
|
||||
git_error: git.error,
|
||||
sandbox_available,
|
||||
sandbox_type,
|
||||
rustc_version: probe_version("rustc", &["--version"], &context.workspace),
|
||||
cargo_version: probe_version("cargo", &["--version"], &context.workspace),
|
||||
};
|
||||
|
||||
ToolResult::json(&diagnostics).map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
fn probe_git(workspace: &Path) -> GitProbe {
|
||||
let rev_parse = run_command("git", &["rev-parse", "--is-inside-work-tree"], workspace);
|
||||
match rev_parse {
|
||||
CommandProbe::Success(out) => {
|
||||
if out.trim() != "true" {
|
||||
return GitProbe {
|
||||
detected: false,
|
||||
branch: None,
|
||||
error: Some(format!("unexpected git rev-parse output: {out}")),
|
||||
};
|
||||
}
|
||||
let branch = run_command("git", &["rev-parse", "--abbrev-ref", "HEAD"], workspace)
|
||||
.into_success();
|
||||
GitProbe {
|
||||
detected: true,
|
||||
branch,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
CommandProbe::Failed { stderr, .. } => GitProbe {
|
||||
detected: false,
|
||||
branch: None,
|
||||
error: stderr,
|
||||
},
|
||||
CommandProbe::Missing => GitProbe {
|
||||
detected: false,
|
||||
branch: None,
|
||||
error: Some("git is not installed or not in PATH".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_version(program: &str, args: &[&str], cwd: &Path) -> Option<String> {
|
||||
run_command(program, args, cwd).into_success()
|
||||
}
|
||||
|
||||
enum CommandProbe {
|
||||
Success(String),
|
||||
Failed { stderr: Option<String> },
|
||||
Missing,
|
||||
}
|
||||
|
||||
impl CommandProbe {
|
||||
fn into_success(self) -> Option<String> {
|
||||
match self {
|
||||
CommandProbe::Success(out) => Some(out),
|
||||
CommandProbe::Failed { .. } | CommandProbe::Missing => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_command(program: &str, args: &[&str], cwd: &Path) -> CommandProbe {
|
||||
let output = Command::new(program).args(args).current_dir(cwd).output();
|
||||
let output = match output {
|
||||
Ok(output) => output,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return CommandProbe::Missing,
|
||||
Err(_) => return CommandProbe::Failed { stderr: None },
|
||||
};
|
||||
|
||||
if output.status.success() {
|
||||
CommandProbe::Success(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
CommandProbe::Failed {
|
||||
stderr: if stderr.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(stderr)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn git_available() -> bool {
|
||||
Command::new("git")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn init_git_repo(root: &Path) {
|
||||
let run = |args: &[&str]| {
|
||||
let status = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(root)
|
||||
.status()
|
||||
.expect("git should spawn");
|
||||
assert!(status.success(), "git {:?} failed", args);
|
||||
};
|
||||
run(&["init", "-q"]);
|
||||
run(&["config", "user.email", "[email protected]"]);
|
||||
run(&["config", "user.name", "Test User"]);
|
||||
fs::write(root.join("README.md"), "init\n").expect("write");
|
||||
run(&["add", "."]);
|
||||
run(&["commit", "-q", "-m", "init"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_runs_best_effort_outside_git_repo() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path());
|
||||
let tool = DiagnosticsTool;
|
||||
let result = tool.execute(json!({}), &ctx).await.expect("execute");
|
||||
assert!(result.success);
|
||||
|
||||
let parsed: DiagnosticsOutput =
|
||||
serde_json::from_str(&result.content).expect("tool result should be json");
|
||||
assert_eq!(parsed.workspace_root, tmp.path().display().to_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_detects_git_repo_when_available() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
init_git_repo(tmp.path());
|
||||
|
||||
let ctx = ToolContext::new(tmp.path());
|
||||
let tool = DiagnosticsTool;
|
||||
let result = tool.execute(json!({}), &ctx).await.expect("execute");
|
||||
assert!(result.success);
|
||||
|
||||
let parsed: DiagnosticsOutput =
|
||||
serde_json::from_str(&result.content).expect("tool result should be json");
|
||||
assert!(parsed.git_repo);
|
||||
assert!(!parsed.git_branch.as_deref().unwrap_or("").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
//! Git power tools: `git_status` and `git_diff`.
|
||||
//!
|
||||
//! These tools are read-only wrappers around common git inspection commands,
|
||||
//! scoped to the workspace and optionally to a sub-path within it.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
optional_bool, optional_str, optional_u64,
|
||||
};
|
||||
|
||||
const MAX_OUTPUT_CHARS: usize = 40_000;
|
||||
const DEFAULT_UNIFIED: u64 = 3;
|
||||
const MAX_UNIFIED: u64 = 50;
|
||||
|
||||
// === GitStatusTool ===
|
||||
|
||||
/// Tool for reading the concise git status of the workspace.
|
||||
pub struct GitStatusTool;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for GitStatusTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"git_status"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Run `git status --porcelain=v1 -b` in the workspace (optionally scoped to a path)."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Optional subdirectory or file to scope the status to (must be within the workspace)."
|
||||
}
|
||||
},
|
||||
"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 git_ctx = resolve_git_context(context, optional_str(&input, "path"))?;
|
||||
|
||||
let mut args = vec![
|
||||
"status".to_string(),
|
||||
"--porcelain=v1".to_string(),
|
||||
"-b".to_string(),
|
||||
];
|
||||
if let Some(pathspec) = &git_ctx.pathspec {
|
||||
args.push("--".to_string());
|
||||
args.push(pathspec.display().to_string());
|
||||
}
|
||||
|
||||
let command_str = format_command(&git_ctx.working_dir, &args);
|
||||
let output = run_git_command(&git_ctx.working_dir, &args)?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let message = format!("git status failed: {}", stderr.trim());
|
||||
return Ok(ToolResult::error(message).with_metadata(json!({
|
||||
"command": command_str,
|
||||
"exit_code": output.status.code(),
|
||||
"stderr": stderr.trim(),
|
||||
})));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
|
||||
|
||||
Ok(ToolResult::success(content).with_metadata(json!({
|
||||
"command": command_str,
|
||||
"working_dir": git_ctx.working_dir,
|
||||
"pathspec": git_ctx.pathspec,
|
||||
"truncated": truncated,
|
||||
"omitted_chars": omitted_chars,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
// === GitDiffTool ===
|
||||
|
||||
/// Tool for reading git diffs in the workspace.
|
||||
pub struct GitDiffTool;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for GitDiffTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"git_diff"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Run `git diff` in the workspace with sensible defaults and safe truncation."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Optional subdirectory or file to scope the diff to (must be within the workspace)."
|
||||
},
|
||||
"cached": {
|
||||
"type": "boolean",
|
||||
"description": "When true, diff staged changes (`--cached`)."
|
||||
},
|
||||
"unified": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": MAX_UNIFIED,
|
||||
"default": DEFAULT_UNIFIED,
|
||||
"description": "Number of context lines to include around changes."
|
||||
}
|
||||
},
|
||||
"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 git_ctx = resolve_git_context(context, optional_str(&input, "path"))?;
|
||||
let cached = optional_bool(&input, "cached", false);
|
||||
let unified = optional_u64(&input, "unified", DEFAULT_UNIFIED).min(MAX_UNIFIED);
|
||||
|
||||
let mut args = vec![
|
||||
"diff".to_string(),
|
||||
"--no-color".to_string(),
|
||||
"--no-ext-diff".to_string(),
|
||||
format!("--unified={unified}"),
|
||||
];
|
||||
if cached {
|
||||
args.push("--cached".to_string());
|
||||
}
|
||||
if let Some(pathspec) = &git_ctx.pathspec {
|
||||
args.push("--".to_string());
|
||||
args.push(pathspec.display().to_string());
|
||||
}
|
||||
|
||||
let command_str = format_command(&git_ctx.working_dir, &args);
|
||||
let output = run_git_command(&git_ctx.working_dir, &args)?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let message = format!("git diff failed: {}", stderr.trim());
|
||||
return Ok(ToolResult::error(message).with_metadata(json!({
|
||||
"command": command_str,
|
||||
"exit_code": output.status.code(),
|
||||
"stderr": stderr.trim(),
|
||||
})));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
|
||||
|
||||
Ok(ToolResult::success(content).with_metadata(json!({
|
||||
"command": command_str,
|
||||
"working_dir": git_ctx.working_dir,
|
||||
"pathspec": git_ctx.pathspec,
|
||||
"cached": cached,
|
||||
"unified": unified,
|
||||
"truncated": truncated,
|
||||
"omitted_chars": omitted_chars,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
struct GitContext {
|
||||
working_dir: PathBuf,
|
||||
pathspec: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn resolve_git_context(context: &ToolContext, path: Option<&str>) -> Result<GitContext, ToolError> {
|
||||
let workspace = canonical_or_workspace(&context.workspace);
|
||||
let mut working_dir = workspace.clone();
|
||||
let mut pathspec = None;
|
||||
|
||||
if let Some(raw) = path {
|
||||
let resolved = context.resolve_path(raw)?;
|
||||
let metadata = fs::metadata(&resolved).map_err(|e| {
|
||||
ToolError::invalid_input(format!(
|
||||
"Path does not exist or is not accessible: {raw} ({e})"
|
||||
))
|
||||
})?;
|
||||
|
||||
if metadata.is_dir() {
|
||||
working_dir = resolved;
|
||||
pathspec = Some(PathBuf::from("."));
|
||||
} else {
|
||||
// For file paths, run from the parent and scope to the file name.
|
||||
let parent = resolved.parent().ok_or_else(|| {
|
||||
ToolError::invalid_input(format!("Path has no parent directory: {raw}"))
|
||||
})?;
|
||||
working_dir = parent.to_path_buf();
|
||||
pathspec = Some(pathspec_from(&working_dir, &resolved));
|
||||
}
|
||||
}
|
||||
|
||||
if !working_dir.exists() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Working directory does not exist: {}",
|
||||
working_dir.display()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(GitContext {
|
||||
working_dir,
|
||||
pathspec,
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_or_workspace(workspace: &Path) -> PathBuf {
|
||||
workspace
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace.to_path_buf())
|
||||
}
|
||||
|
||||
fn pathspec_from(working_dir: &Path, resolved: &Path) -> PathBuf {
|
||||
match resolved.strip_prefix(working_dir) {
|
||||
Ok(rel) if rel.as_os_str().is_empty() => PathBuf::from("."),
|
||||
Ok(rel) => rel.to_path_buf(),
|
||||
Err(_) => PathBuf::from("."),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_git_command(working_dir: &Path, args: &[String]) -> Result<std::process::Output, ToolError> {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.args(args).current_dir(working_dir);
|
||||
cmd.output().map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
ToolError::not_available("git is not installed or not in PATH")
|
||||
} else {
|
||||
ToolError::execution_failed(format!("Failed to run git: {e}"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn format_command(working_dir: &Path, args: &[String]) -> String {
|
||||
format!(
|
||||
"git -C {} {}",
|
||||
working_dir.display(),
|
||||
args.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate_with_note(text: &str, max_chars: usize) -> (String, bool, usize) {
|
||||
if text.chars().count() <= max_chars {
|
||||
return (text.to_string(), false, 0);
|
||||
}
|
||||
let end = char_boundary_index(text, max_chars);
|
||||
let truncated = &text[..end];
|
||||
let omitted_chars = text
|
||||
.chars()
|
||||
.count()
|
||||
.saturating_sub(truncated.chars().count());
|
||||
let note = format!(
|
||||
"\n\n[output truncated to {max_chars} characters; {omitted_chars} characters omitted]"
|
||||
);
|
||||
(format!("{truncated}{note}"), true, omitted_chars)
|
||||
}
|
||||
|
||||
fn char_boundary_index(text: &str, max_chars: usize) -> usize {
|
||||
if max_chars == 0 {
|
||||
return 0;
|
||||
}
|
||||
for (count, (idx, _)) in text.char_indices().enumerate() {
|
||||
if count == max_chars {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
text.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn git_available() -> bool {
|
||||
Command::new("git")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn init_git_repo(root: &Path) {
|
||||
let run = |args: &[&str]| {
|
||||
let status = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(root)
|
||||
.status()
|
||||
.expect("git should spawn");
|
||||
assert!(status.success(), "git {:?} failed", args);
|
||||
};
|
||||
|
||||
run(&["init", "-q"]);
|
||||
run(&["config", "user.email", "[email protected]"]);
|
||||
run(&["config", "user.name", "Test User"]);
|
||||
}
|
||||
|
||||
fn commit_all(root: &Path, message: &str) {
|
||||
let run = |args: &[&str]| {
|
||||
let status = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(root)
|
||||
.status()
|
||||
.expect("git should spawn");
|
||||
assert!(status.success(), "git {:?} failed", args);
|
||||
};
|
||||
run(&["add", "."]);
|
||||
run(&["commit", "-q", "-m", message]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_status_reports_branch_and_changes() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
init_git_repo(tmp.path());
|
||||
|
||||
let file = tmp.path().join("file.txt");
|
||||
fs::write(&file, "hello\n").expect("write");
|
||||
commit_all(tmp.path(), "init");
|
||||
|
||||
fs::write(&file, "hello\nworld\n").expect("modify");
|
||||
|
||||
let ctx = ToolContext::new(tmp.path());
|
||||
let tool = GitStatusTool;
|
||||
let result = tool.execute(json!({}), &ctx).await.expect("execute");
|
||||
assert!(result.success);
|
||||
assert!(result.content.contains("##"));
|
||||
assert!(result.content.contains("file.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_diff_supports_cached_and_path_scoping() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
init_git_repo(tmp.path());
|
||||
|
||||
let subdir = tmp.path().join("src");
|
||||
fs::create_dir_all(&subdir).expect("mkdir");
|
||||
let file = subdir.join("lib.rs");
|
||||
fs::write(&file, "pub fn one() -> i32 { 1 }\n").expect("write");
|
||||
commit_all(tmp.path(), "init");
|
||||
|
||||
fs::write(&file, "pub fn one() -> i32 { 2 }\n").expect("modify");
|
||||
|
||||
let ctx = ToolContext::new(tmp.path());
|
||||
let tool = GitDiffTool;
|
||||
|
||||
let uncached = tool
|
||||
.execute(json!({ "path": "src" }), &ctx)
|
||||
.await
|
||||
.expect("diff");
|
||||
assert!(uncached.success);
|
||||
assert!(uncached.content.contains("diff --git"));
|
||||
assert!(uncached.content.contains("lib.rs"));
|
||||
|
||||
let _ = Command::new("git")
|
||||
.args(["add", "src/lib.rs"])
|
||||
.current_dir(tmp.path())
|
||||
.status()
|
||||
.expect("git add");
|
||||
|
||||
let cached = tool
|
||||
.execute(json!({ "path": "src", "cached": true }), &ctx)
|
||||
.await
|
||||
.expect("diff cached");
|
||||
assert!(cached.success);
|
||||
assert!(cached.content.contains("diff --git"));
|
||||
assert!(
|
||||
cached
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("cached"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_adds_note() {
|
||||
let long = "a".repeat(MAX_OUTPUT_CHARS + 100);
|
||||
let (truncated, did_truncate, omitted) = truncate_with_note(&long, MAX_OUTPUT_CHARS);
|
||||
assert!(did_truncate);
|
||||
assert!(omitted > 0);
|
||||
assert!(truncated.contains("output truncated"));
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@
|
||||
// === Modules ===
|
||||
|
||||
pub mod apply_patch;
|
||||
pub mod diagnostics;
|
||||
pub mod duo;
|
||||
pub mod file;
|
||||
pub mod file_search;
|
||||
pub mod git;
|
||||
pub mod plan;
|
||||
pub mod registry;
|
||||
pub mod review;
|
||||
@@ -16,6 +18,8 @@ pub mod search;
|
||||
pub mod shell;
|
||||
pub mod spec;
|
||||
pub mod subagent;
|
||||
pub mod swarm;
|
||||
pub mod test_runner;
|
||||
pub mod todo;
|
||||
pub mod web_search;
|
||||
|
||||
@@ -43,12 +47,21 @@ pub use review::{ReviewOutput, ReviewTool};
|
||||
// Re-export file tools
|
||||
pub use file::{EditFileTool, ListDirTool, ReadFileTool, WriteFileTool};
|
||||
|
||||
// Re-export diagnostics tool
|
||||
pub use diagnostics::DiagnosticsTool;
|
||||
|
||||
// Re-export git tools
|
||||
pub use git::{GitDiffTool, GitStatusTool};
|
||||
|
||||
// Re-export shell types
|
||||
pub use shell::ExecShellTool;
|
||||
|
||||
// Re-export subagent types
|
||||
pub use subagent::SubAgent;
|
||||
|
||||
// Re-export test runner tool
|
||||
pub use test_runner::RunTestsTool;
|
||||
|
||||
// Re-export todo types
|
||||
pub use todo::TodoWriteTool;
|
||||
|
||||
|
||||
+3
-5
@@ -1,7 +1,8 @@
|
||||
//! Plan tool implementation with step tracking and validation
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -388,10 +389,7 @@ impl ToolSpec for UpdatePlanTool {
|
||||
plan: plan_args,
|
||||
};
|
||||
|
||||
let mut state = self
|
||||
.plan_state
|
||||
.lock()
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to lock plan state: {e}")))?;
|
||||
let mut state = self.plan_state.lock().await;
|
||||
|
||||
state.update(args);
|
||||
|
||||
|
||||
+35
-5
@@ -293,6 +293,28 @@ impl ToolRegistryBuilder {
|
||||
.with_tool(Arc::new(FileSearchTool))
|
||||
}
|
||||
|
||||
/// Include git inspection tools (`git_status`, `git_diff`).
|
||||
#[must_use]
|
||||
pub fn with_git_tools(self) -> Self {
|
||||
use super::git::{GitDiffTool, GitStatusTool};
|
||||
self.with_tool(Arc::new(GitStatusTool))
|
||||
.with_tool(Arc::new(GitDiffTool))
|
||||
}
|
||||
|
||||
/// Include workspace diagnostics tool.
|
||||
#[must_use]
|
||||
pub fn with_diagnostics_tool(self) -> Self {
|
||||
use super::diagnostics::DiagnosticsTool;
|
||||
self.with_tool(Arc::new(DiagnosticsTool))
|
||||
}
|
||||
|
||||
/// Include cargo test runner tool.
|
||||
#[must_use]
|
||||
pub fn with_test_runner_tool(self) -> Self {
|
||||
use super::test_runner::RunTestsTool;
|
||||
self.with_tool(Arc::new(RunTestsTool))
|
||||
}
|
||||
|
||||
/// Include web search tools.
|
||||
#[must_use]
|
||||
pub fn with_web_tools(self) -> Self {
|
||||
@@ -329,7 +351,10 @@ impl ToolRegistryBuilder {
|
||||
.with_note_tool()
|
||||
.with_search_tools()
|
||||
.with_web_tools()
|
||||
.with_patch_tools();
|
||||
.with_patch_tools()
|
||||
.with_git_tools()
|
||||
.with_diagnostics_tool()
|
||||
.with_test_runner_tool();
|
||||
|
||||
if allow_shell {
|
||||
builder.with_shell_tools()
|
||||
@@ -403,11 +428,16 @@ impl ToolRegistryBuilder {
|
||||
runtime: super::subagent::SubAgentRuntime,
|
||||
) -> Self {
|
||||
use super::subagent::{AgentCancelTool, AgentListTool, AgentResultTool, AgentSpawnTool};
|
||||
use super::swarm::AgentSwarmTool;
|
||||
|
||||
self.with_tool(Arc::new(AgentSpawnTool::new(manager.clone(), runtime)))
|
||||
.with_tool(Arc::new(AgentResultTool::new(manager.clone())))
|
||||
.with_tool(Arc::new(AgentCancelTool::new(manager.clone())))
|
||||
.with_tool(Arc::new(AgentListTool::new(manager)))
|
||||
self.with_tool(Arc::new(AgentSpawnTool::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(AgentCancelTool::new(manager.clone())))
|
||||
.with_tool(Arc::new(AgentListTool::new(manager)))
|
||||
}
|
||||
|
||||
/// Build the registry with the given context.
|
||||
|
||||
+227
-18
@@ -29,6 +29,9 @@ use crate::sandbox::{
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Status of a shell process
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -49,6 +52,24 @@ pub struct ShellResult {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub duration_ms: u64,
|
||||
/// Original stdout length in bytes.
|
||||
#[serde(default)]
|
||||
pub stdout_len: usize,
|
||||
/// Original stderr length in bytes.
|
||||
#[serde(default)]
|
||||
pub stderr_len: usize,
|
||||
/// Bytes omitted from stdout due to truncation.
|
||||
#[serde(default)]
|
||||
pub stdout_omitted: usize,
|
||||
/// Bytes omitted from stderr due to truncation.
|
||||
#[serde(default)]
|
||||
pub stderr_omitted: usize,
|
||||
/// Whether stdout was truncated.
|
||||
#[serde(default)]
|
||||
pub stdout_truncated: bool,
|
||||
/// Whether stderr was truncated.
|
||||
#[serde(default)]
|
||||
pub stderr_truncated: bool,
|
||||
/// Whether the command was executed in a sandbox.
|
||||
#[serde(default)]
|
||||
pub sandboxed: bool,
|
||||
@@ -134,13 +155,21 @@ impl BackgroundShell {
|
||||
/// Get a snapshot of the current state
|
||||
pub fn snapshot(&self) -> ShellResult {
|
||||
let sandboxed = !matches!(self.sandbox_type, SandboxType::None);
|
||||
let (stdout, stdout_meta) = truncate_with_meta(&self.stdout);
|
||||
let (stderr, stderr_meta) = truncate_with_meta(&self.stderr);
|
||||
ShellResult {
|
||||
task_id: Some(self.id.clone()),
|
||||
status: self.status.clone(),
|
||||
exit_code: self.exit_code,
|
||||
stdout: truncate_output(&self.stdout),
|
||||
stderr: truncate_output(&self.stderr),
|
||||
stdout,
|
||||
stderr,
|
||||
duration_ms: u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
stdout_len: stdout_meta.original_len,
|
||||
stderr_len: stderr_meta.original_len,
|
||||
stdout_omitted: stdout_meta.omitted,
|
||||
stderr_omitted: stderr_meta.omitted,
|
||||
stdout_truncated: stdout_meta.truncated,
|
||||
stderr_truncated: stderr_meta.truncated,
|
||||
sandboxed,
|
||||
sandbox_type: if sandboxed {
|
||||
Some(self.sandbox_type.to_string())
|
||||
@@ -319,11 +348,14 @@ impl ShellManager {
|
||||
if let Some(status) = child.wait_timeout(timeout)? {
|
||||
let stdout = stdout_thread.join().unwrap_or_default();
|
||||
let stderr = stderr_thread.join().unwrap_or_default();
|
||||
let stderr_str = String::from_utf8_lossy(&stderr);
|
||||
let stdout_str = String::from_utf8_lossy(&stdout).to_string();
|
||||
let stderr_str = String::from_utf8_lossy(&stderr).to_string();
|
||||
let exit_code = status.code().unwrap_or(-1);
|
||||
|
||||
// Check if sandbox denied the operation
|
||||
let sandbox_denied = SandboxManager::was_denied(sandbox_type, exit_code, &stderr_str);
|
||||
let (stdout, stdout_meta) = truncate_with_meta(&stdout_str);
|
||||
let (stderr, stderr_meta) = truncate_with_meta(&stderr_str);
|
||||
|
||||
Ok(ShellResult {
|
||||
task_id: None,
|
||||
@@ -333,9 +365,15 @@ impl ShellManager {
|
||||
ShellStatus::Failed
|
||||
},
|
||||
exit_code: status.code(),
|
||||
stdout: truncate_output(&String::from_utf8_lossy(&stdout)),
|
||||
stderr: truncate_output(&stderr_str),
|
||||
stdout,
|
||||
stderr,
|
||||
duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
stdout_len: stdout_meta.original_len,
|
||||
stderr_len: stderr_meta.original_len,
|
||||
stdout_omitted: stdout_meta.omitted,
|
||||
stderr_omitted: stderr_meta.omitted,
|
||||
stdout_truncated: stdout_meta.truncated,
|
||||
stderr_truncated: stderr_meta.truncated,
|
||||
sandboxed,
|
||||
sandbox_type: if sandboxed {
|
||||
Some(sandbox_type.to_string())
|
||||
@@ -350,14 +388,24 @@ impl ShellManager {
|
||||
let status = child.wait().ok();
|
||||
let stdout = stdout_thread.join().unwrap_or_default();
|
||||
let stderr = stderr_thread.join().unwrap_or_default();
|
||||
let stdout_str = String::from_utf8_lossy(&stdout).to_string();
|
||||
let stderr_str = String::from_utf8_lossy(&stderr).to_string();
|
||||
let (stdout, stdout_meta) = truncate_with_meta(&stdout_str);
|
||||
let (stderr, stderr_meta) = truncate_with_meta(&stderr_str);
|
||||
|
||||
Ok(ShellResult {
|
||||
task_id: None,
|
||||
status: ShellStatus::TimedOut,
|
||||
exit_code: status.and_then(|s| s.code()),
|
||||
stdout: truncate_output(&String::from_utf8_lossy(&stdout)),
|
||||
stderr: truncate_output(&String::from_utf8_lossy(&stderr)),
|
||||
stdout,
|
||||
stderr,
|
||||
duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
stdout_len: stdout_meta.original_len,
|
||||
stderr_len: stderr_meta.original_len,
|
||||
stdout_omitted: stdout_meta.omitted,
|
||||
stderr_omitted: stderr_meta.omitted,
|
||||
stdout_truncated: stdout_meta.truncated,
|
||||
stderr_truncated: stderr_meta.truncated,
|
||||
sandboxed,
|
||||
sandbox_type: if sandboxed {
|
||||
Some(sandbox_type.to_string())
|
||||
@@ -411,6 +459,12 @@ impl ShellManager {
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
stdout_len: 0,
|
||||
stderr_len: 0,
|
||||
stdout_omitted: 0,
|
||||
stderr_omitted: 0,
|
||||
stdout_truncated: false,
|
||||
stderr_truncated: false,
|
||||
sandboxed,
|
||||
sandbox_type: if sandboxed {
|
||||
Some(sandbox_type.to_string())
|
||||
@@ -430,6 +484,12 @@ impl ShellManager {
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
stdout_len: 0,
|
||||
stderr_len: 0,
|
||||
stdout_omitted: 0,
|
||||
stderr_omitted: 0,
|
||||
stdout_truncated: false,
|
||||
stderr_truncated: false,
|
||||
sandboxed,
|
||||
sandbox_type: if sandboxed {
|
||||
Some(sandbox_type.to_string())
|
||||
@@ -518,6 +578,12 @@ impl ShellManager {
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
duration_ms: 0,
|
||||
stdout_len: 0,
|
||||
stderr_len: 0,
|
||||
stdout_omitted: 0,
|
||||
stderr_omitted: 0,
|
||||
stdout_truncated: false,
|
||||
stderr_truncated: false,
|
||||
sandboxed,
|
||||
sandbox_type: if sandboxed {
|
||||
Some(sandbox_type.to_string())
|
||||
@@ -599,19 +665,100 @@ impl ShellManager {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct TruncationMeta {
|
||||
original_len: usize,
|
||||
omitted: usize,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
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`
|
||||
fn truncate_output(output: &str) -> String {
|
||||
if output.len() <= MAX_OUTPUT_SIZE {
|
||||
output.to_string()
|
||||
} else {
|
||||
let truncated = &output[..MAX_OUTPUT_SIZE];
|
||||
format!(
|
||||
"{}...\n\n[Output truncated at {} characters. {} characters omitted.]",
|
||||
truncated,
|
||||
MAX_OUTPUT_SIZE,
|
||||
output.len() - MAX_OUTPUT_SIZE
|
||||
)
|
||||
}
|
||||
truncate_with_meta(output).0
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper for `ShellManager`
|
||||
@@ -777,6 +924,13 @@ impl ToolSpec for ExecShellTool {
|
||||
match result {
|
||||
Ok(result) => {
|
||||
let task_id_str = result.task_id.clone().unwrap_or_default();
|
||||
let stdout_summary = summarize_output(&result.stdout);
|
||||
let stderr_summary = summarize_output(&result.stderr);
|
||||
let summary = if !stderr_summary.is_empty() {
|
||||
stderr_summary.clone()
|
||||
} else {
|
||||
stdout_summary.clone()
|
||||
};
|
||||
let output = if interactive {
|
||||
format!(
|
||||
"Interactive command completed (exit code: {:?})",
|
||||
@@ -808,7 +962,18 @@ impl ToolSpec for ExecShellTool {
|
||||
"status": format!("{:?}", result.status),
|
||||
"duration_ms": result.duration_ms,
|
||||
"sandboxed": result.sandboxed,
|
||||
"sandbox_type": result.sandbox_type,
|
||||
"sandbox_denied": result.sandbox_denied,
|
||||
"task_id": result.task_id,
|
||||
"stdout_len": result.stdout_len,
|
||||
"stderr_len": result.stderr_len,
|
||||
"stdout_truncated": result.stdout_truncated,
|
||||
"stderr_truncated": result.stderr_truncated,
|
||||
"stdout_omitted": result.stdout_omitted,
|
||||
"stderr_omitted": result.stderr_omitted,
|
||||
"summary": summary,
|
||||
"stdout_summary": stdout_summary,
|
||||
"stderr_summary": stderr_summary,
|
||||
"safety_level": format!("{:?}", safety.level),
|
||||
"interactive": interactive,
|
||||
"execpolicy": execpolicy_decision.as_ref().map(|decision| match decision {
|
||||
@@ -900,6 +1065,8 @@ impl ToolSpec for NoteTool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::spec::ToolContext;
|
||||
use serde_json::{Value, json};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn echo_command(message: &str) -> String {
|
||||
@@ -1013,4 +1180,46 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
+63
-30
@@ -6,8 +6,9 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
@@ -17,6 +18,7 @@ use tokio::{sync::mpsc, task::JoinHandle};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::client::DeepSeekClient;
|
||||
use crate::config::MAX_SUBAGENTS;
|
||||
use crate::core::events::Event;
|
||||
use crate::llm_client::LlmClient;
|
||||
use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt, Tool};
|
||||
@@ -87,18 +89,50 @@ impl SubAgentType {
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"apply_patch",
|
||||
"grep_files",
|
||||
"file_search",
|
||||
"web_search",
|
||||
"exec_shell",
|
||||
"note",
|
||||
"todo_write",
|
||||
"todo_add",
|
||||
"todo_update",
|
||||
"todo_list",
|
||||
"update_plan",
|
||||
],
|
||||
Self::Explore => vec!["list_dir", "read_file", "grep_files", "exec_shell"],
|
||||
Self::Plan => vec!["list_dir", "read_file", "note", "update_plan", "todo_write"],
|
||||
Self::Review => vec!["list_dir", "read_file", "grep_files", "note"],
|
||||
Self::Explore => vec![
|
||||
"list_dir",
|
||||
"read_file",
|
||||
"grep_files",
|
||||
"file_search",
|
||||
"web_search",
|
||||
"exec_shell",
|
||||
],
|
||||
Self::Plan => vec![
|
||||
"list_dir",
|
||||
"read_file",
|
||||
"grep_files",
|
||||
"file_search",
|
||||
"note",
|
||||
"update_plan",
|
||||
"todo_write",
|
||||
"todo_add",
|
||||
"todo_update",
|
||||
"todo_list",
|
||||
],
|
||||
Self::Review => vec!["list_dir", "read_file", "grep_files", "file_search", "note"],
|
||||
Self::Custom => vec![], // Must be provided by caller.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SubAgentType {
|
||||
fn default() -> Self {
|
||||
Self::General
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of a sub-agent execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum SubAgentStatus {
|
||||
@@ -215,13 +249,25 @@ impl SubAgentManager {
|
||||
}
|
||||
|
||||
/// Count running agents.
|
||||
fn running_count(&self) -> usize {
|
||||
pub fn running_count(&self) -> usize {
|
||||
self.agents
|
||||
.values()
|
||||
.filter(|agent| agent.status == SubAgentStatus::Running)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Return the maximum number of allowed agents.
|
||||
#[must_use]
|
||||
pub fn max_agents(&self) -> usize {
|
||||
self.max_agents
|
||||
}
|
||||
|
||||
/// Return remaining capacity for new agents.
|
||||
#[must_use]
|
||||
pub fn available_slots(&self) -> usize {
|
||||
self.max_agents.saturating_sub(self.running_count())
|
||||
}
|
||||
|
||||
/// Spawn a new background sub-agent.
|
||||
pub fn spawn_background(
|
||||
&mut self,
|
||||
@@ -338,7 +384,7 @@ pub type SharedSubAgentManager = Arc<Mutex<SubAgentManager>>;
|
||||
/// Create a shared sub-agent manager with a configurable limit.
|
||||
#[must_use]
|
||||
pub fn new_shared_subagent_manager(workspace: PathBuf, max_agents: usize) -> SharedSubAgentManager {
|
||||
let max_agents = max_agents.clamp(1, 5);
|
||||
let max_agents = max_agents.clamp(1, MAX_SUBAGENTS);
|
||||
Arc::new(Mutex::new(SubAgentManager::new(workspace, max_agents)))
|
||||
}
|
||||
|
||||
@@ -423,10 +469,7 @@ impl ToolSpec for AgentSpawnTool {
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let mut manager = self
|
||||
.manager
|
||||
.lock()
|
||||
.map_err(|_| ToolError::execution_failed("Failed to lock sub-agent manager"))?;
|
||||
let mut manager = self.manager.lock().await;
|
||||
|
||||
let result = manager
|
||||
.spawn_background(
|
||||
@@ -503,10 +546,7 @@ impl ToolSpec for AgentResultTool {
|
||||
let result = if block {
|
||||
wait_for_result(&self.manager, agent_id, Duration::from_millis(timeout_ms)).await?
|
||||
} else {
|
||||
let manager = self
|
||||
.manager
|
||||
.lock()
|
||||
.map_err(|_| ToolError::execution_failed("Failed to lock sub-agent manager"))?;
|
||||
let manager = self.manager.lock().await;
|
||||
manager
|
||||
.get_result(agent_id)
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()))?
|
||||
@@ -570,10 +610,7 @@ impl ToolSpec for AgentCancelTool {
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let agent_id = required_str(&input, "agent_id")?;
|
||||
let mut manager = self
|
||||
.manager
|
||||
.lock()
|
||||
.map_err(|_| ToolError::execution_failed("Failed to lock sub-agent manager"))?;
|
||||
let mut manager = self.manager.lock().await;
|
||||
let result = manager
|
||||
.cancel(agent_id)
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to cancel sub-agent: {e}")))?;
|
||||
@@ -621,10 +658,7 @@ impl ToolSpec for AgentListTool {
|
||||
_input: Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let manager = self
|
||||
.manager
|
||||
.lock()
|
||||
.map_err(|_| ToolError::execution_failed("Failed to lock sub-agent manager"))?;
|
||||
let manager = self.manager.lock().await;
|
||||
let results = manager.list();
|
||||
ToolResult::json(&results).map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
@@ -656,11 +690,10 @@ async fn run_subagent_task(task: SubAgentTask) {
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Ok(mut manager) = task.manager_handle.lock() {
|
||||
match &result {
|
||||
Ok(res) => manager.update_from_result(&task.agent_id, res.clone()),
|
||||
Err(err) => manager.update_failed(&task.agent_id, err.to_string()),
|
||||
}
|
||||
let mut manager = task.manager_handle.lock().await;
|
||||
match &result {
|
||||
Ok(res) => manager.update_from_result(&task.agent_id, res.clone()),
|
||||
Err(err) => manager.update_failed(&task.agent_id, err.to_string()),
|
||||
}
|
||||
|
||||
if let Some(event_tx) = task.runtime.event_tx {
|
||||
@@ -794,9 +827,7 @@ async fn wait_for_result(
|
||||
|
||||
loop {
|
||||
let snapshot = {
|
||||
let manager = manager
|
||||
.lock()
|
||||
.map_err(|_| ToolError::execution_failed("Failed to lock sub-agent manager"))?;
|
||||
let manager = manager.lock().await;
|
||||
manager
|
||||
.get_result(agent_id)
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()))?
|
||||
@@ -829,6 +860,8 @@ impl SubAgentToolRegistry {
|
||||
.with_file_tools()
|
||||
.with_search_tools()
|
||||
.with_note_tool()
|
||||
.with_patch_tools()
|
||||
.with_web_tools()
|
||||
.with_todo_tool(todo_list)
|
||||
.with_plan_tool(plan_state);
|
||||
|
||||
|
||||
@@ -0,0 +1,752 @@
|
||||
//! Swarm orchestration for spawning multiple sub-agents with dependencies.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::tools::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
optional_bool, optional_str, optional_u64,
|
||||
};
|
||||
use crate::tools::subagent::{
|
||||
SharedSubAgentManager, SubAgentResult, SubAgentRuntime, SubAgentStatus, SubAgentType,
|
||||
};
|
||||
|
||||
const SWARM_POLL_INTERVAL: Duration = Duration::from_millis(250);
|
||||
const DEFAULT_SWARM_TIMEOUT_MS: u64 = 600_000;
|
||||
const DEFAULT_SWARM_TIMEOUT_NONBLOCK_MS: u64 = 15_000;
|
||||
const MAX_SWARM_TIMEOUT_MS: u64 = 3_600_000;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct SwarmTaskSpec {
|
||||
id: String,
|
||||
prompt: String,
|
||||
#[serde(default, rename = "type")]
|
||||
agent_type: Option<SubAgentType>,
|
||||
#[serde(default)]
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
depends_on: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum SwarmTaskState {
|
||||
Pending,
|
||||
Running { agent_id: String },
|
||||
Done(SubAgentResult),
|
||||
Failed(String),
|
||||
Skipped(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum SwarmTaskStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct SwarmTaskOutcome {
|
||||
task_id: String,
|
||||
agent_id: Option<String>,
|
||||
status: SwarmTaskStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
steps_taken: u32,
|
||||
duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum SwarmStatus {
|
||||
Completed,
|
||||
Partial,
|
||||
Timeout,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct SwarmCounts {
|
||||
total: usize,
|
||||
completed: usize,
|
||||
failed: usize,
|
||||
cancelled: usize,
|
||||
skipped: usize,
|
||||
running: usize,
|
||||
pending: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct SwarmOutcome {
|
||||
swarm_id: String,
|
||||
status: SwarmStatus,
|
||||
duration_ms: u64,
|
||||
counts: SwarmCounts,
|
||||
tasks: Vec<SwarmTaskOutcome>,
|
||||
}
|
||||
|
||||
/// Tool to launch a swarm of sub-agents with dependency-aware scheduling.
|
||||
pub struct AgentSwarmTool {
|
||||
manager: SharedSubAgentManager,
|
||||
runtime: SubAgentRuntime,
|
||||
}
|
||||
|
||||
impl AgentSwarmTool {
|
||||
/// Create a new swarm tool.
|
||||
#[must_use]
|
||||
pub fn new(manager: SharedSubAgentManager, runtime: SubAgentRuntime) -> Self {
|
||||
Self { manager, runtime }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for AgentSwarmTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"agent_swarm"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Spawn multiple sub-agents with optional dependencies and aggregate their results."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"description": "List of swarm tasks to execute.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Unique task id." },
|
||||
"prompt": { "type": "string", "description": "Task prompt for the sub-agent." },
|
||||
"type": { "type": "string", "description": "Sub-agent type: general, explore, plan, review, custom." },
|
||||
"allowed_tools": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Explicit tool allowlist (required for custom type)."
|
||||
},
|
||||
"depends_on": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "List of task ids that must complete successfully first."
|
||||
}
|
||||
},
|
||||
"required": ["id", "prompt"]
|
||||
}
|
||||
},
|
||||
"shared_context": {
|
||||
"type": "string",
|
||||
"description": "Optional shared context prepended to each task prompt."
|
||||
},
|
||||
"block": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to wait for tasks to finish (default: true)."
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "integer",
|
||||
"description": "Max wall time in milliseconds before returning partial results."
|
||||
},
|
||||
"max_parallel": {
|
||||
"type": "integer",
|
||||
"description": "Max concurrent swarm agents (defaults to max_subagents)."
|
||||
},
|
||||
"fail_fast": {
|
||||
"type": "boolean",
|
||||
"description": "Cancel remaining work on first failure (default: false)."
|
||||
}
|
||||
},
|
||||
"required": ["tasks"]
|
||||
})
|
||||
}
|
||||
|
||||
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 tasks_value = input
|
||||
.get("tasks")
|
||||
.cloned()
|
||||
.ok_or_else(|| ToolError::missing_field("tasks"))?;
|
||||
let tasks: Vec<SwarmTaskSpec> = serde_json::from_value(tasks_value)
|
||||
.map_err(|err| ToolError::invalid_input(format!("Invalid tasks payload: {err}")))?;
|
||||
|
||||
validate_swarm_tasks(&tasks)?;
|
||||
|
||||
let block = optional_bool(&input, "block", true);
|
||||
let default_timeout = if block {
|
||||
DEFAULT_SWARM_TIMEOUT_MS
|
||||
} else {
|
||||
DEFAULT_SWARM_TIMEOUT_NONBLOCK_MS
|
||||
};
|
||||
let timeout_ms =
|
||||
optional_u64(&input, "timeout_ms", default_timeout).clamp(1_000, MAX_SWARM_TIMEOUT_MS);
|
||||
let fail_fast = optional_bool(&input, "fail_fast", false);
|
||||
let shared_context = optional_str(&input, "shared_context")
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
let max_parallel = {
|
||||
let manager = self.manager.lock().await;
|
||||
let max_agents = manager.max_agents();
|
||||
let requested = optional_u64(&input, "max_parallel", max_agents as u64);
|
||||
requested.clamp(1, max_agents as u64) as usize
|
||||
};
|
||||
|
||||
let outcome = run_swarm(
|
||||
&self.manager,
|
||||
&self.runtime,
|
||||
tasks,
|
||||
shared_context,
|
||||
Duration::from_millis(timeout_ms),
|
||||
max_parallel,
|
||||
fail_fast,
|
||||
block,
|
||||
)
|
||||
.await?;
|
||||
|
||||
ToolResult::json(&outcome).map_err(|err| ToolError::execution_failed(err.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_swarm(
|
||||
shared_manager: &SharedSubAgentManager,
|
||||
runtime: &SubAgentRuntime,
|
||||
tasks: Vec<SwarmTaskSpec>,
|
||||
shared_context: Option<String>,
|
||||
timeout: Duration,
|
||||
max_parallel: usize,
|
||||
fail_fast: bool,
|
||||
block: bool,
|
||||
) -> Result<SwarmOutcome, ToolError> {
|
||||
let swarm_id = format!("swarm_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let start = Instant::now();
|
||||
let deadline = start + timeout;
|
||||
let task_order = tasks.iter().map(|task| task.id.clone()).collect::<Vec<_>>();
|
||||
|
||||
let mut task_map = HashMap::new();
|
||||
let mut states = HashMap::new();
|
||||
let mut pending = HashSet::new();
|
||||
for task in tasks {
|
||||
pending.insert(task.id.clone());
|
||||
states.insert(task.id.clone(), SwarmTaskState::Pending);
|
||||
task_map.insert(task.id.clone(), task);
|
||||
}
|
||||
|
||||
let mut running: HashMap<String, String> = HashMap::new();
|
||||
let mut fail_fast_triggered = false;
|
||||
let mut timed_out = false;
|
||||
|
||||
loop {
|
||||
let mut changed = false;
|
||||
|
||||
if !running.is_empty() {
|
||||
let snapshots = {
|
||||
let manager = shared_manager.lock().await;
|
||||
manager.list()
|
||||
};
|
||||
let snapshot_map: HashMap<String, SubAgentResult> = snapshots
|
||||
.into_iter()
|
||||
.map(|snapshot| (snapshot.agent_id.clone(), snapshot))
|
||||
.collect();
|
||||
|
||||
let running_ids = running.clone();
|
||||
for (task_id, agent_id) in running_ids {
|
||||
match snapshot_map.get(&agent_id) {
|
||||
Some(snapshot) => {
|
||||
if snapshot.status != SubAgentStatus::Running {
|
||||
states.insert(task_id.clone(), SwarmTaskState::Done(snapshot.clone()));
|
||||
running.remove(&task_id);
|
||||
changed = true;
|
||||
if fail_fast
|
||||
&& matches!(
|
||||
snapshot.status,
|
||||
SubAgentStatus::Failed(_) | SubAgentStatus::Cancelled
|
||||
)
|
||||
{
|
||||
fail_fast_triggered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
states.insert(
|
||||
task_id.clone(),
|
||||
SwarmTaskState::Failed("Agent result not found".to_string()),
|
||||
);
|
||||
running.remove(&task_id);
|
||||
changed = true;
|
||||
if fail_fast {
|
||||
fail_fast_triggered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fail_fast_triggered {
|
||||
apply_fail_fast(shared_manager, &mut states, &mut pending, &mut running).await?;
|
||||
break;
|
||||
}
|
||||
|
||||
let mut newly_skipped = Vec::new();
|
||||
for task_id in pending.iter() {
|
||||
if let Some(task) = task_map.get(task_id)
|
||||
&& dependencies_failed(task, &states)
|
||||
{
|
||||
newly_skipped.push(task_id.clone());
|
||||
}
|
||||
}
|
||||
for task_id in newly_skipped {
|
||||
pending.remove(&task_id);
|
||||
states.insert(
|
||||
task_id,
|
||||
SwarmTaskState::Skipped("Dependency failed".to_string()),
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
let mut ready = Vec::new();
|
||||
for task_id in pending.iter() {
|
||||
if let Some(task) = task_map.get(task_id)
|
||||
&& dependencies_satisfied(task, &states)
|
||||
{
|
||||
ready.push(task_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if !ready.is_empty() {
|
||||
let available_slots = {
|
||||
let manager = shared_manager.lock().await;
|
||||
let global_slots = manager.available_slots();
|
||||
let swarm_slots = max_parallel.saturating_sub(running.len());
|
||||
global_slots.min(swarm_slots)
|
||||
};
|
||||
|
||||
if available_slots > 0 {
|
||||
for task_id in ready.into_iter().take(available_slots) {
|
||||
let task = task_map
|
||||
.get(&task_id)
|
||||
.ok_or_else(|| ToolError::execution_failed("Missing swarm task"))?;
|
||||
let agent_type = task.agent_type.clone().unwrap_or_default();
|
||||
let prompt = format_prompt(shared_context.as_deref(), &task.prompt);
|
||||
|
||||
let spawn_result = {
|
||||
let mut manager = shared_manager.lock().await;
|
||||
manager.spawn_background(
|
||||
Arc::clone(shared_manager),
|
||||
runtime.clone(),
|
||||
agent_type,
|
||||
prompt,
|
||||
task.allowed_tools.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
match spawn_result {
|
||||
Ok(snapshot) => {
|
||||
states.insert(
|
||||
task_id.clone(),
|
||||
SwarmTaskState::Running {
|
||||
agent_id: snapshot.agent_id.clone(),
|
||||
},
|
||||
);
|
||||
running.insert(task_id.clone(), snapshot.agent_id);
|
||||
pending.remove(&task_id);
|
||||
changed = true;
|
||||
}
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
if message.contains("Sub-agent limit reached") {
|
||||
break;
|
||||
}
|
||||
states.insert(task_id.clone(), SwarmTaskState::Failed(message));
|
||||
pending.remove(&task_id);
|
||||
changed = true;
|
||||
if fail_fast {
|
||||
fail_fast_triggered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fail_fast_triggered {
|
||||
apply_fail_fast(shared_manager, &mut states, &mut pending, &mut running).await?;
|
||||
break;
|
||||
}
|
||||
|
||||
if pending.is_empty() && running.is_empty() {
|
||||
break;
|
||||
}
|
||||
if !block {
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
timed_out = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if !changed {
|
||||
tokio::time::sleep(SWARM_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
let outcomes = build_task_outcomes(&task_order, &states);
|
||||
let counts = build_counts(&outcomes);
|
||||
let status = if fail_fast_triggered {
|
||||
SwarmStatus::Failed
|
||||
} else if timed_out {
|
||||
SwarmStatus::Timeout
|
||||
} else if counts.failed > 0
|
||||
|| counts.cancelled > 0
|
||||
|| counts.skipped > 0
|
||||
|| counts.pending > 0
|
||||
|| counts.running > 0
|
||||
{
|
||||
SwarmStatus::Partial
|
||||
} else {
|
||||
SwarmStatus::Completed
|
||||
};
|
||||
|
||||
Ok(SwarmOutcome {
|
||||
swarm_id,
|
||||
status,
|
||||
duration_ms: u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
counts,
|
||||
tasks: outcomes,
|
||||
})
|
||||
}
|
||||
|
||||
fn format_prompt(shared_context: Option<&str>, prompt: &str) -> String {
|
||||
if let Some(context) = shared_context {
|
||||
format!("Shared context:\n{context}\n\nTask:\n{prompt}")
|
||||
} else {
|
||||
prompt.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn dependencies_satisfied(task: &SwarmTaskSpec, states: &HashMap<String, SwarmTaskState>) -> bool {
|
||||
task.depends_on.iter().all(|dep| {
|
||||
matches!(
|
||||
states.get(dep),
|
||||
Some(SwarmTaskState::Done(result))
|
||||
if matches!(result.status, SubAgentStatus::Completed)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn dependencies_failed(task: &SwarmTaskSpec, states: &HashMap<String, SwarmTaskState>) -> bool {
|
||||
task.depends_on.iter().any(|dep| match states.get(dep) {
|
||||
Some(SwarmTaskState::Done(result)) => matches!(
|
||||
result.status,
|
||||
SubAgentStatus::Failed(_) | SubAgentStatus::Cancelled
|
||||
),
|
||||
Some(SwarmTaskState::Failed(_)) | Some(SwarmTaskState::Skipped(_)) => true,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn cancel_running_tasks(
|
||||
manager: &SharedSubAgentManager,
|
||||
running: &HashMap<String, String>,
|
||||
states: &mut HashMap<String, SwarmTaskState>,
|
||||
) -> Result<(), ToolError> {
|
||||
let mut manager = manager.lock().await;
|
||||
for (task_id, agent_id) in running {
|
||||
match manager.cancel(agent_id) {
|
||||
Ok(snapshot) => {
|
||||
states.insert(task_id.clone(), SwarmTaskState::Done(snapshot));
|
||||
}
|
||||
Err(err) => {
|
||||
states.insert(
|
||||
task_id.clone(),
|
||||
SwarmTaskState::Failed(format!("Failed to cancel agent: {err}")),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_fail_fast(
|
||||
manager: &SharedSubAgentManager,
|
||||
states: &mut HashMap<String, SwarmTaskState>,
|
||||
pending: &mut HashSet<String>,
|
||||
running: &mut HashMap<String, String>,
|
||||
) -> Result<(), ToolError> {
|
||||
cancel_running_tasks(manager, running, states).await?;
|
||||
for task_id in pending.drain() {
|
||||
states.insert(
|
||||
task_id,
|
||||
SwarmTaskState::Skipped("Skipped due to fail_fast".to_string()),
|
||||
);
|
||||
}
|
||||
running.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_task_outcomes(
|
||||
order: &[String],
|
||||
states: &HashMap<String, SwarmTaskState>,
|
||||
) -> Vec<SwarmTaskOutcome> {
|
||||
order
|
||||
.iter()
|
||||
.map(|task_id| match states.get(task_id) {
|
||||
Some(SwarmTaskState::Running { agent_id }) => SwarmTaskOutcome {
|
||||
task_id: task_id.clone(),
|
||||
agent_id: Some(agent_id.clone()),
|
||||
status: SwarmTaskStatus::Running,
|
||||
result: None,
|
||||
error: None,
|
||||
steps_taken: 0,
|
||||
duration_ms: 0,
|
||||
},
|
||||
Some(SwarmTaskState::Done(result)) => match &result.status {
|
||||
SubAgentStatus::Completed => SwarmTaskOutcome {
|
||||
task_id: task_id.clone(),
|
||||
agent_id: Some(result.agent_id.clone()),
|
||||
status: SwarmTaskStatus::Completed,
|
||||
result: result.result.clone(),
|
||||
error: None,
|
||||
steps_taken: result.steps_taken,
|
||||
duration_ms: result.duration_ms,
|
||||
},
|
||||
SubAgentStatus::Failed(err) => SwarmTaskOutcome {
|
||||
task_id: task_id.clone(),
|
||||
agent_id: Some(result.agent_id.clone()),
|
||||
status: SwarmTaskStatus::Failed,
|
||||
result: result.result.clone(),
|
||||
error: Some(err.clone()),
|
||||
steps_taken: result.steps_taken,
|
||||
duration_ms: result.duration_ms,
|
||||
},
|
||||
SubAgentStatus::Cancelled => SwarmTaskOutcome {
|
||||
task_id: task_id.clone(),
|
||||
agent_id: Some(result.agent_id.clone()),
|
||||
status: SwarmTaskStatus::Cancelled,
|
||||
result: result.result.clone(),
|
||||
error: Some("Cancelled".to_string()),
|
||||
steps_taken: result.steps_taken,
|
||||
duration_ms: result.duration_ms,
|
||||
},
|
||||
SubAgentStatus::Running => SwarmTaskOutcome {
|
||||
task_id: task_id.clone(),
|
||||
agent_id: Some(result.agent_id.clone()),
|
||||
status: SwarmTaskStatus::Running,
|
||||
result: result.result.clone(),
|
||||
error: None,
|
||||
steps_taken: result.steps_taken,
|
||||
duration_ms: result.duration_ms,
|
||||
},
|
||||
},
|
||||
Some(SwarmTaskState::Failed(message)) => SwarmTaskOutcome {
|
||||
task_id: task_id.clone(),
|
||||
agent_id: None,
|
||||
status: SwarmTaskStatus::Failed,
|
||||
result: None,
|
||||
error: Some(message.clone()),
|
||||
steps_taken: 0,
|
||||
duration_ms: 0,
|
||||
},
|
||||
Some(SwarmTaskState::Skipped(message)) => SwarmTaskOutcome {
|
||||
task_id: task_id.clone(),
|
||||
agent_id: None,
|
||||
status: SwarmTaskStatus::Skipped,
|
||||
result: None,
|
||||
error: Some(message.clone()),
|
||||
steps_taken: 0,
|
||||
duration_ms: 0,
|
||||
},
|
||||
_ => SwarmTaskOutcome {
|
||||
task_id: task_id.clone(),
|
||||
agent_id: None,
|
||||
status: SwarmTaskStatus::Pending,
|
||||
result: None,
|
||||
error: None,
|
||||
steps_taken: 0,
|
||||
duration_ms: 0,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_counts(outcomes: &[SwarmTaskOutcome]) -> SwarmCounts {
|
||||
let mut counts = SwarmCounts {
|
||||
total: outcomes.len(),
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
skipped: 0,
|
||||
running: 0,
|
||||
pending: 0,
|
||||
};
|
||||
|
||||
for outcome in outcomes {
|
||||
match outcome.status {
|
||||
SwarmTaskStatus::Completed => counts.completed += 1,
|
||||
SwarmTaskStatus::Failed => counts.failed += 1,
|
||||
SwarmTaskStatus::Cancelled => counts.cancelled += 1,
|
||||
SwarmTaskStatus::Skipped => counts.skipped += 1,
|
||||
SwarmTaskStatus::Running => counts.running += 1,
|
||||
SwarmTaskStatus::Pending => counts.pending += 1,
|
||||
}
|
||||
}
|
||||
|
||||
counts
|
||||
}
|
||||
|
||||
fn validate_swarm_tasks(tasks: &[SwarmTaskSpec]) -> Result<(), ToolError> {
|
||||
if tasks.is_empty() {
|
||||
return Err(ToolError::invalid_input("tasks cannot be empty"));
|
||||
}
|
||||
|
||||
let mut ids = HashSet::new();
|
||||
for task in tasks {
|
||||
let id = task.id.trim();
|
||||
if id.is_empty() {
|
||||
return Err(ToolError::invalid_input("task id cannot be empty"));
|
||||
}
|
||||
if task.prompt.trim().is_empty() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"task '{id}' prompt cannot be empty"
|
||||
)));
|
||||
}
|
||||
if matches!(task.agent_type, Some(SubAgentType::Custom)) {
|
||||
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"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if !ids.insert(task.id.clone()) {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"duplicate task id '{id}'"
|
||||
)));
|
||||
}
|
||||
if task.depends_on.iter().any(|dep| dep == id) {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"task '{id}' cannot depend on itself"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
for dep in &task.depends_on {
|
||||
if !ids.contains(dep) {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"task '{}' depends on unknown task '{dep}'",
|
||||
task.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_dependency_cycle(tasks) {
|
||||
return Err(ToolError::invalid_input(
|
||||
"task dependencies contain a cycle",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_dependency_cycle(tasks: &[SwarmTaskSpec]) -> bool {
|
||||
let mut deps = HashMap::new();
|
||||
for task in tasks {
|
||||
deps.insert(task.id.clone(), task.depends_on.clone());
|
||||
}
|
||||
|
||||
let mut visiting = HashSet::new();
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
for id in deps.keys() {
|
||||
if visit(id, &deps, &mut visiting, &mut visited) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn visit(
|
||||
id: &str,
|
||||
deps: &HashMap<String, Vec<String>>,
|
||||
visiting: &mut HashSet<String>,
|
||||
visited: &mut HashSet<String>,
|
||||
) -> bool {
|
||||
if visited.contains(id) {
|
||||
return false;
|
||||
}
|
||||
if !visiting.insert(id.to_string()) {
|
||||
return true;
|
||||
}
|
||||
if let Some(children) = deps.get(id) {
|
||||
for child in children {
|
||||
if visit(child, deps, visiting, visited) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
visiting.remove(id);
|
||||
visited.insert(id.to_string());
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SwarmTaskSpec, validate_swarm_tasks};
|
||||
|
||||
fn task(id: &str, deps: &[&str]) -> SwarmTaskSpec {
|
||||
SwarmTaskSpec {
|
||||
id: id.to_string(),
|
||||
prompt: "do work".to_string(),
|
||||
agent_type: None,
|
||||
allowed_tools: None,
|
||||
depends_on: deps.iter().map(|dep| dep.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_swarm_tasks_accepts_valid_graph() {
|
||||
let tasks = vec![task("a", &[]), task("b", &["a"])];
|
||||
assert!(validate_swarm_tasks(&tasks).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_swarm_tasks_rejects_unknown_dependency() {
|
||||
let tasks = vec![task("a", &["missing"])];
|
||||
assert!(validate_swarm_tasks(&tasks).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_swarm_tasks_rejects_cycle() {
|
||||
let tasks = vec![task("a", &["b"]), task("b", &["a"])];
|
||||
assert!(validate_swarm_tasks(&tasks).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Cargo test runner tool: `run_tests`.
|
||||
//!
|
||||
//! This tool intentionally auto-approves test execution to encourage
|
||||
//! frequent verification loops while still scoping execution to the workspace.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
optional_bool, optional_str,
|
||||
};
|
||||
|
||||
const MAX_OUTPUT_CHARS: usize = 40_000;
|
||||
|
||||
/// Tool for running `cargo test` in the workspace root.
|
||||
pub struct RunTestsTool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct RunTestsOutput {
|
||||
success: bool,
|
||||
exit_code: i32,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
command: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for RunTestsTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"run_tests"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Run `cargo test` in the workspace root with optional extra arguments."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"args": {
|
||||
"type": "string",
|
||||
"description": "Optional extra arguments to pass to `cargo test` (shell-style)."
|
||||
},
|
||||
"all_features": {
|
||||
"type": "boolean",
|
||||
"description": "When true, include `--all-features`."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ExecutesCode, ToolCapability::Sandboxable]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
// Tests are encouraged, so avoid gating them behind approval.
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let all_features = optional_bool(&input, "all_features", false);
|
||||
let extra_args = optional_str(&input, "args")
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let mut args = vec!["test".to_string()];
|
||||
if all_features {
|
||||
args.push("--all-features".to_string());
|
||||
}
|
||||
if let Some(extra) = extra_args {
|
||||
let split = shlex::split(extra).ok_or_else(|| {
|
||||
ToolError::invalid_input("Failed to parse 'args' as shell-style tokens")
|
||||
})?;
|
||||
args.extend(split);
|
||||
}
|
||||
|
||||
let command_str = format_command(&context.workspace, &args);
|
||||
let output = run_cargo(&context.workspace, &args)?;
|
||||
|
||||
let exit_code = output.status.code().unwrap_or(-1);
|
||||
let stdout_raw = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr_raw = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = truncate_with_note(&stdout_raw, MAX_OUTPUT_CHARS);
|
||||
let stderr = truncate_with_note(&stderr_raw, MAX_OUTPUT_CHARS);
|
||||
|
||||
let result = RunTestsOutput {
|
||||
success: output.status.success(),
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
command: command_str,
|
||||
};
|
||||
|
||||
ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
fn run_cargo(workspace: &Path, args: &[String]) -> Result<std::process::Output, ToolError> {
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.args(args).current_dir(workspace);
|
||||
cmd.output().map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
ToolError::not_available("cargo is not installed or not in PATH")
|
||||
} else {
|
||||
ToolError::execution_failed(format!("Failed to run cargo: {e}"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn format_command(workspace: &Path, args: &[String]) -> String {
|
||||
format!(
|
||||
"(cd {} && cargo {})",
|
||||
workspace.display(),
|
||||
args.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate_with_note(text: &str, max_chars: usize) -> String {
|
||||
if text.chars().count() <= max_chars {
|
||||
return text.to_string();
|
||||
}
|
||||
let end = char_boundary_index(text, max_chars);
|
||||
let truncated = &text[..end];
|
||||
let omitted_chars = text
|
||||
.chars()
|
||||
.count()
|
||||
.saturating_sub(truncated.chars().count());
|
||||
let note = format!(
|
||||
"\n\n[output truncated to {max_chars} characters; {omitted_chars} characters omitted]"
|
||||
);
|
||||
format!("{truncated}{note}")
|
||||
}
|
||||
|
||||
fn char_boundary_index(text: &str, max_chars: usize) -> usize {
|
||||
if max_chars == 0 {
|
||||
return 0;
|
||||
}
|
||||
for (count, (idx, _)) in text.char_indices().enumerate() {
|
||||
if count == max_chars {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
text.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn cargo_available() -> bool {
|
||||
Command::new("cargo")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn init_cargo_project(root: &Path) -> std::path::PathBuf {
|
||||
let project_dir = root.join("project");
|
||||
fs::create_dir_all(&project_dir).expect("create project dir");
|
||||
let status = Command::new("cargo")
|
||||
.args([
|
||||
"init",
|
||||
"--lib",
|
||||
"--vcs",
|
||||
"none",
|
||||
"-q",
|
||||
"--name",
|
||||
"eval_project",
|
||||
])
|
||||
.current_dir(&project_dir)
|
||||
.status()
|
||||
.expect("cargo should spawn");
|
||||
assert!(status.success(), "cargo init failed");
|
||||
project_dir
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tests_succeeds_on_fresh_project() {
|
||||
if !cargo_available() {
|
||||
return;
|
||||
}
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let project_dir = init_cargo_project(tmp.path());
|
||||
|
||||
let ctx = ToolContext::new(&project_dir);
|
||||
let tool = RunTestsTool;
|
||||
let result = tool.execute(json!({}), &ctx).await.expect("execute");
|
||||
assert!(result.success);
|
||||
|
||||
let parsed: RunTestsOutput =
|
||||
serde_json::from_str(&result.content).expect("tool result should be json");
|
||||
assert!(parsed.success);
|
||||
assert_eq!(parsed.exit_code, 0);
|
||||
assert!(parsed.command.contains("cargo test"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tests_reports_failures_without_hard_error() {
|
||||
if !cargo_available() {
|
||||
return;
|
||||
}
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let project_dir = init_cargo_project(tmp.path());
|
||||
|
||||
let lib_rs = project_dir.join("src/lib.rs");
|
||||
let failing = r#"
|
||||
pub fn add(a: i32, b: i32) -> i32 { a + b }
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn fails() {
|
||||
assert_eq!(2 + 2, 5);
|
||||
}
|
||||
}
|
||||
"#;
|
||||
fs::write(&lib_rs, failing).expect("write failing test");
|
||||
|
||||
let ctx = ToolContext::new(&project_dir);
|
||||
let tool = RunTestsTool;
|
||||
let result = tool.execute(json!({}), &ctx).await.expect("execute");
|
||||
assert!(result.success);
|
||||
|
||||
let parsed: RunTestsOutput =
|
||||
serde_json::from_str(&result.content).expect("tool result should be json");
|
||||
assert!(!parsed.success);
|
||||
assert_ne!(parsed.exit_code, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_adds_note() {
|
||||
let long = "x".repeat(MAX_OUTPUT_CHARS + 128);
|
||||
let truncated = truncate_with_note(&long, MAX_OUTPUT_CHARS);
|
||||
assert!(truncated.contains("output truncated"));
|
||||
}
|
||||
}
|
||||
+6
-17
@@ -1,6 +1,7 @@
|
||||
//! Todo list tool and supporting data structures.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -327,10 +328,7 @@ impl ToolSpec for TodoAddTool {
|
||||
.and_then(TodoStatus::from_str)
|
||||
.unwrap_or(TodoStatus::Pending);
|
||||
|
||||
let mut list = self
|
||||
.todo_list
|
||||
.lock()
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to lock todo list: {e}")))?;
|
||||
let mut list = self.todo_list.lock().await;
|
||||
let item = list.add(content.to_string(), status);
|
||||
let snapshot = list.snapshot();
|
||||
|
||||
@@ -407,10 +405,7 @@ impl ToolSpec for TodoUpdateTool {
|
||||
.and_then(TodoStatus::from_str)
|
||||
.ok_or_else(|| ToolError::invalid_input("Missing or invalid 'status'"))?;
|
||||
|
||||
let mut list = self
|
||||
.todo_list
|
||||
.lock()
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to lock todo list: {e}")))?;
|
||||
let mut list = self.todo_list.lock().await;
|
||||
let updated = list.update_status(id, status);
|
||||
let snapshot = list.snapshot();
|
||||
let result = serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| "{}".to_string());
|
||||
@@ -468,10 +463,7 @@ impl ToolSpec for TodoListTool {
|
||||
_input: serde_json::Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let list = self
|
||||
.todo_list
|
||||
.lock()
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to lock todo list: {e}")))?;
|
||||
let list = self.todo_list.lock().await;
|
||||
let snapshot = list.snapshot();
|
||||
let result = serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| "{}".to_string());
|
||||
Ok(ToolResult::success(format!(
|
||||
@@ -539,10 +531,7 @@ impl ToolSpec for TodoWriteTool {
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| ToolError::invalid_input("Missing or invalid 'todos' array"))?;
|
||||
|
||||
let mut list = self
|
||||
.todo_list
|
||||
.lock()
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to lock todo list: {e}")))?;
|
||||
let mut list = self.todo_list.lock().await;
|
||||
|
||||
// Clear and rebuild the list
|
||||
list.clear();
|
||||
|
||||
+2
-3
@@ -878,9 +878,8 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn clear_todos(&mut self) {
|
||||
if let Ok(mut plan) = self.plan_state.lock() {
|
||||
*plan = crate::tools::plan::PlanState::default();
|
||||
}
|
||||
let mut plan = self.plan_state.blocking_lock();
|
||||
*plan = crate::tools::plan::PlanState::default();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-4
@@ -52,8 +52,18 @@ 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, .. } => {
|
||||
render_message("DeepSeek", content, assistant_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::System { content } => {
|
||||
render_message("System", content, system_style(), width)
|
||||
@@ -1220,11 +1230,13 @@ fn truncate_text(text: &str, max_len: usize) -> String {
|
||||
}
|
||||
|
||||
fn user_style() -> Style {
|
||||
Style::default().fg(palette::DEEPSEEK_BLUE)
|
||||
Style::default()
|
||||
.fg(palette::TEXT_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
fn assistant_style() -> Style {
|
||||
Style::default().fg(palette::DEEPSEEK_BLUE)
|
||||
Style::default().fg(palette::DEEPSEEK_SKY)
|
||||
}
|
||||
|
||||
fn system_style() -> Style {
|
||||
|
||||
+11
-2
@@ -1,7 +1,9 @@
|
||||
//! Cached transcript rendering for the TUI.
|
||||
|
||||
use ratatui::text::Line;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
|
||||
use crate::palette;
|
||||
use crate::tui::history::{HistoryCell, TranscriptRenderOptions};
|
||||
use crate::tui::scrolling::TranscriptLineMeta;
|
||||
|
||||
@@ -60,7 +62,14 @@ impl TranscriptViewCache {
|
||||
}
|
||||
|
||||
if cell_index + 1 < cells.len() && !cell.is_stream_continuation() {
|
||||
lines.push(Line::from(""));
|
||||
// Add subtle horizontal separator between messages
|
||||
let separator = Span::styled(
|
||||
"─".repeat(usize::from(width)),
|
||||
Style::default()
|
||||
.fg(palette::TEXT_MUTED)
|
||||
.add_modifier(Modifier::DIM),
|
||||
);
|
||||
lines.push(Line::from(separator));
|
||||
meta.push(TranscriptLineMeta::Spacer);
|
||||
}
|
||||
}
|
||||
|
||||
+219
-81
@@ -719,6 +719,15 @@ async fn run_event_loop(
|
||||
continue;
|
||||
}
|
||||
|
||||
if key.code == KeyCode::Char('/') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
if app.view_stack.top_kind() == Some(ModalKind::Help) {
|
||||
app.view_stack.pop();
|
||||
} else {
|
||||
app.view_stack.push(HelpView::new());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if !app.view_stack.is_empty() {
|
||||
let events = app.view_stack.handle_key(key);
|
||||
handle_view_events(app, &engine_handle, events).await;
|
||||
@@ -781,6 +790,12 @@ async fn run_event_loop(
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
if app.input.is_empty() {
|
||||
let _ = engine_handle.send(Op::Shutdown).await;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
if app.is_loading {
|
||||
engine_handle.cancel();
|
||||
@@ -813,6 +828,12 @@ async fn run_event_loop(
|
||||
}
|
||||
}
|
||||
// Input handling
|
||||
KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
app.insert_char('\n');
|
||||
}
|
||||
KeyCode::Enter if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||
app.insert_char('\n');
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(input) = app.submit_input() {
|
||||
if handle_plan_choice(app, &engine_handle, &input).await? {
|
||||
@@ -871,6 +892,16 @@ async fn run_event_loop(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Global @ file completion - works in any mode
|
||||
if let Some(path) = input.trim().strip_prefix('@') {
|
||||
let command = format!("/load @{path}");
|
||||
let result = commands::execute(&command, app);
|
||||
if let Some(msg) = result.message {
|
||||
app.add_message(HistoryCell::System { content: msg });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if app.mode == AppMode::Rlm && app.rlm_repl_active {
|
||||
if rlm_repl_should_route_to_chat(app, &input) {
|
||||
app.rlm_repl_active = false;
|
||||
@@ -883,17 +914,6 @@ async fn run_event_loop(
|
||||
}
|
||||
}
|
||||
|
||||
if app.mode == AppMode::Rlm
|
||||
&& let Some(path) = input.trim().strip_prefix('@')
|
||||
{
|
||||
let command = format!("/load @{path}");
|
||||
let result = commands::execute(&command, app);
|
||||
if let Some(msg) = result.message {
|
||||
app.add_message(HistoryCell::System { content: msg });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let queued = if let Some(mut draft) = app.queued_draft.take() {
|
||||
draft.display = input;
|
||||
draft
|
||||
@@ -965,6 +985,13 @@ async fn run_event_loop(
|
||||
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
app.clear_input();
|
||||
}
|
||||
KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
let new_mode = match app.mode {
|
||||
AppMode::Agent => AppMode::Normal,
|
||||
_ => AppMode::Agent,
|
||||
};
|
||||
app.set_mode(new_mode);
|
||||
}
|
||||
KeyCode::Char('v') if is_paste_shortcut(&key) => {
|
||||
app.paste_from_clipboard();
|
||||
}
|
||||
@@ -1107,6 +1134,7 @@ async fn dispatch_user_message(
|
||||
app.system_prompt = Some(prompts::system_prompt_for_mode_with_context(
|
||||
app.mode,
|
||||
&app.workspace,
|
||||
None,
|
||||
rlm_summary.as_deref(),
|
||||
duo_summary.as_deref(),
|
||||
));
|
||||
@@ -1971,7 +1999,13 @@ fn render_status_indicator(f: &mut Frame, area: Rect, app: &App, queued: &[Strin
|
||||
None
|
||||
};
|
||||
let elapsed = app.turn_started_at.map(format_elapsed);
|
||||
let spinner = deepseek_squiggle(app.turn_started_at);
|
||||
// Use typing indicator when streaming content, otherwise use whale spinner
|
||||
let has_streaming_content = app.streaming_message_index.is_some();
|
||||
let spinner = if has_streaming_content {
|
||||
typing_indicator(app.turn_started_at)
|
||||
} else {
|
||||
deepseek_squiggle(app.turn_started_at)
|
||||
};
|
||||
let label = if app.show_thinking {
|
||||
deepseek_thinking_label(app.turn_started_at)
|
||||
} else {
|
||||
@@ -2049,93 +2083,175 @@ fn render_status_indicator(f: &mut Frame, area: Rect, app: &App, queued: &[Strin
|
||||
}
|
||||
|
||||
fn render_footer(f: &mut Frame, area: Rect, app: &App) {
|
||||
let mut spans = vec![
|
||||
Span::styled(
|
||||
format!(" {} ", app.mode.label()),
|
||||
mode_badge_style(app.mode),
|
||||
),
|
||||
Span::raw(" | "),
|
||||
Span::styled(
|
||||
context_indicator(app),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
),
|
||||
];
|
||||
|
||||
if let Some((label, style)) = rlm_usage_badge(app) {
|
||||
spans.push(Span::raw(" | "));
|
||||
spans.push(Span::styled(label, style));
|
||||
}
|
||||
|
||||
if let (Some(prompt), Some(completion)) = (app.last_prompt_tokens, app.last_completion_tokens) {
|
||||
spans.push(Span::raw(" | "));
|
||||
spans.push(Span::styled(
|
||||
format!("last tokens in/out: {prompt}/{completion}"),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
));
|
||||
}
|
||||
|
||||
let width = area.width;
|
||||
let available = width as usize;
|
||||
|
||||
// Build left side: [MODE] context_bar
|
||||
let context_text = context_indicator(app);
|
||||
let mode_badge = Span::styled(
|
||||
format!(" {} ", app.mode.label()),
|
||||
mode_badge_style(app.mode),
|
||||
);
|
||||
|
||||
let context_info = Span::styled(
|
||||
context_text.clone(),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
);
|
||||
|
||||
// Calculate widths for left side
|
||||
let mode_width = mode_badge.content.width();
|
||||
let context_width = context_text.width();
|
||||
let left_min_width = mode_width + 1 + context_width; // mode + space + context
|
||||
|
||||
// Build right side: key hints and other info
|
||||
let mut right_spans = Vec::new();
|
||||
|
||||
// Add scroll info if applicable
|
||||
let can_scroll = app.last_transcript_total > app.last_transcript_visible;
|
||||
if can_scroll {
|
||||
spans.push(Span::raw(" | "));
|
||||
spans.push(Span::styled(
|
||||
"Alt+Up/Down scroll",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
));
|
||||
}
|
||||
|
||||
if can_scroll && !matches!(app.transcript_scroll, TranscriptScroll::ToBottom) {
|
||||
spans.push(Span::raw(" | "));
|
||||
spans.push(Span::styled(
|
||||
format!(
|
||||
"Scrolled {}/{}",
|
||||
app.last_transcript_top + 1,
|
||||
app.last_transcript_total.max(1)
|
||||
),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
right_spans.push(Span::styled(
|
||||
format!("{}% ", app.last_transcript_top + 1),
|
||||
Style::default().fg(palette::TEXT_DIM),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
// Add selection hint if active
|
||||
if app.transcript_selection.is_active() {
|
||||
spans.push(Span::raw(" | "));
|
||||
spans.push(Span::styled(
|
||||
right_spans.push(Span::styled(
|
||||
copy_selection_hint(),
|
||||
Style::default().fg(palette::TEXT_DIM),
|
||||
));
|
||||
right_spans.push(Span::raw(" "));
|
||||
}
|
||||
|
||||
// Add RLM usage badge when in RLM mode
|
||||
if app.mode == AppMode::Rlm {
|
||||
if let Some((badge, style)) = rlm_usage_badge(app) {
|
||||
right_spans.push(Span::styled(badge, style));
|
||||
right_spans.push(Span::styled(
|
||||
" · ",
|
||||
Style::default().fg(palette::TEXT_DIM),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Add key hints
|
||||
right_spans.extend(footer_key_hints(width, app));
|
||||
|
||||
// Calculate right side width
|
||||
let spans_width = |spans: &[Span]| -> usize {
|
||||
spans.iter().map(|s| s.content.width()).sum()
|
||||
};
|
||||
let mut right_width = spans_width(&right_spans);
|
||||
|
||||
// Determine layout based on available space
|
||||
let left_spans: Vec<Span>;
|
||||
let left_width: usize;
|
||||
|
||||
if width >= 80 {
|
||||
// Wide: show full context bar
|
||||
left_spans = vec![mode_badge, Span::raw(" "), context_info];
|
||||
left_width = left_min_width;
|
||||
} else if width >= 50 {
|
||||
// Medium: show percentage only
|
||||
let percent = get_context_percent(app);
|
||||
let short_context = format!("{}%", percent);
|
||||
let short_width = mode_width + 1 + short_context.width();
|
||||
left_spans = vec![mode_badge, Span::raw(" "), Span::styled(
|
||||
short_context,
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
));
|
||||
)];
|
||||
left_width = short_width;
|
||||
} else {
|
||||
// Narrow: just mode badge
|
||||
left_spans = vec![mode_badge];
|
||||
left_width = mode_width;
|
||||
}
|
||||
|
||||
spans.extend(footer_key_hints(area.width, app));
|
||||
|
||||
// Trim right side if it doesn't fit
|
||||
let available_for_right = available.saturating_sub(left_width);
|
||||
if right_width > available_for_right {
|
||||
while right_width > available_for_right && !right_spans.is_empty() {
|
||||
if let Some(span) = right_spans.pop() {
|
||||
right_width = right_width.saturating_sub(span.content.width());
|
||||
}
|
||||
}
|
||||
while let Some(last) = right_spans.last() {
|
||||
let content = last.content.as_ref();
|
||||
if content.trim().is_empty() || content == " · " {
|
||||
let span = right_spans.pop().unwrap();
|
||||
right_width = right_width.saturating_sub(span.content.width());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mid_spacing = available.saturating_sub(left_width + right_width);
|
||||
|
||||
// Combine all spans
|
||||
let mut all_spans = left_spans;
|
||||
|
||||
// Add spacing between left and right
|
||||
if mid_spacing > 0 {
|
||||
all_spans.push(Span::raw(" ".repeat(mid_spacing)));
|
||||
}
|
||||
|
||||
// Add right side spans
|
||||
all_spans.extend(right_spans);
|
||||
|
||||
// Add status message if present (replaces everything)
|
||||
if let Some(ref msg) = app.status_message {
|
||||
spans.push(Span::raw(" | "));
|
||||
spans.push(Span::styled(
|
||||
msg,
|
||||
Style::default().fg(palette::DEEPSEEK_SKY),
|
||||
));
|
||||
let status_span = Span::styled(msg, Style::default().fg(palette::DEEPSEEK_SKY));
|
||||
all_spans = vec![status_span];
|
||||
}
|
||||
|
||||
let footer = Paragraph::new(Line::from(spans));
|
||||
|
||||
let footer = Paragraph::new(Line::from(all_spans));
|
||||
f.render_widget(footer, area);
|
||||
}
|
||||
|
||||
/// Get context usage percentage for compact display
|
||||
fn get_context_percent(app: &App) -> u8 {
|
||||
let used = if app.total_conversation_tokens > 0 {
|
||||
Some(i64::from(app.total_conversation_tokens))
|
||||
} else {
|
||||
estimated_context_tokens(app)
|
||||
};
|
||||
|
||||
if let Some(max) = context_window_for_model(&app.model) {
|
||||
if let Some(used) = used {
|
||||
let max_i64 = i64::from(max);
|
||||
let remaining = (max_i64 - used).max(0);
|
||||
let percent_remaining = ((remaining.saturating_mul(100) + max_i64 / 2) / max_i64).clamp(0, 100);
|
||||
100 - percent_remaining as u8
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_key_hints(width: u16, app: &App) -> Vec<Span<'static>> {
|
||||
let mut hints = vec!["F1 help", "Ctrl+R sessions", "l pager", "Ctrl+V paste"];
|
||||
if app.transcript_selection.is_active() {
|
||||
hints.push("Enter preview");
|
||||
}
|
||||
|
||||
let max_hints = if width < 70 {
|
||||
let max_hints = if width < 60 {
|
||||
1
|
||||
} else if width < 100 {
|
||||
} else if width < 90 {
|
||||
2
|
||||
} else if width < 130 {
|
||||
} else if width < 120 {
|
||||
3
|
||||
} else {
|
||||
hints.len()
|
||||
};
|
||||
|
||||
let mut spans = Vec::new();
|
||||
for hint in hints.into_iter().take(max_hints) {
|
||||
spans.push(Span::raw(" | "));
|
||||
for (idx, hint) in hints.into_iter().take(max_hints).enumerate() {
|
||||
if idx > 0 {
|
||||
spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM)));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
hint.to_string(),
|
||||
Style::default().fg(palette::TEXT_DIM),
|
||||
@@ -2179,12 +2295,12 @@ fn rlm_usage_badge(app: &App) -> Option<(String, Style)> {
|
||||
|
||||
fn mode_color(mode: AppMode) -> Color {
|
||||
match mode {
|
||||
AppMode::Normal => palette::DEEPSEEK_SLATE,
|
||||
AppMode::Agent => palette::DEEPSEEK_BLUE,
|
||||
AppMode::Yolo => palette::DEEPSEEK_SKY,
|
||||
AppMode::Plan => palette::DEEPSEEK_SKY,
|
||||
AppMode::Rlm => palette::DEEPSEEK_INK,
|
||||
AppMode::Duo => palette::DEEPSEEK_NAVY,
|
||||
AppMode::Normal => palette::MODE_NORMAL,
|
||||
AppMode::Agent => palette::MODE_AGENT,
|
||||
AppMode::Yolo => palette::MODE_YOLO,
|
||||
AppMode::Plan => palette::MODE_PLAN,
|
||||
AppMode::Rlm => palette::MODE_RLM,
|
||||
AppMode::Duo => palette::MODE_DUO,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2212,6 +2328,17 @@ fn prompt_for_mode(mode: AppMode, rlm_repl_active: bool) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_context_bar(percentage: u8, width: usize) -> String {
|
||||
let filled = (percentage as usize * width / 100).min(width);
|
||||
let empty = width - filled;
|
||||
format!(
|
||||
"[{}{}] {}%",
|
||||
"█".repeat(filled),
|
||||
"░".repeat(empty),
|
||||
percentage
|
||||
)
|
||||
}
|
||||
|
||||
fn context_indicator(app: &App) -> String {
|
||||
let used = if app.total_conversation_tokens > 0 {
|
||||
Some(i64::from(app.total_conversation_tokens))
|
||||
@@ -2223,15 +2350,16 @@ fn context_indicator(app: &App) -> String {
|
||||
if let Some(used) = used {
|
||||
let max_i64 = i64::from(max);
|
||||
let remaining = (max_i64 - used).max(0);
|
||||
let percent = ((remaining.saturating_mul(100) + max_i64 / 2) / max_i64).clamp(0, 100);
|
||||
format!("{percent}% context left")
|
||||
let percent_remaining = ((remaining.saturating_mul(100) + max_i64 / 2) / max_i64).clamp(0, 100);
|
||||
let percent_used = (100 - percent_remaining) as u8;
|
||||
render_context_bar(percent_used, 10)
|
||||
} else {
|
||||
"100% context left".to_string()
|
||||
render_context_bar(0, 10)
|
||||
}
|
||||
} else if let Some(used) = used {
|
||||
format!("{used} used")
|
||||
} else {
|
||||
"100% context left".to_string()
|
||||
render_context_bar(0, 10)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2268,6 +2396,16 @@ fn deepseek_squiggle(start: Option<Instant>) -> &'static str {
|
||||
FRAMES[idx]
|
||||
}
|
||||
|
||||
/// Braille pattern frames for typing/thinking indicator animation.
|
||||
const TYPING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
/// Returns the typing indicator frame based on elapsed time.
|
||||
fn typing_indicator(start: Option<Instant>) -> &'static str {
|
||||
let elapsed_ms = start.map_or(0, |t| t.elapsed().as_millis());
|
||||
let idx = ((elapsed_ms / 80) as usize) % TYPING_FRAMES.len();
|
||||
TYPING_FRAMES[idx]
|
||||
}
|
||||
|
||||
fn deepseek_thinking_label(start: Option<Instant>) -> &'static str {
|
||||
const TAGLINES: [&str; 5] = [
|
||||
"Thinking",
|
||||
|
||||
+73
-32
@@ -202,6 +202,75 @@ impl ModalView for HelpView {
|
||||
Style::default().fg(palette::DEEPSEEK_BLUE).bold(),
|
||||
)]),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Navigation ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Up / Down - Scroll transcript (or navigate history)"),
|
||||
Line::from(" Ctrl+Up / Ctrl+Down - Navigate input history"),
|
||||
Line::from(" Alt+Up / Alt+Down - Scroll transcript"),
|
||||
Line::from(" PageUp / PageDown - Scroll transcript by page"),
|
||||
Line::from(" Home / End - Jump to top / bottom of transcript"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Input Editing ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Left / Right - Move cursor"),
|
||||
Line::from(" Ctrl+A / Ctrl+E - Jump to start / end of line"),
|
||||
Line::from(" Backspace / Delete - Delete character before / after cursor"),
|
||||
Line::from(" Ctrl+U - Clear entire input line"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Multi-line Input ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Ctrl+J / Alt+Enter - Insert newline (without submitting)"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Actions ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Enter - Submit message"),
|
||||
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(" l - Open pager for last message (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(" Ctrl+X - Toggle between Agent and Normal modes"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Sessions ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Ctrl+R - Open session picker"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Clipboard ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Ctrl+V - Paste from clipboard (Cmd+V on macOS)"),
|
||||
Line::from(" Ctrl+Shift+C - Copy selection (Cmd+C on macOS)"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Help ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" F1 / Ctrl+/ - Toggle this help view"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Mouse ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Scroll wheel - Scroll transcript"),
|
||||
Line::from(" Drag to select - Select text (auto-copies on release)"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"Modes:",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
@@ -226,12 +295,12 @@ impl ModalView for HelpView {
|
||||
"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(" /rlm or /aleph - Enter external memory mode"));
|
||||
help_lines.push(Line::from(
|
||||
" /load @path - load a file into RLM context",
|
||||
" /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(" /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(
|
||||
@@ -243,34 +312,6 @@ impl ModalView for HelpView {
|
||||
));
|
||||
help_lines.push(Line::from(" mcp_* - Tools exposed by MCP servers"));
|
||||
help_lines.push(Line::from(""));
|
||||
help_lines.push(Line::from(vec![Span::styled(
|
||||
"Keyboard Shortcuts:",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]));
|
||||
help_lines.push(Line::from(" Enter - Send message"));
|
||||
help_lines.push(Line::from(" Esc - Cancel request / clear input"));
|
||||
help_lines.push(Line::from(" Ctrl+C - Exit"));
|
||||
help_lines.push(Line::from(" Ctrl+U - Clear input line"));
|
||||
help_lines.push(Line::from(" Ctrl+A / E - Move to start/end of line"));
|
||||
help_lines.push(Line::from(" Alt+Up/Down - Scroll transcript"));
|
||||
help_lines.push(Line::from(" Ctrl+R - Session picker"));
|
||||
help_lines.push(Line::from(" l - Pager for last message"));
|
||||
help_lines.push(Line::from(" Enter (sel) - Pager for selection"));
|
||||
help_lines.push(Line::from(
|
||||
" Ctrl+Shift+C - Copy selection (Cmd+C on macOS)",
|
||||
));
|
||||
help_lines.push(Line::from(" Ctrl+V - Paste (Cmd+V on macOS)"));
|
||||
help_lines.push(Line::from(" F1 - Toggle this help"));
|
||||
help_lines.push(Line::from(""));
|
||||
help_lines.push(Line::from(vec![Span::styled(
|
||||
"Mouse:",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]));
|
||||
help_lines.push(Line::from(" Scroll wheel - Scroll transcript"));
|
||||
help_lines.push(Line::from(
|
||||
" Drag select - Select text (auto‑copies on release)",
|
||||
));
|
||||
help_lines.push(Line::from(""));
|
||||
|
||||
let total_lines = help_lines.len();
|
||||
let visible_lines = (popup_height as usize).saturating_sub(3);
|
||||
|
||||
+102
-22
@@ -17,7 +17,9 @@ use super::Renderable;
|
||||
/// Data required to render the header bar.
|
||||
pub struct HeaderData<'a> {
|
||||
pub model: &'a str,
|
||||
pub mode: AppMode,
|
||||
pub is_streaming: bool,
|
||||
pub context_percent: Option<u8>,
|
||||
pub background: ratatui::style::Color,
|
||||
}
|
||||
|
||||
@@ -25,15 +27,24 @@ impl<'a> HeaderData<'a> {
|
||||
/// Create header data from common app fields.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
_mode: AppMode,
|
||||
mode: AppMode,
|
||||
model: &'a str,
|
||||
_context_used: u32,
|
||||
context_used: u32,
|
||||
is_streaming: bool,
|
||||
background: ratatui::style::Color,
|
||||
) -> Self {
|
||||
// Calculate context percentage
|
||||
let context_percent = crate::models::context_window_for_model(model).map(|max| {
|
||||
let max_u32 = max;
|
||||
let pct = (context_used * 100 / max_u32.max(1)).min(100) as u8;
|
||||
pct
|
||||
});
|
||||
|
||||
Self {
|
||||
model,
|
||||
mode,
|
||||
is_streaming,
|
||||
context_percent,
|
||||
background,
|
||||
}
|
||||
}
|
||||
@@ -41,7 +52,7 @@ impl<'a> HeaderData<'a> {
|
||||
|
||||
/// Header bar widget (1 line height).
|
||||
///
|
||||
/// Layout: `[MODE] | model-name | Context: XX% | [streaming indicator]`
|
||||
/// Layout: `[MODE] model-name | Context: XX% [streaming indicator]`
|
||||
pub struct HeaderWidget<'a> {
|
||||
data: HeaderData<'a>,
|
||||
}
|
||||
@@ -52,11 +63,35 @@ impl<'a> HeaderWidget<'a> {
|
||||
Self { data }
|
||||
}
|
||||
|
||||
/// Get the color for a mode.
|
||||
fn mode_color(mode: AppMode) -> ratatui::style::Color {
|
||||
match mode {
|
||||
AppMode::Normal => palette::MODE_NORMAL,
|
||||
AppMode::Agent => palette::MODE_AGENT,
|
||||
AppMode::Yolo => palette::MODE_YOLO,
|
||||
AppMode::Plan => palette::MODE_PLAN,
|
||||
AppMode::Rlm => palette::MODE_RLM,
|
||||
AppMode::Duo => palette::MODE_DUO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the mode badge span.
|
||||
fn mode_badge(&self) -> Span<'static> {
|
||||
let label = self.data.mode.label();
|
||||
let color = Self::mode_color(self.data.mode);
|
||||
Span::styled(
|
||||
format!("[{label}]"),
|
||||
Style::default()
|
||||
.fg(color)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the model name span.
|
||||
fn model_span(&self) -> Span<'static> {
|
||||
// Truncate long model names
|
||||
let display_name = if self.data.model.len() > 20 {
|
||||
format!("{}...", &self.data.model[..17])
|
||||
let display_name = if self.data.model.len() > 25 {
|
||||
format!("{}...", &self.data.model[..22])
|
||||
} else {
|
||||
self.data.model.to_string()
|
||||
};
|
||||
@@ -64,6 +99,23 @@ impl<'a> HeaderWidget<'a> {
|
||||
Span::styled(display_name, Style::default().fg(palette::TEXT_MUTED))
|
||||
}
|
||||
|
||||
/// Build the context usage span.
|
||||
fn context_span(&self) -> Option<Span<'static>> {
|
||||
let pct = self.data.context_percent?;
|
||||
let color = if pct < 50 {
|
||||
palette::TEXT_DIM
|
||||
} else if pct < 80 {
|
||||
palette::STATUS_WARNING
|
||||
} else {
|
||||
palette::STATUS_ERROR
|
||||
};
|
||||
|
||||
Some(Span::styled(
|
||||
format!(" {pct}% "),
|
||||
Style::default().fg(color),
|
||||
))
|
||||
}
|
||||
|
||||
/// Build the streaming indicator span.
|
||||
fn streaming_indicator(&self) -> Option<Span<'static>> {
|
||||
if !self.data.is_streaming {
|
||||
@@ -71,7 +123,7 @@ impl<'a> HeaderWidget<'a> {
|
||||
}
|
||||
|
||||
Some(Span::styled(
|
||||
" streaming... ",
|
||||
"●",
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -85,42 +137,70 @@ impl Renderable for HeaderWidget<'_> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build left section: model name only (Mode is in footer)
|
||||
let mut left_spans = vec![self.model_span()];
|
||||
// Build left section: mode badge + model name
|
||||
let mode_span = self.mode_badge();
|
||||
let model_span = self.model_span();
|
||||
|
||||
// Build right section: streaming indicator
|
||||
// Build right section: context percentage + streaming indicator
|
||||
let context_span = self.context_span();
|
||||
let streaming_span = self.streaming_indicator();
|
||||
|
||||
// Calculate widths
|
||||
let left_width: usize = left_spans.iter().map(|s| s.content.width()).sum();
|
||||
let right_width = streaming_span.as_ref().map_or(0, |s| s.content.width());
|
||||
let mode_width = mode_span.content.width();
|
||||
let model_width = model_span.content.width();
|
||||
let context_width = context_span.as_ref().map_or(0, |s| s.content.width());
|
||||
let streaming_width = streaming_span.as_ref().map_or(0, |s| s.content.width());
|
||||
|
||||
let left_width = mode_width + 1 + model_width; // mode + space + model
|
||||
let right_width = context_width + streaming_width;
|
||||
|
||||
let total_content = left_width + right_width + 2; // + padding
|
||||
let available = area.width as usize;
|
||||
|
||||
// Build final line based on available space
|
||||
let mut spans = Vec::new();
|
||||
|
||||
if available >= total_content {
|
||||
// Full layout: left | (spacer) | right
|
||||
spans.append(&mut left_spans);
|
||||
if available >= left_width + right_width + 2 {
|
||||
// Full layout: [MODE] model | (spacer) | context streaming
|
||||
spans.push(mode_span);
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(model_span);
|
||||
|
||||
// Spacer
|
||||
// Spacer to push right elements to the end
|
||||
let padding_needed = available.saturating_sub(left_width + right_width);
|
||||
if padding_needed > 0 {
|
||||
spans.push(Span::raw(" ".repeat(padding_needed)));
|
||||
}
|
||||
|
||||
// Add streaming on right
|
||||
// Add context percentage
|
||||
if let Some(context) = context_span {
|
||||
spans.push(context);
|
||||
}
|
||||
|
||||
// Add streaming indicator
|
||||
if let Some(streaming) = streaming_span {
|
||||
spans.push(streaming);
|
||||
}
|
||||
} else if available >= left_width {
|
||||
// Minimal: just model
|
||||
spans.append(&mut left_spans);
|
||||
} else if available >= mode_width + 1 + model_width.min(10) {
|
||||
// Compact layout: [MODE] truncated_model
|
||||
spans.push(mode_span);
|
||||
spans.push(Span::raw(" "));
|
||||
// Truncate model if needed
|
||||
let model_str = self.data.model;
|
||||
let display_model = if model_str.len() > 10 {
|
||||
format!("{}...", &model_str[..7])
|
||||
} else {
|
||||
model_str.to_string()
|
||||
};
|
||||
spans.push(Span::styled(display_model, Style::default().fg(palette::TEXT_MUTED)));
|
||||
} else if available >= mode_width {
|
||||
// Minimal: just mode badge
|
||||
spans.push(mode_span);
|
||||
} else {
|
||||
// Ultra-minimal: just model
|
||||
spans.push(self.model_span());
|
||||
// Ultra-minimal: truncated mode
|
||||
spans.push(Span::styled(
|
||||
&self.data.mode.label()[..1.min(self.data.mode.label().len())],
|
||||
Style::default().fg(Self::mode_color(self.data.mode)),
|
||||
));
|
||||
}
|
||||
|
||||
let line = Line::from(spans);
|
||||
|
||||
@@ -0,0 +1,785 @@
|
||||
//! Repo-aware working set tracking and prompt context packing.
|
||||
//!
|
||||
//! The goal of this module is to keep a small, high-signal list of
|
||||
//! "active" paths that the assistant should prioritize. It observes
|
||||
//! user messages and tool calls, extracts likely paths, and produces:
|
||||
//! - a compact working-set summary block for the system prompt
|
||||
//! - pinned message indices that compaction should preserve
|
||||
|
||||
use crate::models::{ContentBlock, Message};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Configuration for working-set tracking.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkingSetConfig {
|
||||
/// Maximum number of entries to keep.
|
||||
pub max_entries: usize,
|
||||
/// Maximum number of paths to pin during compaction.
|
||||
pub max_pinned_paths: usize,
|
||||
/// Maximum characters to scan per text block when pinning messages.
|
||||
pub max_scan_chars: usize,
|
||||
/// Maximum entries to show in the system prompt block.
|
||||
pub max_prompt_entries: usize,
|
||||
}
|
||||
|
||||
impl Default for WorkingSetConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_entries: 16,
|
||||
max_pinned_paths: 8,
|
||||
max_scan_chars: 2_000,
|
||||
max_prompt_entries: 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The source that most recently updated an entry.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum WorkingSetSource {
|
||||
UserMessage,
|
||||
ToolInput,
|
||||
ToolOutput,
|
||||
Rebuild,
|
||||
}
|
||||
|
||||
/// A single working-set entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkingSetEntry {
|
||||
/// Workspace-relative path string.
|
||||
pub path: String,
|
||||
/// Whether the path is a directory (best-effort).
|
||||
pub is_dir: bool,
|
||||
/// Whether the path exists on disk (best-effort).
|
||||
pub exists: bool,
|
||||
/// Number of times this path was observed.
|
||||
pub touches: u32,
|
||||
/// The last observed turn index.
|
||||
pub last_turn: u64,
|
||||
/// The last update source.
|
||||
pub last_source: WorkingSetSource,
|
||||
}
|
||||
|
||||
impl WorkingSetEntry {
|
||||
fn new(path: String, exists: bool, is_dir: bool, turn: u64, source: WorkingSetSource) -> Self {
|
||||
Self {
|
||||
path,
|
||||
is_dir,
|
||||
exists,
|
||||
touches: 1,
|
||||
last_turn: turn,
|
||||
last_source: source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Repo-aware working-set state.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct WorkingSet {
|
||||
/// Tracking configuration.
|
||||
pub config: WorkingSetConfig,
|
||||
/// Monotonic turn counter (increments on user messages).
|
||||
pub turn: u64,
|
||||
/// Path entries keyed by workspace-relative path.
|
||||
pub entries: HashMap<String, WorkingSetEntry>,
|
||||
}
|
||||
|
||||
impl WorkingSet {
|
||||
/// Advance to the next turn.
|
||||
pub fn next_turn(&mut self) {
|
||||
self.turn = self.turn.saturating_add(1);
|
||||
}
|
||||
|
||||
/// Observe a user message and update the working set.
|
||||
pub fn observe_user_message(&mut self, text: &str, workspace: &Path) {
|
||||
self.next_turn();
|
||||
let paths = extract_paths_from_text(text);
|
||||
self.record_candidates(paths, workspace, WorkingSetSource::UserMessage);
|
||||
}
|
||||
|
||||
/// Observe a tool call (input and optional output).
|
||||
pub fn observe_tool_call(
|
||||
&mut self,
|
||||
tool_name: &str,
|
||||
input: &Value,
|
||||
output: Option<&str>,
|
||||
workspace: &Path,
|
||||
) {
|
||||
let input_candidates = extract_paths_from_value(input, Some(tool_name));
|
||||
self.record_candidates(input_candidates, workspace, WorkingSetSource::ToolInput);
|
||||
|
||||
if let Some(text) = output {
|
||||
let output_candidates = extract_paths_from_text(text);
|
||||
self.record_candidates(output_candidates, workspace, WorkingSetSource::ToolOutput);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the working set from existing messages (best effort).
|
||||
///
|
||||
/// This is used when syncing a resumed session.
|
||||
pub fn rebuild_from_messages(&mut self, messages: &[Message], workspace: &Path) {
|
||||
self.entries.clear();
|
||||
self.turn = 0;
|
||||
|
||||
for message in messages {
|
||||
if message.role == "user" {
|
||||
self.next_turn();
|
||||
}
|
||||
let candidates = extract_paths_from_message(message);
|
||||
if candidates.is_empty() {
|
||||
continue;
|
||||
}
|
||||
self.record_candidates(candidates, workspace, WorkingSetSource::Rebuild);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a compact working-set block for the system prompt.
|
||||
pub fn summary_block(&self, workspace: &Path) -> Option<String> {
|
||||
let entries = self.sorted_entries();
|
||||
let prompt_entries: Vec<&WorkingSetEntry> = entries
|
||||
.into_iter()
|
||||
.take(self.config.max_prompt_entries)
|
||||
.collect();
|
||||
|
||||
let repo_summary = summarize_repo_root(workspace);
|
||||
|
||||
if repo_summary.is_none() && prompt_entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push("## Repo Working Set".to_string());
|
||||
lines.push(format!("Workspace: {}", workspace.display()));
|
||||
|
||||
if let Some(summary) = repo_summary {
|
||||
lines.push(summary);
|
||||
}
|
||||
|
||||
if !prompt_entries.is_empty() {
|
||||
lines.push("Active paths (prioritize these):".to_string());
|
||||
for entry in prompt_entries {
|
||||
let age = self.turn.saturating_sub(entry.last_turn);
|
||||
let kind = if entry.is_dir { "dir" } else { "file" };
|
||||
lines.push(format!(
|
||||
"- {} ({kind}, touches: {}, last seen: {} turn(s) ago)",
|
||||
entry.path, entry.touches, age
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"When in doubt, use tools to verify and keep changes focused on the working set."
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
/// Return the most relevant paths in score order.
|
||||
pub fn top_paths(&self, limit: usize) -> Vec<String> {
|
||||
self.sorted_entries()
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|entry| entry.path.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Identify message indices that should be pinned during compaction.
|
||||
pub fn pinned_message_indices(&self, messages: &[Message], workspace: &Path) -> Vec<usize> {
|
||||
if messages.is_empty() || self.entries.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let pinned_paths: Vec<&WorkingSetEntry> = self
|
||||
.sorted_entries()
|
||||
.into_iter()
|
||||
.take(self.config.max_pinned_paths)
|
||||
.collect();
|
||||
if pinned_paths.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let needles = build_search_needles(&pinned_paths, workspace);
|
||||
if needles.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut pinned: Vec<usize> = Vec::new();
|
||||
for (idx, message) in messages.iter().enumerate() {
|
||||
if message_mentions_any_path(message, &needles, self.config.max_scan_chars) {
|
||||
pinned.push(idx);
|
||||
}
|
||||
}
|
||||
pinned
|
||||
}
|
||||
|
||||
fn record_candidates(
|
||||
&mut self,
|
||||
candidates: Vec<String>,
|
||||
workspace: &Path,
|
||||
source: WorkingSetSource,
|
||||
) {
|
||||
if candidates.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let workspace_canon = workspace.canonicalize().ok();
|
||||
|
||||
for raw in candidates {
|
||||
let Some(normalized) = normalize_candidate(&raw) else {
|
||||
continue;
|
||||
};
|
||||
let Some((rel, exists, is_dir)) =
|
||||
relativize_candidate(&normalized, workspace, workspace_canon.as_deref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
self.record_path(rel, exists, is_dir, source);
|
||||
}
|
||||
|
||||
self.prune();
|
||||
}
|
||||
|
||||
fn record_path(&mut self, rel: String, exists: bool, is_dir: bool, source: WorkingSetSource) {
|
||||
match self.entries.get_mut(&rel) {
|
||||
Some(entry) => {
|
||||
entry.exists |= exists;
|
||||
entry.is_dir |= is_dir;
|
||||
entry.touches = entry.touches.saturating_add(1);
|
||||
entry.last_turn = self.turn;
|
||||
entry.last_source = source;
|
||||
}
|
||||
None => {
|
||||
let entry = WorkingSetEntry::new(rel.clone(), exists, is_dir, self.turn, source);
|
||||
let _ = self.entries.insert(rel, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prune(&mut self) {
|
||||
let max_entries = self.config.max_entries;
|
||||
if self.entries.len() <= max_entries {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rank by score ascending and drop the lowest until within bounds.
|
||||
let mut ranked: Vec<(String, i64)> = self
|
||||
.entries
|
||||
.values()
|
||||
.map(|entry| (entry.path.clone(), score_entry(entry, self.turn)))
|
||||
.collect();
|
||||
ranked.sort_by(|a, b| a.1.cmp(&b.1));
|
||||
|
||||
let to_remove = self.entries.len().saturating_sub(max_entries);
|
||||
for (path, _) in ranked.into_iter().take(to_remove) {
|
||||
let _ = self.entries.remove(&path);
|
||||
}
|
||||
}
|
||||
|
||||
fn sorted_entries(&self) -> Vec<&WorkingSetEntry> {
|
||||
let mut entries: Vec<&WorkingSetEntry> = self.entries.values().collect();
|
||||
entries.sort_by(|a, b| {
|
||||
let sb = score_entry(b, self.turn);
|
||||
let sa = score_entry(a, self.turn);
|
||||
sb.cmp(&sa).then_with(|| a.path.cmp(&b.path))
|
||||
});
|
||||
entries
|
||||
}
|
||||
}
|
||||
|
||||
fn score_entry(entry: &WorkingSetEntry, current_turn: u64) -> i64 {
|
||||
let age = current_turn.saturating_sub(entry.last_turn);
|
||||
let recency_bonus = match age {
|
||||
0 => 6,
|
||||
1 => 4,
|
||||
2 => 3,
|
||||
3..=5 => 2,
|
||||
6..=10 => 1,
|
||||
_ => 0,
|
||||
};
|
||||
i64::from(entry.touches) * 4 + recency_bonus
|
||||
}
|
||||
|
||||
fn normalize_candidate(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim().trim_matches(|c: char| {
|
||||
matches!(
|
||||
c,
|
||||
'"' | '\'' | '`' | ',' | ';' | ':' | '(' | ')' | '[' | ']'
|
||||
)
|
||||
});
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn relativize_candidate(
|
||||
candidate: &str,
|
||||
workspace: &Path,
|
||||
workspace_canon: Option<&Path>,
|
||||
) -> Option<(String, bool, bool)> {
|
||||
let candidate_path = Path::new(candidate);
|
||||
|
||||
// Reject obvious URLs and non-paths early.
|
||||
if candidate.contains("://") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (rel_path, abs_path) = if candidate_path.is_absolute() {
|
||||
let within_workspace = workspace_canon
|
||||
.map(|ws| candidate_path.starts_with(ws))
|
||||
.unwrap_or_else(|| candidate_path.starts_with(workspace));
|
||||
if !within_workspace {
|
||||
return None;
|
||||
}
|
||||
let rel = candidate_path.strip_prefix(workspace).ok()?.to_path_buf();
|
||||
(rel, candidate_path.to_path_buf())
|
||||
} else {
|
||||
if starts_with_parent_dir(candidate_path) {
|
||||
return None;
|
||||
}
|
||||
let rel = clean_relative(candidate_path);
|
||||
let abs = workspace.join(&rel);
|
||||
(rel, abs)
|
||||
};
|
||||
|
||||
let metadata = fs::metadata(&abs_path).ok();
|
||||
let exists = metadata.is_some();
|
||||
let is_dir = metadata
|
||||
.as_ref()
|
||||
.map(fs::Metadata::is_dir)
|
||||
.unwrap_or_else(|| candidate.ends_with('/'));
|
||||
|
||||
let rel_string = path_to_string(&rel_path)?;
|
||||
Some((rel_string, exists, is_dir))
|
||||
}
|
||||
|
||||
fn starts_with_parent_dir(path: &Path) -> bool {
|
||||
matches!(
|
||||
path.components().next(),
|
||||
Some(std::path::Component::ParentDir)
|
||||
)
|
||||
}
|
||||
|
||||
fn clean_relative(path: &Path) -> PathBuf {
|
||||
use std::path::Component;
|
||||
|
||||
let mut parts: Vec<PathBuf> = Vec::new();
|
||||
for comp in path.components() {
|
||||
match comp {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
let _ = parts.pop();
|
||||
}
|
||||
Component::Normal(p) => parts.push(PathBuf::from(p)),
|
||||
Component::RootDir | Component::Prefix(_) => {}
|
||||
}
|
||||
}
|
||||
let mut out = PathBuf::new();
|
||||
for part in parts {
|
||||
out.push(part);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn path_to_string(path: &Path) -> Option<String> {
|
||||
path.as_os_str().to_str().map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn extract_paths_from_message(message: &Message) -> Vec<String> {
|
||||
let mut paths = Vec::new();
|
||||
for block in &message.content {
|
||||
match block {
|
||||
ContentBlock::Text { text, .. } => {
|
||||
paths.extend(extract_paths_from_text(text));
|
||||
}
|
||||
ContentBlock::ToolUse { input, .. } => {
|
||||
paths.extend(extract_paths_from_value(input, None));
|
||||
}
|
||||
ContentBlock::ToolResult { content, .. } => {
|
||||
paths.extend(extract_paths_from_text(content));
|
||||
}
|
||||
ContentBlock::Thinking { .. } => {}
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
fn extract_paths_from_value(value: &Value, tool_hint: Option<&str>) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
extract_paths_from_value_inner(value, tool_hint, None, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn extract_paths_from_value_inner(
|
||||
value: &Value,
|
||||
tool_hint: Option<&str>,
|
||||
key_hint: Option<&str>,
|
||||
out: &mut Vec<String>,
|
||||
) {
|
||||
match value {
|
||||
Value::String(s) => {
|
||||
let key_suggests_path = key_hint.map(key_is_path_like).unwrap_or(false);
|
||||
if key_suggests_path || looks_like_path(s) {
|
||||
out.extend(extract_paths_from_text(s));
|
||||
if key_suggests_path && !s.contains('/') && !s.contains('\\') {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
} else if tool_hint == Some("exec_shell") && s.len() < 400 {
|
||||
out.extend(extract_paths_from_text(s));
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
for item in arr {
|
||||
extract_paths_from_value_inner(item, tool_hint, key_hint, out);
|
||||
}
|
||||
}
|
||||
Value::Object(map) => {
|
||||
for (k, v) in map {
|
||||
extract_paths_from_value_inner(v, tool_hint, Some(k.as_str()), out);
|
||||
}
|
||||
}
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn key_is_path_like(key: &str) -> bool {
|
||||
let lower = key.to_ascii_lowercase();
|
||||
lower.contains("path")
|
||||
|| lower.contains("file")
|
||||
|| lower.contains("dir")
|
||||
|| lower.contains("cwd")
|
||||
|| lower.contains("workspace")
|
||||
|| lower.contains("root")
|
||||
|| lower == "target"
|
||||
}
|
||||
|
||||
fn looks_like_path(text: &str) -> bool {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if trimmed.contains('/') || trimmed.contains('\\') {
|
||||
return true;
|
||||
}
|
||||
match Path::new(trimmed).extension().and_then(OsStr::to_str) {
|
||||
Some(ext) => COMMON_EXTENSIONS.contains(&ext),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
const COMMON_EXTENSIONS: &[&str] = &[
|
||||
"rs", "toml", "md", "txt", "json", "yaml", "yml", "ts", "tsx", "js", "jsx", "py", "go", "java",
|
||||
"c", "cc", "cpp", "h", "hpp", "sh", "bash", "zsh", "sql", "html", "css", "scss",
|
||||
];
|
||||
|
||||
fn extract_paths_from_text(text: &str) -> Vec<String> {
|
||||
if text.trim().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let re = path_regex();
|
||||
re.find_iter(text)
|
||||
.map(|m| m.as_str().to_string())
|
||||
.filter(|s| looks_like_path(s))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn path_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
// Path-ish tokens with separators or file extensions.
|
||||
Regex::new(
|
||||
r#"(?x)
|
||||
(?:
|
||||
(?:[A-Za-z]:\\)? # optional Windows drive
|
||||
(?:\./|\../|/)? # optional leading
|
||||
[A-Za-z0-9._-]+
|
||||
(?:[/\\][A-Za-z0-9._-]+)+
|
||||
(?:\.[A-Za-z0-9]{1,8})? # optional extension
|
||||
)
|
||||
|
|
||||
(?:
|
||||
[A-Za-z0-9._-]+\.[A-Za-z0-9]{1,8}
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.expect("path regex should compile")
|
||||
})
|
||||
}
|
||||
|
||||
fn truncate_chars(text: &str, max_chars: usize) -> &str {
|
||||
if max_chars == 0 {
|
||||
return "";
|
||||
}
|
||||
match text.char_indices().nth(max_chars) {
|
||||
Some((idx, _)) => &text[..idx],
|
||||
None => text,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_search_needles(entries: &[&WorkingSetEntry], workspace: &Path) -> Vec<String> {
|
||||
let mut needles: HashSet<String> = HashSet::new();
|
||||
for entry in entries {
|
||||
let rel = entry.path.clone();
|
||||
if rel.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let abs = workspace.join(&rel);
|
||||
let abs_str = abs.as_os_str().to_str().map(ToOwned::to_owned);
|
||||
|
||||
let _ = needles.insert(rel.clone());
|
||||
if let Some(abs_str) = abs_str {
|
||||
let _ = needles.insert(abs_str);
|
||||
}
|
||||
}
|
||||
needles.into_iter().collect()
|
||||
}
|
||||
|
||||
fn message_mentions_any_path(message: &Message, needles: &[String], max_scan_chars: usize) -> bool {
|
||||
if needles.is_empty() {
|
||||
return false;
|
||||
}
|
||||
for block in &message.content {
|
||||
match block {
|
||||
ContentBlock::Text { text, .. } => {
|
||||
let snippet = truncate_chars(text, max_scan_chars);
|
||||
if contains_any(snippet, needles) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
ContentBlock::ToolUse { input, .. } => {
|
||||
if let Ok(json) = serde_json::to_string(input)
|
||||
&& contains_any(&json, needles)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
ContentBlock::ToolResult { content, .. } => {
|
||||
let snippet = truncate_chars(content, max_scan_chars);
|
||||
if contains_any(snippet, needles) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
ContentBlock::Thinking { .. } => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn contains_any(text: &str, needles: &[String]) -> bool {
|
||||
needles
|
||||
.iter()
|
||||
.any(|needle| !needle.is_empty() && text.contains(needle))
|
||||
}
|
||||
|
||||
fn summarize_repo_root(workspace: &Path) -> Option<String> {
|
||||
let key_files = detect_key_files(workspace);
|
||||
let top_dirs = list_top_level_dirs(workspace, 8);
|
||||
|
||||
if key_files.is_empty() && top_dirs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if !key_files.is_empty() {
|
||||
parts.push(format!("Key files: {}", key_files.join(", ")));
|
||||
}
|
||||
if !top_dirs.is_empty() {
|
||||
parts.push(format!("Top-level dirs: {}", top_dirs.join(", ")));
|
||||
}
|
||||
Some(parts.join("\n"))
|
||||
}
|
||||
|
||||
fn detect_key_files(workspace: &Path) -> Vec<String> {
|
||||
const CANDIDATES: &[&str] = &[
|
||||
"Cargo.toml",
|
||||
"README.md",
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"package.json",
|
||||
"pyproject.toml",
|
||||
"go.mod",
|
||||
"Makefile",
|
||||
];
|
||||
|
||||
CANDIDATES
|
||||
.iter()
|
||||
.filter_map(|name| {
|
||||
let path = workspace.join(name);
|
||||
if path.exists() {
|
||||
Some((*name).to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn list_top_level_dirs(workspace: &Path, limit: usize) -> Vec<String> {
|
||||
let mut dirs = Vec::new();
|
||||
let entries = match fs::read_dir(workspace) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return dirs,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let file_name = entry.file_name();
|
||||
let Some(name) = file_name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if name.starts_with('.') || IGNORED_ROOT_DIRS.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(meta) = entry.metadata()
|
||||
&& meta.is_dir()
|
||||
{
|
||||
dirs.push(name.to_string());
|
||||
}
|
||||
|
||||
if dirs.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dirs.sort();
|
||||
dirs
|
||||
}
|
||||
|
||||
const IGNORED_ROOT_DIRS: &[&str] = &["target", "node_modules", "dist", "build", ".git"];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_message(role: &str, text: &str) -> Message {
|
||||
Message {
|
||||
role: role.to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: text.to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_user_message_tracks_paths() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let src = tmp.path().join("src");
|
||||
let file = src.join("lib.rs");
|
||||
fs::create_dir_all(&src).expect("mkdir");
|
||||
fs::write(&file, "pub fn x() {}").expect("write");
|
||||
|
||||
let mut ws = WorkingSet::default();
|
||||
ws.observe_user_message("Please check src/lib.rs", tmp.path());
|
||||
|
||||
assert!(ws.entries.contains_key("src/lib.rs"));
|
||||
let entry = ws.entries.get("src/lib.rs").expect("entry");
|
||||
assert!(entry.exists);
|
||||
assert!(!entry.is_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_tool_call_extracts_paths_from_input() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let file = tmp.path().join("Cargo.toml");
|
||||
fs::write(&file, "[package]\nname = \"x\"").expect("write");
|
||||
|
||||
let mut ws = WorkingSet::default();
|
||||
let input = serde_json::json!({ "path": "Cargo.toml" });
|
||||
ws.observe_tool_call("read_file", &input, None, tmp.path());
|
||||
|
||||
assert!(ws.entries.contains_key("Cargo.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_message_indices_respects_working_set() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let src = tmp.path().join("src");
|
||||
fs::create_dir_all(&src).expect("mkdir");
|
||||
let file = src.join("main.rs");
|
||||
fs::write(&file, "fn main() {}").expect("write");
|
||||
|
||||
let mut ws = WorkingSet::default();
|
||||
ws.observe_user_message("Edit src/main.rs", tmp.path());
|
||||
|
||||
let messages = vec![
|
||||
make_message("user", "Unrelated text"),
|
||||
make_message("assistant", "I will read src/main.rs next."),
|
||||
make_message("user", "More unrelated text"),
|
||||
];
|
||||
|
||||
let pinned = ws.pinned_message_indices(&messages, tmp.path());
|
||||
assert_eq!(pinned, vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_block_includes_repo_and_working_set() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"").expect("write");
|
||||
let src = tmp.path().join("src");
|
||||
fs::create_dir_all(&src).expect("mkdir");
|
||||
fs::write(src.join("lib.rs"), "pub fn x() {}").expect("write");
|
||||
|
||||
let mut ws = WorkingSet::default();
|
||||
ws.observe_user_message("src/lib.rs", tmp.path());
|
||||
let block = ws.summary_block(tmp.path()).expect("block");
|
||||
|
||||
assert!(block.contains("Repo Working Set"));
|
||||
assert!(block.contains("Cargo.toml"));
|
||||
assert!(block.contains("src"));
|
||||
assert!(block.contains("src/lib.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_paths_from_message_picks_up_tool_results() {
|
||||
let msg = Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tool_1".to_string(),
|
||||
content: "Changed src/compaction.rs".to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let paths = extract_paths_from_message(&msg);
|
||||
assert!(paths.iter().any(|p| p.contains("src/compaction.rs")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinning_prefers_high_signal_paths() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
|
||||
fs::write(tmp.path().join("src/a.rs"), "a").expect("write");
|
||||
fs::write(tmp.path().join("src/b.rs"), "b").expect("write");
|
||||
|
||||
let mut ws = WorkingSet::default();
|
||||
ws.observe_user_message("src/a.rs", tmp.path());
|
||||
ws.observe_tool_call(
|
||||
"read_file",
|
||||
&serde_json::json!({ "path": "src/a.rs" }),
|
||||
Some("src/a.rs"),
|
||||
tmp.path(),
|
||||
);
|
||||
ws.observe_user_message("src/b.rs", tmp.path());
|
||||
|
||||
let a_score = score_entry(ws.entries.get("src/a.rs").expect("a"), ws.turn);
|
||||
let b_score = score_entry(ws.entries.get("src/b.rs").expect("b"), ws.turn);
|
||||
assert!(a_score >= b_score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_tokens_is_available_for_future_budgeting() {
|
||||
use crate::compaction::estimate_tokens;
|
||||
let messages = vec![make_message("user", "src/main.rs")];
|
||||
assert!(estimate_tokens(&messages) > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Integration tests for the offline evaluation harness.
|
||||
|
||||
use std::fs;
|
||||
|
||||
#[path = "../src/eval.rs"]
|
||||
mod eval;
|
||||
|
||||
use eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
|
||||
|
||||
#[test]
|
||||
fn runs_offline_tool_loop_successfully() {
|
||||
let harness = EvalHarness::default();
|
||||
let run = harness.run().expect("eval harness run should succeed");
|
||||
assert_eq!(
|
||||
ScenarioStepKind::parse("patch"),
|
||||
Some(ScenarioStepKind::ApplyPatch)
|
||||
);
|
||||
|
||||
assert!(run.metrics.success, "expected success metrics: {run:#?}");
|
||||
assert_eq!(run.metrics.tool_errors, 0);
|
||||
assert_eq!(run.metrics.steps, 6);
|
||||
assert!(run.metrics.duration.as_millis() > 0);
|
||||
assert!(!run.scenario_name.is_empty());
|
||||
assert!(run.workspace_summary.file_count >= 3);
|
||||
|
||||
for kind in [
|
||||
ScenarioStepKind::List,
|
||||
ScenarioStepKind::Read,
|
||||
ScenarioStepKind::Search,
|
||||
ScenarioStepKind::Edit,
|
||||
ScenarioStepKind::ApplyPatch,
|
||||
ScenarioStepKind::ExecShell,
|
||||
] {
|
||||
let stats = run
|
||||
.metrics
|
||||
.per_tool
|
||||
.get(&kind)
|
||||
.expect("missing per-tool stats");
|
||||
assert_eq!(stats.invocations, 1, "unexpected invocations for {kind:?}");
|
||||
assert_eq!(stats.errors, 0, "unexpected errors for {kind:?}");
|
||||
assert!(stats.total_duration.as_nanos() > 0);
|
||||
}
|
||||
|
||||
let notes_path = run.workspace_root().join("notes.txt");
|
||||
let notes = fs::read_to_string(¬es_path).expect("notes.txt should exist");
|
||||
assert!(notes.contains("edited = true"));
|
||||
assert!(notes.contains("todo: offline metrics (patched)"));
|
||||
|
||||
let report = run.to_report();
|
||||
assert_eq!(report.metrics.success, run.metrics.success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_tool_errors_when_step_fails() {
|
||||
let config = EvalHarnessConfig {
|
||||
fail_step: Some(ScenarioStepKind::ApplyPatch),
|
||||
..EvalHarnessConfig::default()
|
||||
};
|
||||
let harness = EvalHarness::new(config);
|
||||
|
||||
let run = harness
|
||||
.run()
|
||||
.expect("eval harness should return metrics even when a step fails");
|
||||
|
||||
assert!(!run.metrics.success);
|
||||
assert!(run.metrics.tool_errors >= 1);
|
||||
|
||||
let patch_stats = run
|
||||
.metrics
|
||||
.per_tool
|
||||
.get(&ScenarioStepKind::ApplyPatch)
|
||||
.expect("missing apply_patch stats");
|
||||
assert_eq!(patch_stats.invocations, 1);
|
||||
assert_eq!(patch_stats.errors, 1);
|
||||
|
||||
let patch_step = run
|
||||
.steps
|
||||
.iter()
|
||||
.find(|step| step.kind == ScenarioStepKind::ApplyPatch)
|
||||
.expect("missing apply_patch step");
|
||||
assert!(!patch_step.success);
|
||||
assert!(patch_step.error.as_deref().is_some_and(|e| !e.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_can_fail_without_tool_errors() {
|
||||
let config = EvalHarnessConfig {
|
||||
shell_expect_token: "definitely-not-in-output".to_string(),
|
||||
..EvalHarnessConfig::default()
|
||||
};
|
||||
let harness = EvalHarness::new(config);
|
||||
|
||||
let run = harness.run().expect("eval harness run should complete");
|
||||
|
||||
assert_eq!(run.metrics.tool_errors, 0);
|
||||
assert!(
|
||||
!run.metrics.success,
|
||||
"validation should fail due to shell token"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user