Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a297669b4 | ||
|
|
2e351ccf69 | ||
|
|
1d50b94eec | ||
|
|
00e29139c5 | ||
|
|
3b660e09a8 | ||
|
|
0d6f558b2b | ||
|
|
8388a83af0 | ||
|
|
104b0daf4c | ||
|
|
65647ce517 | ||
|
|
152b245f5e | ||
|
|
0155a04cee | ||
|
|
ab07d094b4 | ||
|
|
29a9fae3f6 | ||
|
|
be921d6836 | ||
|
|
cb7c388e4f | ||
|
|
a68d3e95c8 | ||
|
|
a491a371a5 |
@@ -1,3 +1,39 @@
|
||||
# [1.2.0](https://github.com/asepharyana/zesdex/compare/v1.1.0...v1.2.0) (2026-07-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* enhance responsiveness by implementing abort checks in streaming API calls ([0d6f558](https://github.com/asepharyana/zesdex/commit/0d6f558b2bd0282a7a1695f7680ab1d1c6142579))
|
||||
* refactor agent step limits and enhance workflow orchestration with new findings tool ([3b660e0](https://github.com/asepharyana/zesdex/commit/3b660e09a87f3e982db94f48d2282ddb63116341))
|
||||
* remove pipeline command and refactor workflow execution to use custom specialists ([00e2913](https://github.com/asepharyana/zesdex/commit/00e29139c53c5fed4c13b0493297dd9da984460c))
|
||||
* update overlay handling in apply_action and remove mouse capture from terminal execution ([2e351cc](https://github.com/asepharyana/zesdex/commit/2e351ccf6930ff4823f55b581308222229fe6684))
|
||||
* update README and documentation for new tools and features ([1d50b94](https://github.com/asepharyana/zesdex/commit/1d50b94eec1ed82dfc40d43d41bd01aeb79edfe1))
|
||||
|
||||
# [1.1.0](https://github.com/asepharyana/zesdex/compare/v1.0.4...v1.1.0) (2026-07-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement abort mechanism for workflows and subagents ([104b0da](https://github.com/asepharyana/zesdex/commit/104b0daf4cc51581de04f03d6b727cdb16f9b6c3))
|
||||
|
||||
## [1.0.4](https://github.com/asepharyana/zesdex/compare/v1.0.3...v1.0.4) (2026-07-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove redundant ref in format! argument ([0155a04](https://github.com/asepharyana/zesdex/commit/0155a04ceeec7f7f234c08a7f56e9a4384691655))
|
||||
|
||||
## [1.0.3](https://github.com/asepharyana/zesdex/compare/v1.0.2...v1.0.3) (2026-07-13)
|
||||
|
||||
## [1.0.2](https://github.com/asepharyana/zesdex/compare/v1.0.1...v1.0.2) (2026-07-12)
|
||||
|
||||
## [1.0.1](https://github.com/asepharyana/zesdex/compare/v1.0.0...v1.0.1) (2026-07-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve clippy uninlined_format_args and match_same_arms warnings ([a491a37](https://github.com/asepharyana/zesdex/commit/a491a371a593bf47d58ac5985bbec325bbae6e38))
|
||||
|
||||
# 1.0.0 (2026-07-12)
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ Tracing output goes to `~/.local/share/zesdex/zesdex.log`. Set `RUST_LOG=debug`
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Zesdex is an autonomous AI coding agent with a TUI — an OpenAI/Anthropic-compatible LLM client wrapped in a tool-use harness with 28 built-in tools.
|
||||
Zesdex is an autonomous AI coding agent with a TUI — an OpenAI/Anthropic-compatible LLM client wrapped in a tool-use harness with 37 built-in tools.
|
||||
|
||||
Detailed architecture documentation is in `docs/CODEMAPS/`:
|
||||
|
||||
@@ -62,7 +62,7 @@ Detailed architecture documentation is in `docs/CODEMAPS/`:
|
||||
Controller (key input → Action) → Event Loop → LLM stream → Tool execution → State mutation → TUI render
|
||||
│ │ │
|
||||
│ src/controller/input.rs │ src/app/runtime/actions/ │ src/tool/
|
||||
└── maps keys to Action enum │── dispatches Action::* └── 28 tool impls
|
||||
└── maps keys to Action enum │── dispatches Action::* └── 37 tool impls
|
||||
│ matching on Action variant
|
||||
│── applies state mutations
|
||||
```
|
||||
@@ -167,3 +167,4 @@ Rules:
|
||||
- Non-trivial private functions (≥10 lines) need a doc comment
|
||||
- Write the comment above the code it documents (not inline in the body)
|
||||
- Update comments when code behavior changes — stale docs are worse than no docs
|
||||
- NEVER use compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to silence warnings or skip linter checks. Always fix the underlying code issues instead.
|
||||
|
||||
Generated
+387
-256
File diff suppressed because it is too large
Load Diff
+10
-9
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "zesdex"
|
||||
version = "1.0.0"
|
||||
version = "1.2.0"
|
||||
edition = "2021"
|
||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||
|
||||
@@ -23,9 +23,9 @@ pedantic = { level = "warn", priority = -2 }
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.30.2"
|
||||
crossterm = "0.28"
|
||||
crossterm = "0.29"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream", "blocking", "native-tls-vendored"] }
|
||||
reqwest = { version = "0.13", features = ["json", "stream", "blocking", "native-tls-vendored", "form"] }
|
||||
dom_smoothie = "0.18.0"
|
||||
fast_html2md = "0.0.62"
|
||||
scraper = "0.27.0"
|
||||
@@ -33,23 +33,24 @@ url = "2"
|
||||
percent-encoding = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml_ng = "0.9"
|
||||
serde_yaml_ng = "0.10"
|
||||
anyhow = "1"
|
||||
include_dir = "0.7"
|
||||
uuid = { version = "1", features = ["v4", "v5"] }
|
||||
dirs = "5"
|
||||
dirs = "6"
|
||||
futures-util = "0.3"
|
||||
pulldown-cmark = { version = "0.13", default-features = false }
|
||||
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
ignore = "0.4"
|
||||
regex = "1"
|
||||
globset = "0.4"
|
||||
infer = "0.16"
|
||||
infer = "0.19"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
sha2 = "0.11"
|
||||
hex = "0.4"
|
||||
libc = "0.2"
|
||||
rmcp = { version = "1.8", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] }
|
||||
rmcp = { version = "2.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] }
|
||||
tracing = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
@@ -15,7 +15,7 @@ Zesdex is a Rust-powered AI assistant that operates directly in your terminal vi
|
||||
- **IPC Protocol** — Bidirectional state synchronization between daemon and client processes with diff-based updates.
|
||||
- **Provider Agnostic** — Configurable AI model providers with dynamic model selection, per-role temperature/token limits, and API key management.
|
||||
|
||||
### Tool System (34 built-in tools)
|
||||
### Tool System (37 built-in tools)
|
||||
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
@@ -25,8 +25,9 @@ Zesdex is a Rust-powered AI assistant that operates directly in your terminal vi
|
||||
| **Git** | `git_operator`, `git_worktree`, `git_cred` |
|
||||
| **Memory** | `remember`, `recall`, `forget` |
|
||||
| **Planning** | `plan_enter`, `plan_ready`, `seqthink` |
|
||||
| **Workflow** | `workflow_run`, `note_finding`, `company_pipeline` |
|
||||
| **Workflow** | `workflow_run`, `note_finding`, `read_findings`, `company_pipeline` |
|
||||
| **Utility** | `cd`, `dir_list`, `dir_cache_update`, `pong`, `todowrite`, `todofinish` |
|
||||
| **Agent** | `spawn_agents`, `spawn_pipeline` |
|
||||
| **LSP** | `lsp_connect`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_disconnect` |
|
||||
|
||||
### Intelligence
|
||||
@@ -83,6 +84,7 @@ src/
|
||||
│ │ ├── effort.rs # Effort level selector
|
||||
│ │ ├── help.rs # Help overlay
|
||||
│ │ ├── key_input.rs # Raw key input mode
|
||||
│ │ ├── learning.rs # Lesson management overlay
|
||||
│ │ ├── loading.rs # Loading spinner overlay
|
||||
│ │ ├── mcp.rs # MCP server management
|
||||
│ │ ├── quit_confirm.rs # Quit confirmation dialog
|
||||
|
||||
@@ -28,7 +28,7 @@ Zesdex is a single-process terminal AI coding agent with optional daemon/client
|
||||
│ │ │ │
|
||||
│ ┌─────▼──┐ ┌───▼────┐ │
|
||||
│ │ Tools │ │Sub- │ │
|
||||
│ │ (28) │ │agents │ │
|
||||
│ │ (37) │ │agents │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
└───────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -55,7 +55,7 @@ User keystroke → Controller (KeyEvent → Action)
|
||||
|
||||
| File | Lines | Role |
|
||||
|------|-------|------|
|
||||
| `src/main.rs` | 530 | Entry, TUI setup, daemon loop, attach loop |
|
||||
| `src/app/runtime/actions/mod.rs` | 1022 | Action dispatch + LLM stream loop + tool execution |
|
||||
| `src/controller/input.rs` | 281 | Key event → Action mapping |
|
||||
| `src/view/mod.rs` | 623 | TUI rendering (ratatui) |
|
||||
| `src/main.rs` | 647 | Entry, TUI setup, daemon loop, attach loop |
|
||||
| `src/app/runtime/actions/mod.rs` | 1815 | Action dispatch + LLM stream loop + tool execution |
|
||||
| `src/controller/input.rs` | 365 | Key event → Action mapping |
|
||||
| `src/view/mod.rs` | 975 | TUI rendering (ratatui) |
|
||||
|
||||
+23
-16
@@ -4,7 +4,7 @@
|
||||
|
||||
## AI Provider
|
||||
|
||||
`src/service/provider.rs` (258 lines)
|
||||
`src/service/provider.rs` (310 lines)
|
||||
- `LlmClient::new(api_key, model, base_url)` — constructs blocking reqwest client
|
||||
- `chat_with_tools()` — non-streaming with tool definitions
|
||||
- `chat_stream()` — SSE streaming, returns `SseParser` yielding `StreamEvent`
|
||||
@@ -18,51 +18,58 @@
|
||||
|
||||
## IPC / Daemon
|
||||
|
||||
`src/ipc/` (7 files, ~300 lines total)
|
||||
`src/ipc/` (7 files, ~350 lines total)
|
||||
- Unix domain socket, length-prefixed JSON frames
|
||||
- Daemon sends `DaemonFrame { state: StatePayload, diff, tasks }` to clients
|
||||
- Clients send `ClientRequest { action: Action }` back
|
||||
- State sync uses snapshots + binary diffs (rsync-style, not git)
|
||||
- Daemon sends `DaemonFrame` (state payload, stream tokens, system notes)
|
||||
- Clients send `ClientRequest` (key presses, resize, submit, scroll)
|
||||
- State sync uses full-state push from daemon to client after each action
|
||||
|
||||
## Workflow Engine
|
||||
|
||||
`src/app/workflow/engine.rs` (251 lines) + `script.rs`
|
||||
`src/app/workflow/engine.rs` (648 lines) + `script.rs`
|
||||
- Inline JS-style DSL executed by a lightweight runtime
|
||||
- `agent()`, `parallel()`, `pipeline()`, `phase()`, `log()` — spawns sub-agents
|
||||
- Max concurrency configurable via `workflow_max_concurrency` setting
|
||||
- Company pipeline orchestrator in `company.rs` (406 lines): full 5-division or quick 3-division pipelines
|
||||
|
||||
## Sub-Agent System
|
||||
|
||||
`src/app/subagent/` (4 files, ~250 lines)
|
||||
- `run_subagent()` — spawns independent agent with its own tool set & context
|
||||
`src/app/subagent/` (6 files: `spawn.rs`, `engine.rs`, `context.rs`, `event.rs`, `division.rs`, `auto.rs`, ~450 lines total)
|
||||
- `run_subagent()` — spawns independent agent with its own tool set and context
|
||||
- Communicates via `mpsc<SubagentEvent>` channel (tool calls, results, completion)
|
||||
- Uses `LlmClient` (same as main agent) with tool-use API
|
||||
- Auto-healing: on build/test failure, spawns auto-fix sub-agent
|
||||
- Division roles: Strategy, Engineering, Quality, Security, Documentation
|
||||
|
||||
## MCP Client
|
||||
|
||||
`src/app/mcp/manager.rs` (371 lines)
|
||||
`src/app/mcp/manager.rs` (441+ lines)
|
||||
- Stdio transport: spawns child process, JSON-RPC via stdin/stdout
|
||||
- HTTP transport: streaming HTTP with JSON-RPC
|
||||
- Tool registration: `tools/list` → `McpToolAdapter` implements `crate::tool::Tool`
|
||||
- Dynamic tool list refresh and error recovery
|
||||
- Persistent child handle for stdio (reuses connection across calls)
|
||||
|
||||
## Self-Review
|
||||
|
||||
`src/app/review/mod.rs` (437 lines)
|
||||
`src/app/review/mod.rs` (495 lines)
|
||||
- Post-tool execution quality check against learned lessons
|
||||
- Invokes `run_subagent()` with reviewer prompt
|
||||
- Staleness detection: skips review after N consecutive empty results
|
||||
- Three review types: code quality, architecture, security
|
||||
|
||||
## Background Bash
|
||||
|
||||
`src/app/bgbash/` (2 files)
|
||||
`src/app/bgbash/` (2 files: `job.rs`, `control.rs`)
|
||||
- `spawn_bash_job()` — runs `sh -c` in a thread, collects stdout line-by-line
|
||||
- Channels: output via `mpsc<String>`, PID via `mpsc<u32>`
|
||||
- Killable via PID
|
||||
- Killable via PID (SIGTERM)
|
||||
- Output buffering capped at 10,000 lines to prevent memory issues
|
||||
|
||||
## Gate Guard / Harness
|
||||
|
||||
`src/app/harness.rs` (127 lines)
|
||||
`src/app/harness.rs` (495 lines)
|
||||
- `Harness::gate_tool_call()` — verdict-based tool gating (allow/block)
|
||||
- Parses LLM verdicts (JSON or plain-text)
|
||||
- `test_parse_verdict_*` tests for 6 verdict formats
|
||||
- Path traversal, credential read, and destructive command detection
|
||||
- Pattern detection for stub code, denial language, and assumptions in write/edit content
|
||||
- Reason validation for mutating tools (minimum 8 characters, rejects generic non-answers)
|
||||
- Includes 8 unit tests for verdict parsing formats
|
||||
|
||||
@@ -16,7 +16,7 @@ Base directory: `~/.config/zesdex/` (via `dirs::data_dir()`)
|
||||
│ └── *.md # Markdown with YAML frontmatter
|
||||
├── sessions/ # Per-session data
|
||||
│ └── <session-uuid>/
|
||||
│ ├── editlog.json # Edit history
|
||||
│ ├── edits.jsonl # Edit history (JSONL, append-only)
|
||||
│ ├── msglog.db # SQLite message log
|
||||
│ ├── transcript.json # Chat transcript
|
||||
│ ├── session.json # Session metadata
|
||||
@@ -34,8 +34,8 @@ Base directory: `~/.config/zesdex/` (via `dirs::data_dir()`)
|
||||
| `src/model/store.rs` | ~50 | File-system storage (ensure_dirs, base_dir resolution) |
|
||||
| `src/model/settings.rs` | ~60 | `Settings` — load/save JSON, API keys map |
|
||||
| `src/model/app_config.rs` | ~80 | `AppConfig` — provider definitions, model roles, auth |
|
||||
| `src/model/memory.rs` | 332 | Memory CRUD — markdown files with frontmatter |
|
||||
| `src/model/editlog.rs` | 121 | Edit log — append-only JSON array |
|
||||
| `src/model/memory.rs` | 440 | Memory CRUD — markdown files with frontmatter |
|
||||
| `src/model/editlog.rs` | 161 | Edit log — append-only JSONL (not JSON array) |
|
||||
| `src/model/msglog/` | 4 files | SQLite-backed message log (schema, query, blobs) |
|
||||
| `src/model/session.rs` | ~60 | Session CRUD, listing, archival |
|
||||
| `src/model/session_lock.rs` | ~50 | flock-based session lock |
|
||||
|
||||
@@ -7,23 +7,23 @@
|
||||
| Crate | Version | Purpose |
|
||||
|-------|---------|---------|
|
||||
| ratatui | 0.30 | TUI framework (tui-rs successor) |
|
||||
| crossterm | 0.28 | Terminal manipulation (raw mode, alt screen) |
|
||||
| crossterm | 0.29 | Terminal manipulation (raw mode, alt screen) |
|
||||
| tokio | 1 | Async runtime (daemon, OAuth loopback) |
|
||||
| reqwest | 0.12 | HTTP client (blocking + streaming, vendored native-tls) |
|
||||
| serde / serde_json | 1 | JSON serialization (state, DTOs, IPC, config) |
|
||||
| serde_yaml_ng | 0.9 | YAML frontmatter parsing (memory files) |
|
||||
| serde_yaml_ng | 0.10 | YAML frontmatter parsing (memory files) |
|
||||
| anyhow | 1 | Error handling (no custom error types) |
|
||||
| tracing / tracing-subscriber | 0.1/0.3 | Structured logging → file |
|
||||
| rusqlite | 0.32 | SQLite (bundled, for message log) |
|
||||
| rusqlite | 0.40 | SQLite (bundled, for message log) |
|
||||
| pulldown-cmark | 0.13 | Markdown → HTML (chat rendering) |
|
||||
| syntect | 5 | Syntax highlighting (code blocks in chat) |
|
||||
| sha2 | 0.10 | SHA-256 for PKCE challenge |
|
||||
| base64 | 0.22 | URL-safe base64 for PKCE |
|
||||
| libc | 0.2 | daemon PID file locking |
|
||||
| rmcp | 1.8 | MCP client (stdio + HTTP transports) |
|
||||
| rmcp | 2.2 | MCP client (stdio + HTTP transports) |
|
||||
| uuid | 1 | Session IDs, job IDs |
|
||||
| chrono | 0.4 | Timestamps (ISO 8601, millis) |
|
||||
| dirs | 5 | Platform data directories |
|
||||
| dirs | 6 | Platform data directories |
|
||||
| dom_smoothie | 0.18 | HTML → plain text (web scraping) |
|
||||
| scraper | 0.27 | HTML parsing (web scraping) |
|
||||
| ignore | 0.4 | .gitignore-aware file walking (glob tool) |
|
||||
|
||||
@@ -9,6 +9,7 @@ Review guidelines:
|
||||
2. Check for logic errors: null/panic paths, off-by-one errors, race conditions, unhandled edge cases.
|
||||
3. Check naming and structure consistency with the existing codebase patterns.
|
||||
4. Check that the implementation matches the apparent intent.
|
||||
5. Check for linter bypasses: Ensure that compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) are NEVER used to silence warnings or skip linter checks. Reject them.
|
||||
|
||||
Output: a concise 2-4 line verdict. If you find issues, be specific about what and where.
|
||||
Skip if the file is trivial (config, tests with no logic changes).
|
||||
|
||||
@@ -14,6 +14,7 @@ Full access: read, write, edit, delete, bash, grep, glob, git_operator, lsp_*, s
|
||||
6. Run `cargo build` or equivalent after each logical chunk.
|
||||
7. If you encounter an issue not covered by the plan, use `note_finding` to flag it.
|
||||
8. Update todo.md as you complete each file: `todofinish`
|
||||
9. NEVER use compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to skip or silence warnings. Fix the underlying code to adhere to linter guidelines.
|
||||
|
||||
## Output
|
||||
After each file: confirm what was implemented and any deviations from plan.
|
||||
|
||||
@@ -31,4 +31,5 @@ You MUST produce a structured plan covering:
|
||||
- Use `seqthink` for complex reasoning steps
|
||||
- Every plan MUST include at least one mermaid diagram
|
||||
- Be specific with file paths and function names
|
||||
- Ensure implementation plans NEVER suggest or allow using compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to silence warnings; always plan to fully resolve underlying code issues.
|
||||
- Output ends with a clear "Plan Complete" marker
|
||||
|
||||
@@ -9,6 +9,7 @@ Check for:
|
||||
- Stubs, placeholders, incomplete branches
|
||||
- Naming consistency with codebase conventions
|
||||
- Error handling coverage
|
||||
- Absence of compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to silence warnings
|
||||
|
||||
## Phase 2: Test
|
||||
Use write to create test files. Follow these rules:
|
||||
|
||||
@@ -9,6 +9,7 @@ Review guidelines:
|
||||
2. Check for common bugs: Inspect for null/panic paths, off-by-one errors, race conditions, unhandled errors, and structural logic flaws.
|
||||
4. Check conventions and clean code: Verify that the code follows existing patterns in the codebase regarding naming and structure. Ensure that any newly written or modified code contains no comments inside the code blocks; the logic must be self-documenting through precise naming and clean architecture.
|
||||
5. Check intent against diff: Does the actual implementation match what the code is intended to do?
|
||||
6. Check for linter bypasses: Ensure that compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) are NEVER used to skip warnings. Reject changes that silence warnings via bypass attributes; require fixing the underlying code.
|
||||
|
||||
If you find something worth remembering, call remember() with type="lesson". Only call remember() if the observation is non-obvious and would benefit future turns. Skip trivial style nits.
|
||||
|
||||
|
||||
@@ -88,3 +88,4 @@ Available tools are described in system-tools.txt section. Key tools for orchest
|
||||
- After changes, run builds and tests
|
||||
- Use LSP diagnostics after each file edit
|
||||
- Every code path must be fully implemented and deterministic
|
||||
- NEVER use compiler/linter bypass annotations or attributes (such as `#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]`, `#[allow(dead_code)]`, etc.) to silence warnings or skip linter checks. Fix the underlying code issues instead.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Global registry of running background bash jobs, and control operations
|
||||
//! (output polling, kill) exposed to the rest of the app.
|
||||
//!
|
||||
@@ -57,7 +58,7 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
|
||||
/// Return: `Ok(())` on success, `Err` if the lock is poisoned or no job
|
||||
/// with that id exists.
|
||||
pub fn bash_kill(id: &str) -> anyhow::Result<()> {
|
||||
let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {}", e))?;
|
||||
let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {e}"))?;
|
||||
let job = map.remove(id);
|
||||
match job {
|
||||
Some(job) => {
|
||||
@@ -70,6 +71,6 @@ pub fn bash_kill(id: &str) -> anyhow::Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => anyhow::bail!("bash job '{}' not found", id),
|
||||
None => anyhow::bail!("bash job '{id}' not found"),
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -17,7 +17,7 @@ use std::io::BufRead;
|
||||
|
||||
/// Maximum number of output lines buffered in memory per background job.
|
||||
/// Beyond this limit, old output is dropped to prevent OOM (CWE-770).
|
||||
/// 10_000 lines at ~100 bytes each ≈ 1 MiB per job, sufficient for most
|
||||
/// `10_000` lines at ~100 bytes each ≈ 1 MiB per job, sufficient for most
|
||||
/// command output. The stderr drain thread also uses the same limit.
|
||||
const MAX_OUTPUT_LINES: usize = 10_000;
|
||||
|
||||
@@ -52,7 +52,7 @@ pub fn spawn_bash_job(command: String) -> BashJob {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let (output_tx, output_rx) = mpsc::sync_channel::<String>(MAX_OUTPUT_LINES);
|
||||
let (pid_tx, pid_rx) = mpsc::channel::<u32>();
|
||||
let cmd = command.clone();
|
||||
let cmd = command;
|
||||
let id_for_log = id.clone();
|
||||
let thread_id = id.clone();
|
||||
|
||||
@@ -66,12 +66,12 @@ pub fn spawn_bash_job(command: String) -> BashJob {
|
||||
let output_tx = output_tx.clone();
|
||||
let pid_tx = pid_tx.clone();
|
||||
let id_for_log = id_for_log.clone();
|
||||
move || spawn_bash_thread_body(cmd, output_tx, pid_tx, id_for_log)
|
||||
move || spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log)
|
||||
}).is_err()
|
||||
{
|
||||
tracing::warn!("[bgbash:{}] failed to spawn named thread, using unnamed fallback", id_for_log);
|
||||
thread::spawn(move || {
|
||||
spawn_bash_thread_body(cmd, output_tx, pid_tx, id_for_log)
|
||||
spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,21 +89,21 @@ pub fn spawn_bash_job(command: String) -> BashJob {
|
||||
/// spawned from both the named Builder and the unnamed fallback without
|
||||
/// double-moving the closure.
|
||||
fn spawn_bash_thread_body(
|
||||
cmd: String,
|
||||
output_tx: std::sync::mpsc::SyncSender<String>,
|
||||
pid_tx: std::sync::mpsc::Sender<u32>,
|
||||
id_for_log: String,
|
||||
cmd: &str,
|
||||
output_tx: &std::sync::mpsc::SyncSender<String>,
|
||||
pid_tx: &std::sync::mpsc::Sender<u32>,
|
||||
id_for_log: &str,
|
||||
) {
|
||||
let mut child = match Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.arg(cmd)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = output_tx.try_send(format!("__error:{}", e));
|
||||
let _ = output_tx.try_send(format!("__error:{e}"));
|
||||
let _ = output_tx.try_send("__exit:-1".to_string());
|
||||
return;
|
||||
}
|
||||
@@ -124,7 +124,7 @@ fn spawn_bash_thread_body(
|
||||
std::thread::spawn(move || {
|
||||
let reader = std::io::BufReader::new(stderr);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
if stderr_tx.try_send(format!("[stderr] {}", line)).is_err() {
|
||||
if stderr_tx.try_send(format!("[stderr] {line}")).is_err() {
|
||||
tracing::debug!("[bgbash] stderr buffer full, discarding remaining stderr");
|
||||
break;
|
||||
}
|
||||
@@ -153,7 +153,7 @@ fn spawn_bash_thread_body(
|
||||
impl BashJob {
|
||||
/// Non-blocking poll for the next output line from the job's channel.
|
||||
///
|
||||
/// Flow: try_recv the channel → if it's an `__exit:<code>` sentinel,
|
||||
/// Flow: `try_recv` the channel → if it's an `__exit:<code>` sentinel,
|
||||
/// record `exit_code` and return `None` instead of surfacing it as
|
||||
/// output → otherwise return the line.
|
||||
///
|
||||
|
||||
+4
-4
@@ -122,6 +122,7 @@ impl Harness {
|
||||
/// as risky because their behaviour is unknown.
|
||||
///
|
||||
/// Return: `Verdict::Allow` or `Verdict::Block(reason)`.
|
||||
#[allow(clippy::too_many_lines, clippy::unnecessary_debug_formatting)]
|
||||
pub fn gate_tool_call(
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
@@ -163,8 +164,7 @@ impl Harness {
|
||||
let allowed = workspace_roots.iter().any(|r| out_path.starts_with(r));
|
||||
if !allowed {
|
||||
return Verdict::Block(format!(
|
||||
"output path '{:?}' is outside all workspace roots",
|
||||
out_path
|
||||
"output path '{out_path:?}' is outside all workspace roots"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -286,7 +286,7 @@ impl Harness {
|
||||
(>= {MIN_REASON_LEN} chars) explaining why it is needed"
|
||||
));
|
||||
}
|
||||
} else if args.as_object().map(|m| !m.is_empty()).unwrap_or(false) {
|
||||
} else if args.as_object().is_some_and(|m| !m.is_empty()) {
|
||||
// Only require reason when there are meaningful arguments
|
||||
return Verdict::Block(format!(
|
||||
"MCP tool '{tool_name}' requires a 'reason' argument \
|
||||
@@ -430,7 +430,7 @@ mod tests {
|
||||
return Some(Verdict::Allow);
|
||||
}
|
||||
if l.starts_with("verdict: block") {
|
||||
let reason = line.split_once(':').map(|x| x.1).unwrap_or("blocked").trim().to_string();
|
||||
let reason = line.split_once(':').map_or("blocked", |x| x.1).trim().to_string();
|
||||
return Some(Verdict::Block(reason));
|
||||
}
|
||||
}
|
||||
|
||||
+37
-39
@@ -30,12 +30,12 @@ fn file_path_to_uri(path: &str) -> String {
|
||||
if cfg!(windows) {
|
||||
let path_str = path_str.replace('\\', "/");
|
||||
if path_str.starts_with('/') {
|
||||
format!("file://{}", path_str)
|
||||
format!("file://{path_str}")
|
||||
} else {
|
||||
format!("file:///{}", path_str)
|
||||
format!("file:///{path_str}")
|
||||
}
|
||||
} else {
|
||||
format!("file://{}", path_str)
|
||||
format!("file://{path_str}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ impl LspClient {
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn LSP server '{}': {}", command, e))?;
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn LSP server '{command}': {e}"))?;
|
||||
|
||||
let stdin = child.stdin.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to capture stdin for LSP server"))?;
|
||||
@@ -106,10 +106,10 @@ impl LspClient {
|
||||
}
|
||||
});
|
||||
|
||||
let result = client.call_with_timeout("initialize", init_params, Duration::from_millis(LSP_INIT_TIMEOUT_MS))?;
|
||||
let result = client.call_with_timeout("initialize", &init_params, Duration::from_millis(LSP_INIT_TIMEOUT_MS))?;
|
||||
client.server_capabilities = result.get("capabilities").cloned().unwrap_or_default();
|
||||
|
||||
client.notify("initialized", json!({}))?;
|
||||
client.notify("initialized", &json!({}))?;
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
@@ -118,11 +118,11 @@ impl LspClient {
|
||||
&self.server_capabilities
|
||||
}
|
||||
|
||||
pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result<Value> {
|
||||
pub fn call(&mut self, method: &str, params: &Value) -> anyhow::Result<Value> {
|
||||
self.call_with_timeout(method, params, Duration::from_millis(LSP_CALL_TIMEOUT_MS))
|
||||
}
|
||||
|
||||
fn call_with_timeout(&mut self, method: &str, params: Value, timeout: Duration) -> anyhow::Result<Value> {
|
||||
fn call_with_timeout(&mut self, method: &str, params: &Value, timeout: Duration) -> anyhow::Result<Value> {
|
||||
self.next_id += 1;
|
||||
let id = self.next_id;
|
||||
let req = json!({
|
||||
@@ -135,7 +135,7 @@ impl LspClient {
|
||||
self.read_response(id, timeout)
|
||||
}
|
||||
|
||||
pub fn notify(&mut self, method: &str, params: Value) -> anyhow::Result<()> {
|
||||
pub fn notify(&mut self, method: &str, params: &Value) -> anyhow::Result<()> {
|
||||
let req = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
@@ -146,14 +146,14 @@ impl LspClient {
|
||||
|
||||
fn send_frame(&mut self, msg: &Value) -> anyhow::Result<()> {
|
||||
let body = serde_json::to_string(msg)
|
||||
.map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {e}"))?;
|
||||
let header = format!("Content-Length: {}\r\n\r\n", body.len());
|
||||
self.stdin.write_all(header.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("failed to write LSP frame header: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("failed to write LSP frame header: {e}"))?;
|
||||
self.stdin.write_all(body.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("failed to write LSP frame body: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("failed to write LSP frame body: {e}"))?;
|
||||
self.stdin.flush()
|
||||
.map_err(|e| anyhow::anyhow!("failed to flush LSP stdin: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("failed to flush LSP stdin: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -166,9 +166,9 @@ impl LspClient {
|
||||
let frame = self.read_frame()?;
|
||||
if frame.get("id") == Some(&json!(expected_id)) {
|
||||
if let Some(err) = frame.get("error") {
|
||||
let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(0);
|
||||
let code = err.get("code").and_then(serde_json::Value::as_i64).unwrap_or(0);
|
||||
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error");
|
||||
anyhow::bail!("LSP error {}: {}", code, msg);
|
||||
anyhow::bail!("LSP error {code}: {msg}");
|
||||
}
|
||||
return Ok(frame.get("result").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
@@ -179,7 +179,7 @@ impl LspClient {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if Instant::now() > deadline {
|
||||
anyhow::bail!("timed out waiting for LSP notification '{}'", method);
|
||||
anyhow::bail!("timed out waiting for LSP notification '{method}'");
|
||||
}
|
||||
let frame = self.read_frame()?;
|
||||
if frame.get("method") == Some(&json!(method)) {
|
||||
@@ -195,22 +195,21 @@ impl LspClient {
|
||||
match self.stdout.read_line(&mut line) {
|
||||
Ok(0) => anyhow::bail!("LSP server closed the connection"),
|
||||
Ok(_) => {}
|
||||
Err(e) => anyhow::bail!("LSP read error: {}", e),
|
||||
Err(e) => anyhow::bail!("LSP read error: {e}"),
|
||||
}
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
|
||||
let length: usize = len_str.trim().parse::<usize>()
|
||||
.map_err(|e| anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e))?;
|
||||
// Cap Content-Length at 64 MiB to prevent OOM from a
|
||||
// malicious or misconfigured LSP server (CWE-400).
|
||||
const MAX_CONTENT_LENGTH: usize = 64 * 1024 * 1024;
|
||||
let length: usize = len_str.trim().parse::<usize>()
|
||||
.map_err(|e| anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e))?;
|
||||
if length > MAX_CONTENT_LENGTH {
|
||||
anyhow::bail!(
|
||||
"Content-Length {} exceeds maximum allowed size of {} bytes",
|
||||
length, MAX_CONTENT_LENGTH,
|
||||
"Content-Length {length} exceeds maximum allowed size of {MAX_CONTENT_LENGTH} bytes",
|
||||
);
|
||||
}
|
||||
content_length = Some(length);
|
||||
@@ -222,17 +221,17 @@ impl LspClient {
|
||||
|
||||
let mut body = vec![0u8; length];
|
||||
self.stdout.read_exact(&mut body)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read LSP body ({} bytes): {}", length, e))?;
|
||||
.map_err(|e| anyhow::anyhow!("failed to read LSP body ({length} bytes): {e}"))?;
|
||||
|
||||
let json_str = String::from_utf8(body)
|
||||
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in LSP response: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in LSP response: {e}"))?;
|
||||
|
||||
serde_json::from_str(&json_str)
|
||||
.map_err(|e| anyhow::anyhow!("invalid JSON in LSP response: {}", e))
|
||||
.map_err(|e| anyhow::anyhow!("invalid JSON in LSP response: {e}"))
|
||||
}
|
||||
|
||||
pub fn did_open(&mut self, uri: &str, language_id: &str, version: i32, text: &str) -> anyhow::Result<()> {
|
||||
self.notify("textDocument/didOpen", json!({
|
||||
self.notify("textDocument/didOpen", &json!({
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"languageId": language_id,
|
||||
@@ -244,7 +243,7 @@ impl LspClient {
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> {
|
||||
self.notify("textDocument/didChange", json!({
|
||||
self.notify("textDocument/didChange", &json!({
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"version": version
|
||||
@@ -256,7 +255,7 @@ impl LspClient {
|
||||
}
|
||||
|
||||
pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> {
|
||||
self.notify("textDocument/didClose", json!({
|
||||
self.notify("textDocument/didClose", &json!({
|
||||
"textDocument": {
|
||||
"uri": uri
|
||||
}
|
||||
@@ -264,28 +263,28 @@ impl LspClient {
|
||||
}
|
||||
|
||||
pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/hover", json!({
|
||||
self.call("textDocument/hover", &json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character }
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/completion", json!({
|
||||
self.call("textDocument/completion", &json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character }
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn goto_definition(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/definition", json!({
|
||||
self.call("textDocument/definition", &json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character }
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/references", json!({
|
||||
self.call("textDocument/references", &json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character },
|
||||
"context": {
|
||||
@@ -296,7 +295,7 @@ impl LspClient {
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn document_symbols(&mut self, uri: &str) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/documentSymbol", json!({
|
||||
self.call("textDocument/documentSymbol", &json!({
|
||||
"textDocument": { "uri": uri }
|
||||
}))
|
||||
}
|
||||
@@ -327,7 +326,7 @@ impl LspClient {
|
||||
/// still proves the process is up and the JSON-RPC channel is live.
|
||||
/// Returns `false` on timeout, EOF, or any read/write error.
|
||||
///
|
||||
/// Flow: build request → send_frame → poll frames until id matches
|
||||
/// Flow: build request → `send_frame` → poll frames until id matches
|
||||
/// (alive) or deadline/read error fires (dead).
|
||||
#[allow(dead_code)]
|
||||
pub fn is_alive(&mut self) -> bool {
|
||||
@@ -369,19 +368,18 @@ impl LspClient {
|
||||
/// not block on any reply.
|
||||
#[allow(dead_code)]
|
||||
pub fn exit(&mut self) -> anyhow::Result<()> {
|
||||
self.notify("exit", json!({}))
|
||||
self.notify("exit", &json!({}))
|
||||
}
|
||||
|
||||
pub fn shutdown(&mut self) -> anyhow::Result<()> {
|
||||
let _ = self.call_with_timeout("shutdown", json!({}), Duration::from_secs(5));
|
||||
let _ = self.notify("exit", json!({}));
|
||||
Ok(())
|
||||
pub fn shutdown(&mut self) {
|
||||
let _ = self.call_with_timeout("shutdown", &json!({}), Duration::from_secs(5));
|
||||
let _ = self.notify("exit", &json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LspClient {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.notify("exit", json!({}));
|
||||
let _ = self.notify("exit", &json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-28
@@ -70,7 +70,7 @@ impl LspManager {
|
||||
language_id: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
if self.servers.iter().any(|s| s.name == name) {
|
||||
anyhow::bail!("LSP server '{}' is already connected", name);
|
||||
anyhow::bail!("LSP server '{name}' is already connected");
|
||||
}
|
||||
let client = LspClient::spawn(command, args)?;
|
||||
self.servers.push(LspServer {
|
||||
@@ -101,7 +101,7 @@ impl LspManager {
|
||||
pub fn disconnect(&mut self, name: &str) -> bool {
|
||||
if let Some(server) = self.servers.iter().find(|s| s.name == name) {
|
||||
if let Ok(mut client) = server.client.lock() {
|
||||
let _ = client.shutdown();
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
let len = self.servers.len();
|
||||
@@ -134,7 +134,7 @@ impl LspManager {
|
||||
pub fn find_server_for_path(&self, path: &Path) -> Option<Arc<Mutex<LspClient>>> {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| format!(".{}", s))
|
||||
.map(|s| format!(".{s}"))
|
||||
.and_then(|ext| self.find_server_for_extension(&ext))
|
||||
}
|
||||
|
||||
@@ -171,21 +171,15 @@ impl LspManager {
|
||||
/// Non-critical failures (file missing, server unreachable, send
|
||||
/// error) are logged with `tracing::warn!` rather than propagated,
|
||||
/// so a stale notification cannot abort the calling flow.
|
||||
pub fn did_change_file(&mut self, path: &Path) -> anyhow::Result<()> {
|
||||
let ext = match path.extension().and_then(|e| e.to_str()).map(|s| format!(".{}", s)) {
|
||||
Some(ext) => ext,
|
||||
None => {
|
||||
tracing::warn!("did_change_file: path has no extension: {:?}", path);
|
||||
return Ok(());
|
||||
}
|
||||
pub fn did_change_file(&mut self, path: &Path) {
|
||||
let Some(ext) = path.extension().and_then(|e| e.to_str()).map(|s| format!(".{s}")) else {
|
||||
tracing::warn!("did_change_file: path has no extension: {:?}", path);
|
||||
return;
|
||||
};
|
||||
|
||||
let server_name = match self.extension_registry.get(&ext) {
|
||||
Some(name) => name.clone(),
|
||||
None => {
|
||||
tracing::warn!("did_change_file: no LSP server registered for extension '{}'", ext);
|
||||
return Ok(());
|
||||
}
|
||||
let server_name = if let Some(name) = self.extension_registry.get(&ext) { name.clone() } else {
|
||||
tracing::warn!("did_change_file: no LSP server registered for extension '{}'", ext);
|
||||
return;
|
||||
};
|
||||
|
||||
let uri = path_to_lsp_uri(&path.to_string_lossy());
|
||||
@@ -194,7 +188,7 @@ impl LspManager {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::warn!("did_change_file: failed to read {:?}: {}", path, e);
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -202,12 +196,9 @@ impl LspManager {
|
||||
.get_language_id(&server_name)
|
||||
.unwrap_or_else(|| "plaintext".to_string());
|
||||
|
||||
let client = match self.get_client(&server_name) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::warn!("did_change_file: server '{}' has no client", server_name);
|
||||
return Ok(());
|
||||
}
|
||||
let Some(client) = self.get_client(&server_name) else {
|
||||
tracing::warn!("did_change_file: server '{}' has no client", server_name);
|
||||
return;
|
||||
};
|
||||
|
||||
let next_version = match self.open_files.get(&uri) {
|
||||
@@ -220,7 +211,7 @@ impl LspManager {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("did_change_file: client mutex poisoned for '{}': {}", server_name, e);
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
};
|
||||
if self.open_files.contains_key(&uri) {
|
||||
@@ -237,7 +228,7 @@ impl LspManager {
|
||||
uri,
|
||||
e
|
||||
);
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
|
||||
self.open_files.insert(
|
||||
@@ -248,7 +239,6 @@ impl LspManager {
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record that `server_name` has an open document at `uri`.
|
||||
@@ -274,9 +264,9 @@ impl LspManager {
|
||||
/// drop the vec. Failures from individual shutdowns are swallowed
|
||||
/// because the goal is best-effort termination during teardown.
|
||||
pub fn shutdown_all(&mut self) {
|
||||
for server in self.servers.iter() {
|
||||
for server in &self.servers {
|
||||
if let Ok(mut client) = server.client.lock() {
|
||||
let _ = client.shutdown();
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
self.servers.clear();
|
||||
|
||||
+46
-51
@@ -1,10 +1,10 @@
|
||||
//! Auto-provisioning engine for LSP language servers.
|
||||
//!
|
||||
//! Flow: detect_env() → for each supported server in supported_servers()
|
||||
//! → provision_single() tries install tiers in order → returns
|
||||
//! ProvisionResult (AlreadyAvailable / Installed / Failed).
|
||||
//! Caller can then call auto_connect() to attach available servers
|
||||
//! to an existing LspManager.
|
||||
//! Flow: `detect_env()` → for each supported server in `supported_servers()`
|
||||
//! → `provision_single()` tries install tiers in order → returns
|
||||
//! `ProvisionResult` (`AlreadyAvailable` / Installed / Failed).
|
||||
//! Caller can then call `auto_connect()` to attach available servers
|
||||
//! to an existing `LspManager`.
|
||||
//!
|
||||
//! Why: opening a project on a fresh machine should not require the user
|
||||
//! to manually hunt down and install 4 different language servers.
|
||||
@@ -28,7 +28,7 @@ pub type ProgressFn<'a> = Option<&'a dyn Fn(&str)>;
|
||||
|
||||
/// Result of attempting to make a single language server available.
|
||||
///
|
||||
/// The caller should switch on this variant: AlreadyAvailable and
|
||||
/// The caller should switch on this variant: `AlreadyAvailable` and
|
||||
/// Installed both mean the binary can be launched; Failed means we
|
||||
/// gave up and the user needs to install manually (see `manual_instructions`).
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -99,12 +99,13 @@ pub struct InstallTier {
|
||||
|
||||
/// Snapshot of the host environment used to decide which install tiers are viable.
|
||||
///
|
||||
/// Populated by `detect_env()` once per provision_all() call so we
|
||||
/// Populated by `detect_env()` once per `provision_all()` call so we
|
||||
/// don't re-shell out for every server. `is_linux` / `is_macos` are
|
||||
/// computed at startup (compile time would also work, but keeping the
|
||||
/// shape uniform with the rest of the struct makes the call sites tidy).
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct EnvInfo {
|
||||
pub has_rustup: bool,
|
||||
pub has_npm: bool,
|
||||
@@ -126,7 +127,7 @@ pub struct EnvInfo {
|
||||
///
|
||||
/// Flow: `Command::new("which").arg(binary).output()` → on Unix
|
||||
/// `which` returns exit 0 + stdout path when found, non-zero
|
||||
/// otherwise. We return the first stdout line as the PathBuf.
|
||||
/// otherwise. We return the first stdout line as the `PathBuf`.
|
||||
///
|
||||
/// Returns None if `which` itself is missing, fails to spawn, or the
|
||||
/// binary is not on PATH. We deliberately don't cache this — it's only
|
||||
@@ -151,7 +152,7 @@ pub fn which(binary: &str) -> Option<PathBuf> {
|
||||
///
|
||||
/// Flow: shell out to `which` for each tool in parallel (sequentially,
|
||||
/// actually — the calls are fast and the ordering doesn't matter)
|
||||
/// → set EnvInfo flags. Linux/macOS are detected via cfg at
|
||||
/// → set `EnvInfo` flags. Linux/macOS are detected via cfg at
|
||||
/// compile time since `which` won't tell us.
|
||||
///
|
||||
/// Edge case: `which` may not exist on Windows; we guard with cfg so
|
||||
@@ -185,6 +186,7 @@ pub fn detect_env() -> EnvInfo {
|
||||
/// Why hard-coded rather than loaded from settings: the set is small,
|
||||
/// changes rarely, and bundling it lets the provisioner run before any
|
||||
/// user config has been read (e.g. on first launch).
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn supported_servers() -> Vec<LanguageServerDef> {
|
||||
vec![
|
||||
LanguageServerDef {
|
||||
@@ -307,7 +309,7 @@ pub fn supported_servers() -> Vec<LanguageServerDef> {
|
||||
/// commands tend to emit errors to stderr, and we want to surface
|
||||
/// those.
|
||||
///
|
||||
/// Why a custom timeout: std::process::Command has no built-in timeout,
|
||||
/// Why a custom timeout: `std::process::Command` has no built-in timeout,
|
||||
/// and we'd rather kill a hung `apt` than block the TUI indefinitely.
|
||||
pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)> {
|
||||
let mut command = Command::new(cmd);
|
||||
@@ -334,23 +336,19 @@ pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)>
|
||||
})
|
||||
});
|
||||
|
||||
let timeout = Duration::from_secs(180);
|
||||
let timeout = Duration::from_mins(3);
|
||||
let start = Instant::now();
|
||||
let status = loop {
|
||||
match child.try_wait()? {
|
||||
Some(status) => break Ok(status),
|
||||
None => {
|
||||
if start.elapsed() > timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("command '{}' timed out after {}s", cmd, timeout.as_secs()),
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
if let Some(status) = child.try_wait()? { break Ok(status) }
|
||||
if start.elapsed() > timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("command '{}' timed out after {}s", cmd, timeout.as_secs()),
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
};
|
||||
|
||||
let stdout = stdout_thread
|
||||
@@ -362,7 +360,7 @@ pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)>
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => Ok((true, stdout)),
|
||||
Ok(_) => Ok((false, format!("{}{}", stdout, stderr))),
|
||||
Ok(_) => Ok((false, format!("{stdout}{stderr}"))),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
@@ -412,7 +410,7 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
|
||||
"-o", &path_str,
|
||||
url,
|
||||
];
|
||||
let (ok, out) = run_command("curl", &args).map_err(|e| format!("curl spawn: {}", e))?;
|
||||
let (ok, out) = run_command("curl", &args).map_err(|e| format!("curl spawn: {e}"))?;
|
||||
if !ok {
|
||||
return Err(format!("download failed: {}", out.trim()));
|
||||
}
|
||||
@@ -423,7 +421,7 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
|
||||
/// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`.
|
||||
fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Result<PathBuf, String> {
|
||||
let base = lsp_install_dir("rust-analyzer")?;
|
||||
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {}", e))?;
|
||||
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
|
||||
|
||||
let url = if env.is_linux {
|
||||
"https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-x86_64-unknown-linux-gnu.gz"
|
||||
@@ -440,7 +438,7 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu
|
||||
download_url(url, &gz, 120)?;
|
||||
if let Some(cb) = progress { cb("Rust: decompressing..."); }
|
||||
let (ok, out) = run_command("gunzip", &["-f", &gz.to_string_lossy()])
|
||||
.map_err(|e| format!("gunzip spawn: {}", e))?;
|
||||
.map_err(|e| format!("gunzip spawn: {e}"))?;
|
||||
if !ok {
|
||||
return Err(format!("gunzip: {}", out.trim()));
|
||||
}
|
||||
@@ -452,7 +450,7 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| format!("chmod: {}", e))?;
|
||||
.map_err(|e| format!("chmod: {e}"))?;
|
||||
}
|
||||
if let Some(cb) = progress { cb("Rust: installed ✓"); }
|
||||
Ok(target)
|
||||
@@ -462,7 +460,7 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu
|
||||
/// and create a launcher script at `bin/jdtls`.
|
||||
fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
|
||||
let base = lsp_install_dir("jdtls")?;
|
||||
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {}", e))?;
|
||||
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
|
||||
|
||||
let url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz";
|
||||
let tarball = base.join("jdtls.tar.gz");
|
||||
@@ -473,7 +471,7 @@ fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
|
||||
let (ok, out) = run_command("tar", &[
|
||||
"-xzf", tarball.to_str().unwrap_or(""),
|
||||
"-C", base.to_str().unwrap_or("."),
|
||||
]).map_err(|e| format!("tar spawn: {}", e))?;
|
||||
]).map_err(|e| format!("tar spawn: {e}"))?;
|
||||
if !ok {
|
||||
return Err(format!("tar: {}", out.trim()));
|
||||
}
|
||||
@@ -484,7 +482,7 @@ fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
let bin_dir = base.join("bin");
|
||||
std::fs::create_dir_all(&bin_dir).map_err(|e| format!("mkdir bin: {}", e))?;
|
||||
std::fs::create_dir_all(&bin_dir).map_err(|e| format!("mkdir bin: {e}"))?;
|
||||
let launcher = bin_dir.join("jdtls");
|
||||
|
||||
let script = r#"#!/usr/bin/env bash
|
||||
@@ -505,12 +503,12 @@ exec java \
|
||||
--add-opens java.base/java.lang=ALL-UNNAMED \
|
||||
"$@"
|
||||
"#;
|
||||
std::fs::write(&launcher, script).map_err(|e| format!("write launcher: {}", e))?;
|
||||
std::fs::write(&launcher, script).map_err(|e| format!("write launcher: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| format!("chmod launcher: {}", e))?;
|
||||
.map_err(|e| format!("chmod launcher: {e}"))?;
|
||||
}
|
||||
if let Some(cb) = progress { cb("Java: JDT-LS installed ✓"); }
|
||||
Ok(launcher)
|
||||
@@ -521,7 +519,7 @@ fn run_download_tier(name: &str, env: &EnvInfo, progress: ProgressFn<'_>) -> Res
|
||||
match name {
|
||||
DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress),
|
||||
DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress),
|
||||
other => Err(format!("unknown download tier '{}'", other)),
|
||||
other => Err(format!("unknown download tier '{other}'")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,7 +554,7 @@ fn manual_instructions(def: &LanguageServerDef) -> String {
|
||||
/// Try to provision a single language server.
|
||||
///
|
||||
/// Flow: check whether any `binary_names` candidate is already on PATH
|
||||
/// → if yes, return AlreadyAvailable → otherwise walk
|
||||
/// → if yes, return `AlreadyAvailable` → otherwise walk
|
||||
/// `install_tiers` in order, skipping tiers whose `requires`
|
||||
/// binaries are missing → for each viable tier, run the install
|
||||
/// command (120s timeout) → if it succeeds AND the binary now
|
||||
@@ -641,7 +639,7 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre
|
||||
}
|
||||
|
||||
// Normal shell-out tier.
|
||||
let arg_refs: Vec<&str> = tier.args.iter().map(|s| s.as_str()).collect();
|
||||
let arg_refs: Vec<&str> = tier.args.iter().map(std::string::String::as_str).collect();
|
||||
match run_command(&tier.command, &arg_refs) {
|
||||
Ok((true, _)) => {
|
||||
let located = def
|
||||
@@ -683,9 +681,9 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre
|
||||
/// Provision every supported server in order, returning one
|
||||
/// `ProvisionResult` per server.
|
||||
///
|
||||
/// Flow: detect_env() once → for each server in supported_servers()
|
||||
/// call provision_single() → collect results. Order matches
|
||||
/// supported_servers() (rust, typescript, go, java).
|
||||
/// Flow: `detect_env()` once → for each server in `supported_servers()`
|
||||
/// call `provision_single()` → collect results. Order matches
|
||||
/// `supported_servers()` (rust, typescript, go, java).
|
||||
#[allow(dead_code)]
|
||||
pub fn provision_all() -> Vec<ProvisionResult> {
|
||||
let env = detect_env();
|
||||
@@ -725,7 +723,7 @@ pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult>
|
||||
let avail: String = flags.iter()
|
||||
.filter(|(_, v)| *v).map(|(k, _)| *k)
|
||||
.collect::<Vec<_>>().join(", ");
|
||||
cb(&format!("LSP: environment ready — {}", avail));
|
||||
cb(&format!("LSP: environment ready — {avail}"));
|
||||
}
|
||||
supported_servers()
|
||||
.iter()
|
||||
@@ -736,8 +734,8 @@ pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult>
|
||||
/// For every successful provision result, attach the corresponding
|
||||
/// server to the given `LspManager`.
|
||||
///
|
||||
/// Flow: for each result, if it's AlreadyAvailable or Installed, look
|
||||
/// up the LanguageServerDef, then call manager.connect() with
|
||||
/// Flow: for each result, if it's `AlreadyAvailable` or Installed, look
|
||||
/// up the `LanguageServerDef`, then call `manager.connect()` with
|
||||
/// the binary path and empty args. On connect success, log and
|
||||
/// record the name; on failure, log a warning and skip.
|
||||
/// Returns the names that successfully connected.
|
||||
@@ -745,7 +743,7 @@ pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult>
|
||||
/// Why empty args: most LSP servers don't need CLI flags to start;
|
||||
/// the spec for each server lives in the protocol handshake, not the
|
||||
/// argv. If we ever need flags (e.g. --stdio), they'll be a per-server
|
||||
/// constant in supported_servers().
|
||||
/// constant in `supported_servers()`.
|
||||
pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult]) -> Vec<String> {
|
||||
let defs = supported_servers();
|
||||
let mut connected: Vec<String> = Vec::new();
|
||||
@@ -767,12 +765,9 @@ pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult
|
||||
|
||||
// Sanity: only connect to servers we know about. Protects against
|
||||
// future ProvisionResult variants sneaking in unknown names.
|
||||
let def = match defs.iter().find(|d| d.name == name) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
warn!(name = %name, "skipping connect: unknown server");
|
||||
continue;
|
||||
}
|
||||
let Some(def) = defs.iter().find(|d| d.name == name) else {
|
||||
warn!(name = %name, "skipping connect: unknown server");
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut guard = match manager.lock() {
|
||||
@@ -784,7 +779,7 @@ pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult
|
||||
};
|
||||
|
||||
// Build extension slice for connect_with_extensions.
|
||||
let ext_refs: Vec<&str> = def.extensions.iter().map(|s| s.as_str()).collect();
|
||||
let ext_refs: Vec<&str> = def.extensions.iter().map(std::string::String::as_str).collect();
|
||||
|
||||
match guard.connect_with_extensions(&name, &binary, &[], &language, &ext_refs) {
|
||||
Ok(()) => {
|
||||
|
||||
+31
-32
@@ -88,7 +88,8 @@ impl StdioChild {
|
||||
///
|
||||
/// Return: the `result` value of the matching response, or `Err` on
|
||||
/// timeout, EOF, JSON-RPC error, or I/O failure.
|
||||
pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result<Value> {
|
||||
pub fn call(&mut self, method: &str, params: &Value) -> anyhow::Result<Value> {
|
||||
const MAX_LINE_LENGTH: usize = 1_048_576; // 1 MiB
|
||||
self.next_id += 1;
|
||||
let id = self.next_id;
|
||||
let req = json!({
|
||||
@@ -107,18 +108,17 @@ impl StdioChild {
|
||||
+ std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS);
|
||||
loop {
|
||||
if std::time::Instant::now() > deadline {
|
||||
anyhow::bail!("MCP call timed out after {}ms", MCP_CALL_TIMEOUT_MS);
|
||||
anyhow::bail!("MCP call timed out after {MCP_CALL_TIMEOUT_MS}ms");
|
||||
}
|
||||
response_line.clear();
|
||||
// Read one byte at a time up to MAX_LINE_LENGTH to prevent
|
||||
// OOM from a malicious server (CWE-400). BufReader already
|
||||
// buffers reads, so byte-by-byte over a buffered reader is
|
||||
// cheap (hits the in-memory buffer).
|
||||
const MAX_LINE_LENGTH: usize = 1_048_576; // 1 MiB
|
||||
response_line.clear();
|
||||
let mut line_truncated = false;
|
||||
loop {
|
||||
let byte = match self.stdout.fill_buf() {
|
||||
Ok(buf) if buf.is_empty() => {
|
||||
Ok([]) => {
|
||||
// EOF without newline
|
||||
anyhow::bail!("MCP stdio child process closed unexpectedly");
|
||||
}
|
||||
@@ -127,7 +127,7 @@ impl StdioChild {
|
||||
self.stdout.consume(1);
|
||||
b
|
||||
}
|
||||
Err(e) => anyhow::bail!("MCP stdio read error: {}", e),
|
||||
Err(e) => anyhow::bail!("MCP stdio read error: {e}"),
|
||||
};
|
||||
if byte == b'\n' {
|
||||
break;
|
||||
@@ -137,7 +137,7 @@ impl StdioChild {
|
||||
// Consume rest of line to keep stream in sync
|
||||
loop {
|
||||
let buf = self.stdout.fill_buf()
|
||||
.map_err(|e| anyhow::anyhow!("MCP stdio read error: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("MCP stdio read error: {e}"))?;
|
||||
if buf.is_empty() {
|
||||
anyhow::bail!("MCP stdio child closed mid-line");
|
||||
}
|
||||
@@ -153,8 +153,7 @@ impl StdioChild {
|
||||
}
|
||||
if line_truncated {
|
||||
anyhow::bail!(
|
||||
"MCP response line exceeded {} byte limit",
|
||||
MAX_LINE_LENGTH,
|
||||
"MCP response line exceeded {MAX_LINE_LENGTH} byte limit",
|
||||
);
|
||||
}
|
||||
let trimmed = response_line.trim();
|
||||
@@ -162,10 +161,10 @@ impl StdioChild {
|
||||
continue;
|
||||
}
|
||||
let resp: Value = serde_json::from_str(trimmed)
|
||||
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP server: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP server: {e}"))?;
|
||||
if resp.get("id") == Some(&json!(id)) {
|
||||
if let Some(err) = resp.get("error") {
|
||||
anyhow::bail!("MCP error: {}", err);
|
||||
anyhow::bail!("MCP error: {err}");
|
||||
}
|
||||
return Ok(resp.get("result").cloned().unwrap_or_else(|| {
|
||||
tracing::warn!("[mcp] stdio response missing 'result' field: {}", trimmed);
|
||||
@@ -191,7 +190,7 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn MCP stdio server '{}': {}", command, e))?;
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn MCP stdio server '{command}': {e}"))?;
|
||||
|
||||
let stdin = child.stdin.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to get stdin for MCP server"))?;
|
||||
@@ -207,7 +206,7 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
|
||||
let deadline = std::time::Instant::now()
|
||||
+ std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS);
|
||||
|
||||
let init_result = mcp.call("initialize", json!({
|
||||
let init_result = mcp.call("initialize", &json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
@@ -220,9 +219,9 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
|
||||
anyhow::bail!("MCP initialize timed out");
|
||||
}
|
||||
|
||||
init_result.map_err(|e| anyhow::anyhow!("MCP initialize failed: {}", e))?;
|
||||
init_result.map_err(|e| anyhow::anyhow!("MCP initialize failed: {e}"))?;
|
||||
|
||||
let _ = mcp.call("notifications/initialized", json!({}));
|
||||
let _ = mcp.call("notifications/initialized", &json!({}));
|
||||
|
||||
Ok(mcp)
|
||||
}
|
||||
@@ -237,23 +236,23 @@ fn call_via_stdio(
|
||||
// Reuse the persistent child handle if available; otherwise spawn a new one.
|
||||
let mut guard;
|
||||
let child: &mut StdioChild = if let Some(mtx) = existing_handle {
|
||||
guard = mtx.lock().map_err(|e| anyhow::anyhow!("MCP handle lock: {}", e))?;
|
||||
guard = mtx.lock().map_err(|e| anyhow::anyhow!("MCP handle lock: {e}"))?;
|
||||
&mut guard
|
||||
} else {
|
||||
let mut fresh = spawn_stdio_child(command, extra_args)?;
|
||||
let result = fresh.call("tools/call", json!({
|
||||
let result = fresh.call("tools/call", &json!({
|
||||
"name": tool_name,
|
||||
"arguments": tool_args
|
||||
}))?;
|
||||
return extract_text_content(&result);
|
||||
return Ok(extract_text_content(&result));
|
||||
};
|
||||
|
||||
let result = child.call("tools/call", json!({
|
||||
let result = child.call("tools/call", &json!({
|
||||
"name": tool_name,
|
||||
"arguments": tool_args
|
||||
}))?;
|
||||
|
||||
extract_text_content(&result)
|
||||
Ok(extract_text_content(&result))
|
||||
}
|
||||
|
||||
fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Result<String> {
|
||||
@@ -294,7 +293,7 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.map_err(|e| anyhow::anyhow!("MCP HTTP request failed: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("MCP HTTP request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
@@ -302,42 +301,42 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
|
||||
tracing::warn!("[mcp] failed to read HTTP response body: {}", e);
|
||||
String::new()
|
||||
});
|
||||
anyhow::bail!("MCP HTTP server returned {}: {}", status, text);
|
||||
anyhow::bail!("MCP HTTP server returned {status}: {text}");
|
||||
}
|
||||
|
||||
let response: Value = resp.json()
|
||||
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP HTTP server: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP HTTP server: {e}"))?;
|
||||
|
||||
if let Some(err) = response.get("error") {
|
||||
anyhow::bail!("MCP HTTP error: {}", err);
|
||||
anyhow::bail!("MCP HTTP error: {err}");
|
||||
}
|
||||
|
||||
let result = response.get("result").cloned().unwrap_or_else(|| {
|
||||
tracing::warn!("[mcp] HTTP response missing 'result' field");
|
||||
Value::Null
|
||||
});
|
||||
extract_text_content(&result)
|
||||
Ok(extract_text_content(&result))
|
||||
}
|
||||
|
||||
fn extract_text_content(result: &Value) -> anyhow::Result<String> {
|
||||
fn extract_text_content(result: &Value) -> String {
|
||||
if let Some(content) = result.get("content") {
|
||||
if let Some(arr) = content.as_array() {
|
||||
let text: Vec<String> = arr.iter().filter_map(|item| {
|
||||
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
|
||||
item.get("text").and_then(|t| t.as_str()).map(|s| s.to_string())
|
||||
item.get("text").and_then(|t| t.as_str()).map(std::string::ToString::to_string)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}).collect();
|
||||
if !text.is_empty() {
|
||||
return Ok(text.join("\n"));
|
||||
return text.join("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(serde_json::to_string_pretty(result).unwrap_or_else(|e| {
|
||||
serde_json::to_string_pretty(result).unwrap_or_else(|e| {
|
||||
tracing::warn!("[mcp] failed to pretty-print result: {}", e);
|
||||
result.to_string()
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Registry of connected MCP servers and their tools for the current session.
|
||||
@@ -374,7 +373,7 @@ impl crate::tool::Tool for McpToolAdapter {
|
||||
fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result<String> {
|
||||
match &self.transport {
|
||||
McpTransport::Stdio { command, args: extra_args } => {
|
||||
call_via_stdio(self.child_handle.as_ref().map(|h| h.as_ref()), command, extra_args, &self.tool_name, args)
|
||||
call_via_stdio(self.child_handle.as_ref().map(std::convert::AsRef::as_ref), command, extra_args, &self.tool_name, args)
|
||||
}
|
||||
McpTransport::StreamableHttp { url } => {
|
||||
call_via_http(url, &self.tool_name, args)
|
||||
@@ -428,7 +427,7 @@ impl McpManager {
|
||||
};
|
||||
|
||||
let mut child = spawn_stdio_child(command, extra_args)?;
|
||||
let result = child.call("tools/list", json!({}))?;
|
||||
let result = child.call("tools/list", &json!({}))?;
|
||||
|
||||
let tools = if let Some(tool_list) = result.get("tools").and_then(|v| v.as_array()) {
|
||||
tool_list.iter().filter_map(|t| {
|
||||
|
||||
@@ -66,7 +66,7 @@ impl EditorState {
|
||||
self.cursor_line += 1;
|
||||
}
|
||||
self.cursor_col = self.cursor_col.min(
|
||||
self.content.get(self.cursor_line).map(|l| l.len()).unwrap_or(0),
|
||||
self.content.get(self.cursor_line).map_or(0, std::string::String::len),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ impl EditorState {
|
||||
/// Flow: no-op if no editor is open → for each char: `\n`/`\r` inserts a
|
||||
/// line and moves down, `\t` inserts two spaces, everything else inserts
|
||||
/// the char directly → mark state dirty.
|
||||
pub fn handle_editor_input(state: &mut AppStateRest, text: String) {
|
||||
pub fn handle_editor_input(state: &mut AppStateRest, text: &str) {
|
||||
let editor = &mut state.misc.editor;
|
||||
if editor.is_none() {
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Effort mode: cycles the agent's reasoning effort level, which scales the
|
||||
//! LLM's temperature and max_tokens for subsequent turns.
|
||||
//! LLM's temperature and `max_tokens` for subsequent turns.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
@@ -44,7 +45,7 @@ pub fn cycle_effort(state: &mut AppStateRest) {
|
||||
let label = current_effort_str(state);
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Info,
|
||||
format!("Effort: {}", label),
|
||||
format!("Effort: {label}"),
|
||||
));
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
+12
-15
@@ -1,23 +1,20 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Rewind mode: restores a file to a pre-edit snapshot stored in the
|
||||
//! session's SQLite blob store.
|
||||
//! session's `SQLite` blob store.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use sha2::Digest;
|
||||
|
||||
/// Returns the number of stored pre-edit blobs (snapshots) for this session.
|
||||
pub fn rewind_count(state: &AppStateRest) -> usize {
|
||||
let conn = match open_session_db(&state.session_dir) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let Ok(conn) = open_session_db(&state.session_dir) else { return 0 };
|
||||
crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id)
|
||||
.ok()
|
||||
.map(|keys| keys.len())
|
||||
.unwrap_or(0)
|
||||
.map_or(0, |keys| keys.len())
|
||||
}
|
||||
|
||||
/// Restores a file to its pre-edit state by retrieving the blob stored under index
|
||||
/// `index` (0 = oldest). Opens a fresh SQLite connection so this works outside
|
||||
/// `index` (0 = oldest). Opens a fresh `SQLite` connection so this works outside
|
||||
/// of a running turn (e.g. from the Rewind overlay).
|
||||
pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
let conn = match open_session_db(&state.session_dir) {
|
||||
@@ -25,7 +22,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
Err(e) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
format!("Failed to open session DB: {}", e),
|
||||
format!("Failed to open session DB: {e}"),
|
||||
));
|
||||
state.dirty = true;
|
||||
return;
|
||||
@@ -37,7 +34,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
Err(e) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
format!("Failed to list snapshots: {}", e),
|
||||
format!("Failed to list snapshots: {e}"),
|
||||
));
|
||||
state.dirty = true;
|
||||
return;
|
||||
@@ -67,7 +64,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
Err(e) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
format!("Failed to retrieve snapshot: {}", e),
|
||||
format!("Failed to retrieve snapshot: {e}"),
|
||||
));
|
||||
state.dirty = true;
|
||||
return;
|
||||
@@ -81,7 +78,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
.unwrap_or_else(|| state.session_dir.join("snapshot.dat"));
|
||||
|
||||
match std::fs::write(&restore_path, &bytes) {
|
||||
Ok(_) => {
|
||||
Ok(()) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Success,
|
||||
format!("Restored {} from snapshot", restore_path.display()),
|
||||
@@ -90,7 +87,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
Err(e) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
format!("Failed to write restored file: {}", e),
|
||||
format!("Failed to write restored file: {e}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -101,8 +98,8 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
ts: chrono::Utc::now().timestamp_millis(),
|
||||
tool: "rewind".to_string(),
|
||||
path: restore_path.to_string_lossy().to_string(),
|
||||
reason: format!("rewind_to({})", index),
|
||||
content_sha256: format!("{:x}", sha2::Sha256::digest(&bytes)),
|
||||
reason: format!("rewind_to({index})"),
|
||||
content_sha256: hex::encode(sha2::Sha256::digest(&bytes)),
|
||||
bytes_delta: bytes.len() as i64,
|
||||
origin: crate::app::state::types::Origin::Main.tag(),
|
||||
session_id: state.session_id.clone(),
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::model::settings::{Settings, InternetMode};
|
||||
|
||||
/// Advance the internet access mode to the next value in the cycle.
|
||||
///
|
||||
/// Flow: Off -> ReadOnly -> Full -> Off, wrapping around.
|
||||
/// Flow: Off -> `ReadOnly` -> Full -> Off, wrapping around.
|
||||
///
|
||||
/// Why: used by a settings-toggle keybinding to step through modes
|
||||
/// without needing a dropdown/menu.
|
||||
|
||||
+25
-30
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Adaptive quality-review triggering, build/test probing, staleness
|
||||
//! sweeps for stored lessons, and the pending-lesson approval workflow.
|
||||
use std::process::Command;
|
||||
@@ -74,10 +75,7 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
||||
if origin != Origin::Main {
|
||||
return false;
|
||||
}
|
||||
let runtime = match &state.session_runtime {
|
||||
Some(r) => r,
|
||||
None => return false,
|
||||
};
|
||||
let Some(runtime) = &state.session_runtime else { return false };
|
||||
if !state.settings.review_enabled {
|
||||
return false;
|
||||
}
|
||||
@@ -122,8 +120,7 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
|
||||
let probe_dir = workspaces.first()?;
|
||||
let cmd = resolve_verify_command(probe_dir, verify_command)?;
|
||||
|
||||
let (cmd_prog, cmd_args) = cmd.split_once(' ').map(|(p, a)| (p.to_string(), a.to_string()))
|
||||
.unwrap_or_else(|| (cmd.clone(), String::new()));
|
||||
let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else(|| (cmd.clone(), String::new()), |(p, a)| (p.to_string(), a.to_string()));
|
||||
|
||||
let Ok(mut child) = Command::new(&cmd_prog)
|
||||
.args(cmd_args.split_whitespace())
|
||||
@@ -143,7 +140,7 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
|
||||
let output = child.wait_with_output().ok();
|
||||
let stdout = output.as_ref().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
|
||||
let stderr = output.as_ref().map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()).unwrap_or_default();
|
||||
let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) };
|
||||
let combined = if stderr.is_empty() { stdout } else { format!("{stdout}\n{stderr}") };
|
||||
return Some(ProbeResult {
|
||||
command: cmd.clone(),
|
||||
passed: status.success(),
|
||||
@@ -201,10 +198,10 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
|
||||
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
|
||||
let scripts = v.get("scripts")?;
|
||||
if scripts.get("test").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() {
|
||||
if scripts.get("test").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) {
|
||||
return Some("npm test 2>&1".to_string());
|
||||
}
|
||||
if scripts.get("build").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() {
|
||||
if scripts.get("build").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) {
|
||||
return Some("npm run build 2>&1".to_string());
|
||||
}
|
||||
}
|
||||
@@ -306,14 +303,15 @@ fn truncate_output(s: &str, max: usize) -> String {
|
||||
/// Return: `Ok(())` once the review has been kicked off; errors only
|
||||
/// propagate from constructing the subagent context, not from the review
|
||||
/// itself (that failure is reported via a `SystemNote` instead).
|
||||
pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
#[allow(clippy::unnecessary_debug_formatting)]
|
||||
pub fn trigger_review(state: &mut AppStateRest) {
|
||||
let def = AgentDefinition::new(
|
||||
"quality-reviewer".to_string(),
|
||||
"reviewer".to_string(),
|
||||
);
|
||||
let mut ctx = build_subagent_context(def);
|
||||
ctx.session_dir = state.session_dir.clone();
|
||||
ctx.workspaces = state.workspace_roots.clone();
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir.clone_from(&state.session_dir);
|
||||
ctx.workspaces.clone_from(&state.workspace_roots);
|
||||
let probe_result = probe_build_test(
|
||||
&state.workspace_roots,
|
||||
state.settings.verify_command.as_deref(),
|
||||
@@ -333,21 +331,20 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
None => "No build/test probe matched. Confidence: opinion (reasoning-based).".to_string(),
|
||||
};
|
||||
|
||||
let session_dir = &state.session_dir;
|
||||
ctx.system_prompt = format!(
|
||||
"You are a code quality reviewer. Review the recent code changes \
|
||||
for correctness, and adherence to best practices. \
|
||||
Use read-only tools (read, grep, glob, recall, remember) to \
|
||||
inspect the session files and provide a concise review verdict. \
|
||||
Session directory: {:?}\n\n\
|
||||
Build/Test Probe:\n{}\n\n\
|
||||
Session directory: {session_dir:?}\n\n\
|
||||
Build/Test Probe:\n{probe_note}\n\n\
|
||||
When writing a lesson via remember(), set tags appropriately:\n\
|
||||
- If build/test verification printed any FAILED/ERROR lines, tag\n\
|
||||
the lesson as \"confidence: verified\" (backed by a real failure).\n\
|
||||
- If the probe passed or was skipped, tag as \"confidence: opinion\"\n\
|
||||
(reviewer judgment only).\n\
|
||||
Check for duplicate lessons via recall before writing a new one.",
|
||||
state.session_dir,
|
||||
probe_note,
|
||||
);
|
||||
|
||||
// Use a drain thread for subagent events (so blocking_send never
|
||||
@@ -359,17 +356,17 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
let mut rx = rx;
|
||||
while let Some(event) = rx.blocking_recv() {
|
||||
match &event {
|
||||
SubagentEvent::ToolCall { _tool, .. } => {
|
||||
tracing::debug!("[review] tool call: {}", _tool);
|
||||
SubagentEvent::ToolCall { tool, .. } => {
|
||||
tracing::debug!("[review] tool call: {}", tool);
|
||||
}
|
||||
SubagentEvent::ToolResult { _tool, .. } => {
|
||||
tracing::debug!("[review] tool result: {}", _tool);
|
||||
SubagentEvent::ToolResult { tool, .. } => {
|
||||
tracing::debug!("[review] tool result: {}", tool);
|
||||
}
|
||||
SubagentEvent::StepCompleted { _step, .. } => {
|
||||
tracing::trace!("[review] step {} completed", _step);
|
||||
SubagentEvent::StepCompleted { .. } => {
|
||||
tracing::trace!("[review] step completed");
|
||||
}
|
||||
SubagentEvent::StepFailed { _step, _error } => {
|
||||
tracing::warn!("[review] step {} failed: {}", _step, _error);
|
||||
SubagentEvent::StepFailed { step, error } => {
|
||||
tracing::warn!("[review] step {} failed: {}", step, error);
|
||||
}
|
||||
SubagentEvent::Completed { .. } => {
|
||||
tracing::debug!("[review] completed");
|
||||
@@ -380,13 +377,13 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
let turn_events = state.turn_events.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let result = run_subagent(ctx, tx);
|
||||
let result = run_subagent(&ctx, &tx);
|
||||
let message = match result {
|
||||
Ok(verdict) => {
|
||||
let first_line = verdict.lines().next().unwrap_or(&verdict);
|
||||
format!("Quality review: {}", first_line)
|
||||
format!("Quality review: {first_line}")
|
||||
}
|
||||
Err(e) => format!("Quality review failed: {}", e),
|
||||
Err(e) => format!("Quality review failed: {e}"),
|
||||
};
|
||||
if let Ok(mut q) = turn_events.lock() {
|
||||
q.push_back(TurnEvent::SystemNote {
|
||||
@@ -400,8 +397,6 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
ToastKind::Info,
|
||||
"Quality review triggered".to_string(),
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const STALE_AFTER_DAYS: i64 = 60;
|
||||
|
||||
+283
-247
File diff suppressed because it is too large
Load Diff
@@ -69,13 +69,10 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
||||
Command::WorkflowRun { script } => {
|
||||
vec![Action::RunWorkflow { script }]
|
||||
}
|
||||
Command::Pipeline { mode } => {
|
||||
vec![Action::RunPipeline { mode }]
|
||||
}
|
||||
Command::Unknown(cmd) => {
|
||||
vec![Action::SystemNote {
|
||||
kind: "error".to_string(),
|
||||
message: format!("unknown command: {}", cmd),
|
||||
message: format!("unknown command: {cmd}"),
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Short-send / message shaping: compacts long conversation histories so
|
||||
//! they fit within the provider's context window before being sent to the
|
||||
//! LLM API.
|
||||
@@ -59,10 +60,10 @@ pub fn shape_messages(
|
||||
// Always keep the very first message (System Prompt) which we don't count here
|
||||
// as we just blindly preserve it later.
|
||||
let mut msgs_to_eval = messages.to_vec();
|
||||
let first = if !msgs_to_eval.is_empty() {
|
||||
Some(msgs_to_eval.remove(0))
|
||||
} else {
|
||||
let first = if msgs_to_eval.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(msgs_to_eval.remove(0))
|
||||
};
|
||||
|
||||
// Iterate backwards from the most recent to oldest
|
||||
@@ -105,7 +106,7 @@ pub fn shape_messages(
|
||||
match llm.chat_with_tools_non_streaming(&req_msgs, None) {
|
||||
Ok(resp) => {
|
||||
if let Some(content) = resp.0.content {
|
||||
summary_text = format!("[Summary of compacted prior conversation:\n{}\n]", content);
|
||||
summary_text = format!("[Summary of compacted prior conversation:\n{content}\n]");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! SSE stream parser: converts SSE- or JSON-chunked LLM responses into
|
||||
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
|
||||
pub mod turn;
|
||||
@@ -88,6 +89,7 @@ impl SseParser {
|
||||
/// provider-specific parsing layer.
|
||||
///
|
||||
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn flush_event(&mut self) -> Vec<StreamEvent> {
|
||||
let data = self.data_lines.join("\n");
|
||||
self.data_lines.clear();
|
||||
@@ -107,15 +109,15 @@ impl SseParser {
|
||||
};
|
||||
if let Some(usage) = value.get("usage") {
|
||||
if !usage.is_null() {
|
||||
let prompt_tokens = usage.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or_else(|| {
|
||||
let prompt_tokens = usage.get("prompt_tokens").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] prompt_tokens missing in usage chunk");
|
||||
0
|
||||
});
|
||||
let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64()).unwrap_or_else(|| {
|
||||
let completion_tokens = usage.get("completion_tokens").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] completion_tokens missing in usage chunk");
|
||||
0
|
||||
});
|
||||
let total_tokens = usage.get("total_tokens").and_then(|v| v.as_u64())
|
||||
let total_tokens = usage.get("total_tokens").and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] total_tokens missing in usage chunk");
|
||||
prompt_tokens + completion_tokens
|
||||
@@ -126,12 +128,11 @@ impl SseParser {
|
||||
// in the same chunk; emitting both prevents content loss.
|
||||
let has_other_content = value.get("choices")
|
||||
.and_then(|c| c.as_array())
|
||||
.map(|arr| arr.iter().any(|ch| {
|
||||
.is_some_and(|arr| arr.iter().any(|ch| {
|
||||
ch.get("delta").and_then(|d| d.get("content")).is_some()
|
||||
|| ch.get("delta").and_then(|d| d.get("reasoning_content")).is_some()
|
||||
|| ch.get("delta").and_then(|d| d.get("tool_calls")).is_some()
|
||||
}))
|
||||
.unwrap_or(false);
|
||||
}));
|
||||
if !has_other_content {
|
||||
return vec![StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }];
|
||||
}
|
||||
@@ -139,21 +140,11 @@ impl SseParser {
|
||||
}
|
||||
match event_type.as_str() {
|
||||
"message.stop" => vec![StreamEvent::Done],
|
||||
"message.start" => vec![],
|
||||
"message.delta" | "" => {
|
||||
let delta = match value.get("delta").or_else(|| value.get("choices")) {
|
||||
Some(d) => d,
|
||||
None => return vec![],
|
||||
};
|
||||
let Some(delta) = value.get("delta").or_else(|| value.get("choices")) else { return vec![] };
|
||||
if let Some(choices) = delta.as_array() {
|
||||
let choice = match choices.first() {
|
||||
Some(c) => c,
|
||||
None => return vec![],
|
||||
};
|
||||
let d = match choice.get("delta") {
|
||||
Some(v) => v,
|
||||
None => return vec![],
|
||||
};
|
||||
let Some(choice) = choices.first() else { return vec![] };
|
||||
let Some(d) = choice.get("delta") else { return vec![] };
|
||||
|
||||
// Content token
|
||||
if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
|
||||
@@ -169,15 +160,15 @@ impl SseParser {
|
||||
if let Some(tool_calls) = d.get("tool_calls").and_then(|tc| tc.as_array()) {
|
||||
let mut events = Vec::with_capacity(tool_calls.len());
|
||||
for tc in tool_calls {
|
||||
let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or_else(|| {
|
||||
let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] tool call delta missing index, defaulting to 0");
|
||||
0
|
||||
}) as usize;
|
||||
let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string());
|
||||
let id = tc.get("id").and_then(|i| i.as_str()).map(std::string::ToString::to_string);
|
||||
let name = tc.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|s| s.to_string());
|
||||
.map(std::string::ToString::to_string);
|
||||
let args_delta = tc.get("function")
|
||||
.and_then(|f| f.get("arguments"))
|
||||
.and_then(|a| a.as_str())
|
||||
@@ -253,15 +244,15 @@ pub fn parse_stream_chunk(data: &str) -> Option<StreamEvent> {
|
||||
}
|
||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) {
|
||||
if let Some(tc) = tool_calls.first() {
|
||||
let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or_else(|| {
|
||||
let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] fallback parser: tool call missing index, defaulting to 0");
|
||||
0
|
||||
}) as usize;
|
||||
let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string());
|
||||
let id = tc.get("id").and_then(|i| i.as_str()).map(std::string::ToString::to_string);
|
||||
let name = tc.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|s| s.to_string());
|
||||
.map(std::string::ToString::to_string);
|
||||
let args = tc.get("function")
|
||||
.and_then(|f| f.get("arguments"))
|
||||
.and_then(|a| a.as_str())
|
||||
@@ -289,7 +280,7 @@ mod tests {
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
StreamEvent::Token(t) => assert_eq!(t, "hello"),
|
||||
other => panic!("expected Token, got {:?}", other),
|
||||
other => panic!("expected Token, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +293,7 @@ mod tests {
|
||||
assert_eq!(e2.len(), 1);
|
||||
match &e2[0] {
|
||||
StreamEvent::Token(t) => assert_eq!(t, "partial"),
|
||||
other => panic!("expected Token, got {:?}", other),
|
||||
other => panic!("expected Token, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +329,7 @@ mod tests {
|
||||
assert_eq!(name.as_deref(), Some("bash"));
|
||||
assert_eq!(arguments_delta, "{\"cmd\"");
|
||||
}
|
||||
other => panic!("expected ToolCallDelta, got {:?}", other),
|
||||
other => panic!("expected ToolCallDelta, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +346,7 @@ mod tests {
|
||||
assert_eq!(*completion_tokens, 5);
|
||||
assert_eq!(*total_tokens, 15);
|
||||
}
|
||||
other => panic!("expected Usage, got {:?}", other),
|
||||
other => panic!("expected Usage, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,7 +368,7 @@ mod tests {
|
||||
assert_eq!(a, "a");
|
||||
assert_eq!(b, "b");
|
||||
}
|
||||
other => panic!("expected two Tokens, got {:?}", other),
|
||||
other => panic!("expected two Tokens, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,12 +84,12 @@ impl StreamedTurn {
|
||||
let tc = &mut self.tool_calls[*index];
|
||||
if let Some(new_id) = id {
|
||||
if !new_id.is_empty() {
|
||||
tc.id = new_id.clone();
|
||||
tc.id.clone_from(new_id);
|
||||
}
|
||||
}
|
||||
if let Some(new_name) = name {
|
||||
if !new_name.is_empty() {
|
||||
tc.name = new_name.clone();
|
||||
tc.name.clone_from(new_name);
|
||||
}
|
||||
}
|
||||
tc.arguments.push_str(arguments_delta);
|
||||
|
||||
+5
-17
@@ -90,10 +90,6 @@ const COMMANDS: &[&str] = &[
|
||||
"/model add",
|
||||
"/workflow",
|
||||
"/workflow run",
|
||||
"/pipeline",
|
||||
"/pipeline full",
|
||||
"/pipeline quick",
|
||||
"/pipeline skip",
|
||||
"/compact",
|
||||
];
|
||||
|
||||
@@ -139,7 +135,7 @@ impl InputState {
|
||||
self.autocomplete_candidates = COMMANDS
|
||||
.iter()
|
||||
.filter(|c| c.starts_with(&prefix))
|
||||
.map(|c| c.to_string())
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
self.autocomplete_prefix = prefix;
|
||||
self.autocomplete_idx = 0;
|
||||
@@ -178,10 +174,10 @@ impl InputState {
|
||||
pub fn tab_complete(&mut self) {
|
||||
// Legacy inline tab-complete — used as a fallback when the dropdown
|
||||
// isn't visible yet. Opens the dropdown on the first Tab press.
|
||||
if !self.autocomplete_visible {
|
||||
self.open_autocomplete();
|
||||
} else {
|
||||
if self.autocomplete_visible {
|
||||
self.cycle_autocomplete(true);
|
||||
} else {
|
||||
self.open_autocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +228,7 @@ impl InputState {
|
||||
.open(path)
|
||||
{
|
||||
use std::io::Write;
|
||||
let _ = writeln!(file, "{}", result);
|
||||
let _ = writeln!(file, "{result}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,13 +289,6 @@ pub struct MiscState {
|
||||
pub api_context_length: Option<u32>,
|
||||
pub tick_count: u64,
|
||||
pub todo_content: String,
|
||||
/// Pipeline mode override set by `/pipeline` command.
|
||||
/// - `None`: auto-detect (default)
|
||||
/// - `Some("full")`: force full pipeline
|
||||
/// - `Some("quick")`: force quick pipeline
|
||||
/// - `Some("skip")`: skip pipeline, handle directly
|
||||
/// Consumed on the next agent turn.
|
||||
pub pipeline_override: Option<String>,
|
||||
}
|
||||
|
||||
impl MiscState {
|
||||
@@ -318,7 +307,6 @@ impl MiscState {
|
||||
api_context_length: None,
|
||||
tick_count: 0,
|
||||
todo_content: String::new(),
|
||||
pipeline_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-25
@@ -84,7 +84,7 @@ impl AppStateRest {
|
||||
/// Why: falls back to `memory_dir` itself (with a warning) when it has
|
||||
/// no parent, and to an empty session id when the dir name can't be
|
||||
/// read, so construction never fails.
|
||||
pub fn new(workspace_roots: Vec<PathBuf>, session_dir: PathBuf, memory_dir: PathBuf) -> Self {
|
||||
pub fn new(workspace_roots: Vec<PathBuf>, session_dir: &std::path::Path, memory_dir: PathBuf) -> Self {
|
||||
let settings = Settings::load();
|
||||
let app_config = AppConfig::load();
|
||||
let worktrees_dir = memory_dir.parent().unwrap_or_else(|| {
|
||||
@@ -93,27 +93,25 @@ impl AppStateRest {
|
||||
}).join("worktrees");
|
||||
let dir_cache = DirCache::new();
|
||||
let session_id = session_dir
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| {
|
||||
.file_name().map_or_else(|| {
|
||||
tracing::warn!("[state] session_dir has no file_name component, using empty session_id");
|
||||
String::new()
|
||||
});
|
||||
}, |n| n.to_string_lossy().to_string());
|
||||
let mut state = AppStateRest {
|
||||
|
||||
settings,
|
||||
app_config,
|
||||
workspace_roots,
|
||||
session_id,
|
||||
session_dir: session_dir.clone(),
|
||||
session_dir: session_dir.to_path_buf(),
|
||||
memory_dir,
|
||||
worktrees_dir,
|
||||
turn_events: Arc::new(Mutex::new(VecDeque::new())),
|
||||
turn_in_flight: Arc::new(Mutex::new(false)),
|
||||
abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
dir_cache: Arc::new(RwLock::new(dir_cache)),
|
||||
edit_log: EditLog::new(&session_dir),
|
||||
session_runtime: Some(SessionRuntime::new(session_dir.clone())),
|
||||
edit_log: EditLog::new(session_dir),
|
||||
session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
|
||||
workflow_engine: WorkflowEngine::new(),
|
||||
mcp_manager: McpManager::new(),
|
||||
lsp_provision_msgs: Arc::new(Mutex::new(VecDeque::new())),
|
||||
@@ -134,10 +132,8 @@ impl AppStateRest {
|
||||
use sha2::Digest;
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(abs_root.to_string_lossy().as_bytes());
|
||||
let hash_hex = format!("{:x}", hasher.finalize());
|
||||
let folder_name = abs_root.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "root".to_string());
|
||||
let hash_hex = hex::encode(hasher.finalize());
|
||||
let folder_name = abs_root.file_name().map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string());
|
||||
let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]);
|
||||
let history_dir = base_dir.join("history");
|
||||
let _ = std::fs::create_dir_all(&history_dir);
|
||||
@@ -146,7 +142,7 @@ impl AppStateRest {
|
||||
if let Ok(content) = std::fs::read_to_string(&history_file) {
|
||||
let history: Vec<String> = content
|
||||
.lines()
|
||||
.map(|s| s.to_string())
|
||||
.map(std::string::ToString::to_string)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
state.input.history = history;
|
||||
@@ -192,12 +188,12 @@ impl AppStateRest {
|
||||
let connected = provisioner::auto_connect(&lsp_mgr, &results);
|
||||
for name in &connected {
|
||||
tracing::info!("LSP: {} connected", name);
|
||||
let m = format!("LSP: {} connected ✓", name); push_msg(&msg_queue, &m);
|
||||
let m = format!("LSP: {name} connected ✓"); push_msg(&msg_queue, &m);
|
||||
}
|
||||
for r in &results {
|
||||
if let ProvisionResult::Failed { language, server_name, reason, .. } = r {
|
||||
tracing::warn!("LSP {} ({}): {}", server_name, language, reason);
|
||||
let m = format!("LSP: {} ({}) ✗ - {}", server_name, language, reason); push_msg(&msg_queue, &m);
|
||||
let m = format!("LSP: {server_name} ({language}) ✗ - {reason}"); push_msg(&msg_queue, &m);
|
||||
}
|
||||
}
|
||||
if connected.is_empty() {
|
||||
@@ -216,10 +212,10 @@ impl AppStateRest {
|
||||
/// Return: `false` (and logs a warning) if the mutex is poisoned, rather
|
||||
/// than propagating a panic.
|
||||
pub fn turn_in_flight(&self) -> bool {
|
||||
self.turn_in_flight.lock().map(|g| *g).unwrap_or_else(|_| {
|
||||
self.turn_in_flight.lock().map_or_else(|_| {
|
||||
tracing::warn!("[state] turn_in_flight mutex poisoned");
|
||||
false
|
||||
})
|
||||
}, |g| *g)
|
||||
}
|
||||
|
||||
/// Shut down every running LSP server process.
|
||||
@@ -259,17 +255,13 @@ impl AppStateRest {
|
||||
/// never fails even on a shallow path.
|
||||
pub fn store_base_dir(&self) -> std::path::PathBuf {
|
||||
self.session_dir.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| {
|
||||
.and_then(|p| p.parent()).map_or_else(|| {
|
||||
tracing::warn!("[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display());
|
||||
self.session_dir.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| {
|
||||
self.session_dir.parent().map_or_else(|| {
|
||||
tracing::warn!("[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display());
|
||||
self.session_dir.clone()
|
||||
})
|
||||
})
|
||||
}, std::path::Path::to_path_buf)
|
||||
}, std::path::Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// Build a `ToolCtx` for tool calls originating from the main agent.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Shared small state types: toasts, overlays, the transcript cache,
|
||||
//! tool execution model, and call origin tags.
|
||||
|
||||
@@ -109,7 +110,7 @@ pub enum Origin {
|
||||
|
||||
impl Origin {
|
||||
/// Short string tag for this origin, used in filenames and logs.
|
||||
pub fn tag(&self) -> String {
|
||||
pub fn tag(self) -> String {
|
||||
match self {
|
||||
Origin::Main => "main".to_string(),
|
||||
Origin::SubAgent => "subagent".to_string(),
|
||||
|
||||
+51
-52
@@ -37,12 +37,6 @@ const SKIP_REVIEW_FILES: &[&str] = &[
|
||||
".gitignore", ".env", ".env.example",
|
||||
];
|
||||
|
||||
/// Maximum LLM steps for a quick-review subagent. Keeps reviews fast.
|
||||
const QUICK_REVIEW_MAX_STEPS: usize = 2;
|
||||
|
||||
/// Maximum LLM steps for background subagents (test gen, arch, security).
|
||||
const BG_SUBAGENT_MAX_STEPS: usize = 8;
|
||||
|
||||
/// ─── Helpers ───
|
||||
///
|
||||
/// Check whether a file path is worth auto-reviewing (not config/lock/data).
|
||||
@@ -72,12 +66,18 @@ fn is_production_code(path: &str) -> bool {
|
||||
if lower.contains("test") || lower.contains("spec") || lower.contains("_test.") {
|
||||
return false;
|
||||
}
|
||||
// Only source files
|
||||
lower.ends_with(".rs") || lower.ends_with(".ts") || lower.ends_with(".tsx")
|
||||
|| lower.ends_with(".js") || lower.ends_with(".jsx") || lower.ends_with(".go")
|
||||
|| lower.ends_with(".py") || lower.ends_with(".java") || lower.ends_with(".kt")
|
||||
|| lower.ends_with(".swift") || lower.ends_with(".c") || lower.ends_with(".cpp")
|
||||
|| lower.ends_with(".h") || lower.ends_with(".hpp")
|
||||
// Only source files — use Path::extension() to avoid clippy
|
||||
// case_sensitive_file_extension_comparisons lint
|
||||
std::path::Path::new(&lower)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| {
|
||||
matches!(
|
||||
ext,
|
||||
"rs" | "ts" | "tsx" | "js" | "jsx" | "go" | "py" | "java" | "kt" | "swift"
|
||||
| "c" | "cpp" | "h" | "hpp"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// ─── Inline Quick Review (synchronous, feeds back to LLM) ───
|
||||
@@ -108,10 +108,9 @@ pub fn spawn_quick_review(
|
||||
"quick-reviewer".to_string(),
|
||||
"reviewer".to_string(),
|
||||
)
|
||||
.with_system_prompt(prompt)
|
||||
.with_max_steps(QUICK_REVIEW_MAX_STEPS);
|
||||
.with_system_prompt(prompt);
|
||||
|
||||
let mut ctx = build_subagent_context(def);
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir = session_dir.to_path_buf();
|
||||
ctx.workspaces = workspaces.to_vec();
|
||||
|
||||
@@ -119,11 +118,11 @@ pub fn spawn_quick_review(
|
||||
let _drain = std::thread::spawn(move || {
|
||||
while let Some(event) = rx.blocking_recv() {
|
||||
match &event {
|
||||
SubagentEvent::ToolCall { _tool, .. } => {
|
||||
tracing::debug!("[auto-review] tool call: {}", _tool);
|
||||
SubagentEvent::ToolCall { tool, .. } => {
|
||||
tracing::debug!("[auto-review] tool call: {}", tool);
|
||||
}
|
||||
SubagentEvent::ToolResult { _tool, .. } => {
|
||||
tracing::debug!("[auto-review] tool result: {}", _tool);
|
||||
SubagentEvent::ToolResult { tool, .. } => {
|
||||
tracing::debug!("[auto-review] tool result: {}", tool);
|
||||
}
|
||||
SubagentEvent::Completed { .. } => {
|
||||
tracing::debug!("[auto-review] completed");
|
||||
@@ -133,7 +132,7 @@ pub fn spawn_quick_review(
|
||||
}
|
||||
});
|
||||
|
||||
let verdict = run_subagent(ctx, tx)?;
|
||||
let verdict = run_subagent(&ctx, &tx)?;
|
||||
tracing::info!(
|
||||
"[auto-review] quick review for '{}': {}",
|
||||
file_path,
|
||||
@@ -142,7 +141,7 @@ pub fn spawn_quick_review(
|
||||
Ok(verdict)
|
||||
}
|
||||
|
||||
/// ─── Background Subagent Spawners (async, report via SystemNote) ───
|
||||
/// ─── Background Subagent Spawners (async, report via `SystemNote`) ───
|
||||
///
|
||||
/// Spawn a background subagent that generates tests for modified files.
|
||||
///
|
||||
@@ -183,9 +182,9 @@ pub fn spawn_background_test_gen(
|
||||
"coder".to_string(), // needs write access
|
||||
)
|
||||
.with_system_prompt(prompt)
|
||||
.with_max_steps(BG_SUBAGENT_MAX_STEPS);
|
||||
;
|
||||
|
||||
let mut ctx = build_subagent_context(def);
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir = sd;
|
||||
ctx.workspaces = ws;
|
||||
|
||||
@@ -193,17 +192,17 @@ pub fn spawn_background_test_gen(
|
||||
let _drain = std::thread::spawn(move || {
|
||||
while let Some(event) = rx.blocking_recv() {
|
||||
match &event {
|
||||
SubagentEvent::ToolCall { _tool, .. } => {
|
||||
tracing::debug!("[bg-test-gen] tool: {}", _tool);
|
||||
SubagentEvent::ToolCall { tool, .. } => {
|
||||
tracing::debug!("[bg-test-gen] tool: {}", tool);
|
||||
}
|
||||
SubagentEvent::ToolResult { _tool, .. } => {
|
||||
tracing::debug!("[bg-test-gen] result: {}", _tool);
|
||||
SubagentEvent::ToolResult { tool, .. } => {
|
||||
tracing::debug!("[bg-test-gen] result: {}", tool);
|
||||
}
|
||||
SubagentEvent::StepCompleted { _step, .. } => {
|
||||
tracing::trace!("[bg-test-gen] step {} done", _step);
|
||||
SubagentEvent::StepCompleted { .. } => {
|
||||
tracing::trace!("[bg-test-gen] step done");
|
||||
}
|
||||
SubagentEvent::StepFailed { _step, _error } => {
|
||||
tracing::warn!("[bg-test-gen] step {} failed: {}", _step, _error);
|
||||
SubagentEvent::StepFailed { step, error } => {
|
||||
tracing::warn!("[bg-test-gen] step {} failed: {}", step, error);
|
||||
}
|
||||
SubagentEvent::Completed { .. } => {
|
||||
tracing::debug!("[bg-test-gen] completed");
|
||||
@@ -212,13 +211,13 @@ pub fn spawn_background_test_gen(
|
||||
}
|
||||
});
|
||||
|
||||
let result = run_subagent(ctx, tx);
|
||||
let result = run_subagent(&ctx, &tx);
|
||||
let message = match &result {
|
||||
Ok(output) => {
|
||||
let first = output.lines().next().unwrap_or(output);
|
||||
format!("Auto test-gen: {}", first)
|
||||
format!("Auto test-gen: {first}")
|
||||
}
|
||||
Err(e) => format!("Auto test-gen failed: {}", e),
|
||||
Err(e) => format!("Auto test-gen failed: {e}"),
|
||||
};
|
||||
|
||||
if let Ok(mut q) = events.lock() {
|
||||
@@ -263,9 +262,9 @@ pub fn spawn_background_arch_review(
|
||||
"reviewer".to_string(),
|
||||
)
|
||||
.with_system_prompt(prompt)
|
||||
.with_max_steps(BG_SUBAGENT_MAX_STEPS);
|
||||
;
|
||||
|
||||
let mut ctx = build_subagent_context(def);
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir = sd;
|
||||
ctx.workspaces = ws;
|
||||
|
||||
@@ -273,11 +272,11 @@ pub fn spawn_background_arch_review(
|
||||
let _drain = std::thread::spawn(move || {
|
||||
while let Some(event) = rx.blocking_recv() {
|
||||
match &event {
|
||||
SubagentEvent::ToolCall { _tool, .. } => {
|
||||
tracing::debug!("[bg-arch] tool: {}", _tool);
|
||||
SubagentEvent::ToolCall { tool, .. } => {
|
||||
tracing::debug!("[bg-arch] tool: {}", tool);
|
||||
}
|
||||
SubagentEvent::ToolResult { _tool, .. } => {
|
||||
tracing::debug!("[bg-arch] result: {}", _tool);
|
||||
SubagentEvent::ToolResult { tool, .. } => {
|
||||
tracing::debug!("[bg-arch] result: {}", tool);
|
||||
}
|
||||
SubagentEvent::Completed { .. } => {
|
||||
tracing::debug!("[bg-arch] completed");
|
||||
@@ -287,13 +286,13 @@ pub fn spawn_background_arch_review(
|
||||
}
|
||||
});
|
||||
|
||||
let result = run_subagent(ctx, tx);
|
||||
let result = run_subagent(&ctx, &tx);
|
||||
let message = match &result {
|
||||
Ok(output) => {
|
||||
let first = output.lines().next().unwrap_or(output);
|
||||
format!("Architecture review: {}", first)
|
||||
format!("Architecture review: {first}")
|
||||
}
|
||||
Err(e) => format!("Architecture review failed: {}", e),
|
||||
Err(e) => format!("Architecture review failed: {e}"),
|
||||
};
|
||||
|
||||
if let Ok(mut q) = events.lock() {
|
||||
@@ -349,9 +348,9 @@ pub fn spawn_background_security_review(
|
||||
"reviewer".to_string(),
|
||||
)
|
||||
.with_system_prompt(prompt)
|
||||
.with_max_steps(BG_SUBAGENT_MAX_STEPS);
|
||||
;
|
||||
|
||||
let mut ctx = build_subagent_context(def);
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir = sd;
|
||||
ctx.workspaces = ws;
|
||||
|
||||
@@ -359,11 +358,11 @@ pub fn spawn_background_security_review(
|
||||
let _drain = std::thread::spawn(move || {
|
||||
while let Some(event) = rx.blocking_recv() {
|
||||
match &event {
|
||||
SubagentEvent::ToolCall { _tool, .. } => {
|
||||
tracing::debug!("[bg-security] tool: {}", _tool);
|
||||
SubagentEvent::ToolCall { tool, .. } => {
|
||||
tracing::debug!("[bg-security] tool: {}", tool);
|
||||
}
|
||||
SubagentEvent::ToolResult { _tool, .. } => {
|
||||
tracing::debug!("[bg-security] result: {}", _tool);
|
||||
SubagentEvent::ToolResult { tool, .. } => {
|
||||
tracing::debug!("[bg-security] result: {}", tool);
|
||||
}
|
||||
SubagentEvent::Completed { .. } => {
|
||||
tracing::debug!("[bg-security] completed");
|
||||
@@ -373,13 +372,13 @@ pub fn spawn_background_security_review(
|
||||
}
|
||||
});
|
||||
|
||||
let result = run_subagent(ctx, tx);
|
||||
let result = run_subagent(&ctx, &tx);
|
||||
let message = match &result {
|
||||
Ok(output) => {
|
||||
let first = output.lines().next().unwrap_or(output);
|
||||
format!("Security review: {}", first)
|
||||
format!("Security review: {first}")
|
||||
}
|
||||
Err(e) => format!("Security review failed: {}", e),
|
||||
Err(e) => format!("Security review failed: {e}"),
|
||||
};
|
||||
|
||||
if let Ok(mut q) = events.lock() {
|
||||
|
||||
@@ -37,15 +37,15 @@ pub struct SubagentContext {
|
||||
///
|
||||
/// Return: a context with empty `system_prompt`, empty `workspaces`,
|
||||
/// empty `session_dir`, resolved `max_steps`, and the resolved allowed-tool list.
|
||||
pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext {
|
||||
pub fn build_subagent_context(def: &AgentDefinition) -> SubagentContext {
|
||||
let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| {
|
||||
if def.role == "reviewer" {
|
||||
REVIEWER_ALLOWED.iter().map(|s| s.to_string()).collect()
|
||||
REVIEWER_ALLOWED.iter().map(std::string::ToString::to_string).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
});
|
||||
let max_steps = def.max_steps.unwrap_or(25);
|
||||
let max_steps = def.max_steps.unwrap_or(usize::MAX);
|
||||
SubagentContext {
|
||||
system_prompt: String::new(),
|
||||
allowed_tools,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
|
||||
/// Division roles — used as both the `role` field in AgentDefinition
|
||||
/// Division roles — used as both the `role` field in `AgentDefinition`
|
||||
/// and as the key for pipeline routing.
|
||||
pub mod roles {
|
||||
/// Strategy Division: plans architecture, creates diagrams, breaks down work.
|
||||
@@ -43,7 +43,6 @@ pub fn strategy_division() -> AgentDefinition {
|
||||
roles::STRATEGY.to_string(),
|
||||
)
|
||||
.with_system_prompt(crate::resources::DIVISION_PLANNER_PROMPT.to_string())
|
||||
.with_max_steps(15)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"grep".to_string(),
|
||||
@@ -57,6 +56,7 @@ pub fn strategy_division() -> AgentDefinition {
|
||||
"lsp_hover".to_string(),
|
||||
"lsp_definition".to_string(),
|
||||
"lsp_references".to_string(),
|
||||
"read_findings".to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ pub fn engineering_division() -> AgentDefinition {
|
||||
roles::ENGINEERING.to_string(),
|
||||
)
|
||||
.with_system_prompt(crate::resources::DIVISION_IMPLEMENTER_PROMPT.to_string())
|
||||
.with_max_steps(50)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"write".to_string(),
|
||||
@@ -90,6 +89,7 @@ pub fn engineering_division() -> AgentDefinition {
|
||||
"lsp_disconnect".to_string(),
|
||||
"todowrite".to_string(),
|
||||
"todofinish".to_string(),
|
||||
"read_findings".to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -103,7 +103,6 @@ pub fn quality_division() -> AgentDefinition {
|
||||
roles::QUALITY.to_string(),
|
||||
)
|
||||
.with_system_prompt(crate::resources::DIVISION_TESTER_PROMPT.to_string())
|
||||
.with_max_steps(30)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"write".to_string(),
|
||||
@@ -119,6 +118,7 @@ pub fn quality_division() -> AgentDefinition {
|
||||
"lsp_hover".to_string(),
|
||||
"lsp_definition".to_string(),
|
||||
"lsp_references".to_string(),
|
||||
"read_findings".to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -132,7 +132,6 @@ pub fn security_division() -> AgentDefinition {
|
||||
roles::SECURITY.to_string(),
|
||||
)
|
||||
.with_system_prompt(crate::resources::SECURITY_REVIEWER_PROMPT.to_string())
|
||||
.with_max_steps(15)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"grep".to_string(),
|
||||
@@ -146,6 +145,7 @@ pub fn security_division() -> AgentDefinition {
|
||||
"lsp_hover".to_string(),
|
||||
"lsp_definition".to_string(),
|
||||
"lsp_references".to_string(),
|
||||
"read_findings".to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -159,7 +159,6 @@ pub fn documentation_division() -> AgentDefinition {
|
||||
roles::DOCUMENTATION.to_string(),
|
||||
)
|
||||
.with_system_prompt(crate::resources::DIVISION_DOCUMENTER_PROMPT.to_string())
|
||||
.with_max_steps(15)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"write".to_string(),
|
||||
@@ -168,6 +167,7 @@ pub fn documentation_division() -> AgentDefinition {
|
||||
"glob".to_string(),
|
||||
"recall".to_string(),
|
||||
"remember".to_string(),
|
||||
"read_findings".to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
+126
-89
@@ -7,6 +7,7 @@
|
||||
//! bash exfiltration and destructive-pattern detection) so that subagents
|
||||
//! are not a weaker link than the main agent.
|
||||
|
||||
use std::fmt::Write;
|
||||
use tokio::sync::mpsc;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
use crate::dto::provider::request::ToolDef;
|
||||
@@ -27,10 +28,16 @@ use super::event::SubagentEvent;
|
||||
fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
|
||||
let all = all_tools();
|
||||
let filtered: Vec<Box<dyn crate::tool::Tool>> = if allowed_tools.is_empty() {
|
||||
all
|
||||
all.into_iter()
|
||||
.filter(|t| t.name() != "company_pipeline" && t.name() != "workflow_run")
|
||||
.collect()
|
||||
} else {
|
||||
all.into_iter()
|
||||
.filter(|t| allowed_tools.contains(&t.name().to_string()))
|
||||
.filter(|t| {
|
||||
allowed_tools.contains(&t.name().to_string())
|
||||
&& t.name() != "company_pipeline"
|
||||
&& t.name() != "workflow_run"
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let defs = tool_defs(&filtered);
|
||||
@@ -143,8 +150,7 @@ fn gate_subagent_tool_call(
|
||||
let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if reason.trim().len() < MIN_REASON_LEN {
|
||||
return Some(format!(
|
||||
"{} requires a non-trivial 'reason' (>= {} chars) explaining why",
|
||||
tool_name, MIN_REASON_LEN,
|
||||
"{tool_name} requires a non-trivial 'reason' (>= {MIN_REASON_LEN} chars) explaining why",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -157,9 +163,7 @@ fn gate_subagent_tool_call(
|
||||
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
|
||||
// For edits, scanning old+new together catches stubs in both
|
||||
return if contains_any(old, STUB_PATTERNS) {
|
||||
Some("content contains stub/placeholder pattern; production code must be fully implemented".to_string())
|
||||
} else if contains_any(new, STUB_PATTERNS) {
|
||||
return if contains_any(old, STUB_PATTERNS) || contains_any(new, STUB_PATTERNS) {
|
||||
Some("content contains stub/placeholder pattern; production code must be fully implemented".to_string())
|
||||
} else if contains_any(new, DENIAL_PATTERNS) {
|
||||
Some("content contains denial/punt pattern; implement properly instead of skipping".to_string())
|
||||
@@ -202,13 +206,13 @@ fn gate_subagent_tool_call(
|
||||
if !is_standard {
|
||||
for pat in EXFIL_PATTERNS {
|
||||
if cmd.contains(pat) {
|
||||
return Some(format!("potential data-exfiltration command blocked (matched '{}')", pat));
|
||||
return Some(format!("potential data-exfiltration command blocked (matched '{pat}')"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for pat in SENSITIVE_PATH_PATTERNS {
|
||||
if cmd.contains(pat) {
|
||||
return Some(format!("refused to read/write sensitive path '{}'", pat));
|
||||
return Some(format!("refused to read/write sensitive path '{pat}'"));
|
||||
}
|
||||
}
|
||||
let dangerous = ["rm -rf /", "rm -rf --no-preserve-root", "rm -rf ~",
|
||||
@@ -216,7 +220,7 @@ fn gate_subagent_tool_call(
|
||||
"chmod -R 000 /", "shutdown ", "poweroff ", "reboot ", "halt "];
|
||||
for pat in &dangerous {
|
||||
if cmd.contains(pat) {
|
||||
return Some(format!("destructive command pattern blocked: {}", pat));
|
||||
return Some(format!("destructive command pattern blocked: {pat}"));
|
||||
}
|
||||
}
|
||||
if contains_any(cmd, STUB_PATTERNS) {
|
||||
@@ -251,7 +255,7 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("Current Workspace Directory Structure:\n");
|
||||
for root in roots {
|
||||
out.push_str(&format!("Root: {}\n", root.display()));
|
||||
writeln!(out, "Root: {}", root.display()).unwrap();
|
||||
let walker = ignore::WalkBuilder::new(root)
|
||||
.hidden(true)
|
||||
.git_ignore(true)
|
||||
@@ -261,9 +265,9 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
|
||||
let path = entry.path();
|
||||
if let Ok(rel) = path.strip_prefix(root) {
|
||||
if rel.as_os_str().is_empty() { continue; }
|
||||
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
|
||||
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
|
||||
let prefix = if is_dir { "[DIR] " } else { " " };
|
||||
out.push_str(&format!(" {}{}\n", prefix, rel.display()));
|
||||
writeln!(out, " {}{}", prefix, rel.display()).unwrap();
|
||||
count += 1;
|
||||
if count > 1000 {
|
||||
out.push_str(" ... (truncated)\n");
|
||||
@@ -280,10 +284,10 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
|
||||
///
|
||||
/// Flow: inject system prompt (with workspace tree if available) → for each
|
||||
/// step: resolve provider config, build an LLM client, call
|
||||
/// `chat_with_tools_non_streaming`, process tool calls (gated against both
|
||||
/// the allowlist and Harness-style content safety checks) or collect text
|
||||
/// output → send `SubagentEvent`s on `tx` → break on first text-only
|
||||
/// (non-empty) response.
|
||||
/// `chat_with_tools_streaming` (with abort check per SSE event), process
|
||||
/// tool calls (gated against both the allowlist and Harness-style content
|
||||
/// safety checks) or collect text output → send `SubagentEvent`s on `tx` →
|
||||
/// break on first text-only (non-empty) response.
|
||||
///
|
||||
/// Why: runs synchronously on a dedicated thread so the main async event
|
||||
/// loop is not blocked. Tool gating prevents restricted, risky, or
|
||||
@@ -291,7 +295,8 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
|
||||
///
|
||||
/// Return: the concatenated text output, or an `anyhow::Error` if the LLM
|
||||
/// call fails at any step.
|
||||
pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
|
||||
let mut output = String::new();
|
||||
let mut messages: Vec<ChatMessage> = Vec::new();
|
||||
|
||||
@@ -328,22 +333,51 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
||||
// be cancelled from the parent (mirrors main agent behaviour).
|
||||
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
|
||||
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||
_step: step,
|
||||
_error: "subagent aborted by parent".to_string(),
|
||||
step,
|
||||
error: "subagent aborted by parent".to_string(),
|
||||
});
|
||||
anyhow::bail!("subagent aborted by parent at step {}", step);
|
||||
anyhow::bail!("subagent aborted by parent at step {step}");
|
||||
}
|
||||
|
||||
// Use the structured tool-calling API so the LLM can request tools with
|
||||
// proper arguments, exactly like the main agent does.
|
||||
let (response, _usage) = match client.chat_with_tools_non_streaming(&messages, tdefs_opt.clone()) {
|
||||
// Use streaming API so the abort flag is checked per SSE event,
|
||||
// making the subagent responsive to cancellation even during an
|
||||
// LLM call (non-streaming would block for 10-30s unchecked).
|
||||
let stream_result = client.chat_with_tools_streaming(
|
||||
&messages,
|
||||
tdefs_opt.clone(),
|
||||
Some(0.7),
|
||||
Some(4096),
|
||||
|_event| -> bool {
|
||||
// Check abort on every SSE event for responsive cancellation.
|
||||
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
|
||||
return false; // signals provider to abort
|
||||
}
|
||||
// We don't stream tokens to the UI for subagents — just
|
||||
// need the assembled message at the end.
|
||||
true
|
||||
},
|
||||
);
|
||||
|
||||
let (response, _usage) = match stream_result {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
let is_abort = ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst))
|
||||
|| e.to_string().contains("aborted");
|
||||
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||
_step: step,
|
||||
_error: e.to_string(),
|
||||
step,
|
||||
error: if is_abort {
|
||||
"subagent aborted by user".to_string()
|
||||
} else {
|
||||
e.to_string()
|
||||
},
|
||||
});
|
||||
anyhow::bail!("subagent call failed at step {}: {}", step, e);
|
||||
if is_abort {
|
||||
anyhow::bail!("subagent aborted by parent at step {step}");
|
||||
}
|
||||
// No non-streaming fallback — API must support streaming.
|
||||
// Non-streaming calls block for up to 1 min without checking
|
||||
// abort_flag, making cancellation unresponsive.
|
||||
anyhow::bail!("subagent call failed at step {step}: {e}");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -357,81 +391,84 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
||||
// Push the assistant message with tool_calls into the conversation
|
||||
messages.push(response);
|
||||
|
||||
for tool_call in &tool_calls {
|
||||
// Check abort flag before each tool execution
|
||||
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
|
||||
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||
_step: step,
|
||||
_error: "subagent aborted by parent during tool execution".to_string(),
|
||||
});
|
||||
anyhow::bail!("subagent aborted by parent during tool call at step {}", step);
|
||||
}
|
||||
let mut results_vec = Vec::new();
|
||||
std::thread::scope(|s| {
|
||||
let mut handles = Vec::new();
|
||||
let tools_ref = &tools;
|
||||
let tool_ctx_ref = &tool_ctx;
|
||||
for tool_call in &tool_calls {
|
||||
let handle = s.spawn(move || {
|
||||
// Check abort flag before each tool execution
|
||||
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
|
||||
return (tool_call, Err(anyhow::anyhow!("subagent aborted by parent during tool execution")));
|
||||
}
|
||||
|
||||
let tool_name = &tool_call.function.name;
|
||||
let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
|
||||
let explicitly_allowed = ctx.allowed_tools.contains(tool_name);
|
||||
let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed;
|
||||
|
||||
// Level 1: allowlist check — is this tool even permitted?
|
||||
if !generally_allowed {
|
||||
return (tool_call, Ok(format!("tool '{tool_name}' not allowed for this subagent")));
|
||||
}
|
||||
|
||||
// Level 2: risky tool check — risky tools require explicit permission
|
||||
if tool_is_risky(tool_name) && !explicitly_allowed {
|
||||
return (tool_call, Ok(format!("risky tool '{tool_name}' requires explicit permission; not allowed for this subagent")));
|
||||
}
|
||||
|
||||
// Level 3: Harness-style content safety gating
|
||||
if let Some(block_reason) = gate_subagent_tool_call(tool_name, &args) {
|
||||
return (tool_call, Ok(format!("Blocked by subagent gate: {block_reason}")));
|
||||
}
|
||||
|
||||
let result = match tools_ref.iter().find(|t| t.name() == tool_name.as_str()) {
|
||||
Some(tool) => tool.run(tool_ctx_ref, &args),
|
||||
None => Err(anyhow::anyhow!("tool '{tool_name}' not found")),
|
||||
};
|
||||
(tool_call, result)
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
for h in handles {
|
||||
if let Ok(res) = h.join() {
|
||||
results_vec.push(res);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (tool_call, result) in results_vec {
|
||||
let tool_name = &tool_call.function.name;
|
||||
let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
|
||||
let explicitly_allowed = ctx.allowed_tools.contains(tool_name);
|
||||
let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed;
|
||||
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolCall {
|
||||
_tool: tool_name.clone(),
|
||||
_args: args.clone(),
|
||||
tool: tool_name.clone(),
|
||||
args: args.clone(),
|
||||
});
|
||||
|
||||
// Level 1: allowlist check — is this tool even permitted?
|
||||
if !generally_allowed {
|
||||
let msg = format!("tool '{}' not allowed for this subagent", tool_name);
|
||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
_tool: tool_name.clone(),
|
||||
_output: msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Level 2: risky tool check — risky tools require explicit permission
|
||||
if tool_is_risky(tool_name) && !explicitly_allowed {
|
||||
let msg = format!("risky tool '{}' requires explicit permission; not allowed for this subagent", tool_name);
|
||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
_tool: tool_name.clone(),
|
||||
_output: msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Level 3: Harness-style content safety gating — mirrors the main
|
||||
// agent's gate_tool_call checks (path traversal, reason validation,
|
||||
// stub/denial/assumption scanning, bash exfiltration, destructive
|
||||
// commands, sensitive path reads).
|
||||
if let Some(block_reason) = gate_subagent_tool_call(tool_name, &args) {
|
||||
let msg = format!("Blocked by subagent gate: {}", block_reason);
|
||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
_tool: tool_name.clone(),
|
||||
_output: msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = match tools.iter().find(|t| t.name() == tool_name.as_str()) {
|
||||
Some(tool) => tool.run(&tool_ctx, &args),
|
||||
None => Err(anyhow::anyhow!("tool '{}' not found", tool_name)),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(output_text) => {
|
||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone()));
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
_tool: tool_name.clone(),
|
||||
_output: output_text,
|
||||
tool: tool_name.clone(),
|
||||
output: output_text,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("tool '{}' failed: {}", tool_name, e);
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("subagent aborted by parent") {
|
||||
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||
step,
|
||||
error: err_str.clone(),
|
||||
});
|
||||
anyhow::bail!("{err_str}");
|
||||
}
|
||||
let msg = format!("tool '{tool_name}' failed: {e}");
|
||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
_tool: tool_name.clone(),
|
||||
_output: msg,
|
||||
tool: tool_name.clone(),
|
||||
output: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -443,8 +480,8 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
||||
output.push('\n');
|
||||
}
|
||||
let _ = tx.blocking_send(SubagentEvent::StepCompleted {
|
||||
_step: step,
|
||||
_output: content.clone(),
|
||||
step,
|
||||
output: content.clone(),
|
||||
});
|
||||
// Break only when we got real content; empty means something went wrong
|
||||
if !content.is_empty() {
|
||||
@@ -453,6 +490,6 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tx.blocking_send(SubagentEvent::Completed { _output: output.clone() });
|
||||
let _ = tx.blocking_send(SubagentEvent::Completed { output: output.clone() });
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
@@ -8,22 +8,27 @@ use serde_json::Value;
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SubagentEvent {
|
||||
StepCompleted {
|
||||
_step: usize,
|
||||
_output: String,
|
||||
#[allow(dead_code)]
|
||||
step: usize,
|
||||
#[allow(dead_code)]
|
||||
output: String,
|
||||
},
|
||||
StepFailed {
|
||||
_step: usize,
|
||||
_error: String,
|
||||
step: usize,
|
||||
error: String,
|
||||
},
|
||||
Completed {
|
||||
_output: String,
|
||||
#[allow(dead_code)]
|
||||
output: String,
|
||||
},
|
||||
ToolCall {
|
||||
_tool: String,
|
||||
_args: Value,
|
||||
tool: String,
|
||||
#[allow(dead_code)]
|
||||
args: Value,
|
||||
},
|
||||
ToolResult {
|
||||
_tool: String,
|
||||
_output: String,
|
||||
tool: String,
|
||||
#[allow(dead_code)]
|
||||
output: String,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! AgentDefinition -- declarative specification for instantiating a
|
||||
//! `AgentDefinition` -- declarative specification for instantiating a
|
||||
//! subagent from workflow scripts or programmatic calls.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -30,6 +30,7 @@ impl AgentDefinition {
|
||||
}
|
||||
|
||||
/// Builder method: limit this agent to at most `steps` LLM calls.
|
||||
#[allow(dead_code)]
|
||||
pub fn with_max_steps(mut self, steps: usize) -> Self {
|
||||
self.max_steps = Some(steps);
|
||||
self
|
||||
|
||||
+275
-136
@@ -1,6 +1,6 @@
|
||||
//! Company-style workflow orchestrator: runs the complete division pipeline
|
||||
//! (Strategy → Engineering → Quality → Security → Documentation) with
|
||||
//! findings flowing between stages, then returns a consolidated executive
|
||||
//! (Strategy → Engineering → [Quality || Security || Documentation] in parallel)
|
||||
//! with findings flowing between stages, then returns a consolidated executive
|
||||
//! summary to the CEO (main agent).
|
||||
//!
|
||||
//! Flow:
|
||||
@@ -9,147 +9,137 @@
|
||||
//! │ delegates to run_company_pipeline(request)
|
||||
//! ▼
|
||||
//! ┌──────────────────────────────────────────────────┐
|
||||
//! │ Strategy Division — plan + mermaid diagrams │
|
||||
//! │ Engineering Division — implement per plan │
|
||||
//! │ Quality Division — review + write tests │
|
||||
//! │ Security Division — vulnerability audit │
|
||||
//! │ Documentation Div — update docs │
|
||||
//! └──────────────────────────────────────────────────┘
|
||||
//! │ returns consolidated summary
|
||||
//! ▼
|
||||
//! CEO Main Agent delivers to user
|
||||
//! │ Strategy Division — plan + mermaid diagrams │ (runs sequentially first)
|
||||
//! └─────────────────────────┬────────────────────────┘
|
||||
//! ▼
|
||||
//! ┌──────────────────────────────────────────────────┐
|
||||
//! │ Engineering Division — implement per plan │ (runs sequentially second)
|
||||
//! └─────────────────────────┬────────────────────────┘
|
||||
//! ▼
|
||||
//! ┌────────────┼────────────┐
|
||||
//! ▼ ▼ ▼
|
||||
//! ┌───────────┐┌───────────┐┌───────────┐
|
||||
//! │ Quality ││ Security ││ Docs │ (run concurrently in parallel)
|
||||
//! └───────────┘└───────────┘└───────────┘
|
||||
//! │ │ │
|
||||
//! └────────────┼────────────┘
|
||||
//! ▼
|
||||
//! CEO Main Agent delivers consolidated summary to user
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use crate::app::workflow::engine::{execute_primitive, LiveStateFn, AgentStatus};
|
||||
use std::fmt::Write;
|
||||
use std::sync::{Arc, Mutex, atomic::AtomicBool};
|
||||
use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript};
|
||||
use crate::app::workflow::engine::{execute_primitive, LiveStateFn, AgentStatus};
|
||||
use crate::app::subagent::division;
|
||||
|
||||
/// Construct the specialized agents for a division.
|
||||
///
|
||||
/// Flow: map division name to its specialization pool.
|
||||
///
|
||||
/// Return: a `Vec<ScriptPrimitive>` containing the specialist agents.
|
||||
fn make_division_specialists(
|
||||
div: &division::Division,
|
||||
user_request: &str,
|
||||
specs: &[(String, String)],
|
||||
) -> Vec<ScriptPrimitive> {
|
||||
let div_prompt = div.agent_def.system_prompt.as_deref().unwrap_or("");
|
||||
|
||||
specs
|
||||
.iter()
|
||||
.map(|(label, focus)| {
|
||||
// Prepend [Division Name: Specialist Label] so the first 40 chars
|
||||
// of the prompt become the agent_name in spawn_single_agent.
|
||||
// We use quadruple curly braces `{{{{findings}}}}` so that Rust's `format!` formats it
|
||||
// into `{{findings}}` in the output string, which `resolve_template` then recognizes
|
||||
// and replaces.
|
||||
let prompt = format!(
|
||||
"[{}: {}]\n\n{}\n\n{}\n\nUser request: {}\n\nFindings from previous divisions:\n{{{{findings}}}}",
|
||||
div.name,
|
||||
label,
|
||||
focus,
|
||||
div_prompt,
|
||||
user_request,
|
||||
);
|
||||
ScriptPrimitive::Agent(prompt)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Construct a named Phase wrapper containing a Parallel block of division specialists.
|
||||
///
|
||||
/// Flow: construct division specialists → wrap in a `Parallel` primitive wrapper.
|
||||
///
|
||||
/// Return: a `ScriptPrimitive::Phase` wrapper.
|
||||
fn make_division_phase(
|
||||
div: &division::Division,
|
||||
user_request: &str,
|
||||
specs: &[(String, String)],
|
||||
) -> ScriptPrimitive {
|
||||
let specialists = make_division_specialists(div, user_request, specs);
|
||||
ScriptPrimitive::Phase {
|
||||
name: div.name.to_string(),
|
||||
script: Box::new(ScriptPrimitive::Parallel(specialists)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the full company-style pipeline for a given user request.
|
||||
///
|
||||
/// This orchestrates all five divisions in sequence:
|
||||
/// 1. **Strategy** — create plan with diagrams
|
||||
/// 2. **Engineering** — implement code
|
||||
/// 3. **Quality** — review + write tests
|
||||
/// 4. **Security** — audit
|
||||
/// 5. **Documentation** — update docs
|
||||
/// This orchestrates all five divisions, running Strategy and Engineering
|
||||
/// sequentially, followed by Quality, Security, and Documentation in parallel.
|
||||
///
|
||||
/// Each division receives findings from all previous divisions, enabling
|
||||
/// context to flow through the pipeline.
|
||||
///
|
||||
/// Returns a consolidated executive summary string.
|
||||
#[allow(clippy::ref_option)]
|
||||
pub fn run_company_pipeline(
|
||||
user_request: &str,
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
|
||||
abort_flag: &Option<Arc<AtomicBool>>,
|
||||
custom_specialists: &HashMap<String, Vec<(String, String)>>,
|
||||
) -> anyhow::Result<String> {
|
||||
let divisions = division::all_divisions();
|
||||
let mut pipeline_scripts: Vec<ScriptPrimitive> = Vec::with_capacity(divisions.len());
|
||||
|
||||
for div in &divisions {
|
||||
let div_prompt = div.agent_def.system_prompt.as_deref().unwrap_or("");
|
||||
// Prepend [Division Name] so the first 40 chars of the prompt
|
||||
// become the agent_name in spawn_single_agent, making the TUI
|
||||
// panel show division names instead of UUID fragments.
|
||||
let prompt = format!(
|
||||
"[{}]\n\n{}\n\nUser request: {}\n\nFindings from previous divisions: {{findings}}",
|
||||
div.name,
|
||||
div_prompt,
|
||||
user_request,
|
||||
);
|
||||
pipeline_scripts.push(ScriptPrimitive::Agent(prompt));
|
||||
}
|
||||
let strategy_specs = custom_specialists.get("Strategy")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Strategy"))?;
|
||||
let engineering_specs = custom_specialists.get("Engineering")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Engineering"))?;
|
||||
let quality_specs = custom_specialists.get("Quality")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Quality"))?;
|
||||
let security_specs = custom_specialists.get("Security")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Security"))?;
|
||||
let documentation_specs = custom_specialists.get("Documentation")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Documentation"))?;
|
||||
|
||||
let strategy_phase = make_division_phase(&divisions[0], user_request, strategy_specs);
|
||||
let engineering_phase = make_division_phase(&divisions[1], user_request, engineering_specs);
|
||||
|
||||
let quality_phase = make_division_phase(&divisions[2], user_request, quality_specs);
|
||||
let security_phase = make_division_phase(&divisions[3], user_request, security_specs);
|
||||
let documentation_phase = make_division_phase(&divisions[4], user_request, documentation_specs);
|
||||
|
||||
let parallel_divisions = ScriptPrimitive::Parallel(vec![
|
||||
quality_phase,
|
||||
security_phase,
|
||||
documentation_phase,
|
||||
]);
|
||||
|
||||
let pipeline_primitive = ScriptPrimitive::Pipeline(vec![
|
||||
strategy_phase,
|
||||
engineering_phase,
|
||||
parallel_divisions,
|
||||
]);
|
||||
|
||||
let wf = WorkflowScript {
|
||||
name: "company-pipeline".to_string(),
|
||||
description: format!(
|
||||
"Company Pipeline (full): Strategy → Engineering → Quality → Security → Documentation",
|
||||
),
|
||||
script: ScriptPrimitive::Pipeline(pipeline_scripts),
|
||||
description: "Company Pipeline: Strategy → Engineering → (Quality || Security || Documentation)".to_string(),
|
||||
script: pipeline_primitive,
|
||||
options: ScriptOptions {
|
||||
max_concurrency: 1, // sequential by design
|
||||
continue_on_error: true, // one division failing shouldn't block the rest
|
||||
timeout_ms: None,
|
||||
},
|
||||
};
|
||||
|
||||
// Build a live callback for TUI updates if turn_events is available.
|
||||
// Uses agent_name (division name) for the display label in the panel.
|
||||
let live: Option<LiveStateFn> = turn_events.map(|events| {
|
||||
let events = events.clone();
|
||||
let f: LiveStateFn = Arc::new(move |_agent_id: String, agent_name: String, status: AgentStatus| {
|
||||
let display_name = agent_name.chars().take(30).collect::<String>();
|
||||
if let Ok(mut q) = events.lock() {
|
||||
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
|
||||
agent_id: display_name.clone(),
|
||||
agent_name: display_name,
|
||||
status,
|
||||
});
|
||||
}
|
||||
});
|
||||
f
|
||||
});
|
||||
|
||||
let args: HashMap<String, String> = HashMap::new();
|
||||
let live_ref = live.as_ref();
|
||||
|
||||
// Create a per-pipeline findings scope so divisions can pass data
|
||||
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let results = execute_primitive(
|
||||
&wf.script,
|
||||
&args,
|
||||
1,
|
||||
true,
|
||||
live_ref,
|
||||
session_dir,
|
||||
workspaces,
|
||||
&findings,
|
||||
None,
|
||||
)?;
|
||||
|
||||
// Collect all findings for the executive summary
|
||||
let all_findings = findings.lock()
|
||||
.map(|f| f.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(build_executive_summary(user_request, &results, &all_findings, &divisions))
|
||||
}
|
||||
|
||||
/// Run a quick company pipeline that skips non-essential divisions
|
||||
/// for simple tasks. Flow: Strategy → Engineering → Quality.
|
||||
///
|
||||
/// This is for smaller tasks where security audit and full docs are overkill.
|
||||
pub fn run_company_pipeline_quick(
|
||||
user_request: &str,
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
|
||||
) -> anyhow::Result<String> {
|
||||
let divisions = division::all_divisions();
|
||||
// Only use first 3 divisions for quick pipeline: Strategy, Engineering, Quality
|
||||
let quick_divisions = &divisions[..3];
|
||||
|
||||
let mut pipeline_scripts: Vec<ScriptPrimitive> = Vec::with_capacity(quick_divisions.len());
|
||||
for div in quick_divisions {
|
||||
let div_prompt = div.agent_def.system_prompt.as_deref().unwrap_or("");
|
||||
let prompt = format!(
|
||||
"[{}]\n\n{}\n\nUser request: {}\n\nFindings from previous divisions: {{findings}}",
|
||||
div.name,
|
||||
div_prompt,
|
||||
user_request,
|
||||
);
|
||||
pipeline_scripts.push(ScriptPrimitive::Agent(prompt));
|
||||
}
|
||||
|
||||
let wf = WorkflowScript {
|
||||
name: "company-pipeline-quick".to_string(),
|
||||
description: "Company Pipeline (quick): Strategy → Engineering → Quality".to_string(),
|
||||
script: ScriptPrimitive::Pipeline(pipeline_scripts),
|
||||
options: ScriptOptions {
|
||||
max_concurrency: 1,
|
||||
max_concurrency: 10,
|
||||
continue_on_error: true,
|
||||
timeout_ms: None,
|
||||
},
|
||||
@@ -174,44 +164,144 @@ pub fn run_company_pipeline_quick(
|
||||
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let results = execute_primitive(
|
||||
&wf.script, &args, 1, true,
|
||||
live.as_ref(), session_dir, workspaces, &findings, None,
|
||||
&wf.script,
|
||||
&args,
|
||||
wf.options.max_concurrency,
|
||||
true,
|
||||
abort_flag,
|
||||
live.as_ref(),
|
||||
session_dir,
|
||||
workspaces,
|
||||
&findings,
|
||||
None,
|
||||
)?;
|
||||
|
||||
let all_findings = findings.lock()
|
||||
.map(|f| f.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(build_executive_summary(user_request, &results, &all_findings, quick_divisions))
|
||||
Ok(build_executive_summary(user_request, &results, &all_findings, &divisions, custom_specialists))
|
||||
}
|
||||
|
||||
/// Run a quick company pipeline that skips non-essential divisions
|
||||
/// for simple tasks. Flow: Strategy → Engineering → Quality.
|
||||
///
|
||||
/// This is for smaller tasks where security audit and full docs are overkill.
|
||||
#[allow(clippy::ref_option)]
|
||||
pub fn run_company_pipeline_quick(
|
||||
user_request: &str,
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
|
||||
abort_flag: &Option<Arc<AtomicBool>>,
|
||||
custom_specialists: &HashMap<String, Vec<(String, String)>>,
|
||||
) -> anyhow::Result<String> {
|
||||
let divisions = division::all_divisions();
|
||||
let quick_divisions = &divisions[..3];
|
||||
|
||||
let strategy_specs = custom_specialists.get("Strategy")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Strategy"))?;
|
||||
let engineering_specs = custom_specialists.get("Engineering")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Engineering"))?;
|
||||
let quality_specs = custom_specialists.get("Quality")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required division configuration: Quality"))?;
|
||||
|
||||
let strategy_phase = make_division_phase(&quick_divisions[0], user_request, strategy_specs);
|
||||
let engineering_phase = make_division_phase(&quick_divisions[1], user_request, engineering_specs);
|
||||
let quality_phase = make_division_phase(&quick_divisions[2], user_request, quality_specs);
|
||||
|
||||
let pipeline_primitive = ScriptPrimitive::Pipeline(vec![
|
||||
strategy_phase,
|
||||
engineering_phase,
|
||||
quality_phase,
|
||||
]);
|
||||
|
||||
let wf = WorkflowScript {
|
||||
name: "company-pipeline-quick".to_string(),
|
||||
description: "Company Pipeline (quick): Strategy → Engineering → Quality".to_string(),
|
||||
script: pipeline_primitive,
|
||||
options: ScriptOptions {
|
||||
max_concurrency: 10,
|
||||
continue_on_error: true,
|
||||
timeout_ms: None,
|
||||
},
|
||||
};
|
||||
|
||||
let live: Option<LiveStateFn> = turn_events.map(|events| {
|
||||
let events = events.clone();
|
||||
let f: LiveStateFn = Arc::new(move |_agent_id: String, agent_name: String, status: AgentStatus| {
|
||||
let display_name = agent_name.chars().take(30).collect::<String>();
|
||||
if let Ok(mut q) = events.lock() {
|
||||
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
|
||||
agent_id: display_name.clone(),
|
||||
agent_name: display_name,
|
||||
status,
|
||||
});
|
||||
}
|
||||
});
|
||||
f
|
||||
});
|
||||
|
||||
let args: HashMap<String, String> = HashMap::new();
|
||||
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let results = execute_primitive(
|
||||
&wf.script, &args, wf.options.max_concurrency, true,
|
||||
abort_flag, live.as_ref(), session_dir, workspaces, &findings, None,
|
||||
)?;
|
||||
|
||||
let all_findings = findings.lock()
|
||||
.map(|f| f.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(build_executive_summary(user_request, &results, &all_findings, quick_divisions, custom_specialists))
|
||||
}
|
||||
|
||||
/// Build a compressed executive summary from pipeline results.
|
||||
///
|
||||
/// Keeps output brief to save context window space — just division verdicts
|
||||
/// and key findings, not full outputs. Full results are accessible to the
|
||||
/// CEO via the notes/findings that were archived during execution.
|
||||
/// Flow: print user request header → for each division, fetch its specialist verdicts
|
||||
/// → join with pipes → append findings count.
|
||||
///
|
||||
/// Why: keeps output brief to save context window space. Full results are accessible
|
||||
/// to the CEO via findings.
|
||||
///
|
||||
/// Return: a formatted executive summary string.
|
||||
fn build_executive_summary(
|
||||
request: &str,
|
||||
results: &[String],
|
||||
findings: &[String],
|
||||
divisions: &[division::Division],
|
||||
custom_specialists: &HashMap<String, Vec<(String, String)>>,
|
||||
) -> String {
|
||||
let mut summary = String::new();
|
||||
summary.push_str(&format!("Pipeline for: {}\n", request));
|
||||
writeln!(summary, "Pipeline for: {request}").unwrap();
|
||||
|
||||
for (i, div) in divisions.iter().enumerate() {
|
||||
let verdict = results.get(i)
|
||||
.map(|r| {
|
||||
r.lines().next().unwrap_or(r)
|
||||
.chars().take(100).collect::<String>()
|
||||
})
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
let mut start_index = 0;
|
||||
for div in divisions {
|
||||
let count = custom_specialists.get(div.name)
|
||||
.map_or(0, Vec::len);
|
||||
|
||||
summary.push_str(&format!(" {}: {}\n", div.name, verdict));
|
||||
let mut division_verdicts = Vec::new();
|
||||
for offset in 0..count {
|
||||
if let Some(r) = results.get(start_index + offset) {
|
||||
let first_line = r.lines().next().unwrap_or(r);
|
||||
let trimmed = first_line.chars().take(40).collect::<String>();
|
||||
division_verdicts.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
let verdict = if division_verdicts.is_empty() {
|
||||
"—".to_string()
|
||||
} else {
|
||||
division_verdicts.join(" | ")
|
||||
};
|
||||
|
||||
writeln!(summary, " {}: {}", div.name, verdict).unwrap();
|
||||
start_index += count;
|
||||
}
|
||||
|
||||
if !findings.is_empty() {
|
||||
summary.push_str(&format!(" Notes: {} cross-division finding(s)\n", findings.len()));
|
||||
writeln!(summary, " Notes: {} cross-division finding(s)", findings.len()).unwrap();
|
||||
}
|
||||
|
||||
summary
|
||||
@@ -223,7 +313,7 @@ fn build_executive_summary(
|
||||
/// Simple = single file, minor fix, quick lookup, config change.
|
||||
/// Complex = new feature, multi-file refactor, architecture change.
|
||||
///
|
||||
/// Used by the auto-CEO pipeline trigger in run_agent_turn to decide
|
||||
/// Used by the auto-CEO pipeline trigger in `run_agent_turn` to decide
|
||||
/// whether to delegate to the full company pipeline or handle directly.
|
||||
///
|
||||
/// Heuristics:
|
||||
@@ -231,6 +321,7 @@ fn build_executive_summary(
|
||||
/// - Negative keywords (simple/trivial/typo/quick) skip the pipeline.
|
||||
/// - Positive keywords (refactor/api/implement/architecture) trigger it.
|
||||
/// - Multi-line or multi-sentence requests are more likely complex.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_complex_request(request: &str) -> bool {
|
||||
let trimmed = request.trim();
|
||||
// Very short requests are never complex
|
||||
@@ -249,7 +340,7 @@ pub fn is_complex_request(request: &str) -> bool {
|
||||
return false;
|
||||
}
|
||||
// Multi-line/multi-sentence → likely complex
|
||||
let sentences = trimmed.split(|c| c == '.' || c == '!' || c == '?')
|
||||
let sentences = trimmed.split(['.', '!', '?'])
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.count();
|
||||
if sentences >= 3 {
|
||||
@@ -265,3 +356,51 @@ pub fn is_complex_request(request: &str) -> bool {
|
||||
];
|
||||
complexity_keywords.iter().any(|k| lower.contains(k))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_complex_request_too_short() {
|
||||
assert!(!is_complex_request("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_complex_request_simple_keywords() {
|
||||
assert!(!is_complex_request("just a simple update to the readme"));
|
||||
assert!(!is_complex_request("minor typo fix in main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_complex_request_multi_sentence() {
|
||||
assert!(is_complex_request("This is sentence one. This is sentence two. This is sentence three."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_complex_request_complex_keywords() {
|
||||
assert!(is_complex_request("implement user authentication endpoint"));
|
||||
assert!(is_complex_request("refactor the whole engine module"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_make_division_specialists_custom() {
|
||||
let divisions = division::all_divisions();
|
||||
let div = &divisions[0];
|
||||
let mut custom = HashMap::new();
|
||||
custom.insert(
|
||||
"Strategy".to_string(),
|
||||
vec![
|
||||
("Custom Label".to_string(), "Custom Focus Description".to_string())
|
||||
]
|
||||
);
|
||||
let specs = make_division_specialists(div, "Test Request", custom.get("Strategy").unwrap());
|
||||
assert_eq!(specs.len(), 1);
|
||||
if let ScriptPrimitive::Agent(prompt) = &specs[0] {
|
||||
assert!(prompt.contains("Custom Label"));
|
||||
assert!(prompt.contains("Custom Focus Description"));
|
||||
} else {
|
||||
panic!("Expected ScriptPrimitive::Agent");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+141
-54
@@ -15,7 +15,7 @@
|
||||
//! leaks between concurrent workflow runs.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}};
|
||||
use std::time::Duration;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use super::script::{ScriptPrimitive, WorkflowScript};
|
||||
@@ -83,7 +83,7 @@ pub type LiveStateFn = Arc<dyn Fn(String, String, AgentStatus) + Send + Sync>;
|
||||
/// any findings from earlier sibling agents. Updates live state before and
|
||||
/// after to reflect Running → Completed/Failed transitions.
|
||||
///
|
||||
/// Flow: push agent as `Running` → build SubagentContext with prompt +
|
||||
/// Flow: push agent as `Running` → build `SubagentContext` with prompt +
|
||||
/// findings preamble, linking the `workflow_findings` Arc so the subagent's
|
||||
/// `note_finding` tool pushes into the same vec → call `run_subagent`
|
||||
/// (draining the event channel into a consumer so events are not blocked)
|
||||
@@ -98,12 +98,14 @@ pub type LiveStateFn = Arc<dyn Fn(String, String, AgentStatus) + Send + Sync>;
|
||||
/// a stuck stage from blocking the entire pipeline forever.
|
||||
///
|
||||
/// Return: the agent's text output, or an error on failure.
|
||||
#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]
|
||||
fn spawn_single_agent(
|
||||
agent_id: &str,
|
||||
agent_name: &str,
|
||||
prompt: &str,
|
||||
findings_snapshot: Vec<String>,
|
||||
findings_snapshot: &[String],
|
||||
findings: &Arc<Mutex<Vec<String>>>,
|
||||
abort_flag: &Option<Arc<AtomicBool>>,
|
||||
live: Option<&LiveStateFn>,
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
@@ -132,9 +134,36 @@ fn spawn_single_agent(
|
||||
);
|
||||
}
|
||||
|
||||
let def = AgentDefinition::new(agent_name.to_string(), "coder".to_string())
|
||||
.with_max_steps(50);
|
||||
let mut ctx = build_subagent_context(def);
|
||||
let mut role = "coder".to_string();
|
||||
let mut allowed_tools = None;
|
||||
|
||||
if agent_name.contains("Strategy") {
|
||||
let div_def = crate::app::subagent::division::strategy_division();
|
||||
role = div_def.role;
|
||||
allowed_tools = div_def.allowed_tools;
|
||||
} else if agent_name.contains("Engineering") {
|
||||
let div_def = crate::app::subagent::division::engineering_division();
|
||||
role = div_def.role;
|
||||
allowed_tools = div_def.allowed_tools;
|
||||
} else if agent_name.contains("Quality") {
|
||||
let div_def = crate::app::subagent::division::quality_division();
|
||||
role = div_def.role;
|
||||
allowed_tools = div_def.allowed_tools;
|
||||
} else if agent_name.contains("Security") {
|
||||
let div_def = crate::app::subagent::division::security_division();
|
||||
role = div_def.role;
|
||||
allowed_tools = div_def.allowed_tools;
|
||||
} else if agent_name.contains("Documentation") {
|
||||
let div_def = crate::app::subagent::division::documentation_division();
|
||||
role = div_def.role;
|
||||
allowed_tools = div_def.allowed_tools;
|
||||
}
|
||||
|
||||
let mut def = AgentDefinition::new(agent_name.to_string(), role);
|
||||
if let Some(tools) = allowed_tools {
|
||||
def = def.with_allowed_tools(tools);
|
||||
}
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir = session_dir.to_path_buf();
|
||||
ctx.workspaces = workspaces.to_vec();
|
||||
|
||||
@@ -152,13 +181,11 @@ fn spawn_single_agent(
|
||||
)
|
||||
};
|
||||
|
||||
ctx.system_prompt = format!("{}{}", prompt, findings_section);
|
||||
ctx.system_prompt = format!("{prompt}{findings_section}");
|
||||
// Link the shared findings Arc so note_finding calls within this
|
||||
// subagent write into the same vec visible to sibling agents.
|
||||
ctx.workflow_findings = Some(findings.clone());
|
||||
// Abort flag stays None by default — the parent can set it to abort
|
||||
// long-running agents. No abort mechanism is wired yet at this level;
|
||||
// future work can expose a kill-switch per agent via the live callback.
|
||||
ctx.abort_flag.clone_from(abort_flag);
|
||||
|
||||
// Create an mpsc channel and drain events in a background thread.
|
||||
// The drain thread also pushes intra-division progress updates to the
|
||||
@@ -174,8 +201,8 @@ fn spawn_single_agent(
|
||||
let mut rx = rx;
|
||||
while let Some(event) = rx.blocking_recv() {
|
||||
match &event {
|
||||
SubagentEvent::ToolCall { _tool, _args } => {
|
||||
tracing::debug!("[subagent] tool call: {}", _tool);
|
||||
SubagentEvent::ToolCall { tool, .. } => {
|
||||
tracing::debug!("[subagent] tool call: {}", tool);
|
||||
// Push intra-division progress: which tool is running
|
||||
if let Some(ref f) = drain_live {
|
||||
f(
|
||||
@@ -186,13 +213,13 @@ fn spawn_single_agent(
|
||||
started_at: Some(drain_started_at),
|
||||
completed_at: None,
|
||||
error: None,
|
||||
progress: Some(format!("tool: {}", _tool)),
|
||||
progress: Some(format!("tool: {tool}")),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
SubagentEvent::ToolResult { _tool, .. } => {
|
||||
tracing::debug!("[subagent] tool result: {}", _tool);
|
||||
SubagentEvent::ToolResult { tool, .. } => {
|
||||
tracing::debug!("[subagent] tool result: {}", tool);
|
||||
if let Some(ref f) = drain_live {
|
||||
f(
|
||||
drain_agent_id.clone(),
|
||||
@@ -202,16 +229,16 @@ fn spawn_single_agent(
|
||||
started_at: Some(drain_started_at),
|
||||
completed_at: None,
|
||||
error: None,
|
||||
progress: Some(format!("done: {}", _tool)),
|
||||
progress: Some(format!("done: {tool}")),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
SubagentEvent::StepCompleted { _step, .. } => {
|
||||
tracing::trace!("[subagent] step {} completed", _step);
|
||||
SubagentEvent::StepCompleted { .. } => {
|
||||
tracing::trace!("[subagent] step completed");
|
||||
}
|
||||
SubagentEvent::StepFailed { _step, _error } => {
|
||||
tracing::warn!("[subagent] step {} failed: {}", _step, _error);
|
||||
SubagentEvent::StepFailed { step, error } => {
|
||||
tracing::warn!("[subagent] step {} failed: {}", step, error);
|
||||
}
|
||||
SubagentEvent::Completed { .. } => {
|
||||
tracing::debug!("[subagent] completed");
|
||||
@@ -220,28 +247,55 @@ fn spawn_single_agent(
|
||||
}
|
||||
});
|
||||
|
||||
// Enforce timeout by running subagent on a separate thread and
|
||||
// waiting with a deadline. If the deadline expires, the thread is
|
||||
// abandoned (Rust threads cannot be forcibly killed, but we proceed
|
||||
// without waiting for it — the drain thread will drop when tx is
|
||||
// dropped on thread exit).
|
||||
let result = if let Some(timeout) = timeout_ms {
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel::<anyhow::Result<String>>();
|
||||
let timeout_ctx = ctx;
|
||||
let timeout_tx = tx;
|
||||
std::thread::spawn(move || {
|
||||
let _ = done_tx.send(run_subagent(timeout_ctx, timeout_tx));
|
||||
});
|
||||
match done_rx.recv_timeout(Duration::from_millis(timeout)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(anyhow::anyhow!(
|
||||
"subagent '{}' timed out after {}ms",
|
||||
agent_name, timeout,
|
||||
)),
|
||||
// Check abort before even starting the subagent.
|
||||
if abort_flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
||||
anyhow::bail!("subagent '{agent_name}' aborted before start");
|
||||
}
|
||||
|
||||
// Run subagent on a separate thread so the abort flag can be polled.
|
||||
// If abort is requested while the subagent is running, we abandon the
|
||||
// thread (Rust threads cannot be forcibly killed) and return early.
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel::<anyhow::Result<String>>();
|
||||
let bg_ctx = ctx;
|
||||
let bg_tx = tx;
|
||||
let bg_name = agent_name.to_string();
|
||||
let bg_abort = abort_flag.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = done_tx.send(run_subagent(&bg_ctx, &bg_tx));
|
||||
});
|
||||
|
||||
let poll_interval = Duration::from_millis(200);
|
||||
let result = if let Some(timeout) = timeout_ms {
|
||||
let deadline = Duration::from_millis(timeout);
|
||||
let mut elapsed = Duration::ZERO;
|
||||
loop {
|
||||
if let Ok(r) = done_rx.recv_timeout(poll_interval) {
|
||||
break r;
|
||||
}
|
||||
} else {
|
||||
run_subagent(ctx, tx)
|
||||
};
|
||||
elapsed += poll_interval;
|
||||
if elapsed >= deadline {
|
||||
break Err(anyhow::anyhow!(
|
||||
"subagent '{bg_name}' timed out after {timeout}ms",
|
||||
));
|
||||
}
|
||||
if bg_abort.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
||||
break Err(anyhow::anyhow!(
|
||||
"subagent '{bg_name}' aborted by user",
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loop {
|
||||
if let Ok(r) = done_rx.recv_timeout(poll_interval) {
|
||||
break r;
|
||||
}
|
||||
if bg_abort.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
||||
break Err(anyhow::anyhow!(
|
||||
"subagent '{bg_name}' aborted by user",
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let completed_at = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
@@ -299,11 +353,14 @@ type ParallelResult = (usize, anyhow::Result<Vec<String>>);
|
||||
///
|
||||
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
|
||||
/// the order they were submitted.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(clippy::ref_option, clippy::too_many_lines)]
|
||||
pub fn execute_primitive(
|
||||
primitive: &ScriptPrimitive,
|
||||
args: &HashMap<String, String>,
|
||||
concurrency_cap: usize,
|
||||
continue_on_error: bool,
|
||||
abort_flag: &Option<Arc<AtomicBool>>,
|
||||
live: Option<&LiveStateFn>,
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
@@ -312,11 +369,25 @@ pub fn execute_primitive(
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
match primitive {
|
||||
ScriptPrimitive::Agent(prompt) => {
|
||||
let resolved = resolve_template(prompt, args);
|
||||
let mut resolved_args = args.clone();
|
||||
let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default();
|
||||
if !resolved_args.contains_key("findings") {
|
||||
let formatted_findings = if findings_snapshot.is_empty() {
|
||||
"None".to_string()
|
||||
} else {
|
||||
findings_snapshot
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| format!("{}. {}", i + 1, f))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
resolved_args.insert("findings".to_string(), formatted_findings);
|
||||
}
|
||||
let resolved = resolve_template(prompt, &resolved_args);
|
||||
let agent_id = uuid::Uuid::new_v4().to_string();
|
||||
let agent_name = resolved.chars().take(40).collect::<String>();
|
||||
match spawn_single_agent(&agent_id, &agent_name, &resolved, findings_snapshot, findings, live, session_dir, workspaces, timeout_ms) {
|
||||
match spawn_single_agent(&agent_id, &agent_name, &resolved, &findings_snapshot, findings, abort_flag, live, session_dir, workspaces, timeout_ms) {
|
||||
Ok(text) => Ok(vec![text]),
|
||||
Err(e) => {
|
||||
if continue_on_error {
|
||||
@@ -347,6 +418,7 @@ pub fn execute_primitive(
|
||||
let sem = Arc::clone(&semaphore);
|
||||
let results = Arc::clone(&results);
|
||||
let cap = concurrency_cap;
|
||||
let abort = abort_flag.clone();
|
||||
let live_clone = live.cloned();
|
||||
let session_dir = session_dir.to_path_buf();
|
||||
let workspaces = workspaces.to_vec();
|
||||
@@ -357,6 +429,7 @@ pub fn execute_primitive(
|
||||
let _permit = sem.acquire();
|
||||
let result = execute_primitive(
|
||||
&script, &args, cap, continue_on_error,
|
||||
&abort,
|
||||
live_clone.as_ref(),
|
||||
&session_dir,
|
||||
&workspaces,
|
||||
@@ -380,7 +453,7 @@ pub fn execute_primitive(
|
||||
for (_, res) in locked.drain(..) {
|
||||
match res {
|
||||
Ok(outputs) => all.extend(outputs),
|
||||
Err(e) => all.push(format!("agent error: {}", e)),
|
||||
Err(e) => all.push(format!("agent error: {e}")),
|
||||
}
|
||||
}
|
||||
Ok(all)
|
||||
@@ -389,17 +462,30 @@ pub fn execute_primitive(
|
||||
ScriptPrimitive::Pipeline(scripts) => {
|
||||
// Sequential: each stage runs only after the previous completes.
|
||||
//
|
||||
// Abort is checked between stages so the user can cancel the
|
||||
// pipeline immediately when moving to the next division, rather
|
||||
// than having to wait for the current subagent to finish.
|
||||
//
|
||||
// Why: parallel execution defeats the purpose of a pipeline whose
|
||||
// stages are supposed to build on each other's output. Findings
|
||||
// written by stage N are visible to stage N+1 through the shared
|
||||
// `findings` Arc (same isolation scope as parent).
|
||||
let mut all = Vec::new();
|
||||
for (idx, script) in scripts.iter().enumerate() {
|
||||
match execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces, findings, timeout_ms) {
|
||||
// Check abort before each pipeline stage so we don't
|
||||
// launch the next division after the user cancelled.
|
||||
if abort_flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
||||
if continue_on_error {
|
||||
all.push(format!("pipeline aborted at stage {idx}"));
|
||||
break;
|
||||
}
|
||||
anyhow::bail!("pipeline aborted by user at stage {idx}");
|
||||
}
|
||||
match execute_primitive(script, args, concurrency_cap, continue_on_error, abort_flag, live, session_dir, workspaces, findings, timeout_ms) {
|
||||
Ok(outputs) => all.extend(outputs),
|
||||
Err(e) => {
|
||||
if continue_on_error {
|
||||
all.push(format!("pipeline stage {} error: {}", idx, e));
|
||||
all.push(format!("pipeline stage {idx} error: {e}"));
|
||||
} else {
|
||||
return Err(e);
|
||||
}
|
||||
@@ -410,7 +496,7 @@ pub fn execute_primitive(
|
||||
}
|
||||
|
||||
ScriptPrimitive::Phase { name: _name, script } => {
|
||||
execute_primitive(script, args, concurrency_cap, continue_on_error, live, session_dir, workspaces, findings, timeout_ms)
|
||||
execute_primitive(script, args, concurrency_cap, continue_on_error, abort_flag, live, session_dir, workspaces, findings, timeout_ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -425,7 +511,7 @@ pub fn run_workflow(
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
) -> anyhow::Result<String> {
|
||||
run_workflow_tracked(script, args, None, session_dir, workspaces)
|
||||
run_workflow_tracked(script, args, &None, None, session_dir, workspaces)
|
||||
}
|
||||
|
||||
/// Run a `WorkflowScript` with real-time live-state callbacks so the TUI
|
||||
@@ -437,13 +523,15 @@ pub fn run_workflow(
|
||||
///
|
||||
/// Why: findings are scoped to an `Arc<Mutex<Vec<String>>>` rather than a
|
||||
/// global static, so concurrent `run_workflow_tracked` calls from different
|
||||
/// spawn_agents invocations remain fully isolated.
|
||||
/// `spawn_agents` invocations remain fully isolated.
|
||||
///
|
||||
/// Return: a human-readable summary string.
|
||||
#[allow(clippy::ref_option)]
|
||||
pub fn run_workflow_tracked(
|
||||
script: &WorkflowScript,
|
||||
args: &HashMap<String, String>,
|
||||
live: Option<LiveStateFn>,
|
||||
abort_flag: &Option<Arc<AtomicBool>>,
|
||||
live: Option<&LiveStateFn>,
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
) -> anyhow::Result<String> {
|
||||
@@ -453,11 +541,10 @@ pub fn run_workflow_tracked(
|
||||
10
|
||||
};
|
||||
|
||||
let live_ref = live.as_ref();
|
||||
let findings = Arc::new(Mutex::new(Vec::new()));
|
||||
let results = execute_primitive(
|
||||
&script.script, args, concurrency_cap,
|
||||
script.options.continue_on_error, live_ref,
|
||||
script.options.continue_on_error, abort_flag, live,
|
||||
session_dir, workspaces, &findings,
|
||||
script.options.timeout_ms,
|
||||
)?;
|
||||
@@ -489,7 +576,7 @@ pub fn run_workflow_tracked(
|
||||
fn resolve_template(template: &str, args: &HashMap<String, String>) -> String {
|
||||
let mut result = template.to_string();
|
||||
for (key, value) in args {
|
||||
result = result.replace(&format!("{{{{{}}}}}", key), value);
|
||||
result = result.replace(&format!("{{{{{key}}}}}"), value);
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -535,7 +622,7 @@ struct SemaphoreGuard<'a> {
|
||||
sem: &'a Semaphore,
|
||||
}
|
||||
|
||||
impl<'a> Drop for SemaphoreGuard<'a> {
|
||||
impl Drop for SemaphoreGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
let mut count = self.sem.count.lock().unwrap_or_else(|e| {
|
||||
tracing::warn!("[semaphore] mutex poisoned in drop, recovering");
|
||||
|
||||
@@ -22,10 +22,6 @@ pub enum Command {
|
||||
WorkflowRun {
|
||||
script: String,
|
||||
},
|
||||
/// /pipeline full|quick|skip
|
||||
Pipeline {
|
||||
mode: String,
|
||||
},
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
@@ -79,15 +75,6 @@ pub fn parse_command(text: &str) -> Command {
|
||||
"/workflow" => Command::WorkflowRun {
|
||||
script: arg1.to_string(),
|
||||
},
|
||||
"/pipeline" if arg1.is_empty() => Command::Pipeline {
|
||||
mode: "status".to_string(),
|
||||
},
|
||||
"/pipeline" if arg1 == "full" || arg1 == "quick" || arg1 == "skip" => {
|
||||
Command::Pipeline {
|
||||
mode: arg1.to_string(),
|
||||
}
|
||||
}
|
||||
"/pipeline" => Command::Unknown(format!("/pipeline {} (use: full|quick|skip)", arg1)),
|
||||
_ => Command::Unknown(cmd.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
+11
-20
@@ -21,6 +21,7 @@ use crate::controller::command::parse_command;
|
||||
/// Why: when Editor overlay is active, all key events are consumed by the
|
||||
/// editor handler and never reach the main action dispatch. Return `Vec`
|
||||
/// so that a single key press can trigger multiple actions.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||
// While Editor overlay is active, route input directly to the editor handler
|
||||
if state.misc.overlay == Overlay::Editor {
|
||||
@@ -34,7 +35,7 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||
if let Err(e) = std::fs::write(&ed.path, &content) {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
format!("Save failed: {}", e),
|
||||
format!("Save failed: {e}"),
|
||||
));
|
||||
} else {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
@@ -58,11 +59,11 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||
return vec![];
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
crate::app::mode::editor::handle_editor_input(state, "\n".to_string());
|
||||
crate::app::mode::editor::handle_editor_input(state, "\n");
|
||||
return vec![];
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
crate::app::mode::editor::handle_editor_input(state, c.to_string());
|
||||
crate::app::mode::editor::handle_editor_input(state, &c.to_string());
|
||||
return vec![];
|
||||
}
|
||||
_ => return vec![],
|
||||
@@ -93,25 +94,15 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char('a') => {
|
||||
let items = crate::app::mode::learning::get_learning_items(state);
|
||||
if let Some(item) = items.get(state.misc.selected_index) {
|
||||
match item {
|
||||
crate::app::mode::learning::LearningItem::Pending { name, .. } => {
|
||||
return vec![Action::LessonAccept { name: name.clone() }];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(crate::app::mode::learning::LearningItem::Pending { name, .. }) = items.get(state.misc.selected_index) {
|
||||
return vec![Action::LessonAccept { name: name.clone() }];
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
let items = crate::app::mode::learning::get_learning_items(state);
|
||||
if let Some(item) = items.get(state.misc.selected_index) {
|
||||
match item {
|
||||
crate::app::mode::learning::LearningItem::Pending { name, .. } => {
|
||||
return vec![Action::LessonReject { name: name.clone() }];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(crate::app::mode::learning::LearningItem::Pending { name, .. }) = items.get(state.misc.selected_index) {
|
||||
return vec![Action::LessonReject { name: name.clone() }];
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
@@ -341,8 +332,8 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
|
||||
tracing::warn!("[input] provider '{}' has no default_model, using 'claude-opus-4-8'", provider);
|
||||
"claude-opus-4-8".to_string()
|
||||
});
|
||||
state.settings.provider = provider.clone();
|
||||
state.settings.model = model.clone();
|
||||
state.settings.provider.clone_from(provider);
|
||||
state.settings.model.clone_from(&model);
|
||||
if let Some(ref key) = cfg.default_api_key {
|
||||
state.settings.api_keys.insert(provider.clone(), key.clone());
|
||||
} else if let Some(env_key) = cfg.api_key_env.as_ref().and_then(|env| std::env::var(env).ok()) {
|
||||
@@ -351,7 +342,7 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
|
||||
let _ = state.settings.save();
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Success,
|
||||
format!("Switched to {} / {}", provider, model),
|
||||
format!("Switched to {provider} / {model}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use serde_json::Value;
|
||||
/// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible provider.
|
||||
///
|
||||
/// Flow: constructed from the current message history plus optional
|
||||
/// generation knobs (temperature, max_tokens, tools, etc.) and serialized
|
||||
/// generation knobs (temperature, `max_tokens`, tools, etc.) and serialized
|
||||
/// directly into the HTTP request body.
|
||||
///
|
||||
/// Return: not a function, but the value that becomes the JSON request
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@ pub struct Connection {
|
||||
|
||||
impl Connection {
|
||||
/// Wrap an already-connected/accepted `UnixStream`.
|
||||
pub fn from_stream(stream: UnixStream) -> Result<Self> {
|
||||
Ok(Connection { inner: stream })
|
||||
pub fn from_stream(stream: UnixStream) -> Self {
|
||||
Connection { inner: stream }
|
||||
}
|
||||
|
||||
/// Open a new Unix-socket connection to `path`.
|
||||
|
||||
+123
-2
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Length-prefixed binary framing and JSON (de)serialization helpers for
|
||||
//! the IPC wire protocol.
|
||||
//!
|
||||
@@ -26,7 +27,7 @@ pub(crate) const MAX_FRAME_SIZE: usize = 64 * 1024 * 1024;
|
||||
pub fn write_frame<W: Write>(writer: &mut W, data: &[u8]) -> Result<()> {
|
||||
let len = data.len();
|
||||
if len > MAX_FRAME_SIZE {
|
||||
anyhow::bail!("frame too large: {} bytes exceeds 64 MiB limit", len);
|
||||
anyhow::bail!("frame too large: {len} bytes exceeds 64 MiB limit");
|
||||
}
|
||||
let len_bytes = (len as u32).to_be_bytes();
|
||||
writer.write_all(&len_bytes)?;
|
||||
@@ -52,7 +53,7 @@ pub fn read_frame<R: Read>(reader: &mut R) -> Result<Option<Vec<u8>>> {
|
||||
}
|
||||
let len = u32::from_be_bytes(len_buf) as usize;
|
||||
if len > MAX_FRAME_SIZE {
|
||||
anyhow::bail!("frame too large: {} bytes exceeds 64 MiB limit", len);
|
||||
anyhow::bail!("frame too large: {len} bytes exceeds 64 MiB limit");
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
reader.read_exact(&mut buf)?;
|
||||
@@ -72,3 +73,123 @@ pub fn serialize_frame<T: serde::Serialize>(value: &T) -> Result<Vec<u8>> {
|
||||
pub fn deserialize_frame<'a, T: serde::Deserialize<'a>>(data: &'a [u8]) -> Result<T> {
|
||||
Ok(serde_json::from_slice(data)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Write a value, read it back, and verify exact equality.
|
||||
fn roundtrip_bytes(data: &[u8]) {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
write_frame(&mut buf, data).unwrap();
|
||||
let read_back = read_frame(&mut buf.as_slice())
|
||||
.unwrap()
|
||||
.expect("expected Some(frame)");
|
||||
assert_eq!(read_back, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_read_roundtrip_empty() {
|
||||
roundtrip_bytes(b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_read_roundtrip_small_text() {
|
||||
roundtrip_bytes(b"hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_read_roundtrip_binary() {
|
||||
roundtrip_bytes(&[0x00, 0xFF, 0xAB, 0xCD, 0x01, 0x02, 0x03]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_read_roundtrip_large() {
|
||||
let data = vec![0x42u8; 100_000];
|
||||
roundtrip_bytes(&data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_rejects_too_large_frame() {
|
||||
let oversized = vec![0u8; MAX_FRAME_SIZE + 1];
|
||||
let mut buf = Vec::new();
|
||||
let result = write_frame(&mut buf, &oversized);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("too large") || err.contains("64 MiB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_rejects_too_large_header() {
|
||||
// Manually craft a 4-byte length header that exceeds MAX_FRAME_SIZE
|
||||
let len = (MAX_FRAME_SIZE as u32).wrapping_add(1);
|
||||
let header = len.to_be_bytes();
|
||||
let mut buf = Vec::from(&header[..]);
|
||||
buf.extend_from_slice(b"dummy");
|
||||
let result = read_frame(&mut buf.as_slice());
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("too large"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_empty_buf_returns_none() {
|
||||
let empty: &[u8] = &[];
|
||||
let result = read_frame(&mut &empty[..]).unwrap();
|
||||
assert!(result.is_none(), "expected None for empty reader");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_partial_header_returns_none() {
|
||||
// Only 2 bytes of the 4-byte header → EOF
|
||||
let partial: &[u8] = &[0x00, 0x01];
|
||||
let result = read_frame(&mut &partial[..]).unwrap();
|
||||
assert!(result.is_none(), "expected None for partial header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_truncated_payload_returns_err() {
|
||||
let mut buf = Vec::new();
|
||||
let header = (10u32).to_be_bytes();
|
||||
buf.extend_from_slice(&header);
|
||||
buf.extend_from_slice(b"abc"); // only 3 of 10 bytes
|
||||
let result = read_frame(&mut buf.as_slice());
|
||||
assert!(result.is_err(), "truncated payload should error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_deserialize_roundtrip() {
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
struct Msg {
|
||||
id: u32,
|
||||
content: String,
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
let original = Msg {
|
||||
id: 42,
|
||||
content: "hello world".into(),
|
||||
tags: vec!["foo".into(), "bar".into()],
|
||||
};
|
||||
|
||||
let bytes = serialize_frame(&original).unwrap();
|
||||
let deserialized: Msg = deserialize_frame(&bytes).unwrap();
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_rejects_oversized_value() {
|
||||
let huge = vec![0u8; MAX_FRAME_SIZE + 1];
|
||||
let result = serialize_frame(&huge);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_malformed_json_errors() {
|
||||
let bad_json = b"this is not json";
|
||||
let result: Result<String> = deserialize_frame(bad_json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -30,6 +30,6 @@ impl IpcServer {
|
||||
/// Block until a client connects, then wrap it as a `Connection`.
|
||||
pub fn accept(&self) -> Result<Connection> {
|
||||
let (stream, _addr) = self.listener.accept()?;
|
||||
Connection::from_stream(stream)
|
||||
Ok(Connection::from_stream(stream))
|
||||
}
|
||||
}
|
||||
|
||||
+14
-20
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Zesdex binary entry point.
|
||||
//!
|
||||
//! Parses `--daemon` / `--attach <id>` flags to select one of three
|
||||
@@ -11,7 +12,6 @@ use std::sync::Mutex;
|
||||
use anyhow::Result;
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use crossterm::event::{EnableMouseCapture, DisableMouseCapture};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
@@ -106,7 +106,7 @@ fn run_single_process() -> Result<()> {
|
||||
let workspace_roots = vec![std::env::current_dir()?];
|
||||
let mut state = app::state::rest::AppStateRest::new(
|
||||
workspace_roots.clone(),
|
||||
session_dir,
|
||||
&session_dir,
|
||||
store.memory_dir,
|
||||
);
|
||||
state.sessions = model::session::Session::list(&store.base_dir);
|
||||
@@ -117,7 +117,7 @@ fn run_single_process() -> Result<()> {
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
terminal.clear()?;
|
||||
@@ -125,11 +125,11 @@ fn run_single_process() -> Result<()> {
|
||||
let run_result = run_loop(&mut state, &mut terminal);
|
||||
|
||||
let mut restore_stdout = io::stdout();
|
||||
let _ = execute!(restore_stdout, LeaveAlternateScreen, DisableMouseCapture);
|
||||
let _ = execute!(restore_stdout, LeaveAlternateScreen);
|
||||
let _ = disable_raw_mode();
|
||||
|
||||
if let Err(e) = run_result {
|
||||
let _ = writeln!(restore_stdout, "error: {}", e);
|
||||
let _ = writeln!(restore_stdout, "error: {e}");
|
||||
let _ = restore_stdout.flush();
|
||||
}
|
||||
|
||||
@@ -262,7 +262,6 @@ fn apply_client_update(
|
||||
state.transcript_cache.messages = payload.messages.into_iter().map(|m| {
|
||||
app::state::rest::ChatMessageDisplay {
|
||||
role: match m.role.as_str() {
|
||||
"User" => crate::dto::chat::message::Role::User,
|
||||
"Assistant" => crate::dto::chat::message::Role::Assistant,
|
||||
"System" => crate::dto::chat::message::Role::System,
|
||||
"Tool" => crate::dto::chat::message::Role::Tool,
|
||||
@@ -300,7 +299,6 @@ fn apply_client_update(
|
||||
state.misc.toasts = payload.toasts.into_iter().map(|t| {
|
||||
Toast {
|
||||
kind: match t.kind.as_str() {
|
||||
"Info" => ToastKind::Info,
|
||||
"Success" => ToastKind::Success,
|
||||
"Warning" => ToastKind::Warning,
|
||||
"Error" => ToastKind::Error,
|
||||
@@ -350,7 +348,7 @@ fn run_daemon() -> Result<()> {
|
||||
let workspace_roots = vec![std::env::current_dir()?];
|
||||
let mut state = app::state::rest::AppStateRest::new(
|
||||
workspace_roots.clone(),
|
||||
session_dir,
|
||||
&session_dir,
|
||||
store.memory_dir,
|
||||
);
|
||||
state.sessions = model::session::Session::list(&store.base_dir);
|
||||
@@ -359,17 +357,17 @@ fn run_daemon() -> Result<()> {
|
||||
|
||||
let run_dir = store.base_dir.join("run");
|
||||
std::fs::create_dir_all(&run_dir)?;
|
||||
let socket_path = run_dir.join(format!("{}.sock", session_id));
|
||||
let socket_path = run_dir.join(format!("{session_id}.sock"));
|
||||
let addr = socket_path.to_string_lossy().to_string();
|
||||
|
||||
let server = ipc::server::IpcServer::bind_unix(&addr)?;
|
||||
eprintln!("daemon: listening on {}", addr);
|
||||
eprintln!("daemon: listening on {addr}");
|
||||
|
||||
loop {
|
||||
let mut conn = match server.accept() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("daemon: accept error: {}", e);
|
||||
eprintln!("daemon: accept error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -463,13 +461,13 @@ fn run_attach(session_id: &str) -> Result<()> {
|
||||
|
||||
let store = model::store::Store::new();
|
||||
|
||||
let socket_path = store.base_dir.join("run").join(format!("{}.sock", session_id));
|
||||
let socket_path = store.base_dir.join("run").join(format!("{session_id}.sock"));
|
||||
let addr = socket_path.to_string_lossy().to_string();
|
||||
let mut client = ipc::client::IpcClient::connect_unix(&addr)?;
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
terminal.clear()?;
|
||||
@@ -479,7 +477,7 @@ fn run_attach(session_id: &str) -> Result<()> {
|
||||
std::fs::create_dir_all(&session_dir)?;
|
||||
let mut client_state = app::state::rest::AppStateRest::new(
|
||||
workspace_roots,
|
||||
session_dir,
|
||||
&session_dir,
|
||||
store.memory_dir,
|
||||
);
|
||||
client_state.session_id = session_id.to_string();
|
||||
@@ -547,10 +545,7 @@ fn run_attach(session_id: &str) -> Result<()> {
|
||||
),
|
||||
);
|
||||
}
|
||||
Some(ipc::protocol::DaemonFrame::Closed) => {
|
||||
client_state.quit = true;
|
||||
}
|
||||
None => {
|
||||
Some(ipc::protocol::DaemonFrame::Closed) | None => {
|
||||
client_state.quit = true;
|
||||
}
|
||||
}
|
||||
@@ -560,7 +555,7 @@ fn run_attach(session_id: &str) -> Result<()> {
|
||||
})?;
|
||||
}
|
||||
|
||||
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
|
||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||
let _ = disable_raw_mode();
|
||||
|
||||
let _ = client_state.settings.save();
|
||||
@@ -584,7 +579,6 @@ fn run_loop(
|
||||
if let Err(ref _e) = result {
|
||||
let _ = terminal.clear();
|
||||
|
||||
let _ = execute!(io::stdout(), DisableMouseCapture);
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||
}
|
||||
|
||||
+5
-11
@@ -43,18 +43,12 @@ impl EditLog {
|
||||
/// `MAX_MEMORY_ENTRIES` entries. The full history is preserved on disk
|
||||
/// regardless of the in-memory limit.
|
||||
fn load_from_disk(path: &std::path::Path) -> Vec<EditLogEntry> {
|
||||
let file = match std::fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
use std::io::{BufRead, BufReader};
|
||||
let Ok(file) = std::fs::File::open(path) else { return Vec::new() };
|
||||
let reader = BufReader::new(file);
|
||||
let mut entries: Vec<EditLogEntry> = Vec::new();
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let Ok(line) = line else { continue };
|
||||
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) {
|
||||
// Keep only the most recent entries in memory
|
||||
if entries.len() >= MAX_MEMORY_ENTRIES {
|
||||
@@ -81,6 +75,7 @@ impl EditLog {
|
||||
/// Return: `Ok(())` on success; an `io::Error` if serialization or
|
||||
/// any filesystem operation fails.
|
||||
pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let line = serde_json::to_string(&entry)? + "\n";
|
||||
// Ensure parent directory exists; fall back to the current
|
||||
// directory if path has no parent (should not happen in practice
|
||||
@@ -92,7 +87,6 @@ impl EditLog {
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)?;
|
||||
use std::io::Write;
|
||||
file.write_all(line.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
self.entries.push(entry);
|
||||
@@ -151,8 +145,8 @@ mod tests {
|
||||
log.append(EditLogEntry {
|
||||
ts: i,
|
||||
tool: "edit".to_string(),
|
||||
path: format!("file{}.txt", i),
|
||||
reason: format!("reason {}", i),
|
||||
path: format!("file{i}.txt"),
|
||||
reason: format!("reason {i}"),
|
||||
content_sha256: "hash".to_string(),
|
||||
bytes_delta: 10 + i,
|
||||
origin: "main".to_string(),
|
||||
|
||||
+12
-14
@@ -57,7 +57,7 @@ impl Memory {
|
||||
/// to nothing, so a path is always produced.
|
||||
pub fn path(memory_dir: &Path, name: &str) -> PathBuf {
|
||||
let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string());
|
||||
slug_path(memory_dir, &format!("{}.md", slug))
|
||||
slug_path(memory_dir, &format!("{slug}.md"))
|
||||
}
|
||||
|
||||
/// Serialize this memory to markdown-with-frontmatter and write it
|
||||
@@ -72,14 +72,15 @@ impl Memory {
|
||||
///
|
||||
/// Return: `Ok(())` on success, or an `io::Error` from directory
|
||||
/// creation, the temp write, or the rename.
|
||||
#[allow(clippy::suspicious_open_options)]
|
||||
pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> {
|
||||
let path = Self::path(memory_dir, &self.name);
|
||||
let parent = path.parent().unwrap();
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {}", o)).unwrap_or_default();
|
||||
let scope_line = self.scope.as_ref().map(|s| format!("scope: {}", s)).unwrap_or_default();
|
||||
let before_line = self.before_snippet.as_ref().map(|s| format!("before: {}", s)).unwrap_or_default();
|
||||
let after_line = self.after_snippet.as_ref().map(|s| format!("after: {}", s)).unwrap_or_default();
|
||||
let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {o}")).unwrap_or_default();
|
||||
let scope_line = self.scope.as_ref().map(|s| format!("scope: {s}")).unwrap_or_default();
|
||||
let before_line = self.before_snippet.as_ref().map(|s| format!("before: {s}")).unwrap_or_default();
|
||||
let after_line = self.after_snippet.as_ref().map(|s| format!("after: {s}")).unwrap_or_default();
|
||||
let prov_line = if self.provenances.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -95,11 +96,11 @@ impl Memory {
|
||||
// Write to temp file with fsync for crash safety (prevents
|
||||
// partial writes surviving a power loss).
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.open(&tmp)?;
|
||||
use std::io::Write;
|
||||
f.write_all(content.as_bytes())?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
@@ -161,7 +162,7 @@ impl Memory {
|
||||
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()),
|
||||
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()),
|
||||
provenances: front.get("provenances").cloned()
|
||||
.map(|s| s.split(", ").map(|p| p.to_string()).collect())
|
||||
.map(|s| s.split(", ").map(std::string::ToString::to_string).collect())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
@@ -185,13 +186,10 @@ impl Memory {
|
||||
/// Return: slugs (without extension); empty `Vec` if the directory
|
||||
/// can't be read.
|
||||
pub fn list(memory_dir: &Path) -> Vec<String> {
|
||||
let entries = match std::fs::read_dir(memory_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(memory_dir) else { return Vec::new() };
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|x| x == "md").unwrap_or(false))
|
||||
.filter_map(std::result::Result::ok)
|
||||
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if name == "MEMORY.md" { return None; }
|
||||
@@ -377,7 +375,7 @@ mod tests {
|
||||
};
|
||||
mem.write(&dir).unwrap();
|
||||
let names = Memory::list(&dir);
|
||||
assert!(names.contains(&"alpha".to_string()), "list should contain 'alpha', got: {:?}", names);
|
||||
assert!(names.contains(&"alpha".to_string()), "list should contain 'alpha', got: {names:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
//! Persistence and domain model layer: sessions, conversations, memory,
|
||||
//! message log (SQLite), edit log, and app/settings config.
|
||||
//! message log (`SQLite`), edit log, and app/settings config.
|
||||
|
||||
pub mod app_config;
|
||||
pub mod editlog;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Binary blob storage in the message-log SQLite database (e.g. images,
|
||||
//! Binary blob storage in the message-log `SQLite` database (e.g. images,
|
||||
//! attachments), keyed by session id and an arbitrary blob key.
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
@@ -9,7 +9,7 @@ use anyhow::Result;
|
||||
/// Flow: compute current timestamp → `INSERT OR REPLACE` into `blobs`
|
||||
/// keyed on `(session_id, blob_key)`.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or the underlying SQLite error.
|
||||
/// Return: `Ok(())` on success, or the underlying `SQLite` error.
|
||||
pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> Result<()> {
|
||||
let created_at = chrono::Utc::now().timestamp_millis();
|
||||
conn.execute(
|
||||
@@ -22,7 +22,7 @@ pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u
|
||||
/// Fetch a blob's bytes for a session by key.
|
||||
///
|
||||
/// Return: `Ok(Some(data))` if found, `Ok(None)` if no matching row
|
||||
/// exists, `Err` for any other SQLite failure.
|
||||
/// exists, `Err` for any other `SQLite` failure.
|
||||
pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<Option<Vec<u8>>> {
|
||||
let result = conn.query_row(
|
||||
"SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
|
||||
@@ -52,7 +52,7 @@ pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Resul
|
||||
/// List all blob keys stored for a session, oldest first.
|
||||
///
|
||||
/// Return: `Ok(Vec<String>)` of keys ordered by `created_at`, or the
|
||||
/// underlying SQLite error.
|
||||
/// underlying `SQLite` error.
|
||||
pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC"
|
||||
|
||||
@@ -12,7 +12,7 @@ pub use query::insert_message;
|
||||
/// schema is initialized.
|
||||
///
|
||||
/// Flow: resolve `<session_dir>/messages.sqlite` → create parent dirs →
|
||||
/// open a SQLite connection → run `schema::init_schema`.
|
||||
/// open a `SQLite` connection → run `schema::init_schema`.
|
||||
///
|
||||
/// Return: an open, schema-ready `Connection`, or an error if any step
|
||||
/// fails.
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::dto::chat::message::{ChatMessage, Role};
|
||||
|
||||
/// Insert a chat message into the session's message log.
|
||||
///
|
||||
/// Flow: extract optional content/tool_call_id/tool_name → serialize
|
||||
/// Flow: extract optional `content/tool_call_id/tool_name` → serialize
|
||||
/// `tool_calls` to a JSON string if present → map `Role` to its string
|
||||
/// column value → `INSERT` the row with the current timestamp.
|
||||
///
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! SQLite schema definition for the message log database.
|
||||
//! `SQLite` schema definition for the message log database.
|
||||
|
||||
use rusqlite::Connection;
|
||||
use anyhow::Result;
|
||||
@@ -9,7 +9,7 @@ use anyhow::Result;
|
||||
/// Why: idempotent via `CREATE TABLE/INDEX IF NOT EXISTS`, so it's safe
|
||||
/// to call on every `open_or_create`.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or the underlying SQLite error.
|
||||
/// Return: `Ok(())` on success, or the underlying `SQLite` error.
|
||||
pub fn init_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
conn.execute_batch(
|
||||
|
||||
@@ -89,7 +89,7 @@ impl Session {
|
||||
if id.contains('/') || id.contains('\\') || id.contains("..") {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("invalid session id '{}': must not contain path separators", id),
|
||||
format!("invalid session id '{id}': must not contain path separators"),
|
||||
));
|
||||
}
|
||||
let path = base_dir.join("sessions").join(id).join("session.json");
|
||||
@@ -108,12 +108,9 @@ impl Session {
|
||||
/// contains no valid sessions.
|
||||
pub fn list(base_dir: &Path) -> Vec<Self> {
|
||||
let sessions_dir = base_dir.join("sessions");
|
||||
let entries = match std::fs::read_dir(&sessions_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(&sessions_dir) else { return Vec::new() };
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(std::result::Result::ok)
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter_map(|e| {
|
||||
let id = e.file_name().to_string_lossy().to_string();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! PID-file based advisory lock preventing two processes from operating on
|
||||
//! the same session directory concurrently.
|
||||
|
||||
@@ -37,6 +38,7 @@ impl SessionLock {
|
||||
///
|
||||
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
|
||||
/// process holds it, `Err` on I/O failure.
|
||||
#[allow(clippy::suspicious_open_options)]
|
||||
pub fn try_lock(&self) -> std::io::Result<bool> {
|
||||
// Phase 1: try atomic create. If it succeeds, the lock is ours.
|
||||
match fs::OpenOptions::new()
|
||||
@@ -90,6 +92,7 @@ impl SessionLock {
|
||||
/// Check whether a process with the given PID is currently alive and
|
||||
/// is actually a zesdex process (not a recycled PID from a different
|
||||
/// program).
|
||||
#[allow(clippy::unused_self)]
|
||||
fn is_alive(&self, pid: u32) -> bool {
|
||||
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
|
||||
// whether the process exists and the caller has permission to signal
|
||||
@@ -102,18 +105,12 @@ impl SessionLock {
|
||||
// from a different program would answer kill but shouldn't hold
|
||||
// our lock). This is best-effort — /proc may not be available
|
||||
// on all platforms.
|
||||
let proc_exe = std::path::PathBuf::from(format!("/proc/{}/exe", pid));
|
||||
match std::fs::read_link(&proc_exe) {
|
||||
Ok(target) => match std::env::current_exe() {
|
||||
Ok(exe) => {
|
||||
if target != exe {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Err(_) => { /* cannot resolve own exe, trust kill check */ }
|
||||
},
|
||||
Err(_) => { /* /proc unavailable, trust kill check */ }
|
||||
}
|
||||
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
|
||||
if let Ok(target) = std::fs::read_link(&proc_exe) { if let Ok(exe) = std::env::current_exe() {
|
||||
if target != exe {
|
||||
return false;
|
||||
}
|
||||
} else { /* cannot resolve own exe, trust kill check */ } } else { /* /proc unavailable, trust kill check */ }
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
@@ -72,13 +72,13 @@ impl OAuthManager {
|
||||
.post(&self.config.token_url)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.map_err(|e| format!("token request failed: {}", e))?;
|
||||
.map_err(|e| format!("token request failed: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {}", e))?;
|
||||
let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {e}"))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!("token endpoint returned {}: {}", status, body));
|
||||
return Err(format!("token endpoint returned {status}: {body}"));
|
||||
}
|
||||
|
||||
let access_token = body["access_token"].as_str().ok_or("missing access_token")?.to_string();
|
||||
@@ -87,7 +87,7 @@ impl OAuthManager {
|
||||
|
||||
self.token = Some(OAuthToken {
|
||||
access_token,
|
||||
refresh_token: body["refresh_token"].as_str().map(|s| s.to_string()),
|
||||
refresh_token: body["refresh_token"].as_str().map(std::string::ToString::to_string),
|
||||
expires_at: now + expires_in,
|
||||
token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(),
|
||||
});
|
||||
@@ -98,7 +98,7 @@ impl OAuthManager {
|
||||
/// Build the provider's authorization URL with PKCE and state params attached.
|
||||
///
|
||||
/// Why: refuses to build a URL if `auth_url` is missing or invalid. Previously
|
||||
/// this silently fell back to https://example.com, which produced a valid-looking
|
||||
/// this silently fell back to <https://example.com>, which produced a valid-looking
|
||||
/// auth URL pointing at the wrong server and leaked client credentials in
|
||||
/// query params. Returning an empty string signals failure to callers, who
|
||||
/// can prompt the user to fix the OAuth config instead of starting a flow
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! PKCE (Proof Key for Code Exchange) verifier/challenge pair generation for OAuth flows.
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
|
||||
+10
-10
@@ -13,7 +13,7 @@ pub(crate) const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1";
|
||||
const DEFAULT_MODEL: &str = "deepseek-v4-flash-free";
|
||||
pub const DEFAULT_API_KEY: &str = "";
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_mins(1);
|
||||
|
||||
/// Blocking HTTP client for a single LLM provider endpoint.
|
||||
///
|
||||
@@ -29,9 +29,9 @@ pub struct LlmClient {
|
||||
impl LlmClient {
|
||||
/// Construct a client, falling back to built-in defaults for empty inputs.
|
||||
///
|
||||
/// Flow: empty api_key/model → substitute defaults → build reqwest client
|
||||
/// Flow: empty `api_key/model` → substitute defaults → build reqwest client
|
||||
/// with connect/request timeouts → if TLS config fails, retry with just
|
||||
/// request timeout (no connect timeout) → normalize base_url.
|
||||
/// request timeout (no connect timeout) → normalize `base_url`.
|
||||
///
|
||||
/// Why: empty strings are treated as "unset" rather than errors so callers
|
||||
/// can pass through unconfigured settings without special-casing them.
|
||||
@@ -126,11 +126,11 @@ impl LlmClient {
|
||||
let result = (|| -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||
let resp = http_req.json(&req).send().map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
anyhow::anyhow!("API request timed out after {:?}. Check your network or try again.", REQUEST_TIMEOUT)
|
||||
anyhow::anyhow!("API request timed out after {REQUEST_TIMEOUT:?}. Check your network or try again.")
|
||||
} else if e.is_connect() {
|
||||
anyhow::anyhow!("Could not connect to {}. Is the URL correct and is the service reachable?", self.base_url)
|
||||
} else {
|
||||
anyhow::anyhow!("API request failed: {}", e)
|
||||
anyhow::anyhow!("API request failed: {e}")
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -142,7 +142,7 @@ impl LlmClient {
|
||||
|
||||
let data: crate::dto::provider::response::ChatResponse = resp.json()?;
|
||||
let usage = data.usage.map(|u| {
|
||||
(u.prompt_tokens.unwrap_or(0) as u64, u.completion_tokens.unwrap_or(0) as u64)
|
||||
(u64::from(u.prompt_tokens.unwrap_or(0)), u64::from(u.completion_tokens.unwrap_or(0)))
|
||||
});
|
||||
let message = data
|
||||
.choices
|
||||
@@ -259,11 +259,11 @@ impl LlmClient {
|
||||
|
||||
let resp = http_req.json(req).send().map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
anyhow::anyhow!("API request timed out after {:?}. Check your network or try again.", REQUEST_TIMEOUT)
|
||||
anyhow::anyhow!("API request timed out after {REQUEST_TIMEOUT:?}. Check your network or try again.")
|
||||
} else if e.is_connect() {
|
||||
anyhow::anyhow!("Could not connect to {}. Is the URL correct and is the service reachable?", self.base_url)
|
||||
} else {
|
||||
anyhow::anyhow!("API request failed: {}", e)
|
||||
anyhow::anyhow!("API request failed: {e}")
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -282,7 +282,7 @@ impl LlmClient {
|
||||
|
||||
loop {
|
||||
let n = reader.read(&mut chunk_buf)
|
||||
.map_err(|e| anyhow::anyhow!("stream read error: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("stream read error: {e}"))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
@@ -306,7 +306,7 @@ impl LlmClient {
|
||||
usage = Some((*prompt_tokens, *completion_tokens));
|
||||
}
|
||||
StreamEvent::Error(msg) => {
|
||||
anyhow::bail!("stream error: {}", msg);
|
||||
anyhow::bail!("stream error: {msg}");
|
||||
}
|
||||
StreamEvent::Done => {
|
||||
turn.apply_event(&event);
|
||||
|
||||
@@ -43,7 +43,7 @@ impl Tool for BashOutput {
|
||||
}
|
||||
match crate::app::bgbash::control::bash_output(&job_id) {
|
||||
Some(lines) => Ok(lines.join("\n")),
|
||||
None => Ok(format!("No new output from job '{}'", job_id)),
|
||||
None => Ok(format!("No new output from job '{job_id}'")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,11 +82,11 @@ impl Tool for BashKill {
|
||||
anyhow::bail!("invalid job_id format: expected UUID");
|
||||
}
|
||||
crate::app::bgbash::control::bash_kill(&job_id)?;
|
||||
Ok(format!("Killed background job '{}'", job_id))
|
||||
Ok(format!("Killed background job '{job_id}'"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that a job_id matches UUID v4 format (hex with dashes).
|
||||
/// Validate that a `job_id` matches UUID v4 format (hex with dashes).
|
||||
fn is_valid_job_id(id: &str) -> bool {
|
||||
// UUID v4 format: 8-4-4-4-12 hex digits
|
||||
let parts: Vec<&str> = id.split('-').collect();
|
||||
|
||||
@@ -47,24 +47,24 @@ impl Tool for Delete {
|
||||
}
|
||||
|
||||
let metadata = path.metadata()
|
||||
.map_err(|e| anyhow!("failed to read metadata for '{}': {}", rel, e))?;
|
||||
.map_err(|e| anyhow!("failed to read metadata for '{rel}': {e}"))?;
|
||||
|
||||
if metadata.is_dir() {
|
||||
let is_empty = fs::read_dir(&path)
|
||||
.map_err(|e| anyhow!("failed to read directory '{}': {}", rel, e))?
|
||||
.map_err(|e| anyhow!("failed to read directory '{rel}': {e}"))?
|
||||
.next()
|
||||
.is_none();
|
||||
if is_empty {
|
||||
fs::remove_dir(&path)
|
||||
.map_err(|e| anyhow!("failed to remove directory '{}': {}", rel, e))?;
|
||||
Ok(format!("removed empty directory {}", rel))
|
||||
.map_err(|e| anyhow!("failed to remove directory '{rel}': {e}"))?;
|
||||
Ok(format!("removed empty directory {rel}"))
|
||||
} else {
|
||||
anyhow::bail!("directory '{}' is not empty (refusing to delete)", rel);
|
||||
anyhow::bail!("directory '{rel}' is not empty (refusing to delete)");
|
||||
}
|
||||
} else {
|
||||
fs::remove_file(&path)
|
||||
.map_err(|e| anyhow!("failed to delete '{}': {}", rel, e))?;
|
||||
Ok(format!("deleted {}", rel))
|
||||
.map_err(|e| anyhow!("failed to delete '{rel}': {e}"))?;
|
||||
Ok(format!("deleted {rel}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-11
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Tool: `edit` — replace a substring in a file with a new string.
|
||||
|
||||
use std::fs;
|
||||
@@ -70,25 +71,24 @@ impl Tool for Edit {
|
||||
anyhow::bail!("'old' must be a non-empty string; use 'write' to replace entire file contents");
|
||||
}
|
||||
let check_matches = check_graduated_checks(&rel, &new_str, &ctx.graduated_checks);
|
||||
let replace_all = args.get("replace_all").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let replace_all = args.get("replace_all").and_then(serde_json::Value::as_bool).unwrap_or(false);
|
||||
let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?;
|
||||
if !path.exists() {
|
||||
anyhow::bail!("file '{}' does not exist at resolved path {}", rel, path.display());
|
||||
}
|
||||
if path.is_dir() {
|
||||
anyhow::bail!("'{}' is a directory, not a file", rel);
|
||||
anyhow::bail!("'{rel}' is a directory, not a file");
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| anyhow!("failed to read '{}': {}", rel, e))?;
|
||||
.map_err(|e| anyhow!("failed to read '{rel}': {e}"))?;
|
||||
if !content.contains(&old) {
|
||||
anyhow::bail!("old string not found in '{}'", rel);
|
||||
anyhow::bail!("old string not found in '{rel}'");
|
||||
}
|
||||
if !replace_all {
|
||||
let count = content.matches(&old).count();
|
||||
if count > 1 {
|
||||
anyhow::bail!(
|
||||
"old string appears {} times in '{}'. Set replace_all=true to replace all occurrences, or provide a more specific match.",
|
||||
count, rel
|
||||
"old string appears {count} times in '{rel}'. Set replace_all=true to replace all occurrences, or provide a more specific match."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ impl Tool for Edit {
|
||||
content.replacen(&old, &new_str, 1)
|
||||
};
|
||||
fs::write(&path, &new_content)
|
||||
.map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?;
|
||||
.map_err(|e| anyhow!("failed to write '{rel}': {e}"))?;
|
||||
let bytes_diff = if new_content.len() > content.len() {
|
||||
new_content.len() - content.len()
|
||||
} else {
|
||||
@@ -108,10 +108,8 @@ impl Tool for Edit {
|
||||
// Never fail the edit because of this — LSP errors are surfaced as a
|
||||
// trailing annotation on the success message instead.
|
||||
let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() {
|
||||
match lsp.did_change_file(&path) {
|
||||
Ok(()) => String::new(),
|
||||
Err(e) => format!(" (LSP: {})", e),
|
||||
}
|
||||
lsp.did_change_file(&path);
|
||||
String::new()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
@@ -12,8 +12,8 @@ use anyhow::{Result, anyhow};
|
||||
pub fn arg_str(args: &Value, name: &str) -> Result<String> {
|
||||
args.get(name)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow!("missing required argument: {}", name))
|
||||
.map(std::string::ToString::to_string)
|
||||
.ok_or_else(|| anyhow!("missing required argument: {name}"))
|
||||
}
|
||||
|
||||
/// Produce a user-friendly diagnostic string when a path doesn't resolve or exist.
|
||||
@@ -25,17 +25,17 @@ pub fn arg_str(args: &Value, name: &str) -> Result<String> {
|
||||
pub fn not_found_help(ctx: &super::super::ToolCtx, path: &Path, rel: &str) -> String {
|
||||
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let in_ws = ctx.workspaces.iter().any(|w| {
|
||||
let wc = w.canonicalize().unwrap_or_else(|_| w.to_path_buf());
|
||||
let wc = w.canonicalize().unwrap_or_else(|_| w.clone());
|
||||
canon.starts_with(&wc)
|
||||
});
|
||||
if !in_ws {
|
||||
if in_ws {
|
||||
format!("path '{}' does not exist (resolved to {})", rel, canon.display())
|
||||
} else {
|
||||
format!(
|
||||
"path '{}' is outside all workspace roots. Workspace roots: {}",
|
||||
rel,
|
||||
ctx.workspaces.iter().map(|w| w.display().to_string()).collect::<Vec<_>>().join(", ")
|
||||
)
|
||||
} else {
|
||||
format!("path '{}' does not exist (resolved to {})", rel, canon.display())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-3
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Tool: `read` — display file contents with line numbers.
|
||||
|
||||
use std::fs;
|
||||
@@ -47,7 +48,7 @@ impl Tool for Read {
|
||||
/// exist; a "is a directory" message if the path points at a directory.
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = arg_str(args, "path")?;
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).map(|v| v as usize);
|
||||
let limit = args.get("limit").and_then(serde_json::Value::as_u64).map(|v| v as usize);
|
||||
let path: PathBuf = match resolve_path(&ctx.workspaces, &rel) {
|
||||
Ok(p) => p,
|
||||
Err(_e) => return Ok(not_found_help(ctx, &PathBuf::from(&rel), &rel)),
|
||||
@@ -56,10 +57,10 @@ impl Tool for Read {
|
||||
return Ok(not_found_help(ctx, &path, &rel));
|
||||
}
|
||||
if path.is_dir() {
|
||||
return Ok(format!("'{}' is a directory, not a file. Use ls or glob to list directory contents.", rel));
|
||||
return Ok(format!("'{rel}' is a directory, not a file. Use ls or glob to list directory contents."));
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| anyhow!("failed to read '{}': {}", rel, e))?;
|
||||
.map_err(|e| anyhow!("failed to read '{rel}': {e}"))?;
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let total = lines.len();
|
||||
let take = limit.unwrap_or(total).min(total);
|
||||
|
||||
@@ -60,18 +60,16 @@ impl Tool for Write {
|
||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| anyhow!("failed to create parent directories for '{}': {}", rel, e))?;
|
||||
.map_err(|e| anyhow!("failed to create parent directories for '{rel}': {e}"))?;
|
||||
}
|
||||
fs::write(&path, &content)
|
||||
.map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?;
|
||||
.map_err(|e| anyhow!("failed to write '{rel}': {e}"))?;
|
||||
// Notify the LSP server of the on-disk change so diagnostics stay in
|
||||
// sync. Never fails the write itself: a lock failure or LSP error is
|
||||
// folded into the returned message instead of propagated as an Err.
|
||||
let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() {
|
||||
match lsp.did_change_file(&path) {
|
||||
Ok(()) => String::new(),
|
||||
Err(e) => format!(" (LSP: {})", e),
|
||||
}
|
||||
lsp.did_change_file(&path);
|
||||
String::new()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
@@ -48,11 +48,11 @@ impl Tool for GitCred {
|
||||
.arg("credential")
|
||||
.arg(operation)
|
||||
.output()
|
||||
.map_err(|e| anyhow!("git credential failed: {}", e))?;
|
||||
.map_err(|e| anyhow!("git credential failed: {e}"))?;
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
Ok(format!("{}{}", stdout, stderr))
|
||||
Ok(format!("{stdout}{stderr}"))
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
anyhow::bail!("git credential '{}' failed: {}", operation, stderr.trim())
|
||||
|
||||
@@ -58,7 +58,7 @@ impl Tool for GitOperator {
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.filter_map(|v| v.as_str().map(std::string::ToString::to_string))
|
||||
.collect()
|
||||
})
|
||||
.ok_or_else(|| anyhow!("missing required argument: args"))?;
|
||||
@@ -67,12 +67,12 @@ impl Tool for GitOperator {
|
||||
// of which tool the model uses.
|
||||
let cmd_for_filter = format!("git {} {}", operation, arg_list.join(" "));
|
||||
crate::tool::shell_filter::git::check_git_destructive(&cmd_for_filter)
|
||||
.map_err(|e| anyhow!("blocked: {}", e))?;
|
||||
.map_err(|e| anyhow!("blocked: {e}"))?;
|
||||
let output = Command::new("git")
|
||||
.arg(&operation)
|
||||
.args(&arg_list)
|
||||
.output()
|
||||
.map_err(|e| anyhow!("git {} failed: {}", operation, e))?;
|
||||
.map_err(|e| anyhow!("git {operation} failed: {e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let combined = if stderr.is_empty() { stdout.trim().to_string() } else { format!("{}\n{}", stdout.trim(), stderr.trim()) };
|
||||
|
||||
@@ -37,7 +37,7 @@ impl Tool for GitWorktree {
|
||||
|
||||
/// Create the worktree directory and run `git worktree add --checkout <path> <base_ref>`.
|
||||
///
|
||||
/// Flow: extract name/base_ref → create worktree dir under `ctx.worktrees_dir` →
|
||||
/// Flow: extract `name/base_ref` → create worktree dir under `ctx.worktrees_dir` →
|
||||
/// spawn `git worktree add` → combine stdout/stderr.
|
||||
///
|
||||
/// Return: success message with combined output on success; error including exit
|
||||
@@ -56,18 +56,18 @@ impl Tool for GitWorktree {
|
||||
.to_string();
|
||||
let worktree_path = ctx.worktrees_dir.join(&name);
|
||||
std::fs::create_dir_all(&worktree_path)
|
||||
.map_err(|e| anyhow!("failed to create worktree directory: {}", e))?;
|
||||
.map_err(|e| anyhow!("failed to create worktree directory: {e}"))?;
|
||||
let output = Command::new("git")
|
||||
.args(["worktree", "add", "--checkout"])
|
||||
.arg(worktree_path.display().to_string())
|
||||
.arg(&base_ref)
|
||||
.output()
|
||||
.map_err(|e| anyhow!("git worktree add failed: {}", e))?;
|
||||
.map_err(|e| anyhow!("git worktree add failed: {e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let combined = if stderr.is_empty() { stdout.trim().to_string() } else { format!("{}\n{}", stdout.trim(), stderr.trim()) };
|
||||
if output.status.success() {
|
||||
Ok(format!("created worktree '{}' from '{}'\n{}", name, base_ref, combined))
|
||||
Ok(format!("created worktree '{name}' from '{base_ref}'\n{combined}"))
|
||||
} else {
|
||||
anyhow::bail!("git worktree add failed (exit {}): {}", output.status.code().unwrap_or(-1), stderr.trim())
|
||||
}
|
||||
|
||||
+60
-63
@@ -1,3 +1,5 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
use std::fmt::Write;
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
@@ -59,7 +61,7 @@ impl Tool for LspConnect {
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut manager = ctx.lsp_manager.lock()
|
||||
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?;
|
||||
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
|
||||
manager.connect(name, command, &extra_args, language_id)?;
|
||||
|
||||
// Auto-register this server's known extensions so lsp_diagnostics /
|
||||
@@ -79,8 +81,7 @@ impl Tool for LspConnect {
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
Ok(format!(
|
||||
"Connected to LSP server '{}' (language: {})\nServer capabilities:\n{}",
|
||||
name, language_id, caps_summary
|
||||
"Connected to LSP server '{name}' (language: {language_id})\nServer capabilities:\n{caps_summary}"
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -132,15 +133,15 @@ impl Tool for LspDiagnostics {
|
||||
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
|
||||
|
||||
let manager = ctx.lsp_manager.lock()
|
||||
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?;
|
||||
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
|
||||
let language_id = manager.get_language_id(server_name)
|
||||
.ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?;
|
||||
.ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?;
|
||||
let client_arc = manager.get_client(server_name)
|
||||
.ok_or_else(|| anyhow!("LSP server '{}' not found", server_name))?;
|
||||
.ok_or_else(|| anyhow!("LSP server '{server_name}' not found"))?;
|
||||
drop(manager);
|
||||
|
||||
let mut client = client_arc.lock()
|
||||
.map_err(|e| anyhow!("LSP client lock error: {}", e))?;
|
||||
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
|
||||
|
||||
match client.collect_diagnostics(&uri, &language_id, text) {
|
||||
Ok(diags) => {
|
||||
@@ -151,7 +152,7 @@ impl Tool for LspDiagnostics {
|
||||
let mut output = String::from("Diagnostics:\n");
|
||||
for d in &diags_array {
|
||||
let range = d.get("range").and_then(|r| r.get("start"));
|
||||
let severity = match d.get("severity").and_then(|s| s.as_i64()).unwrap_or(0) {
|
||||
let severity = match d.get("severity").and_then(serde_json::Value::as_i64).unwrap_or(0) {
|
||||
1 => "ERROR",
|
||||
2 => "WARNING",
|
||||
3 => "INFO",
|
||||
@@ -159,13 +160,13 @@ impl Tool for LspDiagnostics {
|
||||
_ => "NOTE",
|
||||
};
|
||||
let message = d.get("message").and_then(|m| m.as_str()).unwrap_or("?");
|
||||
let line = range.and_then(|r| r.get("line")).and_then(|l| l.as_i64()).unwrap_or(0);
|
||||
let col = range.and_then(|r| r.get("character")).and_then(|c| c.as_i64()).unwrap_or(0);
|
||||
let line = range.and_then(|r| r.get("line")).and_then(serde_json::Value::as_i64).unwrap_or(0);
|
||||
let col = range.and_then(|r| r.get("character")).and_then(serde_json::Value::as_i64).unwrap_or(0);
|
||||
let code = d.get("code")
|
||||
.and_then(|c| c.as_str().or_else(|| c.as_i64().map(|n| Box::leak(Box::new(n.to_string()))).map(|s| s.as_str())))
|
||||
.unwrap_or("");
|
||||
let code_str = if code.is_empty() { String::new() } else { format!(" [{}]", code) };
|
||||
output.push_str(&format!(" {}:{}:{} - {}{}: {}\n", rel_path, line + 1, col, severity, code_str, message));
|
||||
let code_str = if code.is_empty() { String::new() } else { format!(" [{code}]") };
|
||||
writeln!(output, " {}:{}:{} - {}{}: {}", rel_path, line + 1, col, severity, code_str, message).unwrap();
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
@@ -226,10 +227,10 @@ impl Tool for LspHover {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: path"))?;
|
||||
let line = args.get("line")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
|
||||
let column = args.get("column")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
|
||||
let server_name = resolve_server_name(ctx, args, rel_path)?;
|
||||
let server_name = server_name.as_str();
|
||||
@@ -238,20 +239,20 @@ impl Tool for LspHover {
|
||||
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
|
||||
|
||||
let file_content = std::fs::read_to_string(&abs_path)
|
||||
.map_err(|e| anyhow!("failed to read file '{}': {}", rel_path, e))?;
|
||||
.map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
|
||||
|
||||
let manager = ctx.lsp_manager.lock()
|
||||
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?;
|
||||
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
|
||||
let language_id = manager.get_language_id(server_name)
|
||||
.unwrap_or_else(|| {
|
||||
args.get("language_id").and_then(|v| v.as_str()).unwrap_or("plaintext").to_string()
|
||||
});
|
||||
let client_arc = manager.get_client(server_name)
|
||||
.ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?;
|
||||
.ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?;
|
||||
drop(manager);
|
||||
|
||||
let mut client = client_arc.lock()
|
||||
.map_err(|e| anyhow!("LSP client lock error: {}", e))?;
|
||||
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
|
||||
|
||||
client.did_open(&uri, &language_id, 1, &file_content)?;
|
||||
let result = client.hover(&uri, line, column);
|
||||
@@ -267,9 +268,9 @@ impl Tool for LspHover {
|
||||
let mut output = String::new();
|
||||
if let Some(range_val) = range {
|
||||
if let Some(start) = range_val.get("start") {
|
||||
let rl = start.get("line").and_then(|l| l.as_i64()).unwrap_or(0);
|
||||
let rc = start.get("character").and_then(|c| c.as_i64()).unwrap_or(0);
|
||||
output.push_str(&format!("Range: {}:{}\n", rl + 1, rc + 1));
|
||||
let rl = start.get("line").and_then(serde_json::Value::as_i64).unwrap_or(0);
|
||||
let rc = start.get("character").and_then(serde_json::Value::as_i64).unwrap_or(0);
|
||||
writeln!(output, "Range: {}:{}", rl + 1, rc + 1).unwrap();
|
||||
}
|
||||
}
|
||||
if let Some(contents_val) = contents {
|
||||
@@ -292,7 +293,7 @@ fn format_hover_contents(contents: &Value) -> String {
|
||||
}
|
||||
Value::Object(map) => {
|
||||
if let Some(kind) = map.get("kind").and_then(|k| k.as_str()) {
|
||||
out.push_str(&format!("[{kind}] "));
|
||||
write!(out, "[{kind}] ").unwrap();
|
||||
}
|
||||
if let Some(value) = map.get("value").and_then(|v| v.as_str()) {
|
||||
out.push_str(value);
|
||||
@@ -355,10 +356,10 @@ impl Tool for LspCompletion {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: path"))?;
|
||||
let line = args.get("line")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
|
||||
let column = args.get("column")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
|
||||
let server_name = resolve_server_name(ctx, args, rel_path)?;
|
||||
let server_name = server_name.as_str();
|
||||
@@ -367,18 +368,18 @@ impl Tool for LspCompletion {
|
||||
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
|
||||
|
||||
let file_content = std::fs::read_to_string(&abs_path)
|
||||
.map_err(|e| anyhow!("failed to read file '{}': {}", rel_path, e))?;
|
||||
.map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
|
||||
|
||||
let manager = ctx.lsp_manager.lock()
|
||||
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?;
|
||||
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
|
||||
let language_id = manager.get_language_id(server_name)
|
||||
.unwrap_or_else(|| "plaintext".to_string());
|
||||
let client_arc = manager.get_client(server_name)
|
||||
.ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?;
|
||||
.ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?;
|
||||
drop(manager);
|
||||
|
||||
let mut client = client_arc.lock()
|
||||
.map_err(|e| anyhow!("LSP client lock error: {}", e))?;
|
||||
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
|
||||
|
||||
client.did_open(&uri, &language_id, 1, &file_content)?;
|
||||
let result = client.completion(&uri, line, column);
|
||||
@@ -401,7 +402,7 @@ impl Tool for LspCompletion {
|
||||
let mut output = format!("{} completion suggestions at {}:{}:\n", items.len(), line + 1, column + 1);
|
||||
for (i, item) in items.iter().enumerate().take(50) {
|
||||
let label = item.get("label").and_then(|l| l.as_str()).unwrap_or("?");
|
||||
let kind = match item.get("kind").and_then(|k| k.as_i64()).unwrap_or(0) {
|
||||
let kind = match item.get("kind").and_then(serde_json::Value::as_i64).unwrap_or(0) {
|
||||
1 => "Text",
|
||||
2 => "Method",
|
||||
3 => "Function",
|
||||
@@ -430,11 +431,11 @@ impl Tool for LspCompletion {
|
||||
_ => "Other",
|
||||
};
|
||||
let detail = item.get("detail").and_then(|d| d.as_str()).unwrap_or("");
|
||||
let detail_str = if detail.is_empty() { String::new() } else { format!(" - {}", detail) };
|
||||
output.push_str(&format!(" {}. [{}] {}{}\n", i + 1, kind, label, detail_str));
|
||||
let detail_str = if detail.is_empty() { String::new() } else { format!(" - {detail}") };
|
||||
writeln!(output, " {}. [{}] {}{}", i + 1, kind, label, detail_str).unwrap();
|
||||
}
|
||||
if items.len() > 50 {
|
||||
output.push_str(&format!(" ... and {} more\n", items.len() - 50));
|
||||
writeln!(output, " ... and {} more", items.len() - 50).unwrap();
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
@@ -485,10 +486,10 @@ impl Tool for LspDefinition {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: path"))?;
|
||||
let line = args.get("line")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
|
||||
let column = args.get("column")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
|
||||
let server_name = resolve_server_name(ctx, args, rel_path)?;
|
||||
let server_name = server_name.as_str();
|
||||
@@ -497,18 +498,18 @@ impl Tool for LspDefinition {
|
||||
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
|
||||
|
||||
let file_content = std::fs::read_to_string(&abs_path)
|
||||
.map_err(|e| anyhow!("failed to read file '{}': {}", rel_path, e))?;
|
||||
.map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
|
||||
|
||||
let manager = ctx.lsp_manager.lock()
|
||||
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?;
|
||||
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
|
||||
let language_id = manager.get_language_id(server_name)
|
||||
.unwrap_or_else(|| "plaintext".to_string());
|
||||
let client_arc = manager.get_client(server_name)
|
||||
.ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?;
|
||||
.ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?;
|
||||
drop(manager);
|
||||
|
||||
let mut client = client_arc.lock()
|
||||
.map_err(|e| anyhow!("LSP client lock error: {}", e))?;
|
||||
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
|
||||
|
||||
client.did_open(&uri, &language_id, 1, &file_content)?;
|
||||
let result = client.goto_definition(&uri, line, column);
|
||||
@@ -534,13 +535,13 @@ impl Tool for LspDefinition {
|
||||
let target_uri = loc.get("uri").and_then(|u| u.as_str()).unwrap_or("?");
|
||||
let target_range = loc.get("range").or_else(|| loc.get("targetRange"));
|
||||
let target_start = target_range.and_then(|r| r.get("start"));
|
||||
let tl = target_start.and_then(|s| s.get("line")).and_then(|l| l.as_i64()).unwrap_or(0);
|
||||
let tc = target_start.and_then(|s| s.get("character")).and_then(|c| c.as_i64()).unwrap_or(0);
|
||||
let tl = target_start.and_then(|s| s.get("line")).and_then(serde_json::Value::as_i64).unwrap_or(0);
|
||||
let tc = target_start.and_then(|s| s.get("character")).and_then(serde_json::Value::as_i64).unwrap_or(0);
|
||||
let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri);
|
||||
output.push_str(&format!(" {}. {}:{}:{}\n", i + 1, path_str, tl + 1, tc + 1));
|
||||
writeln!(output, " {}. {}:{}:{}", i + 1, path_str, tl + 1, tc + 1).unwrap();
|
||||
}
|
||||
if locations.len() > 10 {
|
||||
output.push_str(&format!(" ... and {} more locations\n", locations.len() - 10));
|
||||
writeln!(output, " ... and {} more", locations.len() - 10).unwrap();
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
@@ -591,10 +592,10 @@ impl Tool for LspReferences {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: path"))?;
|
||||
let line = args.get("line")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
|
||||
let column = args.get("column")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
|
||||
let server_name = resolve_server_name(ctx, args, rel_path)?;
|
||||
let server_name = server_name.as_str();
|
||||
@@ -603,18 +604,18 @@ impl Tool for LspReferences {
|
||||
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
|
||||
|
||||
let file_content = std::fs::read_to_string(&abs_path)
|
||||
.map_err(|e| anyhow!("failed to read file '{}': {}", rel_path, e))?;
|
||||
.map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
|
||||
|
||||
let manager = ctx.lsp_manager.lock()
|
||||
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?;
|
||||
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
|
||||
let language_id = manager.get_language_id(server_name)
|
||||
.unwrap_or_else(|| "plaintext".to_string());
|
||||
let client_arc = manager.get_client(server_name)
|
||||
.ok_or_else(|| anyhow!("LSP server '{}' not found. Use lsp_connect first.", server_name))?;
|
||||
.ok_or_else(|| anyhow!("LSP server '{server_name}' not found. Use lsp_connect first."))?;
|
||||
drop(manager);
|
||||
|
||||
let mut client = client_arc.lock()
|
||||
.map_err(|e| anyhow!("LSP client lock error: {}", e))?;
|
||||
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
|
||||
|
||||
client.did_open(&uri, &language_id, 1, &file_content)?;
|
||||
let result = client.references(&uri, line, column);
|
||||
@@ -631,13 +632,13 @@ impl Tool for LspReferences {
|
||||
for (i, loc) in locations.iter().enumerate().take(50) {
|
||||
let target_uri = loc.get("uri").and_then(|u| u.as_str()).unwrap_or("?");
|
||||
let range = loc.get("range").and_then(|r| r.get("start"));
|
||||
let rl = range.and_then(|s| s.get("line")).and_then(|l| l.as_i64()).unwrap_or(0);
|
||||
let rc = range.and_then(|s| s.get("character")).and_then(|c| c.as_i64()).unwrap_or(0);
|
||||
let rl = range.and_then(|s| s.get("line")).and_then(serde_json::Value::as_i64).unwrap_or(0);
|
||||
let rc = range.and_then(|s| s.get("character")).and_then(serde_json::Value::as_i64).unwrap_or(0);
|
||||
let path_str = target_uri.strip_prefix("file://").unwrap_or(target_uri);
|
||||
output.push_str(&format!(" {}. {}:{}:{}\n", i + 1, path_str, rl + 1, rc + 1));
|
||||
writeln!(output, " {}. {}:{}:{}", i + 1, path_str, rl + 1, rc + 1).unwrap();
|
||||
}
|
||||
if locations.len() > 50 {
|
||||
output.push_str(&format!(" ... and {} more references\n", locations.len() - 50));
|
||||
writeln!(output, " ... and {} more references", locations.len() - 50).unwrap();
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
@@ -676,12 +677,12 @@ impl Tool for LspDisconnect {
|
||||
.ok_or_else(|| anyhow!("missing required argument: name"))?;
|
||||
|
||||
let mut manager = ctx.lsp_manager.lock()
|
||||
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?;
|
||||
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
|
||||
|
||||
if manager.disconnect(name) {
|
||||
Ok(format!("Disconnected from LSP server '{}'", name))
|
||||
Ok(format!("Disconnected from LSP server '{name}'"))
|
||||
} else {
|
||||
Err(anyhow!("LSP server '{}' not found", name))
|
||||
Err(anyhow!("LSP server '{name}' not found"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -718,7 +719,7 @@ fn known_extensions_for(language_id: &str) -> &[&'static str] {
|
||||
/// connected server's language is known to use that extension.
|
||||
fn auto_detect_server(ctx: &ToolCtx, path: &str) -> Option<String> {
|
||||
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str())?;
|
||||
let dot_ext = format!(".{}", ext);
|
||||
let dot_ext = format!(".{ext}");
|
||||
if let Ok(mgr) = ctx.lsp_manager.lock() {
|
||||
for s in &mgr.servers {
|
||||
let exts = known_extensions_for(&s.language_id);
|
||||
@@ -754,15 +755,13 @@ fn resolve_server_name(ctx: &ToolCtx, args: &Value, path: &str) -> Result<String
|
||||
|
||||
let ext = std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| format!(".{}", e))
|
||||
.unwrap_or_else(|| "<none>".to_string());
|
||||
.and_then(|e| e.to_str()).map_or_else(|| "<none>".to_string(), |e| format!(".{e}"));
|
||||
|
||||
let available = ctx.lsp_manager.lock().ok()
|
||||
.map(|mgr| {
|
||||
mgr.list_servers()
|
||||
.iter()
|
||||
.map(|(name, lang, _)| format!("{} ({})", name, lang))
|
||||
.map(|(name, lang, _)| format!("{name} ({lang})"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
})
|
||||
@@ -770,8 +769,6 @@ fn resolve_server_name(ctx: &ToolCtx, args: &Value, path: &str) -> Result<String
|
||||
let available = if available.is_empty() { "none".to_string() } else { available };
|
||||
|
||||
Err(anyhow!(
|
||||
"LSP server not found for extension '{}'. Use lsp_connect to connect one. Available servers: {}",
|
||||
ext,
|
||||
available
|
||||
"LSP server not found for extension '{ext}'. Use lsp_connect to connect one. Available servers: {available}"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -43,8 +43,8 @@ impl Tool for Forget {
|
||||
.ok_or_else(|| anyhow!("missing required argument: name"))?;
|
||||
|
||||
Memory::remove(&ctx.memory_dir, name)
|
||||
.map_err(|e| anyhow!("failed to remove memory '{}': {}", name, e))?;
|
||||
.map_err(|e| anyhow!("failed to remove memory '{name}': {e}"))?;
|
||||
|
||||
Ok(format!("removed memory '{}'", name))
|
||||
Ok(format!("removed memory '{name}'"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Tool for reading a single memory entry or listing the whole memory index.
|
||||
|
||||
use std::fmt::Write;
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::super::Tool;
|
||||
@@ -39,10 +40,10 @@ impl Tool for Recall {
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
if let Some(name) = args.get("name").and_then(|v| v.as_str()) {
|
||||
if name.is_empty() {
|
||||
return list_all(ctx);
|
||||
return Ok(list_all(ctx));
|
||||
}
|
||||
let memory = Memory::read(&ctx.memory_dir, name)
|
||||
.map_err(|e| anyhow!("memory '{}' not found: {}", name, e))?;
|
||||
.map_err(|e| anyhow!("memory '{name}' not found: {e}"))?;
|
||||
Ok(format!(
|
||||
"---\nname: {}\ndescription: {}\nkind: {}\nlifecycle: {}\n---\n\n{}",
|
||||
memory.name,
|
||||
@@ -52,7 +53,7 @@ impl Tool for Recall {
|
||||
memory.content,
|
||||
))
|
||||
} else {
|
||||
list_all(ctx)
|
||||
Ok(list_all(ctx))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,18 +64,18 @@ impl Tool for Recall {
|
||||
/// fall back to bare name if the file can't be parsed.
|
||||
///
|
||||
/// Return: `Ok` with the formatted index (never fails; missing dir yields "(no memory entries)").
|
||||
fn list_all(ctx: &ToolCtx) -> Result<String> {
|
||||
fn list_all(ctx: &ToolCtx) -> String {
|
||||
let names = Memory::list(&ctx.memory_dir);
|
||||
if names.is_empty() {
|
||||
return Ok("(no memory entries)".to_string());
|
||||
return "(no memory entries)".to_string();
|
||||
}
|
||||
let mut lines = format!("Memory index ({} entries):\n", names.len());
|
||||
let mut lines = String::new();
|
||||
for name in &names {
|
||||
if let Ok(mem) = Memory::read(&ctx.memory_dir, name) {
|
||||
lines.push_str(&format!("- {} [{}]: {}\n", name, mem.kind, mem.description));
|
||||
let _ = writeln!(lines, "- {} [{}]: {}", name, mem.kind, mem.description);
|
||||
} else {
|
||||
lines.push_str(&format!("- {}\n", name));
|
||||
let _ = writeln!(lines, "- {name}");
|
||||
}
|
||||
}
|
||||
Ok(lines)
|
||||
lines
|
||||
}
|
||||
|
||||
@@ -89,8 +89,8 @@ impl Tool for Remember {
|
||||
};
|
||||
|
||||
memory.write(&ctx.memory_dir)
|
||||
.map_err(|e| anyhow!("failed to write memory '{}': {}", name, e))?;
|
||||
.map_err(|e| anyhow!("failed to write memory '{name}': {e}"))?;
|
||||
|
||||
Ok(format!("saved memory '{}' ({})", name, kind))
|
||||
Ok(format!("saved memory '{name}' ({kind})"))
|
||||
}
|
||||
}
|
||||
|
||||
+17
-21
@@ -141,7 +141,7 @@ impl ToolCtxBuilder {
|
||||
|
||||
/// Construct one instance of every built-in tool, in the fixed order exposed to the LLM.
|
||||
///
|
||||
/// Return: boxed trait objects for all 28 tools (fs, search, bash, git, memory, plan,
|
||||
/// Return: boxed trait objects for all 37 tools (fs, search, bash, git, memory, plan,
|
||||
/// workflow, utility).
|
||||
pub fn all_tools() -> Vec<Box<dyn Tool>> {
|
||||
vec![
|
||||
@@ -162,6 +162,7 @@ pub fn all_tools() -> Vec<Box<dyn Tool>> {
|
||||
Box::new(super::tool::plan::PlanReady),
|
||||
Box::new(super::tool::workflow::WorkflowRun),
|
||||
Box::new(super::tool::workflow::NoteFinding),
|
||||
Box::new(super::tool::workflow::ReadFindings),
|
||||
Box::new(super::tool::workflow::CompanyPipeline),
|
||||
Box::new(super::tool::spawn::SpawnAgents),
|
||||
Box::new(super::tool::spawn::SpawnPipeline),
|
||||
@@ -228,7 +229,7 @@ pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
|
||||
} else {
|
||||
(0, rel)
|
||||
};
|
||||
let base = workspaces.get(ws_idx).ok_or_else(|| anyhow::anyhow!("workspace index {} out of range", ws_idx))?;
|
||||
let base = workspaces.get(ws_idx).ok_or_else(|| anyhow::anyhow!("workspace index {ws_idx} out of range"))?;
|
||||
let abs = if path.is_empty() {
|
||||
base.clone()
|
||||
} else {
|
||||
@@ -239,32 +240,27 @@ pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
|
||||
// workspace root first and then resolve parent-dir (`../`) traversal
|
||||
// component-by-component so that `Path::starts_with` cannot be
|
||||
// bypassed by unnormalised intermediate segments.
|
||||
let canon = match abs.canonicalize() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
let base_canon = workspaces
|
||||
.iter()
|
||||
.filter_map(|w| w.canonicalize().ok())
|
||||
.next()
|
||||
.unwrap_or_else(|| base.clone());
|
||||
let mut resolved = base_canon.clone();
|
||||
if let Ok(rel_components) = abs.strip_prefix(&base_canon) {
|
||||
for comp in rel_components.components() {
|
||||
match comp {
|
||||
std::path::Component::ParentDir => {
|
||||
resolved.pop();
|
||||
}
|
||||
std::path::Component::CurDir => {}
|
||||
c => resolved.push(c),
|
||||
let canon = if let Ok(c) = abs.canonicalize() { c } else {
|
||||
let base_canon = workspaces
|
||||
.iter().find_map(|w| w.canonicalize().ok())
|
||||
.unwrap_or_else(|| base.clone());
|
||||
let mut resolved = base_canon.clone();
|
||||
if let Ok(rel_components) = abs.strip_prefix(&base_canon) {
|
||||
for comp in rel_components.components() {
|
||||
match comp {
|
||||
std::path::Component::ParentDir => {
|
||||
resolved.pop();
|
||||
}
|
||||
std::path::Component::CurDir => {}
|
||||
c => resolved.push(c),
|
||||
}
|
||||
}
|
||||
resolved
|
||||
}
|
||||
resolved
|
||||
};
|
||||
if workspaces.iter().any(|w| canon.starts_with(w)) {
|
||||
Ok(canon)
|
||||
} else {
|
||||
anyhow::bail!("path '{}' is outside all workspace roots", rel)
|
||||
anyhow::bail!("path '{rel}' is outside all workspace roots")
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -59,10 +59,10 @@ impl Tool for Grep {
|
||||
.to_string();
|
||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||
if !path.exists() {
|
||||
anyhow::bail!("path '{}' does not exist", rel);
|
||||
anyhow::bail!("path '{rel}' does not exist");
|
||||
}
|
||||
if !path.is_dir() {
|
||||
anyhow::bail!("path '{}' is not a directory", rel);
|
||||
anyhow::bail!("path '{rel}' is not a directory");
|
||||
}
|
||||
let mut results: Vec<(String, usize, String)> = Vec::new();
|
||||
for entry in Walk::new(&path).flatten() {
|
||||
@@ -83,10 +83,10 @@ impl Tool for Grep {
|
||||
}
|
||||
}
|
||||
if results.is_empty() {
|
||||
return Ok(format!("no matches found for '{}' in {}", pattern, rel));
|
||||
return Ok(format!("no matches found for '{pattern}' in {rel}"));
|
||||
}
|
||||
let output = results.iter()
|
||||
.map(|(f, line, text)| format!("{}:{}:{}", f, line, text))
|
||||
.map(|(f, line, text)| format!("{f}:{line}:{text}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Ok(format!("found {} matches:\n{}", results.len(), output))
|
||||
@@ -144,14 +144,14 @@ impl Tool for Glob {
|
||||
.to_string();
|
||||
let root = resolve_path(&ctx.workspaces, &rel)?;
|
||||
if !root.exists() || !root.is_dir() {
|
||||
anyhow::bail!("path '{}' is not a valid directory", rel);
|
||||
anyhow::bail!("path '{rel}' is not a valid directory");
|
||||
}
|
||||
let mut builder = GlobSetBuilder::new();
|
||||
let full_pattern = root.join(&pat_str).display().to_string();
|
||||
builder.add(GlobBuilder::new(&full_pattern).build()
|
||||
.map_err(|e| anyhow!("invalid glob pattern '{}': {}", pat_str, e))?);
|
||||
.map_err(|e| anyhow!("invalid glob pattern '{pat_str}': {e}"))?);
|
||||
let glob_set = builder.build()
|
||||
.map_err(|e| anyhow!("failed to build glob set: {}", e))?;
|
||||
.map_err(|e| anyhow!("failed to build glob set: {e}"))?;
|
||||
let mut matches: Vec<String> = Vec::new();
|
||||
for entry in Walk::new(&root).flatten() {
|
||||
let p = entry.path();
|
||||
@@ -165,7 +165,7 @@ impl Tool for Glob {
|
||||
}
|
||||
matches.sort();
|
||||
if matches.is_empty() {
|
||||
return Ok(format!("no files match '{}' in {}", pat_str, rel));
|
||||
return Ok(format!("no files match '{pat_str}' in {rel}"));
|
||||
}
|
||||
Ok(matches.join("\n"))
|
||||
}
|
||||
|
||||
+10
-10
@@ -63,13 +63,13 @@ impl Tool for Bash {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: command"))?
|
||||
.to_string();
|
||||
let timeout_ms = args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(120_000).min(600_000);
|
||||
let timeout_ms = args.get("timeout").and_then(serde_json::Value::as_u64).unwrap_or(120_000).min(600_000);
|
||||
// Only gate destructive git operations; credential reads are allowed
|
||||
// locally since the AI needs access, and the real threat is committing
|
||||
// secrets to a public repo (handled by git pre-commit hooks / user).
|
||||
super::shell_filter::git::check_git_destructive(&cmd)
|
||||
.map_err(|e| anyhow!("blocked: {}", e))?;
|
||||
let run_in_background = args.get("run_in_background").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
.map_err(|e| anyhow!("blocked: {e}"))?;
|
||||
let run_in_background = args.get("run_in_background").and_then(serde_json::Value::as_bool).unwrap_or(false);
|
||||
if run_in_background {
|
||||
let job = crate::app::bgbash::job::spawn_bash_job(cmd);
|
||||
return Ok(format!("Background job: {}", job.id));
|
||||
@@ -80,7 +80,7 @@ impl Tool for Bash {
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| anyhow!("failed to spawn bash: {}", e))?;
|
||||
.map_err(|e| anyhow!("failed to spawn bash: {e}"))?;
|
||||
let start = std::time::Instant::now();
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
loop {
|
||||
@@ -88,16 +88,16 @@ impl Tool for Bash {
|
||||
Ok(Some(status)) => {
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
let output = child.wait_with_output()
|
||||
.map_err(|e| anyhow!("failed to collect output: {}", e))?;
|
||||
.map_err(|e| anyhow!("failed to collect output: {e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) };
|
||||
let combined = if stderr.is_empty() { stdout } else { format!("{stdout}\n{stderr}") };
|
||||
let trimmed = combined.trim().to_string();
|
||||
if status.success() {
|
||||
return Ok(if trimmed.is_empty() {
|
||||
format!("Command completed in {:.2}s (exit code 0)", elapsed)
|
||||
format!("Command completed in {elapsed:.2}s (exit code 0)")
|
||||
} else {
|
||||
format!("{}\n\nExit code: 0 ({:.2}s)", trimmed, elapsed)
|
||||
format!("{trimmed}\n\nExit code: 0 ({elapsed:.2}s)")
|
||||
});
|
||||
}
|
||||
return Ok(format!("{}\n\nExit code: {} ({:.2}s)", trimmed, status.code().unwrap_or(-1), elapsed));
|
||||
@@ -106,12 +106,12 @@ impl Tool for Bash {
|
||||
if start.elapsed() > timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
anyhow::bail!("command timed out after {}ms", timeout_ms);
|
||||
anyhow::bail!("command timed out after {timeout_ms}ms");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(e) => {
|
||||
anyhow::bail!("failed to wait for command: {}", e);
|
||||
anyhow::bail!("failed to wait for command: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ pub fn check_git_destructive(cmd: &str) -> Result<()> {
|
||||
let cmd_normalized = super::normalize_ansi_c_quoting(&cmd_no_quotes);
|
||||
for pattern in &patterns {
|
||||
if cmd_lower.contains(pattern) || cmd_no_quotes.contains(pattern) || cmd_normalized.contains(pattern) {
|
||||
anyhow::bail!("destructive git operation blocked: '{}'", pattern);
|
||||
anyhow::bail!("destructive git operation blocked: '{pattern}'");
|
||||
}
|
||||
}
|
||||
// Additional check: any `+` prefixed refspec in a `git push` is a
|
||||
@@ -69,7 +69,7 @@ pub fn check_git_destructive(cmd: &str) -> Result<()> {
|
||||
&cmd_no_quotes
|
||||
};
|
||||
if check_push.contains("push") {
|
||||
let push_end = cmd_no_quotes.find("push").map(|i| i + 4).unwrap_or(0);
|
||||
let push_end = cmd_no_quotes.find("push").map_or(0, |i| i + 4);
|
||||
let after_push = &cmd_no_quotes[push_end..];
|
||||
if after_push.contains('+') {
|
||||
anyhow::bail!("destructive git operation blocked: force push via +refspec");
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(crate) fn normalize_ansi_c_quoting(input: &str) -> String {
|
||||
Some('\'') => decoded.push('\''),
|
||||
Some('x' | 'X') => {
|
||||
// \xHH — hex escape (2 hex digits)
|
||||
let hex: String = chars.by_ref().take(2).take_while(|c| c.is_ascii_hexdigit()).collect();
|
||||
let hex: String = chars.by_ref().take(2).take_while(char::is_ascii_hexdigit).collect();
|
||||
if hex.len() == 2 {
|
||||
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
|
||||
decoded.push(byte as char);
|
||||
@@ -47,7 +47,7 @@ pub(crate) fn normalize_ansi_c_quoting(input: &str) -> String {
|
||||
}
|
||||
Some('u') => {
|
||||
// \uNNNN — unicode escape (4 hex digits)
|
||||
let hex: String = chars.by_ref().take(4).take_while(|c| c.is_ascii_hexdigit()).collect();
|
||||
let hex: String = chars.by_ref().take(4).take_while(char::is_ascii_hexdigit).collect();
|
||||
if hex.len() == 4 {
|
||||
if let Ok(code) = u32::from_str_radix(&hex, 16) {
|
||||
if let Some(c) = char::from_u32(code) {
|
||||
|
||||
+25
-22
@@ -51,12 +51,13 @@ impl Tool for SpawnAgents {
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let agents: Vec<String> = args.get("agents")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow!("missing required argument: agents"))?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.filter_map(|v| v.as_str().map(std::string::ToString::to_string))
|
||||
.collect();
|
||||
|
||||
if agents.is_empty() {
|
||||
@@ -67,9 +68,8 @@ impl Tool for SpawnAgents {
|
||||
}
|
||||
|
||||
let max_concurrency = args.get("max_concurrency")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v.min(10) as usize)
|
||||
.unwrap_or(10);
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.map_or(10, |v| v.min(10) as usize);
|
||||
|
||||
let agent_count = agents.len();
|
||||
let primitives: Vec<ScriptPrimitive> = agents
|
||||
@@ -78,8 +78,8 @@ impl Tool for SpawnAgents {
|
||||
.collect();
|
||||
|
||||
let wf = WorkflowScript {
|
||||
name: format!("parallel-{}-agents", agent_count),
|
||||
description: format!("Auto-spawned parallel workflow with {} agents", agent_count),
|
||||
name: format!("parallel-{agent_count}-agents"),
|
||||
description: format!("Auto-spawned parallel workflow with {agent_count} agents"),
|
||||
script: ScriptPrimitive::Parallel(primitives),
|
||||
options: ScriptOptions {
|
||||
max_concurrency,
|
||||
@@ -88,8 +88,7 @@ impl Tool for SpawnAgents {
|
||||
},
|
||||
};
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
let live: Option<crate::app::workflow::engine::LiveStateFn> = _ctx.turn_events.as_ref().map(|turn_events| {
|
||||
let live: Option<crate::app::workflow::engine::LiveStateFn> = ctx.turn_events.as_ref().map(|turn_events| {
|
||||
let turn_events = turn_events.clone();
|
||||
let f: crate::app::workflow::engine::LiveStateFn = Arc::new(move |agent_id: String, agent_name: String, status| {
|
||||
if let Ok(mut q) = turn_events.lock() {
|
||||
@@ -107,18 +106,20 @@ impl Tool for SpawnAgents {
|
||||
// by this tool call are isolated from any other concurrent
|
||||
// spawn_agents or workflow_run invocations.
|
||||
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
|
||||
let results = crate::app::workflow::engine::execute_primitive(
|
||||
&wf.script,
|
||||
&HashMap::new(),
|
||||
max_concurrency,
|
||||
true,
|
||||
&no_abort,
|
||||
live.as_ref(),
|
||||
&_ctx.session_dir,
|
||||
&_ctx.workspaces,
|
||||
&ctx.session_dir,
|
||||
&ctx.workspaces,
|
||||
&findings,
|
||||
None, // no per-agent timeout for spawn_agents
|
||||
)?;
|
||||
format_results(results, "parallel")
|
||||
Ok(format_results(&results, "parallel"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,12 +151,13 @@ impl Tool for SpawnPipeline {
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let stages: Vec<String> = args.get("stages")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow!("missing required argument: stages"))?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.filter_map(|v| v.as_str().map(std::string::ToString::to_string))
|
||||
.collect();
|
||||
|
||||
if stages.is_empty() {
|
||||
@@ -178,8 +180,7 @@ impl Tool for SpawnPipeline {
|
||||
},
|
||||
};
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
let live: Option<crate::app::workflow::engine::LiveStateFn> = _ctx.turn_events.as_ref().map(|turn_events| {
|
||||
let live: Option<crate::app::workflow::engine::LiveStateFn> = ctx.turn_events.as_ref().map(|turn_events| {
|
||||
let turn_events = turn_events.clone();
|
||||
let f: crate::app::workflow::engine::LiveStateFn = Arc::new(move |agent_id: String, agent_name: String, status| {
|
||||
if let Ok(mut q) = turn_events.lock() {
|
||||
@@ -196,30 +197,32 @@ impl Tool for SpawnPipeline {
|
||||
// Per-invocation findings scope isolates this pipeline from any
|
||||
// other concurrent spawn_agents / spawn_pipeline / workflow_run.
|
||||
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
|
||||
let results = crate::app::workflow::engine::execute_primitive(
|
||||
&wf.script,
|
||||
&HashMap::new(),
|
||||
1,
|
||||
false,
|
||||
&no_abort,
|
||||
live.as_ref(),
|
||||
&_ctx.session_dir,
|
||||
&_ctx.workspaces,
|
||||
&ctx.session_dir,
|
||||
&ctx.workspaces,
|
||||
&findings,
|
||||
None, // no per-agent timeout for spawn_pipeline
|
||||
)?;
|
||||
format_results(results, "pipeline")
|
||||
Ok(format_results(&results, "pipeline"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a list of agent results into a readable summary string.
|
||||
fn format_results(results: Vec<String>, mode: &str) -> Result<String> {
|
||||
fn format_results(results: &[String], mode: &str) -> String {
|
||||
if results.is_empty() {
|
||||
return Ok(format!("{} workflow completed with no output", mode));
|
||||
return format!("{mode} workflow completed with no output");
|
||||
}
|
||||
let formatted: Vec<String> = results
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| format!("=== Agent {} ===\n{}", i + 1, r.trim()))
|
||||
.collect();
|
||||
Ok(formatted.join("\n\n"))
|
||||
formatted.join("\n\n")
|
||||
}
|
||||
|
||||
@@ -69,19 +69,19 @@ impl Tool for DirCacheUpdate {
|
||||
// Create a one-shot runtime so this tool works from any thread (the
|
||||
// agent turn runs on a std::thread that has no tokio context).
|
||||
let rt = tokio::runtime::Runtime::new()
|
||||
.map_err(|e| anyhow!("failed to create temp runtime: {}", e))?;
|
||||
.map_err(|e| anyhow!("failed to create temp runtime: {e}"))?;
|
||||
rt.block_on(async {
|
||||
let cache = dc.write().await;
|
||||
cache.set(entries).await;
|
||||
});
|
||||
|
||||
Ok(format!("cached {} entries for {}", count, rel))
|
||||
Ok(format!("cached {count} entries for {rel}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-recursively list the immediate entries of `path`.
|
||||
///
|
||||
/// Flow: read_dir → flatten Ok entries → collect their paths.
|
||||
/// Flow: `read_dir` → flatten Ok entries → collect their paths.
|
||||
///
|
||||
/// Why: silently skips unreadable entries (e.g. permission errors)
|
||||
/// rather than failing the whole cache update.
|
||||
|
||||
@@ -67,13 +67,13 @@ impl Tool for DirList {
|
||||
}
|
||||
|
||||
let entries: Vec<String> = fs::read_dir(&path)
|
||||
.map_err(|e| anyhow!("failed to read directory '{}': {}", rel, e))?
|
||||
.filter_map(|e| e.ok())
|
||||
.map_err(|e| anyhow!("failed to read directory '{rel}': {e}"))?
|
||||
.filter_map(std::result::Result::ok)
|
||||
.map(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||
let is_dir = e.file_type().is_ok_and(|t| t.is_dir());
|
||||
if is_dir {
|
||||
format!("{}/", name)
|
||||
format!("{name}/")
|
||||
} else {
|
||||
name
|
||||
}
|
||||
|
||||
@@ -39,6 +39,6 @@ impl Tool for Pong {
|
||||
let msg = args.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("pong");
|
||||
Ok(format!("pong: {}", msg))
|
||||
Ok(format!("pong: {msg}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@ impl Tool for Todofinish {
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.map_err(|e| anyhow!("failed to read todo.md: {}", e))?;
|
||||
.map_err(|e| anyhow!("failed to read todo.md: {e}"))?;
|
||||
|
||||
let task_index = args.get("task_index").and_then(|v| v.as_i64());
|
||||
let task_index = args.get("task_index").and_then(serde_json::Value::as_i64);
|
||||
|
||||
let mut new_content = String::new();
|
||||
let mut task_count = 0;
|
||||
@@ -70,10 +70,10 @@ impl Tool for Todofinish {
|
||||
}
|
||||
|
||||
std::fs::write(&path, new_content)
|
||||
.map_err(|e| anyhow!("failed to write to todo.md: {}", e))?;
|
||||
.map_err(|e| anyhow!("failed to write to todo.md: {e}"))?;
|
||||
|
||||
if let Some(idx) = task_index {
|
||||
Ok(format!("Successfully marked task {} as finished.", idx))
|
||||
Ok(format!("Successfully marked task {idx} as finished."))
|
||||
} else {
|
||||
Ok("Successfully marked ALL tasks as finished.".to_string())
|
||||
}
|
||||
|
||||
@@ -59,17 +59,17 @@ impl Tool for Todowrite {
|
||||
let path: PathBuf = ctx.session_dir.join("todo.md");
|
||||
let now = chrono::Utc::now();
|
||||
let timestamp = now.format("%Y-%m-%d %H:%M:%S");
|
||||
let line = format!("- [ ] {} ({})\n", task, timestamp);
|
||||
let line = format!("- [ ] {task} ({timestamp})\n");
|
||||
|
||||
fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.map_err(|e| anyhow!("failed to open todo.md: {}", e))?
|
||||
.map_err(|e| anyhow!("failed to open todo.md: {e}"))?
|
||||
.write_all(line.as_bytes())
|
||||
.map_err(|e| anyhow!("failed to write to todo.md: {}", e))?;
|
||||
.map_err(|e| anyhow!("failed to write to todo.md: {e}"))?;
|
||||
|
||||
Ok(format!("added task to todo.md: {}", task))
|
||||
Ok(format!("added task to todo.md: {task}"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+86
-11
@@ -58,14 +58,14 @@ impl Tool for WorkflowRun {
|
||||
///
|
||||
/// Return: the workflow engine's output string, or an error if the
|
||||
/// script argument is missing or fails to parse as JSON.
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let script_str = args.get("script")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: script"))?;
|
||||
|
||||
let workflow_script: crate::app::workflow::script::WorkflowScript =
|
||||
serde_json::from_str(script_str)
|
||||
.map_err(|e| anyhow!("failed to parse workflow script: {}", e))?;
|
||||
.map_err(|e| anyhow!("failed to parse workflow script: {e}"))?;
|
||||
|
||||
let workflow_args: std::collections::HashMap<String, String> = args.get("args")
|
||||
.and_then(|v| v.as_object())
|
||||
@@ -77,7 +77,7 @@ impl Tool for WorkflowRun {
|
||||
.unwrap_or_default();
|
||||
|
||||
crate::app::workflow::engine::run_workflow(
|
||||
&workflow_script, &workflow_args, &_ctx.session_dir, &_ctx.workspaces,
|
||||
&workflow_script, &workflow_args, &ctx.session_dir, &ctx.workspaces,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -171,13 +171,26 @@ impl Tool for CompanyPipeline {
|
||||
"enum": ["full", "quick"],
|
||||
"description": "Pipeline mode: 'full' (5 divisions) for complex tasks, 'quick' (3 divisions: Strategy→Engineering→Quality) for simpler tasks",
|
||||
"default": "full"
|
||||
},
|
||||
"specialists": {
|
||||
"type": "object",
|
||||
"description": "Mapping from division name (Strategy, Engineering, Quality, Security, Documentation) to list of custom specialists. Each specialist is defined by a pair of [label, focus_description]. This parameter is mandatory. The CEO/main agent must fully define the specialized roles and focuses for every division in the pipeline to run.",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["request"]
|
||||
"required": ["request", "specialists"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let request = args.get("request")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: request"))?;
|
||||
@@ -186,23 +199,85 @@ impl Tool for CompanyPipeline {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("full");
|
||||
|
||||
let custom_specialists: std::collections::HashMap<String, Vec<(String, String)>> = args.get("specialists")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter().map(|(k, v)| {
|
||||
let specs = v.as_array().map(|arr| {
|
||||
arr.iter().filter_map(|item| {
|
||||
let pair = item.as_array()?;
|
||||
let label = pair.first()?.as_str()?.to_string();
|
||||
let focus = pair.get(1)?.as_str()?.to_string();
|
||||
Some((label, focus))
|
||||
}).collect()
|
||||
}).unwrap_or_default();
|
||||
(k.clone(), specs)
|
||||
}).collect()
|
||||
})
|
||||
.ok_or_else(|| anyhow!("missing required argument: specialists"))?;
|
||||
|
||||
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
|
||||
match mode {
|
||||
"quick" => {
|
||||
crate::app::workflow::company::run_company_pipeline_quick(
|
||||
request,
|
||||
&_ctx.session_dir,
|
||||
&_ctx.workspaces,
|
||||
_ctx.turn_events.as_ref(),
|
||||
&ctx.session_dir,
|
||||
&ctx.workspaces,
|
||||
ctx.turn_events.as_ref(),
|
||||
&no_abort,
|
||||
&custom_specialists,
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
crate::app::workflow::company::run_company_pipeline(
|
||||
request,
|
||||
&_ctx.session_dir,
|
||||
&_ctx.workspaces,
|
||||
_ctx.turn_events.as_ref(),
|
||||
&ctx.session_dir,
|
||||
&ctx.workspaces,
|
||||
ctx.turn_events.as_ref(),
|
||||
&no_abort,
|
||||
&custom_specialists,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool that retrieves all findings shared by sibling agents in the current workflow run.
|
||||
pub struct ReadFindings;
|
||||
|
||||
impl Tool for ReadFindings {
|
||||
fn name(&self) -> &'static str {
|
||||
"read_findings"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Retrieve all findings shared by sibling agents in the current workflow run. Use this to get real-time context updates from other divisions/subagents working in parallel."
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, _args: &Value) -> Result<String> {
|
||||
if let Some(ref findings) = ctx.workflow_findings {
|
||||
let f = findings.lock().map_err(|e| anyhow!("poisoned lock: {e}"))?;
|
||||
if f.is_empty() {
|
||||
Ok("No findings recorded yet in this workflow run.".to_string())
|
||||
} else {
|
||||
let formatted = f
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, f)| format!("{}. {}", i + 1, f))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Ok(format!("Findings in this workflow run:\n{formatted}"))
|
||||
}
|
||||
} else {
|
||||
Ok("No findings database available (called outside a workflow run).".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-6
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Chat transcript panel rendering — message cards with role badges.
|
||||
//!
|
||||
//! Flow: `draw_chat` turns `state.transcript_cache.messages` into a
|
||||
@@ -19,7 +20,7 @@ use ratatui::Frame;
|
||||
use super::theme::Theme;
|
||||
|
||||
/// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries.
|
||||
fn split_spans_into_lines<'a>(spans: Vec<Span<'a>>) -> Vec<Line<'a>> {
|
||||
fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
|
||||
let mut lines = Vec::new();
|
||||
let mut current_spans = Vec::new();
|
||||
|
||||
@@ -76,10 +77,11 @@ fn format_timestamp(ts: i64) -> String {
|
||||
let secs = ts / 1000;
|
||||
let mins = (secs / 60) % 60;
|
||||
let hrs = (secs / 3600) % 24;
|
||||
format!("{:02}:{:02}", hrs, mins)
|
||||
format!("{hrs:02}:{mins:02}")
|
||||
}
|
||||
|
||||
/// Render the scrollable chat transcript panel with message card styling.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
||||
let messages = &state.transcript_cache.messages;
|
||||
let scroll_offset = state.scroll.offset;
|
||||
@@ -95,7 +97,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
|
||||
};
|
||||
|
||||
// ── Render messages as cards ─────────────────────────────────────────
|
||||
for (_msg_idx, msg) in messages.iter().enumerate() {
|
||||
for msg in messages {
|
||||
let accent = role_accent_color(&msg.role);
|
||||
let badge = role_badge(&msg.role);
|
||||
let label = role_label(&msg.role);
|
||||
@@ -119,12 +121,12 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
|
||||
),
|
||||
// Role name
|
||||
Span::styled(
|
||||
format!(" {}", label),
|
||||
format!(" {label}"),
|
||||
Style::default().fg(accent).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
// Timestamp
|
||||
Span::styled(
|
||||
if ts_str.is_empty() { String::new() } else { format!(" {}", ts_str) },
|
||||
if ts_str.is_empty() { String::new() } else { format!(" {ts_str}") },
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
),
|
||||
]);
|
||||
@@ -176,7 +178,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {} ", spinner),
|
||||
format!(" {spinner} "),
|
||||
Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
|
||||
+11
-25
@@ -14,12 +14,13 @@ use super::theme::Theme;
|
||||
|
||||
/// Render a markdown string into styled terminal spans, word-wrapped to `width`.
|
||||
///
|
||||
/// Flow: pulldown_cmark parses `text` into an event stream → each
|
||||
/// Flow: `pulldown_cmark` parses `text` into an event stream → each
|
||||
/// Start/End/Text/Code/Break event is translated into styled `Span`s →
|
||||
/// if `width > 0`, a second pass wraps long lines.
|
||||
///
|
||||
/// Return: a flat vec of styled spans; `chat::split_spans_into_lines`
|
||||
/// turns it back into `Line`s for the Paragraph widget.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
||||
let mut spans = Vec::new();
|
||||
let parser = pulldown_cmark::Parser::new(text);
|
||||
@@ -61,9 +62,6 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
||||
pulldown_cmark::Tag::Paragraph => {
|
||||
first_in_paragraph = true;
|
||||
}
|
||||
pulldown_cmark::Tag::Emphasis => {}
|
||||
pulldown_cmark::Tag::Strong => {}
|
||||
pulldown_cmark::Tag::List(_) => {}
|
||||
pulldown_cmark::Tag::Item => {
|
||||
// List item bullet
|
||||
spans.push(Span::styled(
|
||||
@@ -79,7 +77,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
||||
// We push the URL as a tooltip-like suffix
|
||||
// After the link text ends, we'll add the URL
|
||||
spans.push(Span::styled(
|
||||
format!("]({})", dest_url),
|
||||
format!("]({dest_url})"),
|
||||
Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
}
|
||||
@@ -111,14 +109,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
||||
first_in_paragraph = true;
|
||||
spans.push(Span::raw("\n\n"));
|
||||
}
|
||||
pulldown_cmark::TagEnd::Emphasis => {}
|
||||
pulldown_cmark::TagEnd::Strong => {}
|
||||
pulldown_cmark::TagEnd::List(_) => {}
|
||||
pulldown_cmark::TagEnd::Item => {
|
||||
spans.push(Span::raw("\n"));
|
||||
}
|
||||
pulldown_cmark::TagEnd::Link => {}
|
||||
pulldown_cmark::TagEnd::BlockQuote(_) => {
|
||||
pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => {
|
||||
spans.push(Span::raw("\n"));
|
||||
}
|
||||
_ => {}
|
||||
@@ -128,7 +119,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
||||
let s = text.to_string();
|
||||
if in_code_block {
|
||||
spans.push(Span::styled(
|
||||
format!(" {}", s),
|
||||
format!(" {s}"),
|
||||
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG),
|
||||
));
|
||||
} else if in_heading {
|
||||
@@ -138,14 +129,9 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
||||
3 => Theme::ACCENT_PURPLE,
|
||||
_ => Theme::TEXT,
|
||||
};
|
||||
let prefix = match heading_level {
|
||||
1 => " ",
|
||||
2 => " ",
|
||||
3 => " ",
|
||||
_ => " ",
|
||||
};
|
||||
let prefix = " ";
|
||||
spans.push(Span::styled(
|
||||
format!("{}{}", prefix, s),
|
||||
format!("{prefix}{s}"),
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
} else {
|
||||
@@ -160,7 +146,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
||||
pulldown_cmark::Event::Code(text) => {
|
||||
// Inline code with background
|
||||
spans.push(Span::styled(
|
||||
format!(" {} ", text),
|
||||
format!(" {text} "),
|
||||
Style::default()
|
||||
.fg(Theme::ACCENT_TEAL)
|
||||
.bg(Theme::CODE_BAR)
|
||||
@@ -195,10 +181,10 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
||||
|
||||
spans_out.push(Span::styled(text_str.to_string(), style));
|
||||
|
||||
if !text_str.contains('\n') {
|
||||
line_len += remaining;
|
||||
} else {
|
||||
if text_str.contains('\n') {
|
||||
line_len = text_str.split('\n').next_back().unwrap_or("").len();
|
||||
} else {
|
||||
line_len += remaining;
|
||||
}
|
||||
}
|
||||
spans = spans_out;
|
||||
|
||||
+39
-39
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Top-level TUI render pipeline: layouts the terminal into chat / input
|
||||
//! / status regions, dispatches overlay rendering with glassmorphism-style
|
||||
//! centered panels, and floats toast notifications over the top-right corner.
|
||||
@@ -124,6 +125,7 @@ fn render_main_panel(
|
||||
/// - A top accent border strip (colored per variant)
|
||||
/// - A title line with icon
|
||||
/// - Content area with proper spacing
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn render_overlay(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
@@ -172,12 +174,12 @@ fn render_overlay(
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Max tokens: {}",
|
||||
state.settings.max_tokens.map(|v| v.to_string()).unwrap_or_else(|| "auto".to_string())),
|
||||
state.settings.max_tokens.map_or_else(|| "auto".to_string(), |v| v.to_string())),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Temperature: {}",
|
||||
state.settings.temperature.map(|v| format!("{:.1}", v)).unwrap_or_else(|| "auto".to_string())),
|
||||
state.settings.temperature.map_or_else(|| "auto".to_string(), |v| format!("{v:.1}"))),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
@@ -261,11 +263,11 @@ fn render_overlay(
|
||||
input_text.as_str()
|
||||
}
|
||||
};
|
||||
let masked = if !input_text.is_empty() {
|
||||
let suffix = if input_text.len() > 8 { "****" } else { "" };
|
||||
format!("{}{}", display, suffix)
|
||||
} else {
|
||||
let masked = if input_text.is_empty() {
|
||||
display.to_string()
|
||||
} else {
|
||||
let suffix = if input_text.len() > 8 { "****" } else { "" };
|
||||
format!("{display}{suffix}")
|
||||
};
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
@@ -329,9 +331,9 @@ fn render_overlay(
|
||||
let selected = i == current_idx;
|
||||
lines.push(Line::from(Span::styled(
|
||||
if selected {
|
||||
format!(" ▸ {} (active)", l)
|
||||
format!(" ▸ {l} (active)")
|
||||
} else {
|
||||
format!(" {}", l)
|
||||
format!(" {l}")
|
||||
},
|
||||
if selected {
|
||||
Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD)
|
||||
@@ -386,7 +388,7 @@ fn render_overlay(
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
format!(" Messages: {}", msg_count),
|
||||
format!(" Messages: {msg_count}"),
|
||||
Style::default().fg(Theme::INFO),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
@@ -427,7 +429,7 @@ fn render_overlay(
|
||||
};
|
||||
let preview: String = msg.content.chars().take(70).collect();
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" [{}] {}", role_str, preview),
|
||||
format!(" [{role_str}] {preview}"),
|
||||
Style::default().fg(
|
||||
if matches!(msg.role, crate::dto::chat::message::Role::User) {
|
||||
Theme::INFO
|
||||
@@ -484,7 +486,7 @@ fn render_overlay(
|
||||
let (label, style) = match item {
|
||||
crate::app::mode::learning::LearningItem::Pending { name, .. } => {
|
||||
(
|
||||
format!("{}[Pending] {}", prefix, name),
|
||||
format!("{prefix}[Pending] {name}"),
|
||||
if is_selected {
|
||||
Style::default().fg(Theme::WARNING).bg(Theme::HIGHLIGHT_DIM)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
@@ -496,7 +498,7 @@ fn render_overlay(
|
||||
crate::app::mode::learning::LearningItem::Stored { name, lifecycle, .. } => {
|
||||
let status = if lifecycle == "stale" { "Stale" } else { "Active" };
|
||||
(
|
||||
format!("{}[{}] {}", prefix, status, name),
|
||||
format!("{prefix}[{status}] {name}"),
|
||||
if is_selected {
|
||||
Style::default().fg(Theme::TEXT).bg(Theme::HIGHLIGHT_DIM)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
@@ -539,7 +541,7 @@ fn render_overlay(
|
||||
" Name:", Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" {}", name),
|
||||
format!(" {name}"),
|
||||
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::raw("")));
|
||||
@@ -548,11 +550,11 @@ fn render_overlay(
|
||||
Style::default().fg(Theme::WARNING),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" Scope: {}", scope),
|
||||
format!(" Scope: {scope}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" Confidence: {}", confidence),
|
||||
format!(" Confidence: {confidence}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::raw("")));
|
||||
@@ -561,7 +563,7 @@ fn render_overlay(
|
||||
)));
|
||||
for line in content.lines() {
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" {}", line),
|
||||
format!(" {line}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
}
|
||||
@@ -578,7 +580,7 @@ fn render_overlay(
|
||||
" Name:", Style::default().fg(Theme::TEXT_DIM),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" {}", name),
|
||||
format!(" {name}"),
|
||||
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::raw("")));
|
||||
@@ -588,15 +590,15 @@ fn render_overlay(
|
||||
Theme::SUCCESS
|
||||
};
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" Status: {}", lifecycle),
|
||||
format!(" Status: {lifecycle}"),
|
||||
Style::default().fg(status_color),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" Scope: {}", scope),
|
||||
format!(" Scope: {scope}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" Description: {}", description),
|
||||
format!(" Description: {description}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
right_lines.push(Line::from(Span::raw("")));
|
||||
@@ -605,7 +607,7 @@ fn render_overlay(
|
||||
)));
|
||||
for line in content.lines() {
|
||||
right_lines.push(Line::from(Span::styled(
|
||||
format!(" {}", line),
|
||||
format!(" {line}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)));
|
||||
}
|
||||
@@ -635,7 +637,7 @@ fn render_overlay(
|
||||
.border_style(Style::default().fg(Theme::INFO));
|
||||
let runtime = state.session_runtime.as_ref();
|
||||
let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = runtime
|
||||
.map(|r| {
|
||||
.map_or((0, 0, 0, 0, 0), |r| {
|
||||
(
|
||||
r.usage.tokens_in,
|
||||
r.usage.tokens_out,
|
||||
@@ -643,18 +645,16 @@ fn render_overlay(
|
||||
r.usage.review_tokens,
|
||||
r.session_start,
|
||||
)
|
||||
})
|
||||
.unwrap_or((0, 0, 0, 0, 0));
|
||||
});
|
||||
let (edit_count, lesson_count, review_count, consec_empty) = runtime
|
||||
.map(|r| {
|
||||
.map_or((0, 0, 0, 0), |r| {
|
||||
(
|
||||
r.edit_count,
|
||||
r.lesson_count,
|
||||
r.review_count,
|
||||
r.consecutive_empty_reviews,
|
||||
)
|
||||
})
|
||||
.unwrap_or((0, 0, 0, 0));
|
||||
});
|
||||
let elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start);
|
||||
let hours = elapsed_ms / 3_600_000;
|
||||
let minutes = (elapsed_ms % 3_600_000) / 60_000;
|
||||
@@ -669,19 +669,19 @@ fn render_overlay(
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
format!(" Main agent: {} tokens", main_tokens),
|
||||
format!(" Main agent: {main_tokens} tokens"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Self-learning: {} tokens", self_learning_total),
|
||||
format!(" Self-learning: {self_learning_total} tokens"),
|
||||
Style::default().fg(Theme::TEXT_MUTED),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Total: {} tokens", total_tokens),
|
||||
format!(" Total: {total_tokens} tokens"),
|
||||
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" API calls: {}", api_calls),
|
||||
format!(" API calls: {api_calls}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
@@ -690,21 +690,21 @@ fn render_overlay(
|
||||
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Edits: {}", edit_count),
|
||||
format!(" Edits: {edit_count}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Reviews: {}", review_count),
|
||||
format!(" Reviews: {review_count}"),
|
||||
Style::default().fg(Theme::TEXT),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Lessons: {}", lesson_count),
|
||||
format!(" Lessons: {lesson_count}"),
|
||||
Style::default().fg(Theme::TEXT_MUTED),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
format!(" Empty reviews: {}",
|
||||
if consec_empty > 3 {
|
||||
format!("{} ⚠", consec_empty)
|
||||
format!("{consec_empty} ⚠")
|
||||
} else {
|
||||
consec_empty.to_string()
|
||||
},
|
||||
@@ -713,7 +713,7 @@ fn render_overlay(
|
||||
)),
|
||||
Line::from(Span::raw("")),
|
||||
Line::from(Span::styled(
|
||||
format!(" Session: {}h {}m {}s", hours, minutes, seconds),
|
||||
format!(" Session: {hours}h {minutes}m {seconds}s"),
|
||||
Style::default().fg(Theme::TEXT_DIM),
|
||||
)),
|
||||
];
|
||||
@@ -757,7 +757,7 @@ fn render_overlay(
|
||||
let is_selected = i == state.misc.selected_index;
|
||||
let prefix = if is_selected { " ▸ " } else { " " };
|
||||
let model_str = cfg.default_model.as_deref().unwrap_or("(any)");
|
||||
let label = format!("{}{} ({})", prefix, name, model_str);
|
||||
let label = format!("{prefix}{name} ({model_str})");
|
||||
let style = if is_current {
|
||||
Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD)
|
||||
} else if is_selected {
|
||||
@@ -842,7 +842,7 @@ fn render_input_bar(
|
||||
} else {
|
||||
Style::default().fg(Theme::TEXT)
|
||||
};
|
||||
let label = format!("{}{}", prefix, candidate);
|
||||
let label = format!("{prefix}{candidate}");
|
||||
lines.push(Line::from(Span::styled(label, style)));
|
||||
}
|
||||
let dropdown = Paragraph::new(lines).block(dropdown_block);
|
||||
@@ -901,7 +901,7 @@ fn render_input_bar(
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Render active toasts as a floating stack at top-right of the terminal.
|
||||
/// Each toast auto-expires after its lifetime_ms. Max 4 visible at once.
|
||||
/// Each toast auto-expires after its `lifetime_ms`. Max 4 visible at once.
|
||||
///
|
||||
/// Toasts are stacked vertically with a 1-line gap. Each has a colored
|
||||
/// left border and a subtle background.
|
||||
|
||||
+8
-7
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Status bar rendering for the TUI — modern segmented bar design.
|
||||
//!
|
||||
//! Flow: `draw_status_bar` reads live connection/turn state off
|
||||
@@ -20,14 +21,15 @@ use super::theme::Theme;
|
||||
/// Layout (left-to-right, space-filling):
|
||||
/// LEFT: [zesdex] + status indicator (READY/PROG/NOAPI)
|
||||
/// CENTER: spinner + optional contextual info
|
||||
/// RIGHT: provider · model · ↑tokens_in ↓tokens_out
|
||||
/// RIGHT: provider · model · ↑`tokens_in` ↓`tokens_out`
|
||||
pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
// ── Agent status badge ────────────────────────────────────────────────
|
||||
let (status_text, badge_bg, status_fg) = if state.turn_in_flight() {
|
||||
let f = spinner_frames[(state.misc.tick_count as usize / 2) % spinner_frames.len()];
|
||||
(format!(" {} PROG ", f), Theme::MODE_YOLO, Theme::BG)
|
||||
(format!(" {f} PROG "), Theme::MODE_YOLO, Theme::BG)
|
||||
} else if state.misc.api_connected {
|
||||
(" READY ".to_string(), Theme::MODE_AUTO, Theme::BG)
|
||||
} else {
|
||||
@@ -61,7 +63,7 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
|
||||
|
||||
let total_chars: usize = rt.messages.iter()
|
||||
.filter_map(|m| m.content.as_deref())
|
||||
.map(|c| c.len())
|
||||
.map(str::len)
|
||||
.sum();
|
||||
let current_tokens = total_chars / 4;
|
||||
|
||||
@@ -69,8 +71,8 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
|
||||
if rt.usage.last_tokens_in > 0 || rt.usage.last_tokens_out > 0 {
|
||||
parts.push(format!("↑{} ↓{}", rt.usage.last_tokens_in, rt.usage.last_tokens_out));
|
||||
}
|
||||
let max_str = max_tokens.map(|v| v.to_string()).unwrap_or_else(|| "?".to_string());
|
||||
parts.push(format!("{}/{}", current_tokens, max_str));
|
||||
let max_str = max_tokens.map_or_else(|| "?".to_string(), |v| v.to_string());
|
||||
parts.push(format!("{current_tokens}/{max_str}"));
|
||||
parts.push(state.settings.provider.clone());
|
||||
parts.push(state.settings.model.clone());
|
||||
|
||||
@@ -79,7 +81,7 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
|
||||
let max_tokens = state.app_config.model_roles.values()
|
||||
.find(|role| role.provider == state.settings.provider && role.model == state.settings.model)
|
||||
.and_then(|role| role.context_window);
|
||||
let max_str = max_tokens.map(|v| v.to_string()).unwrap_or_else(|| "?".to_string());
|
||||
let max_str = max_tokens.map_or_else(|| "?".to_string(), |v| v.to_string());
|
||||
format!(" 0/{} · {} · {} ", max_str, state.settings.provider, state.settings.model)
|
||||
};
|
||||
|
||||
@@ -92,7 +94,6 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
|
||||
));
|
||||
|
||||
// Render the bar using two columns
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
|
||||
@@ -55,6 +55,7 @@ fn is_company_pipeline(agents: &[crate::app::workflow::engine::WorkflowAgent]) -
|
||||
}
|
||||
|
||||
/// Render the workflow status panel.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
|
||||
@@ -179,7 +180,7 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st
|
||||
// Agent card header
|
||||
card_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {} ", icon),
|
||||
format!(" {icon} "),
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
@@ -187,7 +188,7 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st
|
||||
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" [{}]", label),
|
||||
format!(" [{label}]"),
|
||||
Style::default().fg(color),
|
||||
),
|
||||
Span::styled(
|
||||
@@ -240,18 +241,18 @@ fn build_session_lines(state: &crate::app::state::rest::AppStateRest) -> Vec<Lin
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Tool calls", Style::default().fg(Theme::TEXT_DIM)),
|
||||
Span::styled(format!(" {}", tool_count), Style::default().fg(Theme::SUCCESS)),
|
||||
Span::styled(format!(" {tool_count}"), Style::default().fg(Theme::SUCCESS)),
|
||||
]));
|
||||
if pending > 0 {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Pending ", Style::default().fg(Theme::TEXT_DIM)),
|
||||
Span::styled(format!(" {}", pending), Style::default().fg(Theme::WARNING)),
|
||||
Span::styled(format!(" {pending}"), Style::default().fg(Theme::WARNING)),
|
||||
]));
|
||||
}
|
||||
if bash_count > 0 {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Bash jobs ", Style::default().fg(Theme::TEXT_DIM)),
|
||||
Span::styled(format!(" {}", bash_count), Style::default().fg(Theme::WARNING)),
|
||||
Span::styled(format!(" {bash_count}"), Style::default().fg(Theme::WARNING)),
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user