Refactor view modules for improved readability and consistency
- Updated markdown rendering logic to use more concise methods for obtaining vector lengths. - Changed review status display to use the correct flag from settings. - Cleaned up sidebar rendering code for better formatting and readability. - Enhanced status bar rendering with improved string formatting and consistent style application. - Refined workflow panel rendering, ensuring consistent style usage and improved readability. - Added architecture overview and detailed documentation for backend, data, dependencies, and frontend structures.
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
# Architecture Overview
|
||||||
|
|
||||||
|
## System Layout
|
||||||
|
|
||||||
|
Zesdex is an autonomous AI coding agent with a TUI — an LLM client wrapped in a tool-use harness with 37 built-in tools.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Process Mode │
|
||||||
|
│ Single-Process ─── Daemon (background) ─── Attach (client) │
|
||||||
|
└──────────────────────────┬──────────────────────────────────┘
|
||||||
|
│ IPC (Unix domain socket)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ src/main.rs │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │
|
||||||
|
│ │ Controller │──▶│ Runtime │──▶│ View │ │
|
||||||
|
│ │ (input.rs) │ │ (actions.rs) │ │ (chat,status,…)│ │
|
||||||
|
│ └──────────────┘ └──────┬───────┘ └────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────▼────────┐ │
|
||||||
|
│ │ Harness │ │
|
||||||
|
│ │ (tool dispatch)│ │
|
||||||
|
│ └───────┬────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────────────────┼─────────────────┐ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌─────────┐ ┌────────────┐ ┌───────────────┐ │
|
||||||
|
│ │ Tools │ │ Subagents │ │ Workflow │ │
|
||||||
|
│ │ (37x) │ │ (auto/gen) │ │ Engine │ │
|
||||||
|
│ └─────────┘ └────────────┘ │ (hive_mind) │ │
|
||||||
|
│ └───────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Process Modes
|
||||||
|
|
||||||
|
| Mode | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| **Single-process** | TUI + agent run in the same process. Simplest mode. |
|
||||||
|
| **Daemon** | `--daemon` flag. Agent processes state in background; clients attach to render. |
|
||||||
|
| **Attach** | `--attach <id>` flag. Connect to existing daemon with IPC. |
|
||||||
|
|
||||||
|
In daemon mode, the daemon runs the full agent loop; clients are stateless renderers that sync via Unix domain sockets with diff-based state synchronization.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. **Input** → `controller/input.rs` handles key events and autocomplete
|
||||||
|
2. **Dispatch** → `app/runtime/actions/mod.rs` applies actions to state (`AppStateRest`)
|
||||||
|
3. **LLM Stream** → `app/runtime/stream/mod.rs` parses SSE chunks into typed events
|
||||||
|
4. **Tool Execution** → `app/harness.rs` gates and runs tool calls via the `Tool` trait
|
||||||
|
5. **Rendering** → `view/` modules read `AppStateRest` and render via ratatui
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `src/main.rs` | Entry point, process mode dispatch, TUI init |
|
||||||
|
| `src/app/state/rest.rs` | Single source-of-truth state struct |
|
||||||
|
| `src/app/runtime/actions/mod.rs` | State reducer (`apply_action`) |
|
||||||
|
| `src/app/runtime/stream/mod.rs` | SSE stream parser |
|
||||||
|
| `src/app/harness.rs` | Tool harness with safety gating |
|
||||||
|
| `src/app/workflow/hive_mind.rs` | Multi-agent orchestration |
|
||||||
|
| `src/tool/mod.rs` | Tool trait + registry (37 tools) |
|
||||||
|
| `src/view/mod.rs` | TUI render pipeline |
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Backend Architecture
|
||||||
|
|
||||||
|
## Provider Layer
|
||||||
|
|
||||||
|
The provider abstraction in `dto/provider/` and `service/provider.rs` wraps LLM API calls:
|
||||||
|
|
||||||
|
- **Configuration**: `model/app_config.rs` loads Anthropic/OpenAI-compatible endpoint settings
|
||||||
|
- **Authentication**: `service/oauth/` handles OAuth 2.0 with PKCE flow and token management
|
||||||
|
- **Requests**: `dto/provider/request.rs` builds provider-agnostic request structs
|
||||||
|
- **Responses**: `dto/provider/response.rs` parses streaming and non-streaming responses
|
||||||
|
- **Token tracking**: `dto/provider/usage.rs` tracks token consumption
|
||||||
|
|
||||||
|
## IPC (Inter-Process Communication)
|
||||||
|
|
||||||
|
The daemon-client protocol in `src/ipc/`:
|
||||||
|
|
||||||
|
- **Transport**: Unix domain sockets
|
||||||
|
- **Framing**: Length-prefixed frames with `serde_json` serialization (`ipc/frame.rs`)
|
||||||
|
- **State Sync**: Full state push from daemon after each action (`ipc/snapshot.rs`); diff-based updates for efficiency (`ipc/diff.rs`)
|
||||||
|
- **Protocol**: `ipc/protocol.rs` defines message types (Action, StateSnapshot, etc.)
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
```
|
||||||
|
Client ──Action──▶ Daemon ──apply_action()──▶ State mutated
|
||||||
|
│
|
||||||
|
└──StatePayload──▶ Client (render)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow Engine
|
||||||
|
|
||||||
|
Located in `src/app/workflow/`:
|
||||||
|
|
||||||
|
- **Script DSL** (`engine.rs`): Executes the workflow script language (agent/parallel/pipeline/phase). Supports subagent spawning with schema-validated output, concurrency limiting, and budget tracking.
|
||||||
|
- **Hive Mind** (`hive_mind.rs`): Core Intelligence spawns a CognitiveCyclePlan — ordered cycles of parallel processing nodes. Each node has a directive and access tier (`read`/`write`/`full`). Node outputs merge into a shared collective state in real time. Final consensus synthesis completes the convergence.
|
||||||
|
- **Docs** (`docs.rs`): Deterministic (not LLM) convergence writer — records every node's output + final consensus to `docs/runs/`.
|
||||||
|
|
||||||
|
## MCP (Model Context Protocol)
|
||||||
|
|
||||||
|
`src/app/mcp/manager.rs` manages MCP client connections:
|
||||||
|
|
||||||
|
- Uses the `rmcp` crate for the MCP protocol
|
||||||
|
- Supports stdio-based transport (child process) and streamable HTTP
|
||||||
|
- Tool discovery via `list_tools()` and dynamic tool registration
|
||||||
|
|
||||||
|
## LSP Integration
|
||||||
|
|
||||||
|
`src/app/lsp/` provides Language Server Protocol support:
|
||||||
|
|
||||||
|
- **Auto-provisioner** (`provisioner.rs`): Detects and starts LSP servers for Rust, TypeScript, Python, Go, and other languages
|
||||||
|
- **Client** (`client.rs`): JSON-RPC-based LSP client with typed notifications
|
||||||
|
- **Tools** (`tool/lsp/mod.rs`): 7 LSP tools (connect, hover, completion, definition, references, diagnostics, disconnect)
|
||||||
|
|
||||||
|
## Background Bash
|
||||||
|
|
||||||
|
`src/app/bgbash/` manages long-running shell jobs:
|
||||||
|
|
||||||
|
- **Control** (`control.rs`): Job lifecycle management (spawn, signal, terminate) using Unix process groups
|
||||||
|
- **Job** (`job.rs`): Individual job state tracking with output buffering and progress monitoring
|
||||||
|
|
||||||
|
## Review System
|
||||||
|
|
||||||
|
`src/app/subagent/auto.rs` spawns background reviews:
|
||||||
|
|
||||||
|
- Quick review after every edit
|
||||||
|
- Background test generation
|
||||||
|
- Architecture review
|
||||||
|
- Security review
|
||||||
|
- All retry once on failure, escalate to blocking error if retry also fails
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Data Architecture
|
||||||
|
|
||||||
|
## State Model
|
||||||
|
|
||||||
|
The single source of truth is `AppStateRest` (`src/app/state/rest.rs`):
|
||||||
|
|
||||||
|
```
|
||||||
|
AppStateRest
|
||||||
|
├── session: SessionRuntime (hive_mind state, convergence flag)
|
||||||
|
├── runtime: RuntimeState (mode, provider status)
|
||||||
|
├── chat: ChatState (messages, scroll)
|
||||||
|
├── input: InputState (text, cursor, autocomplete)
|
||||||
|
├── settings: Settings (provider, model, temperature, concise_output)
|
||||||
|
├── config: AppConfig (endpoints, credentials)
|
||||||
|
├── scroll: ScrollState (per-panel offset)
|
||||||
|
├── diff: DiffState (edit review)
|
||||||
|
├── tools: Vec with outputs
|
||||||
|
├── statusline, sidebar, etc.
|
||||||
|
└── toasts: pending notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mutation rules** (per CLAUDE.md):
|
||||||
|
- Mutated in-place from exactly two locations: `actions/mod.rs` (apply_action) and `controller/input.rs` (key handlers)
|
||||||
|
- Read-only from every other module
|
||||||
|
- No generic update function — direct field mutation only
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
### SQLite Message Log (`src/model/msglog/`)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `schema.rs` | Table definitions (messages, sessions) |
|
||||||
|
| `mod.rs` | CRUD operations |
|
||||||
|
| `query.rs` | Query helpers (search, filter) |
|
||||||
|
| `blobs.rs` | Large message blob storage |
|
||||||
|
| `summary.rs` | Conversation summary cache |
|
||||||
|
|
||||||
|
Schema uses `rusqlite` (bundled) with per-session isolation — each session gets its own database.
|
||||||
|
|
||||||
|
### Memory System (`src/model/memory.rs`)
|
||||||
|
|
||||||
|
File-based memory stored under `~/.claude/projects/<project>/memory/`:
|
||||||
|
|
||||||
|
- Each memory is one markdown file with frontmatter (name, description, type)
|
||||||
|
- Types: `user`, `feedback`, `project`, `reference`
|
||||||
|
- Memory index in MEMORY.md
|
||||||
|
- Export/import for lesson sharing
|
||||||
|
- PID-file session lock prevents concurrent access
|
||||||
|
|
||||||
|
### Settings & Config (`src/model/`)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `settings.rs` | Serialized user preferences (provider, model, theme) |
|
||||||
|
| `app_config.rs` | Provider endpoints, API key resolution from env |
|
||||||
|
| `session.rs` | Current session metadata |
|
||||||
|
| `conversation.rs` | In-memory conversation state |
|
||||||
|
| `editlog.rs` | Append-only JSONL edit audit trail |
|
||||||
|
|
||||||
|
### Edit Log
|
||||||
|
|
||||||
|
`src/model/editlog.rs` records every file mutation:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ts": 123, "tool": "edit", "path": "src/main.rs",
|
||||||
|
"reason": "fix bug", "content_sha256": "abc123",
|
||||||
|
"bytes_delta": 15, "origin": "chat", "session_id": "sess-1"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Max 5000 entries held in memory before pruning oldest.
|
||||||
|
|
||||||
|
## Context Management (`src/app/runtime/context/`)
|
||||||
|
|
||||||
|
| Module | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `tokens.rs` | Token counting via `tiktoken-rs` |
|
||||||
|
| `window.rs` | Token window resolution (fit within model context) |
|
||||||
|
| `dedup.rs` | Deduplication of repeated tool outputs |
|
||||||
|
| `squash.rs` | Compression of large JSON tool results |
|
||||||
|
| `shaping.rs` | Message dropping when context exceeds limits |
|
||||||
|
|
||||||
|
## IPC Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Daemon State ──diff──▶ serialize ──frame──▶ socket ──▶ Client
|
||||||
|
│
|
||||||
|
Client State ◀── apply_diff ◀── deserialize ◀──── socket ─┘
|
||||||
|
```
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Dependencies
|
||||||
|
|
||||||
|
## Rust Crates (30+ direct)
|
||||||
|
|
||||||
|
### Core Framework
|
||||||
|
| Crate | Version | Purpose |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `ratatui` | 0.30.2 | TUI framework |
|
||||||
|
| `crossterm` | 0.29 | Terminal manipulation |
|
||||||
|
| `tokio` | 1 | Async runtime (multi-thread, macros, sync, time, net, io-util, signal) |
|
||||||
|
|
||||||
|
### HTTP & Networking
|
||||||
|
| Crate | Version | Purpose |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `reqwest` | 0.13 | HTTP client (JSON, streaming, native-tls-vendored, form) |
|
||||||
|
| `rmcp` | 2.2 | MCP client (child-process, streamable HTTP) |
|
||||||
|
| `webbrowser` | 1 | Open URLs in browser |
|
||||||
|
| `url` | 2 | URL parsing |
|
||||||
|
| `percent-encoding` | 2 | URL encoding |
|
||||||
|
|
||||||
|
### HTML/Markdown
|
||||||
|
| Crate | Version | Purpose |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `dom_smoothie` | 0.18.0 | HTML DOM manipulation |
|
||||||
|
| `fast_html2md` | 0.0.62 | HTML-to-Markdown conversion |
|
||||||
|
| `scraper` | 0.27.0 | HTML parsing/selecting |
|
||||||
|
| `pulldown-cmark` | 0.13 | Markdown parsing (no default features) |
|
||||||
|
|
||||||
|
### Serialization
|
||||||
|
| Crate | Version | Purpose |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `serde` | 1 | Serialization framework |
|
||||||
|
| `serde_json` | 1 | JSON serialization |
|
||||||
|
| `serde_yaml_ng` | 0.10 | YAML serialization |
|
||||||
|
|
||||||
|
### Storage & Files
|
||||||
|
| Crate | Version | Purpose |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `rusqlite` | 0.40 | SQLite (bundled) |
|
||||||
|
| `ignore` | 0.4 | `.gitignore`-aware file walking |
|
||||||
|
| `globset` | 0.4 | Glob pattern matching |
|
||||||
|
| `include_dir` | 0.7 | Embed directory contents in binary |
|
||||||
|
| `infer` | 0.19 | File type detection |
|
||||||
|
| `dirs` | 6 | Standard OS directories |
|
||||||
|
|
||||||
|
### Text & Search
|
||||||
|
| Crate | Version | Purpose |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `regex` | 1 | Regular expressions |
|
||||||
|
| `nucleo-matcher` | 0.3 | Fuzzy matching (for @mention autocomplete) |
|
||||||
|
| `similar` | 3 | Diff computation |
|
||||||
|
| `syntect` | 5 | Syntax highlighting |
|
||||||
|
| `tiktoken-rs` | 0.12 | OpenAI token counting |
|
||||||
|
|
||||||
|
### Cryptography & Encoding
|
||||||
|
| Crate | Version | Purpose |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `base64` | 0.22 | Base64 encoding |
|
||||||
|
| `sha2` | 0.11 | SHA-256 hashing |
|
||||||
|
| `hex` | 0.4 | Hex encoding |
|
||||||
|
| `uuid` | 1 | UUID generation (v4, v5) |
|
||||||
|
| `libc` | 0.2 | Raw C FFI bindings |
|
||||||
|
|
||||||
|
### Error Handling & Logging
|
||||||
|
| Crate | Version | Purpose |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `anyhow` | 1 | Error handling |
|
||||||
|
| `tracing` | 0.1 | Structured logging |
|
||||||
|
| `tracing-subscriber` | 0.3 | Log subscriber with env-filter |
|
||||||
|
| `chrono` | 0.4 | Date/time with serde |
|
||||||
|
|
||||||
|
### Other
|
||||||
|
| Crate | Version | Purpose |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `lsp-types` | 0.97 | LSP protocol types |
|
||||||
|
| `futures-util` | 0.3 | Async stream combinators |
|
||||||
|
|
||||||
|
## External Services
|
||||||
|
|
||||||
|
| Service | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| **Anthropic API** | Primary LLM provider |
|
||||||
|
| **OpenAI API** | Alternative LLM provider (including OAuth) |
|
||||||
|
| **GitHub** | Release artifacts via semantic-release CI |
|
||||||
|
| **MCP Servers** | External tool servers (stdio or HTTP) |
|
||||||
|
| **LSP Servers** | Language servers (rust-analyzer, TypeScript, Pyright, gopls, etc.) |
|
||||||
|
|
||||||
|
## Build Configuration
|
||||||
|
|
||||||
|
### Compiler Lints (`.cargo/config.toml`)
|
||||||
|
All unused code, dead code, and deprecation warnings promoted to errors:
|
||||||
|
`-W unused`, `-W dead_code`, `-W unreachable_code`, `-D warnings`
|
||||||
|
|
||||||
|
### Release Profile
|
||||||
|
`opt-level=3`, LTO="fat", `codegen-units=1`, `panic="abort"`, `strip="symbols"`, `overflow-checks=true`
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
- **CI**: cargo build + test + clippy on every push
|
||||||
|
- **Release**: semantic-release with changelog generation, Cargo.toml version bump, GitHub artifact upload
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Frontend (TUI) Architecture
|
||||||
|
|
||||||
|
## Render Pipeline
|
||||||
|
|
||||||
|
The TUI is built with [ratatui](https://github.com/ratatui-org/ratatui) and [crossterm](https://github.com/crossterm-rs/crossterm).
|
||||||
|
|
||||||
|
```
|
||||||
|
Timer tick
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
main.rs: fn tui_loop()
|
||||||
|
│
|
||||||
|
├── controller/input.rs: handle_key() → action
|
||||||
|
├── app/runtime/actions/mod.rs: apply_action()
|
||||||
|
│ │
|
||||||
|
│ └── state mutates (AppStateRest)
|
||||||
|
│
|
||||||
|
└── view/mod.rs: build TUI layout
|
||||||
|
│
|
||||||
|
├── view/chat.rs: Chat transcript
|
||||||
|
├── view/sidebar.rs: Usage dashboard
|
||||||
|
├── view/status.rs: Status bar
|
||||||
|
├── view/markdown.rs: Message renderer
|
||||||
|
├── view/workflow.rs: Hive-mind progress
|
||||||
|
└── view/theme.rs: Tokyo Night palette
|
||||||
|
```
|
||||||
|
|
||||||
|
## Overlay System
|
||||||
|
|
||||||
|
16 overlays managed by `app/mode/`:
|
||||||
|
|
||||||
|
| Overlay | File | Purpose |
|
||||||
|
|---------|------|---------|
|
||||||
|
| Chat input | `mod.rs` | Main input bar with autocomplete |
|
||||||
|
| Bash | `bash.rs` | Interactive shell panel |
|
||||||
|
| Editor | `editor.rs` | Built-in file editor |
|
||||||
|
| Effort | `effort.rs` | LLM effort selector |
|
||||||
|
| Help | `help.rs` | Keybindings help |
|
||||||
|
| Key Input | `key_input.rs` | Custom key binding |
|
||||||
|
| Learning | `learning.rs` | Lesson viewer |
|
||||||
|
| Loading | `loading.rs` | Spinner overlay |
|
||||||
|
| MCP | `mcp.rs` | MCP server management |
|
||||||
|
| Quit Confirm | `quit_confirm.rs` | Exit confirmation dialog |
|
||||||
|
| Rewind | `rewind.rs` | Message/history rewind |
|
||||||
|
| Settings | `settings.rs` | Settings panel |
|
||||||
|
| Todo | `todo.rs` | Task/TODO list |
|
||||||
|
| Workflow | (via view) | Workflow progress |
|
||||||
|
|
||||||
|
## Layout Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Status Bar (view/status.rs) │
|
||||||
|
├──────────────────────┬──────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ Chat Transcript │ Sidebar │
|
||||||
|
│ (view/chat.rs) │ (view/sidebar.rs) │
|
||||||
|
│ scrollable, │ tokens, status, │
|
||||||
|
│ inline-log style │ agent info │
|
||||||
|
│ │ │
|
||||||
|
├──────────────────────┴──────────────────────┤
|
||||||
|
│ Input Bar + Autocomplete dropdown │
|
||||||
|
│ (view/mod.rs) │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Input Handling
|
||||||
|
|
||||||
|
`controller/input.rs`:
|
||||||
|
|
||||||
|
- Normal mode: keystrokes go to the active overlay
|
||||||
|
- `@mention` triggers fuzzy autocomplete (via `nucleo-matcher`)
|
||||||
|
- Tab cycles autocomplete candidates
|
||||||
|
- `Ctrl+Y` copies selected text to clipboard (via OSC52 escape sequence)
|
||||||
|
- Arrow keys scroll chat, sidebar, and other scrollable panels
|
||||||
|
|
||||||
|
## Theme
|
||||||
|
|
||||||
|
`view/theme.rs` defines a Tokyo Night color palette as constants (`Theme::PRIMARY`, `Theme::ERROR`, `Theme::TEXT_MUTED`, etc.) rather than using a theme enum or hot-reloadable config. All view modules import and apply these constants directly.
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
#![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
|
//! Global registry of running background bash jobs, and control operations
|
||||||
//! (output polling, kill) exposed to the rest of the app.
|
//! (output polling, kill) exposed to the rest of the app.
|
||||||
//!
|
//!
|
||||||
@@ -10,7 +15,6 @@
|
|||||||
//! Why: a single static map (rather than storing jobs in `AppStateRest`)
|
//! Why: a single static map (rather than storing jobs in `AppStateRest`)
|
||||||
//! lets background jobs outlive the borrow of any particular state mutation
|
//! lets background jobs outlive the borrow of any particular state mutation
|
||||||
//! and be looked up by id from tool calls issued at arbitrary points.
|
//! and be looked up by id from tool calls issued at arbitrary points.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
@@ -44,7 +48,11 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
|
|||||||
while let Some(line) = job.try_read_line() {
|
while let Some(line) = job.try_read_line() {
|
||||||
lines.push(line);
|
lines.push(line);
|
||||||
}
|
}
|
||||||
if lines.is_empty() { None } else { Some(lines) }
|
if lines.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(lines)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Terminate a running background bash job and remove it from the registry.
|
/// Terminate a running background bash job and remove it from the registry.
|
||||||
@@ -58,7 +66,9 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
|
|||||||
/// Return: `Ok(())` on success, `Err` if the lock is poisoned or no job
|
/// Return: `Ok(())` on success, `Err` if the lock is poisoned or no job
|
||||||
/// with that id exists.
|
/// with that id exists.
|
||||||
pub fn bash_kill(id: &str) -> anyhow::Result<()> {
|
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);
|
let job = map.remove(id);
|
||||||
match job {
|
match job {
|
||||||
Some(job) => {
|
Some(job) => {
|
||||||
|
|||||||
+19
-13
@@ -9,11 +9,10 @@
|
|||||||
//! Why: running bash commands on a detached thread with a channel (rather
|
//! Why: running bash commands on a detached thread with a channel (rather
|
||||||
//! than synchronously) lets the TUI stay responsive while long-running
|
//! than synchronously) lets the TUI stay responsive while long-running
|
||||||
//! shell commands execute in the background.
|
//! shell commands execute in the background.
|
||||||
|
use std::io::BufRead;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::io::BufRead;
|
|
||||||
|
|
||||||
/// Maximum number of output lines buffered in memory per background job.
|
/// Maximum number of output lines buffered in memory per background job.
|
||||||
/// Beyond this limit, old output is dropped to prevent OOM (CWE-770).
|
/// Beyond this limit, old output is dropped to prevent OOM (CWE-770).
|
||||||
@@ -59,17 +58,23 @@ pub fn spawn_bash_job(command: String) -> BashJob {
|
|||||||
// Spawn a named thread for easier debugging. If Builder::spawn fails
|
// Spawn a named thread for easier debugging. If Builder::spawn fails
|
||||||
// (e.g. OS resource limit), fall back to unnameable thread::spawn.
|
// (e.g. OS resource limit), fall back to unnameable thread::spawn.
|
||||||
let thread_name = format!("bgbash-{}", &thread_id[..8.min(thread_id.len())]);
|
let thread_name = format!("bgbash-{}", &thread_id[..8.min(thread_id.len())]);
|
||||||
if thread::Builder::new().name(thread_name).spawn({
|
if thread::Builder::new()
|
||||||
// Clone everything the closure captures so we can also pass it
|
.name(thread_name)
|
||||||
// to the fallback thread without moving.
|
.spawn({
|
||||||
let cmd = cmd.clone();
|
// Clone everything the closure captures so we can also pass it
|
||||||
let output_tx = output_tx.clone();
|
// to the fallback thread without moving.
|
||||||
let pid_tx = pid_tx.clone();
|
let cmd = cmd.clone();
|
||||||
let id_for_log = id_for_log.clone();
|
let output_tx = output_tx.clone();
|
||||||
move || spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log)
|
let pid_tx = pid_tx.clone();
|
||||||
}).is_err()
|
let id_for_log = id_for_log.clone();
|
||||||
|
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);
|
tracing::warn!(
|
||||||
|
"[bgbash:{}] failed to spawn named thread, using unnamed fallback",
|
||||||
|
id_for_log
|
||||||
|
);
|
||||||
thread::spawn(move || {
|
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);
|
||||||
});
|
});
|
||||||
@@ -139,7 +144,8 @@ fn spawn_bash_thread_body(
|
|||||||
if output_tx.try_send(line).is_err() {
|
if output_tx.try_send(line).is_err() {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"[bgbash:{}] output buffer full ({} lines), discarding remaining output",
|
"[bgbash:{}] output buffer full ({} lines), discarding remaining output",
|
||||||
id_for_log, MAX_OUTPUT_LINES,
|
id_for_log,
|
||||||
|
MAX_OUTPUT_LINES,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! Background bash: run shell commands off the main thread, poll their
|
//! Background bash: run shell commands off the main thread, poll their
|
||||||
//! output non-blockingly, and terminate them on demand.
|
//! output non-blockingly, and terminate them on demand.
|
||||||
|
|
||||||
pub mod control;
|
pub mod control;
|
||||||
pub mod job;
|
pub mod job;
|
||||||
|
|||||||
+231
-158
@@ -84,10 +84,17 @@ const ASSUMPTION_PATTERNS: &[&str] = &[
|
|||||||
|
|
||||||
/// Network-exfiltration and credential-disclosure patterns for bash.
|
/// Network-exfiltration and credential-disclosure patterns for bash.
|
||||||
const EXFIL_PATTERNS: &[&str] = &[
|
const EXFIL_PATTERNS: &[&str] = &[
|
||||||
"curl ", "wget ", "nc -e ", "ncat ", "/dev/tcp/",
|
"curl ",
|
||||||
"base64 -d |", "base64 --decode |",
|
"wget ",
|
||||||
"openssl s_client", "ssh -R ",
|
"nc -e ",
|
||||||
"scp /", "rsync /",
|
"ncat ",
|
||||||
|
"/dev/tcp/",
|
||||||
|
"base64 -d |",
|
||||||
|
"base64 --decode |",
|
||||||
|
"openssl s_client",
|
||||||
|
"ssh -R ",
|
||||||
|
"scp /",
|
||||||
|
"rsync /",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Substrings of well-known credential / secret files that bash must not read.
|
/// Substrings of well-known credential / secret files that bash must not read.
|
||||||
@@ -115,169 +122,63 @@ impl Harness {
|
|||||||
/// Decide whether a tool call is allowed to execute.
|
/// Decide whether a tool call is allowed to execute.
|
||||||
///
|
///
|
||||||
/// Flow: ALL tools are gated (not just risky ones), closing the bypass
|
/// Flow: ALL tools are gated (not just risky ones), closing the bypass
|
||||||
/// for MCP tools (which are never in the risky list). Basic path
|
/// for MCP tools (which are never in the risky list). Delegates to
|
||||||
/// traversal and reason validation applies to any tool with a `path`
|
/// smaller helper methods for each concern: path traversal, output
|
||||||
/// argument. Heavy content scanning (stub/denial/assumption/exfiltration)
|
/// path validation, content scanning, bash safety, and reason checks.
|
||||||
/// only applies to risky tools. MCP tools (mcp__ prefix) are treated
|
|
||||||
/// as risky because their behaviour is unknown.
|
|
||||||
///
|
///
|
||||||
/// Return: `Verdict::Allow` or `Verdict::Block(reason)`.
|
/// Return: `Verdict::Allow` or `Verdict::Block(reason)`.
|
||||||
#[allow(clippy::too_many_lines, clippy::unnecessary_debug_formatting)]
|
|
||||||
pub fn gate_tool_call(
|
pub fn gate_tool_call(
|
||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
args: &serde_json::Value,
|
args: &serde_json::Value,
|
||||||
workspace_roots: &[&std::path::Path],
|
workspace_roots: &[&std::path::Path],
|
||||||
) -> Verdict {
|
) -> Verdict {
|
||||||
|
|
||||||
let is_risky = crate::tool::tool_is_risky(tool_name);
|
let is_risky = crate::tool::tool_is_risky(tool_name);
|
||||||
let is_mcp = tool_name.starts_with("mcp__");
|
let is_mcp = tool_name.starts_with("mcp__");
|
||||||
|
|
||||||
// ── Universal checks applied to EVERY tool ──
|
// Universal checks applied to EVERY tool.
|
||||||
|
if let Some(v) = Self::check_path_traversal(args, workspace_roots) {
|
||||||
// Path traversal: check ANY tool that accepts a path argument,
|
return v;
|
||||||
// not just write/edit/delete, so tools like read, MCP tools,
|
}
|
||||||
// and future tools are also protected.
|
if let Some(v) = Self::check_output_path(tool_name, args, workspace_roots) {
|
||||||
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
|
return v;
|
||||||
if path.contains("..") {
|
|
||||||
return Verdict::Block(
|
|
||||||
"path traversal detected in 'path' argument".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if !workspace_roots.is_empty() {
|
|
||||||
let abs_check = std::path::PathBuf::from(path);
|
|
||||||
if abs_check.is_absolute()
|
|
||||||
&& !workspace_roots.iter().any(|r| abs_check.starts_with(r))
|
|
||||||
{
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"absolute path '{path}' is outside all workspace roots"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workspace-root validation for output path.
|
// Non-risky, non-MCP tools pass after universal checks.
|
||||||
if let Some(out_path) = Self::find_output_path(tool_name, args) {
|
|
||||||
if !workspace_roots.is_empty()
|
|
||||||
&& !out_path.starts_with("/tmp")
|
|
||||||
&& !out_path.is_absolute()
|
|
||||||
{
|
|
||||||
let allowed = workspace_roots.iter().any(|r| out_path.starts_with(r));
|
|
||||||
if !allowed {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"output path '{out_path:?}' is outside all workspace roots"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Risky / MCP tool checks ──
|
|
||||||
// Non-risky, non-MCP tools (read, grep, glob, recall, etc.) are
|
|
||||||
// allowed after universal checks above.
|
|
||||||
if !is_risky && !is_mcp {
|
if !is_risky && !is_mcp {
|
||||||
return Verdict::Allow;
|
return Verdict::Allow;
|
||||||
}
|
}
|
||||||
|
|
||||||
// File-mutating tools: write / edit / delete
|
// File-mutating tools: require a meaningful reason.
|
||||||
if matches!(tool_name, "write" | "edit" | "delete") {
|
if matches!(tool_name, "write" | "edit" | "delete") {
|
||||||
match Self::validate_reason(tool_name, args) {
|
if let Err(msg) = Self::validate_reason(tool_name, args) {
|
||||||
Ok(()) => {}
|
return Verdict::Block(msg);
|
||||||
Err(msg) => return Verdict::Block(msg),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// write / edit content must not contain stubs, denial language, or
|
// write / edit content scanning for stub/denial/assumption patterns.
|
||||||
// assumption language.
|
if let Some(v) = Self::check_content_safety(tool_name, args) {
|
||||||
if matches!(tool_name, "write" | "edit") {
|
return v;
|
||||||
if let Some(content) = Self::extract_content(tool_name, args) {
|
|
||||||
if let Some(pat) = Self::first_match(&content, STUB_PATTERNS) {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"content contains stub/placeholder pattern '{pat}'; \
|
|
||||||
production code must be fully implemented — \
|
|
||||||
replace the stub with a real implementation"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(pat) = Self::first_match(&content, DENIAL_PATTERNS) {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"content contains denial/punt pattern '{pat}'; \
|
|
||||||
implement the change properly instead of skipping"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(pat) = Self::first_match(&content, ASSUMPTION_PATTERNS) {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"content contains assumption pattern '{pat}'; \
|
|
||||||
verify against data/tests instead of guessing"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bash: destructive patterns, exfiltration (ALL commands checked,
|
// Bash-specific destructive / exfiltration checks.
|
||||||
// no safe-command whitelist), sensitive-path reads.
|
if let Some(v) = Self::check_bash_safety(args) {
|
||||||
if tool_name == "bash" {
|
return v;
|
||||||
let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
|
}
|
||||||
if cmd.contains("..") {
|
|
||||||
return Verdict::Block(
|
// git_operator: require a non-trivial reason.
|
||||||
"path traversal detected in bash command".to_string(),
|
if tool_name == "git_operator" && !Self::has_valid_reason(args, MIN_REASON_LEN) {
|
||||||
);
|
if args.get("reason").and_then(|v| v.as_str()).is_some() {
|
||||||
}
|
|
||||||
// Exfiltration patterns are checked on EVERY bash command,
|
|
||||||
// regardless of prefix. The safe-command whitelist was removed
|
|
||||||
// because it could be bypassed with command chaining.
|
|
||||||
for pat in EXFIL_PATTERNS {
|
|
||||||
if cmd.contains(pat) {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"potential data-exfiltration command blocked (matched '{pat}')"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for pat in SENSITIVE_PATH_PATTERNS {
|
|
||||||
if cmd.contains(pat) {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"refused to read/write sensitive path '{pat}'"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let dangerous_patterns = [
|
|
||||||
"rm -rf /", "rm -rf --no-preserve-root",
|
|
||||||
"rm -rf ~", "rm -fr /", "mkfs.", "dd if=",
|
|
||||||
":(){", "> /dev/sda", "chmod -R 000 /",
|
|
||||||
"shutdown ", "poweroff ", "reboot ", "halt ",
|
|
||||||
];
|
|
||||||
for pat in &dangerous_patterns {
|
|
||||||
if cmd.contains(pat) {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"destructive command pattern blocked: {pat}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Also scan heredocs / -c / inline content for stub/denial
|
|
||||||
// language (e.g. `bash -c 'echo todo!()'`)
|
|
||||||
if let Some(pat) = Self::first_match(cmd, STUB_PATTERNS) {
|
|
||||||
return Verdict::Block(format!(
|
return Verdict::Block(format!(
|
||||||
"bash command contains stub pattern '{pat}'"
|
"git_operator requires a non-trivial 'reason' \
|
||||||
|
(>= {MIN_REASON_LEN} chars) explaining the operation"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
return Verdict::Block(
|
||||||
|
"git_operator requires a 'reason' argument explaining the operation".to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// git_operator: require a non-trivial reason as well.
|
// MCP tools: require a reason when they take meaningful arguments.
|
||||||
if tool_name == "git_operator" {
|
|
||||||
if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
|
|
||||||
if reason.trim().len() < MIN_REASON_LEN {
|
|
||||||
return Verdict::Block(format!(
|
|
||||||
"git_operator requires a non-trivial 'reason' \
|
|
||||||
(>= {MIN_REASON_LEN} chars) explaining the operation"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Verdict::Block(
|
|
||||||
"git_operator requires a 'reason' argument explaining the operation"
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MCP tools: unknown behaviour — require a reason if they take
|
|
||||||
// arguments, to discourage lazy invocations.
|
|
||||||
if is_mcp {
|
if is_mcp {
|
||||||
if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
|
if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
|
||||||
if reason.trim().len() < MIN_REASON_LEN {
|
if reason.trim().len() < MIN_REASON_LEN {
|
||||||
@@ -287,7 +188,6 @@ impl Harness {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
} else if args.as_object().is_some_and(|m| !m.is_empty()) {
|
} else if args.as_object().is_some_and(|m| !m.is_empty()) {
|
||||||
// Only require reason when there are meaningful arguments
|
|
||||||
return Verdict::Block(format!(
|
return Verdict::Block(format!(
|
||||||
"MCP tool '{tool_name}' requires a 'reason' argument \
|
"MCP tool '{tool_name}' requires a 'reason' argument \
|
||||||
explaining the operation"
|
explaining the operation"
|
||||||
@@ -298,6 +198,163 @@ impl Harness {
|
|||||||
Verdict::Allow
|
Verdict::Allow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check for path traversal in the `path` argument and verify it stays
|
||||||
|
/// within workspace roots.
|
||||||
|
///
|
||||||
|
/// Flow: reject any path containing `..` → if workspace roots are set,
|
||||||
|
/// reject absolute paths outside every root.
|
||||||
|
///
|
||||||
|
/// Return: `Some(Verdict::Block)` on violation, `None` if the check
|
||||||
|
/// passes or the tool has no `path` argument.
|
||||||
|
fn check_path_traversal(
|
||||||
|
args: &serde_json::Value,
|
||||||
|
workspace_roots: &[&std::path::Path],
|
||||||
|
) -> Option<Verdict> {
|
||||||
|
let path = args.get("path")?.as_str()?;
|
||||||
|
if path.contains("..") {
|
||||||
|
return Some(Verdict::Block(
|
||||||
|
"path traversal detected in 'path' argument".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !workspace_roots.is_empty() {
|
||||||
|
let abs_check = std::path::PathBuf::from(path);
|
||||||
|
if abs_check.is_absolute() && !workspace_roots.iter().any(|r| abs_check.starts_with(r))
|
||||||
|
{
|
||||||
|
return Some(Verdict::Block(format!(
|
||||||
|
"absolute path '{path}' is outside all workspace roots"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that a tool's output path (if any) stays within workspace roots.
|
||||||
|
///
|
||||||
|
/// Flow: if `find_output_path` yields a path, reject it unless it's
|
||||||
|
/// under `/tmp`, already absolute, or within a workspace root.
|
||||||
|
///
|
||||||
|
/// Return: `Some(Verdict::Block)` on violation, `None` otherwise.
|
||||||
|
fn check_output_path(
|
||||||
|
tool_name: &str,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
workspace_roots: &[&std::path::Path],
|
||||||
|
) -> Option<Verdict> {
|
||||||
|
let out_path = Self::find_output_path(tool_name, args)?;
|
||||||
|
if !workspace_roots.is_empty() && !out_path.starts_with("/tmp") && !out_path.is_absolute() {
|
||||||
|
let allowed = workspace_roots.iter().any(|r| out_path.starts_with(r));
|
||||||
|
if !allowed {
|
||||||
|
return Some(Verdict::Block(format!(
|
||||||
|
"output path '{}' is outside all workspace roots",
|
||||||
|
out_path.display(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check write/edit content for stub, denial, and assumption patterns.
|
||||||
|
///
|
||||||
|
/// Return: `Some(Verdict::Block)` with a description of the first
|
||||||
|
/// matched pattern, `None` if the content is clean or not applicable.
|
||||||
|
fn check_content_safety(tool_name: &str, args: &serde_json::Value) -> Option<Verdict> {
|
||||||
|
if !matches!(tool_name, "write" | "edit") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let content = Self::extract_content(tool_name, args)?;
|
||||||
|
for (patterns, msg_prefix) in [
|
||||||
|
(&STUB_PATTERNS, "stub/placeholder"),
|
||||||
|
(&DENIAL_PATTERNS, "denial/punt"),
|
||||||
|
(&ASSUMPTION_PATTERNS, "assumption"),
|
||||||
|
] {
|
||||||
|
if let Some(pat) = Self::first_match(&content, patterns) {
|
||||||
|
let msg = match msg_prefix {
|
||||||
|
"stub/placeholder" => format!(
|
||||||
|
"content contains stub/placeholder pattern '{pat}'; \
|
||||||
|
production code must be fully implemented — \
|
||||||
|
replace the stub with a real implementation"
|
||||||
|
),
|
||||||
|
"denial/punt" => format!(
|
||||||
|
"content contains denial/punt pattern '{pat}'; \
|
||||||
|
implement the change properly instead of skipping"
|
||||||
|
),
|
||||||
|
_ => format!(
|
||||||
|
"content contains assumption pattern '{pat}'; \
|
||||||
|
verify against data/tests instead of guessing"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return Some(Verdict::Block(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check bash commands for path traversal, exfiltration, sensitive
|
||||||
|
/// path reads, destructive patterns, and stub language.
|
||||||
|
///
|
||||||
|
/// Flow: extract the `command` argument → check each category in
|
||||||
|
/// sequence, returning the first violation found.
|
||||||
|
///
|
||||||
|
/// Return: `Some(Verdict::Block)` on any violation, `None` if the
|
||||||
|
/// tool is not bash or the command is safe.
|
||||||
|
fn check_bash_safety(args: &serde_json::Value) -> Option<Verdict> {
|
||||||
|
let cmd = args.get("command")?.as_str()?;
|
||||||
|
if cmd.contains("..") {
|
||||||
|
return Some(Verdict::Block(
|
||||||
|
"path traversal detected in bash command".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for pat in EXFIL_PATTERNS {
|
||||||
|
if cmd.contains(pat) {
|
||||||
|
return Some(Verdict::Block(format!(
|
||||||
|
"potential data-exfiltration command blocked (matched '{pat}')"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pat in SENSITIVE_PATH_PATTERNS {
|
||||||
|
if cmd.contains(pat) {
|
||||||
|
return Some(Verdict::Block(format!(
|
||||||
|
"refused to read/write sensitive path '{pat}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dangerous_patterns = [
|
||||||
|
"rm -rf /",
|
||||||
|
"rm -rf --no-preserve-root",
|
||||||
|
"rm -rf ~",
|
||||||
|
"rm -fr /",
|
||||||
|
"mkfs.",
|
||||||
|
"dd if=",
|
||||||
|
":(){",
|
||||||
|
"> /dev/sda",
|
||||||
|
"chmod -R 000 /",
|
||||||
|
"shutdown ",
|
||||||
|
"poweroff ",
|
||||||
|
"reboot ",
|
||||||
|
"halt ",
|
||||||
|
];
|
||||||
|
for pat in &dangerous_patterns {
|
||||||
|
if cmd.contains(pat) {
|
||||||
|
return Some(Verdict::Block(format!(
|
||||||
|
"destructive command pattern blocked: {pat}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(pat) = Self::first_match(cmd, STUB_PATTERNS) {
|
||||||
|
return Some(Verdict::Block(format!(
|
||||||
|
"bash command contains stub pattern '{pat}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether the given `args` contain a non-trivial `reason`
|
||||||
|
/// argument meeting the minimum length requirement.
|
||||||
|
fn has_valid_reason(args: &serde_json::Value, min_len: usize) -> bool {
|
||||||
|
args.get("reason")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.is_some_and(|r| r.trim().len() >= min_len)
|
||||||
|
}
|
||||||
|
|
||||||
/// Validate the `reason` argument for a mutating tool.
|
/// Validate the `reason` argument for a mutating tool.
|
||||||
///
|
///
|
||||||
/// Flow: require the field to exist and be a non-empty string ≥
|
/// Flow: require the field to exist and be a non-empty string ≥
|
||||||
@@ -317,17 +374,13 @@ impl Harness {
|
|||||||
Some(v) => match v.as_str() {
|
Some(v) => match v.as_str() {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => {
|
None => {
|
||||||
return Err(format!(
|
return Err(format!("{tool_name} 'reason' must be a string"));
|
||||||
"{tool_name} 'reason' must be a string"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let trimmed = reason.trim();
|
let trimmed = reason.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return Err(format!(
|
return Err(format!("{tool_name} 'reason' must not be empty"));
|
||||||
"{tool_name} 'reason' must not be empty"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if trimmed.len() < MIN_REASON_LEN {
|
if trimmed.len() < MIN_REASON_LEN {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -339,9 +392,20 @@ impl Harness {
|
|||||||
// Reject generic non-answers
|
// Reject generic non-answers
|
||||||
let lower = trimmed.to_lowercase();
|
let lower = trimmed.to_lowercase();
|
||||||
let non_answers = [
|
let non_answers = [
|
||||||
"fix", "update", "change", "edit", "modify",
|
"fix",
|
||||||
"implement", "add", "remove", "delete",
|
"update",
|
||||||
"make it work", "make work", "test", "wip", "tbd",
|
"change",
|
||||||
|
"edit",
|
||||||
|
"modify",
|
||||||
|
"implement",
|
||||||
|
"add",
|
||||||
|
"remove",
|
||||||
|
"delete",
|
||||||
|
"make it work",
|
||||||
|
"make work",
|
||||||
|
"test",
|
||||||
|
"wip",
|
||||||
|
"tbd",
|
||||||
];
|
];
|
||||||
if non_answers.iter().any(|n| lower == *n) {
|
if non_answers.iter().any(|n| lower == *n) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -356,7 +420,10 @@ impl Harness {
|
|||||||
/// Extract the textual content of a write/edit call, if any.
|
/// Extract the textual content of a write/edit call, if any.
|
||||||
fn extract_content(tool_name: &str, args: &serde_json::Value) -> Option<String> {
|
fn extract_content(tool_name: &str, args: &serde_json::Value) -> Option<String> {
|
||||||
match tool_name {
|
match tool_name {
|
||||||
"write" => args.get("content").and_then(|v| v.as_str()).map(String::from),
|
"write" => args
|
||||||
|
.get("content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(String::from),
|
||||||
"edit" => {
|
"edit" => {
|
||||||
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
|
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("");
|
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
@@ -377,9 +444,10 @@ impl Harness {
|
|||||||
/// Extract a candidate output path from a tool call, if one exists.
|
/// Extract a candidate output path from a tool call, if one exists.
|
||||||
fn find_output_path(tool_name: &str, args: &serde_json::Value) -> Option<std::path::PathBuf> {
|
fn find_output_path(tool_name: &str, args: &serde_json::Value) -> Option<std::path::PathBuf> {
|
||||||
match tool_name {
|
match tool_name {
|
||||||
"write" | "edit" | "delete" | "read" => {
|
"write" | "edit" | "delete" | "read" => args
|
||||||
args.get("path").and_then(|v| v.as_str()).map(std::path::PathBuf::from)
|
.get("path")
|
||||||
}
|
.and_then(|v| v.as_str())
|
||||||
|
.map(std::path::PathBuf::from),
|
||||||
"bash" => {
|
"bash" => {
|
||||||
let cmd = args.get("command").and_then(|v| v.as_str())?;
|
let cmd = args.get("command").and_then(|v| v.as_str())?;
|
||||||
let lower = cmd.to_lowercase();
|
let lower = cmd.to_lowercase();
|
||||||
@@ -397,7 +465,6 @@ impl Harness {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Harness {
|
impl Default for Harness {
|
||||||
@@ -418,7 +485,10 @@ mod tests {
|
|||||||
return match verdict.to_lowercase().as_str() {
|
return match verdict.to_lowercase().as_str() {
|
||||||
"allow" => Some(Verdict::Allow),
|
"allow" => Some(Verdict::Allow),
|
||||||
"block" => Some(Verdict::Block(
|
"block" => Some(Verdict::Block(
|
||||||
v.get("reason").and_then(|r| r.as_str()).unwrap_or("blocked").to_string()
|
v.get("reason")
|
||||||
|
.and_then(|r| r.as_str())
|
||||||
|
.unwrap_or("blocked")
|
||||||
|
.to_string(),
|
||||||
)),
|
)),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
@@ -430,7 +500,11 @@ mod tests {
|
|||||||
return Some(Verdict::Allow);
|
return Some(Verdict::Allow);
|
||||||
}
|
}
|
||||||
if l.starts_with("verdict: block") {
|
if l.starts_with("verdict: block") {
|
||||||
let reason = line.split_once(':').map_or("blocked", |x| x.1).trim().to_string();
|
let reason = line
|
||||||
|
.split_once(':')
|
||||||
|
.map_or("blocked", |x| x.1)
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
return Some(Verdict::Block(reason));
|
return Some(Verdict::Block(reason));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,7 +524,6 @@ mod tests {
|
|||||||
assert_eq!(result, Verdict::Allow);
|
assert_eq!(result, Verdict::Allow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_verdict_json_allow() {
|
fn test_parse_verdict_json_allow() {
|
||||||
let v = parse_verdict(r#"{"verdict": "allow"}"#);
|
let v = parse_verdict(r#"{"verdict": "allow"}"#);
|
||||||
|
|||||||
+120
-119
@@ -47,13 +47,20 @@ impl LspClient {
|
|||||||
cmd.stdout(Stdio::piped());
|
cmd.stdout(Stdio::piped());
|
||||||
cmd.stderr(Stdio::piped());
|
cmd.stderr(Stdio::piped());
|
||||||
|
|
||||||
let mut child = cmd.spawn()
|
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()
|
let stdin = child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
.ok_or_else(|| anyhow::anyhow!("failed to capture stdin for LSP server"))?;
|
.ok_or_else(|| anyhow::anyhow!("failed to capture stdin for LSP server"))?;
|
||||||
let stdout = BufReader::new(child.stdout.take()
|
let stdout = BufReader::new(
|
||||||
.ok_or_else(|| anyhow::anyhow!("failed to capture stdout for LSP server"))?);
|
child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("failed to capture stdout for LSP server"))?,
|
||||||
|
);
|
||||||
|
|
||||||
let mut client = LspClient {
|
let mut client = LspClient {
|
||||||
stdin,
|
stdin,
|
||||||
@@ -106,7 +113,11 @@ 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.server_capabilities = result.get("capabilities").cloned().unwrap_or_default();
|
||||||
|
|
||||||
client.notify("initialized", &json!({}))?;
|
client.notify("initialized", &json!({}))?;
|
||||||
@@ -122,7 +133,12 @@ impl LspClient {
|
|||||||
self.call_with_timeout(method, params, Duration::from_millis(LSP_CALL_TIMEOUT_MS))
|
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;
|
self.next_id += 1;
|
||||||
let id = self.next_id;
|
let id = self.next_id;
|
||||||
let req = json!({
|
let req = json!({
|
||||||
@@ -148,11 +164,14 @@ impl LspClient {
|
|||||||
let body = serde_json::to_string(msg)
|
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());
|
let header = format!("Content-Length: {}\r\n\r\n", body.len());
|
||||||
self.stdin.write_all(header.as_bytes())
|
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())
|
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()
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -166,8 +185,14 @@ impl LspClient {
|
|||||||
let frame = self.read_frame()?;
|
let frame = self.read_frame()?;
|
||||||
if frame.get("id") == Some(&json!(expected_id)) {
|
if frame.get("id") == Some(&json!(expected_id)) {
|
||||||
if let Some(err) = frame.get("error") {
|
if let Some(err) = frame.get("error") {
|
||||||
let code = err.get("code").and_then(serde_json::Value::as_i64).unwrap_or(0);
|
let code = err
|
||||||
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error");
|
.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));
|
return Ok(frame.get("result").cloned().unwrap_or(Value::Null));
|
||||||
@@ -205,8 +230,9 @@ impl LspClient {
|
|||||||
// Cap Content-Length at 64 MiB to prevent OOM from a
|
// Cap Content-Length at 64 MiB to prevent OOM from a
|
||||||
// malicious or misconfigured LSP server (CWE-400).
|
// malicious or misconfigured LSP server (CWE-400).
|
||||||
const MAX_CONTENT_LENGTH: usize = 64 * 1024 * 1024;
|
const MAX_CONTENT_LENGTH: usize = 64 * 1024 * 1024;
|
||||||
let length: usize = len_str.trim().parse::<usize>()
|
let length: usize = len_str.trim().parse::<usize>().map_err(|e| {
|
||||||
.map_err(|e| anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e))?;
|
anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e)
|
||||||
|
})?;
|
||||||
if length > MAX_CONTENT_LENGTH {
|
if length > MAX_CONTENT_LENGTH {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"Content-Length {length} exceeds maximum allowed size of {MAX_CONTENT_LENGTH} bytes",
|
"Content-Length {length} exceeds maximum allowed size of {MAX_CONTENT_LENGTH} bytes",
|
||||||
@@ -220,7 +246,8 @@ impl LspClient {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("missing Content-Length header in LSP response"))?;
|
.ok_or_else(|| anyhow::anyhow!("missing Content-Length header in LSP response"))?;
|
||||||
|
|
||||||
let mut body = vec![0u8; length];
|
let mut body = vec![0u8; length];
|
||||||
self.stdout.read_exact(&mut body)
|
self.stdout
|
||||||
|
.read_exact(&mut body)
|
||||||
.map_err(|e| anyhow::anyhow!("failed to read LSP body ({length} bytes): {e}"))?;
|
.map_err(|e| anyhow::anyhow!("failed to read LSP body ({length} bytes): {e}"))?;
|
||||||
|
|
||||||
let json_str = String::from_utf8(body)
|
let json_str = String::from_utf8(body)
|
||||||
@@ -230,74 +257,98 @@ impl LspClient {
|
|||||||
.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<()> {
|
pub fn did_open(
|
||||||
self.notify("textDocument/didOpen", &json!({
|
&mut self,
|
||||||
"textDocument": {
|
uri: &str,
|
||||||
"uri": uri,
|
language_id: &str,
|
||||||
"languageId": language_id,
|
version: i32,
|
||||||
"version": version,
|
text: &str,
|
||||||
"text": text
|
) -> anyhow::Result<()> {
|
||||||
}
|
self.notify(
|
||||||
}))
|
"textDocument/didOpen",
|
||||||
|
&json!({
|
||||||
|
"textDocument": {
|
||||||
|
"uri": uri,
|
||||||
|
"languageId": language_id,
|
||||||
|
"version": version,
|
||||||
|
"text": text
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> {
|
pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> {
|
||||||
self.notify("textDocument/didChange", &json!({
|
self.notify(
|
||||||
"textDocument": {
|
"textDocument/didChange",
|
||||||
"uri": uri,
|
&json!({
|
||||||
"version": version
|
"textDocument": {
|
||||||
},
|
"uri": uri,
|
||||||
"contentChanges": [{
|
"version": version
|
||||||
"text": text
|
},
|
||||||
}]
|
"contentChanges": [{
|
||||||
}))
|
"text": text
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> {
|
pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> {
|
||||||
self.notify("textDocument/didClose", &json!({
|
self.notify(
|
||||||
"textDocument": {
|
"textDocument/didClose",
|
||||||
"uri": uri
|
&json!({
|
||||||
}
|
"textDocument": {
|
||||||
}))
|
"uri": uri
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
||||||
self.call("textDocument/hover", &json!({
|
self.call(
|
||||||
"textDocument": { "uri": uri },
|
"textDocument/hover",
|
||||||
"position": { "line": line, "character": character }
|
&json!({
|
||||||
}))
|
"textDocument": { "uri": uri },
|
||||||
|
"position": { "line": line, "character": character }
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
||||||
self.call("textDocument/completion", &json!({
|
self.call(
|
||||||
"textDocument": { "uri": uri },
|
"textDocument/completion",
|
||||||
"position": { "line": line, "character": character }
|
&json!({
|
||||||
}))
|
"textDocument": { "uri": uri },
|
||||||
|
"position": { "line": line, "character": character }
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn goto_definition(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
pub fn goto_definition(
|
||||||
self.call("textDocument/definition", &json!({
|
&mut self,
|
||||||
"textDocument": { "uri": uri },
|
uri: &str,
|
||||||
"position": { "line": line, "character": character }
|
line: u32,
|
||||||
}))
|
character: u32,
|
||||||
|
) -> anyhow::Result<Value> {
|
||||||
|
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> {
|
pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
||||||
self.call("textDocument/references", &json!({
|
self.call(
|
||||||
"textDocument": { "uri": uri },
|
"textDocument/references",
|
||||||
"position": { "line": line, "character": character },
|
&json!({
|
||||||
"context": {
|
"textDocument": { "uri": uri },
|
||||||
"includeDeclaration": true
|
"position": { "line": line, "character": character },
|
||||||
}
|
"context": {
|
||||||
}))
|
"includeDeclaration": true
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
#[allow(dead_code)]
|
)
|
||||||
pub fn document_symbols(&mut self, uri: &str) -> anyhow::Result<Value> {
|
|
||||||
self.call("textDocument/documentSymbol", &json!({
|
|
||||||
"textDocument": { "uri": uri }
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn collect_diagnostics(
|
pub fn collect_diagnostics(
|
||||||
@@ -313,64 +364,14 @@ impl LspClient {
|
|||||||
);
|
);
|
||||||
self.did_close(uri)?;
|
self.did_close(uri)?;
|
||||||
match result {
|
match result {
|
||||||
Ok(params) => Ok(params.get("diagnostics").cloned().unwrap_or_else(|| json!([]))),
|
Ok(params) => Ok(params
|
||||||
|
.get("diagnostics")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]))),
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Health-check the LSP server.
|
|
||||||
///
|
|
||||||
/// Sends a `textDocument/documentSymbol` request on a dummy URI with a
|
|
||||||
/// 2-second timeout. Returns `true` if the server responds at all —
|
|
||||||
/// including with an error response such as "file not found", which
|
|
||||||
/// 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
|
|
||||||
/// (alive) or deadline/read error fires (dead).
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn is_alive(&mut self) -> bool {
|
|
||||||
self.next_id += 1;
|
|
||||||
let id = self.next_id;
|
|
||||||
let req = json!({
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": id,
|
|
||||||
"method": "textDocument/documentSymbol",
|
|
||||||
"params": {
|
|
||||||
"textDocument": { "uri": "file:///__zesdex_lsp_health_check__.txt" }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if self.send_frame(&req).is_err() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let timeout = Duration::from_secs(2);
|
|
||||||
let deadline = Instant::now() + timeout;
|
|
||||||
loop {
|
|
||||||
if Instant::now() > deadline {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
match self.read_frame() {
|
|
||||||
Ok(frame) => {
|
|
||||||
if frame.get("id") == Some(&json!(id)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Skip unrelated notifications/responses on the same channel.
|
|
||||||
}
|
|
||||||
Err(_) => return false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send the LSP `exit` notification to request graceful shutdown.
|
|
||||||
///
|
|
||||||
/// Per the LSP spec, `exit` is a notification — the server is expected
|
|
||||||
/// to terminate after receiving it without sending a response. We do
|
|
||||||
/// not block on any reply.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn exit(&mut self) -> anyhow::Result<()> {
|
|
||||||
self.notify("exit", &json!({}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn shutdown(&mut self) {
|
pub fn shutdown(&mut self) {
|
||||||
let _ = self.call_with_timeout("shutdown", &json!({}), Duration::from_secs(5));
|
let _ = self.call_with_timeout("shutdown", &json!({}), Duration::from_secs(5));
|
||||||
let _ = self.notify("exit", &json!({}));
|
let _ = self.notify("exit", &json!({}));
|
||||||
|
|||||||
+54
-113
@@ -13,12 +13,6 @@ pub use client::{path_to_lsp_uri, LspClient};
|
|||||||
/// to issue LSP requests from threads or async tasks.
|
/// to issue LSP requests from threads or async tasks.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct LspServer {
|
pub struct LspServer {
|
||||||
#[allow(dead_code)]
|
|
||||||
pub name: String,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub command: String,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub args: Vec<String>,
|
|
||||||
pub language_id: String,
|
pub language_id: String,
|
||||||
pub client: Arc<Mutex<LspClient>>,
|
pub client: Arc<Mutex<LspClient>>,
|
||||||
}
|
}
|
||||||
@@ -38,12 +32,12 @@ pub struct OpenDoc {
|
|||||||
///
|
///
|
||||||
/// Flow: caller calls `connect*` -> client spawned -> entry pushed to
|
/// Flow: caller calls `connect*` -> client spawned -> entry pushed to
|
||||||
/// `servers` -> `extension_registry` is populated by `register_extensions`.
|
/// `servers` -> `extension_registry` is populated by `register_extensions`.
|
||||||
/// File edits route through `find_server_for_path` / `find_server_for_extension`
|
/// File edits route through `extension_registry` and are dispatched as
|
||||||
/// and are dispatched as `didOpen` / `didChange` notifications.
|
/// `didOpen` / `didChange` notifications.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct LspManager {
|
pub struct LspManager {
|
||||||
pub servers: Vec<LspServer>,
|
pub servers: Vec<LspServer>,
|
||||||
/// Maps file extension (".rs", ".ts", ...) -> server name.
|
/// Maps file extension (".rs", ".ts", ...) -> language id.
|
||||||
pub extension_registry: HashMap<String, String>,
|
pub extension_registry: HashMap<String, String>,
|
||||||
/// Maps document URI -> tracked open document state.
|
/// Maps document URI -> tracked open document state.
|
||||||
pub open_files: HashMap<String, OpenDoc>,
|
pub open_files: HashMap<String, OpenDoc>,
|
||||||
@@ -59,112 +53,73 @@ impl LspManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn an LSP server and register it under `name`.
|
/// Spawn an LSP server and register it under `language_id`.
|
||||||
///
|
///
|
||||||
/// Fails if a server with the same name is already connected.
|
/// Fails if a server with the same `language_id` is already connected.
|
||||||
pub fn connect(
|
pub fn connect(
|
||||||
&mut self,
|
&mut self,
|
||||||
name: &str,
|
|
||||||
command: &str,
|
command: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
language_id: &str,
|
language_id: &str,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
if self.servers.iter().any(|s| s.name == name) {
|
if self.servers.iter().any(|s| s.language_id == language_id) {
|
||||||
anyhow::bail!("LSP server '{name}' is already connected");
|
anyhow::bail!("LSP server for language '{language_id}' is already connected");
|
||||||
}
|
}
|
||||||
let client = LspClient::spawn(command, args)?;
|
let client = LspClient::spawn(command, args)?;
|
||||||
self.servers.push(LspServer {
|
self.servers.push(LspServer {
|
||||||
name: name.to_string(),
|
|
||||||
command: command.to_string(),
|
|
||||||
args: args.to_vec(),
|
|
||||||
language_id: language_id.to_string(),
|
language_id: language_id.to_string(),
|
||||||
client: Arc::new(Mutex::new(client)),
|
client: Arc::new(Mutex::new(client)),
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look up a connected server by name and return a reference to its entry.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn find_server(&self, name: &str) -> Option<&LspServer> {
|
|
||||||
self.servers.iter().find(|s| s.name == name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return a clone of the `Arc<Mutex<LspClient>>` for a connected server.
|
/// Return a clone of the `Arc<Mutex<LspClient>>` for a connected server.
|
||||||
///
|
///
|
||||||
/// Cloning the `Arc` lets callers issue requests without holding a
|
/// Cloning the `Arc` lets callers issue requests without holding a
|
||||||
/// borrow on the manager.
|
/// borrow on the manager.
|
||||||
pub fn get_client(&self, name: &str) -> Option<Arc<Mutex<LspClient>>> {
|
pub fn get_client(&self, language_id: &str) -> Option<Arc<Mutex<LspClient>>> {
|
||||||
self.servers.iter().find(|s| s.name == name).map(|s| s.client.clone())
|
self.servers
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.language_id == language_id)
|
||||||
|
.map(|s| s.client.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shut down and remove a server by name. Returns true if it existed.
|
/// Shut down and remove a server by language. Returns true if it existed.
|
||||||
pub fn disconnect(&mut self, name: &str) -> bool {
|
pub fn disconnect(&mut self, language_id: &str) -> bool {
|
||||||
if let Some(server) = self.servers.iter().find(|s| s.name == name) {
|
if let Some(server) = self.servers.iter().find(|s| s.language_id == language_id) {
|
||||||
if let Ok(mut client) = server.client.lock() {
|
if let Ok(mut client) = server.client.lock() {
|
||||||
client.shutdown();
|
client.shutdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let len = self.servers.len();
|
let len = self.servers.len();
|
||||||
self.servers.retain(|s| s.name != name);
|
self.servers.retain(|s| s.language_id != language_id);
|
||||||
self.servers.len() < len
|
self.servers.len() < len
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the language id (e.g. "rust") registered for `name`.
|
/// Return the language id (e.g. "rust") registered for `language_id`.
|
||||||
pub fn get_language_id(&self, name: &str) -> Option<String> {
|
pub fn get_language_id(&self, language_id: &str) -> Option<String> {
|
||||||
self.servers.iter().find(|s| s.name == name).map(|s| s.language_id.clone())
|
self.servers
|
||||||
}
|
.iter()
|
||||||
|
.find(|s| s.language_id == language_id)
|
||||||
/// Resolve an extension (".rs", ".ts", ...) to its server's client.
|
.map(|s| s.language_id.clone())
|
||||||
///
|
|
||||||
/// Flow: lookup `extension_registry` -> resolve server name -> clone client.
|
|
||||||
/// Returns `None` if no server has been registered for `ext`.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn find_server_for_extension(&self, ext: &str) -> Option<Arc<Mutex<LspClient>>> {
|
|
||||||
self.extension_registry
|
|
||||||
.get(ext)
|
|
||||||
.and_then(|name| self.get_client(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve a file path to its server's client by extension.
|
|
||||||
///
|
|
||||||
/// Flow: extract the extension from `path` -> delegate to
|
|
||||||
/// `find_server_for_extension`. Files without an extension or with
|
|
||||||
/// an unmapped extension return `None`.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
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}"))
|
|
||||||
.and_then(|ext| self.find_server_for_extension(&ext))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a set of file extensions for an already-connected server.
|
/// Register a set of file extensions for an already-connected server.
|
||||||
///
|
///
|
||||||
/// Flow: for each `ext`, write `server_name` into `extension_registry`.
|
/// Flow: for each `ext`, write `language_id` into `extension_registry`.
|
||||||
/// Re-registration overwrites the previous target. Unknown server
|
/// Re-registration overwrites the previous target. Unknown language IDs
|
||||||
/// names are accepted at this layer — caller must ensure `server_name`
|
/// are accepted at this layer — caller must ensure a server for
|
||||||
/// is connected or will be connected later.
|
/// `language_id` is connected or will be connected later.
|
||||||
pub fn register_extensions(&mut self, server_name: &str, extensions: &[&str]) {
|
pub fn register_extensions(&mut self, language_id: &str, extensions: &[&str]) {
|
||||||
for ext in extensions {
|
for ext in extensions {
|
||||||
self.extension_registry.insert(ext.to_string(), server_name.to_string());
|
self.extension_registry
|
||||||
|
.insert(ext.to_string(), language_id.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the registered server name for a given language id.
|
|
||||||
///
|
|
||||||
/// Flow: scan `servers` for the first entry whose `language_id` matches.
|
|
||||||
/// Used when callers have a language hint rather than a file path.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn get_server_name(&self, language: &str) -> Option<String> {
|
|
||||||
self.servers
|
|
||||||
.iter()
|
|
||||||
.find(|s| s.language_id == language)
|
|
||||||
.map(|s| s.name.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Notify the relevant LSP server that a file's contents have changed.
|
/// Notify the relevant LSP server that a file's contents have changed.
|
||||||
///
|
///
|
||||||
/// Flow: resolve server by extension -> read file contents ->
|
/// Flow: resolve language by extension from the registry -> read file contents ->
|
||||||
/// either send `didOpen` (first time) or `didChange` (already tracked)
|
/// either send `didOpen` (first time) or `didChange` (already tracked)
|
||||||
/// -> update `open_files` with the new version.
|
/// -> update `open_files` with the new version.
|
||||||
///
|
///
|
||||||
@@ -172,13 +127,20 @@ impl LspManager {
|
|||||||
/// error) are logged with `tracing::warn!` rather than propagated,
|
/// error) are logged with `tracing::warn!` rather than propagated,
|
||||||
/// so a stale notification cannot abort the calling flow.
|
/// so a stale notification cannot abort the calling flow.
|
||||||
pub fn did_change_file(&mut self, path: &Path) {
|
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 {
|
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);
|
tracing::warn!("did_change_file: path has no extension: {:?}", path);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let server_name = if let Some(name) = self.extension_registry.get(&ext) { name.clone() } else {
|
let Some(language_id) = self.extension_registry.get(&ext).cloned() else {
|
||||||
tracing::warn!("did_change_file: no LSP server registered for extension '{}'", ext);
|
tracing::warn!(
|
||||||
|
"did_change_file: no LSP server registered for extension '{}'",
|
||||||
|
ext
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -192,12 +154,8 @@ impl LspManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let language_id = self
|
let Some(client) = self.get_client(&language_id) else {
|
||||||
.get_language_id(&server_name)
|
tracing::warn!("did_change_file: no client for language '{}'", language_id);
|
||||||
.unwrap_or_else(|| "plaintext".to_string());
|
|
||||||
|
|
||||||
let Some(client) = self.get_client(&server_name) else {
|
|
||||||
tracing::warn!("did_change_file: server '{}' has no client", server_name);
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -210,7 +168,11 @@ impl LspManager {
|
|||||||
let mut client = match client.lock() {
|
let mut client = match client.lock() {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("did_change_file: client mutex poisoned for '{}': {}", server_name, e);
|
tracing::warn!(
|
||||||
|
"did_change_file: client mutex poisoned for '{}': {}",
|
||||||
|
language_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -224,7 +186,7 @@ impl LspManager {
|
|||||||
if let Err(e) = send_result {
|
if let Err(e) = send_result {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"did_change_file: failed to notify '{}' for {}: {}",
|
"did_change_file: failed to notify '{}' for {}: {}",
|
||||||
server_name,
|
language_id,
|
||||||
uri,
|
uri,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
@@ -238,24 +200,6 @@ impl LspManager {
|
|||||||
version: next_version,
|
version: next_version,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record that `server_name` has an open document at `uri`.
|
|
||||||
///
|
|
||||||
/// Flow: insert/overwrite the `OpenDoc` entry in `open_files`.
|
|
||||||
/// Does not contact the LSP server — pure local bookkeeping.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn track_open_doc(&mut self, server_name: &str, uri: &str, language: &str, version: i32) {
|
|
||||||
// server_name retained for future routing extensions; not stored today.
|
|
||||||
let _ = server_name;
|
|
||||||
self.open_files.insert(
|
|
||||||
uri.to_string(),
|
|
||||||
OpenDoc {
|
|
||||||
language: language.to_string(),
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shut down every connected server and clear the server list.
|
/// Shut down every connected server and clear the server list.
|
||||||
@@ -272,21 +216,20 @@ impl LspManager {
|
|||||||
self.servers.clear();
|
self.servers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot the connected servers as `(name, language_id, has_open_docs)` triples.
|
/// Snapshot the connected servers as `(language_id, has_open_docs)` pairs.
|
||||||
///
|
///
|
||||||
/// `has_open_docs` is true if any tracked `OpenDoc` was registered
|
/// `has_open_docs` is true if any tracked `OpenDoc` was registered
|
||||||
/// against this server's clients. Useful for status displays.
|
/// against this server's clients. Useful for status displays.
|
||||||
pub fn list_servers(&self) -> Vec<(String, String, bool)> {
|
pub fn list_servers(&self) -> Vec<(String, bool)> {
|
||||||
self.servers
|
self.servers
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| {
|
.map(|s| {
|
||||||
let name = s.name.clone();
|
|
||||||
let lang = s.language_id.clone();
|
let lang = s.language_id.clone();
|
||||||
let has_open = self
|
let has_open = self
|
||||||
.open_files
|
.open_files
|
||||||
.values()
|
.values()
|
||||||
.any(|d| d.language == s.language_id);
|
.any(|d| d.language == s.language_id);
|
||||||
(name, lang, has_open)
|
(lang, has_open)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -294,18 +237,17 @@ impl LspManager {
|
|||||||
/// Connect an LSP server and register its default extensions in one call.
|
/// Connect an LSP server and register its default extensions in one call.
|
||||||
///
|
///
|
||||||
/// Flow: invoke `connect` -> on success, register `extensions` against
|
/// Flow: invoke `connect` -> on success, register `extensions` against
|
||||||
/// `name` in `extension_registry`. If `connect` fails, the registries
|
/// `language_id` in `extension_registry`. If `connect` fails, the registries
|
||||||
/// are left untouched and the error is propagated.
|
/// are left untouched and the error is propagated.
|
||||||
pub fn connect_with_extensions(
|
pub fn connect_with_extensions(
|
||||||
&mut self,
|
&mut self,
|
||||||
name: &str,
|
|
||||||
command: &str,
|
command: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
language_id: &str,
|
language_id: &str,
|
||||||
extensions: &[&str],
|
extensions: &[&str],
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
self.connect(name, command, args, language_id)?;
|
self.connect(command, args, language_id)?;
|
||||||
self.register_extensions(name, extensions);
|
self.register_extensions(language_id, extensions);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,4 +257,3 @@ impl Default for LspManager {
|
|||||||
Self::new()
|
Self::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+205
-161
@@ -11,7 +11,6 @@
|
|||||||
//! Each tier is a fallback for the previous, so we try the most
|
//! Each tier is a fallback for the previous, so we try the most
|
||||||
//! user-friendly path first (rustup component, npm global, etc.) and
|
//! user-friendly path first (rustup component, npm global, etc.) and
|
||||||
//! only fall back to package managers or manual download if those fail.
|
//! only fall back to package managers or manual download if those fail.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -45,14 +44,11 @@ pub enum ProvisionResult {
|
|||||||
language: String,
|
language: String,
|
||||||
binary_path: String,
|
binary_path: String,
|
||||||
},
|
},
|
||||||
/// Every install tier failed — `manual_instructions` tells the user how
|
/// Every install tier failed. Tells the user how to install by hand.
|
||||||
/// to install by hand.
|
|
||||||
Failed {
|
Failed {
|
||||||
language: String,
|
language: String,
|
||||||
server_name: String,
|
server_name: String,
|
||||||
reason: String,
|
reason: String,
|
||||||
#[allow(dead_code)]
|
|
||||||
manual_instructions: String,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,28 +93,55 @@ pub struct InstallTier {
|
|||||||
pub args: Vec<String>,
|
pub args: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rust toolchain availability on the host PATH.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RustToolchain {
|
||||||
|
pub has_rustup: bool,
|
||||||
|
pub has_cargo: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Web / scripting language toolchain availability.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WebToolchain {
|
||||||
|
pub has_npm: bool,
|
||||||
|
pub has_go: bool,
|
||||||
|
pub has_java: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// General-purpose platform utilities.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PlatformUtils {
|
||||||
|
pub has_curl: bool,
|
||||||
|
pub has_tar: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pacman and Brew package managers (Arch / macOS).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PacmanBrew {
|
||||||
|
pub has_pacman: bool,
|
||||||
|
pub has_brew: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apt and DNF package managers (Debian / Fedora).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AptDnf {
|
||||||
|
pub has_apt: bool,
|
||||||
|
pub has_dnf: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Snapshot of the host environment used to decide which install tiers are viable.
|
/// 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_with_progress()` call so we
|
||||||
/// don't re-shell out for every server. `is_linux` / `is_macos` are
|
/// don't re-shell out for every server. `is_linux` / `is_macos` are
|
||||||
/// computed at startup (compile time would also work, but keeping the
|
/// computed at startup (compile time would also work, but keeping the
|
||||||
/// shape uniform with the rest of the struct makes the call sites tidy).
|
/// shape uniform with the rest of the struct makes the call sites tidy).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[allow(dead_code)]
|
|
||||||
#[allow(clippy::struct_excessive_bools)]
|
|
||||||
pub struct EnvInfo {
|
pub struct EnvInfo {
|
||||||
pub has_rustup: bool,
|
pub rust: RustToolchain,
|
||||||
pub has_npm: bool,
|
pub web: WebToolchain,
|
||||||
pub has_go: bool,
|
pub platform: PlatformUtils,
|
||||||
pub has_java: bool,
|
pub pacman_brew: PacmanBrew,
|
||||||
pub has_cargo: bool,
|
pub apt_dnf: AptDnf,
|
||||||
pub has_curl: bool,
|
|
||||||
pub has_wget: bool,
|
|
||||||
pub has_tar: bool,
|
|
||||||
pub has_pacman: bool,
|
|
||||||
pub has_apt: bool,
|
|
||||||
pub has_brew: bool,
|
|
||||||
pub has_dnf: bool,
|
|
||||||
pub is_linux: bool,
|
pub is_linux: bool,
|
||||||
pub is_macos: bool,
|
pub is_macos: bool,
|
||||||
}
|
}
|
||||||
@@ -159,18 +182,27 @@ pub fn which(binary: &str) -> Option<PathBuf> {
|
|||||||
/// this only ever runs on Unix-like targets.
|
/// this only ever runs on Unix-like targets.
|
||||||
pub fn detect_env() -> EnvInfo {
|
pub fn detect_env() -> EnvInfo {
|
||||||
EnvInfo {
|
EnvInfo {
|
||||||
has_rustup: which("rustup").is_some(),
|
rust: RustToolchain {
|
||||||
has_npm: which("npm").is_some(),
|
has_rustup: which("rustup").is_some(),
|
||||||
has_go: which("go").is_some(),
|
has_cargo: which("cargo").is_some(),
|
||||||
has_java: which("java").is_some(),
|
},
|
||||||
has_cargo: which("cargo").is_some(),
|
web: WebToolchain {
|
||||||
has_curl: which("curl").is_some(),
|
has_npm: which("npm").is_some(),
|
||||||
has_wget: which("wget").is_some(),
|
has_go: which("go").is_some(),
|
||||||
has_tar: which("tar").is_some(),
|
has_java: which("java").is_some(),
|
||||||
has_pacman: which("pacman").is_some(),
|
},
|
||||||
has_apt: which("apt").is_some() || which("apt-get").is_some(),
|
platform: PlatformUtils {
|
||||||
has_brew: which("brew").is_some(),
|
has_curl: which("curl").is_some(),
|
||||||
has_dnf: which("dnf").is_some(),
|
has_tar: which("tar").is_some(),
|
||||||
|
},
|
||||||
|
pacman_brew: PacmanBrew {
|
||||||
|
has_pacman: which("pacman").is_some(),
|
||||||
|
has_brew: which("brew").is_some(),
|
||||||
|
},
|
||||||
|
apt_dnf: AptDnf {
|
||||||
|
has_apt: which("apt").is_some() || which("apt-get").is_some(),
|
||||||
|
has_dnf: which("dnf").is_some(),
|
||||||
|
},
|
||||||
is_linux: cfg!(target_os = "linux"),
|
is_linux: cfg!(target_os = "linux"),
|
||||||
is_macos: cfg!(target_os = "macos"),
|
is_macos: cfg!(target_os = "macos"),
|
||||||
}
|
}
|
||||||
@@ -179,14 +211,13 @@ pub fn detect_env() -> EnvInfo {
|
|||||||
/// Return the static set of supported language servers.
|
/// Return the static set of supported language servers.
|
||||||
///
|
///
|
||||||
/// The order is significant: it determines provisioning order and
|
/// The order is significant: it determines provisioning order and
|
||||||
/// the order results appear in `provision_all()`. Tier 1 paths are
|
/// the order results appear in `provision_all_with_progress()`. Tier 1 paths are
|
||||||
/// the canonical/idiomatic install for each ecosystem; later tiers
|
/// the canonical/idiomatic install for each ecosystem; later tiers
|
||||||
/// are fallbacks for hosts that lack the primary tooling.
|
/// are fallbacks for hosts that lack the primary tooling.
|
||||||
///
|
///
|
||||||
/// Why hard-coded rather than loaded from settings: the set is small,
|
/// Why hard-coded rather than loaded from settings: the set is small,
|
||||||
/// changes rarely, and bundling it lets the provisioner run before any
|
/// changes rarely, and bundling it lets the provisioner run before any
|
||||||
/// user config has been read (e.g. on first launch).
|
/// user config has been read (e.g. on first launch).
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
pub fn supported_servers() -> Vec<LanguageServerDef> {
|
pub fn supported_servers() -> Vec<LanguageServerDef> {
|
||||||
vec![
|
vec![
|
||||||
LanguageServerDef {
|
LanguageServerDef {
|
||||||
@@ -199,13 +230,22 @@ pub fn supported_servers() -> Vec<LanguageServerDef> {
|
|||||||
label: "rustup component".to_string(),
|
label: "rustup component".to_string(),
|
||||||
requires: vec!["rustup".to_string()],
|
requires: vec!["rustup".to_string()],
|
||||||
command: "rustup".to_string(),
|
command: "rustup".to_string(),
|
||||||
args: vec!["component".to_string(), "add".to_string(), "rust-analyzer".to_string()],
|
args: vec![
|
||||||
|
"component".to_string(),
|
||||||
|
"add".to_string(),
|
||||||
|
"rust-analyzer".to_string(),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
InstallTier {
|
InstallTier {
|
||||||
label: "pacman".to_string(),
|
label: "pacman".to_string(),
|
||||||
requires: vec!["pacman".to_string()],
|
requires: vec!["pacman".to_string()],
|
||||||
command: "pacman".to_string(),
|
command: "pacman".to_string(),
|
||||||
args: vec!["-S".to_string(), "--noconfirm".to_string(), "--needed".to_string(), "rust-analyzer".to_string()],
|
args: vec![
|
||||||
|
"-S".to_string(),
|
||||||
|
"--noconfirm".to_string(),
|
||||||
|
"--needed".to_string(),
|
||||||
|
"rust-analyzer".to_string(),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
InstallTier {
|
InstallTier {
|
||||||
label: "brew".to_string(),
|
label: "brew".to_string(),
|
||||||
@@ -217,7 +257,11 @@ pub fn supported_servers() -> Vec<LanguageServerDef> {
|
|||||||
label: "cargo install".to_string(),
|
label: "cargo install".to_string(),
|
||||||
requires: vec!["cargo".to_string()],
|
requires: vec!["cargo".to_string()],
|
||||||
command: "cargo".to_string(),
|
command: "cargo".to_string(),
|
||||||
args: vec!["install".to_string(), "--locked".to_string(), "rust-analyzer".to_string()],
|
args: vec![
|
||||||
|
"install".to_string(),
|
||||||
|
"--locked".to_string(),
|
||||||
|
"rust-analyzer".to_string(),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
InstallTier {
|
InstallTier {
|
||||||
label: "download prebuilt".to_string(),
|
label: "download prebuilt".to_string(),
|
||||||
@@ -268,19 +312,33 @@ pub fn supported_servers() -> Vec<LanguageServerDef> {
|
|||||||
name: "jdtls".to_string(),
|
name: "jdtls".to_string(),
|
||||||
language: "java".to_string(),
|
language: "java".to_string(),
|
||||||
extensions: vec![".java".to_string()],
|
extensions: vec![".java".to_string()],
|
||||||
binary_names: vec!["jdtls".to_string(), "eclipse-jdt-ls".to_string(), "jdtls-launcher".to_string()],
|
binary_names: vec![
|
||||||
|
"jdtls".to_string(),
|
||||||
|
"eclipse-jdt-ls".to_string(),
|
||||||
|
"jdtls-launcher".to_string(),
|
||||||
|
],
|
||||||
install_tiers: vec![
|
install_tiers: vec![
|
||||||
InstallTier {
|
InstallTier {
|
||||||
label: "pacman".to_string(),
|
label: "pacman".to_string(),
|
||||||
requires: vec!["java".to_string(), "pacman".to_string()],
|
requires: vec!["java".to_string(), "pacman".to_string()],
|
||||||
command: "pacman".to_string(),
|
command: "pacman".to_string(),
|
||||||
args: vec!["-S".to_string(), "--noconfirm".to_string(), "--needed".to_string(), "eclipse-jdt-ls".to_string()],
|
args: vec![
|
||||||
|
"-S".to_string(),
|
||||||
|
"--noconfirm".to_string(),
|
||||||
|
"--needed".to_string(),
|
||||||
|
"eclipse-jdt-ls".to_string(),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
InstallTier {
|
InstallTier {
|
||||||
label: "apt".to_string(),
|
label: "apt".to_string(),
|
||||||
requires: vec!["java".to_string(), "apt".to_string()],
|
requires: vec!["java".to_string(), "apt".to_string()],
|
||||||
command: "sudo".to_string(),
|
command: "sudo".to_string(),
|
||||||
args: vec!["apt".to_string(), "install".to_string(), "-y".to_string(), "eclipse-jdt-ls".to_string()],
|
args: vec![
|
||||||
|
"apt".to_string(),
|
||||||
|
"install".to_string(),
|
||||||
|
"-y".to_string(),
|
||||||
|
"eclipse-jdt-ls".to_string(),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
InstallTier {
|
InstallTier {
|
||||||
label: "brew".to_string(),
|
label: "brew".to_string(),
|
||||||
@@ -339,7 +397,9 @@ pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)>
|
|||||||
let timeout = Duration::from_mins(3);
|
let timeout = Duration::from_mins(3);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let status = loop {
|
let status = loop {
|
||||||
if let Some(status) = child.try_wait()? { break Ok(status) }
|
if let Some(status) = child.try_wait()? {
|
||||||
|
break Ok(status);
|
||||||
|
}
|
||||||
if start.elapsed() > timeout {
|
if start.elapsed() > timeout {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
@@ -405,9 +465,12 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
|
|||||||
info!(url = url, dest = %path_str, "downloading");
|
info!(url = url, dest = %path_str, "downloading");
|
||||||
let args = [
|
let args = [
|
||||||
"-fsSL",
|
"-fsSL",
|
||||||
"--connect-timeout", "15",
|
"--connect-timeout",
|
||||||
"--max-time", &max_secs.to_string(),
|
"15",
|
||||||
"-o", &path_str,
|
"--max-time",
|
||||||
|
&max_secs.to_string(),
|
||||||
|
"-o",
|
||||||
|
&path_str,
|
||||||
url,
|
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}"))?;
|
||||||
@@ -419,7 +482,10 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
|
|||||||
|
|
||||||
/// Download rust-analyzer from GitHub releases and install into
|
/// Download rust-analyzer from GitHub releases and install into
|
||||||
/// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`.
|
/// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`.
|
||||||
fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Result<PathBuf, String> {
|
fn install_rust_analyzer_binary(
|
||||||
|
env: &EnvInfo,
|
||||||
|
progress: ProgressFn<'_>,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
let base = lsp_install_dir("rust-analyzer")?;
|
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}"))?;
|
||||||
|
|
||||||
@@ -434,9 +500,13 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu
|
|||||||
let gz = base.join("rust-analyzer.gz");
|
let gz = base.join("rust-analyzer.gz");
|
||||||
let target = base.join("rust-analyzer");
|
let target = base.join("rust-analyzer");
|
||||||
|
|
||||||
if let Some(cb) = progress { cb("Rust: downloading prebuilt binary..."); }
|
if let Some(cb) = progress {
|
||||||
|
cb("Rust: downloading prebuilt binary...");
|
||||||
|
}
|
||||||
download_url(url, &gz, 120)?;
|
download_url(url, &gz, 120)?;
|
||||||
if let Some(cb) = progress { cb("Rust: decompressing..."); }
|
if let Some(cb) = progress {
|
||||||
|
cb("Rust: decompressing...");
|
||||||
|
}
|
||||||
let (ok, out) = run_command("gunzip", &["-f", &gz.to_string_lossy()])
|
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 {
|
if !ok {
|
||||||
@@ -452,7 +522,9 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu
|
|||||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))
|
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 ✓"); }
|
if let Some(cb) = progress {
|
||||||
|
cb("Rust: installed ✓");
|
||||||
|
}
|
||||||
Ok(target)
|
Ok(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,14 +536,24 @@ fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
|
|||||||
|
|
||||||
let url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz";
|
let url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz";
|
||||||
let tarball = base.join("jdtls.tar.gz");
|
let tarball = base.join("jdtls.tar.gz");
|
||||||
if let Some(cb) = progress { cb("Java: downloading JDT-LS (~150MB)..."); }
|
if let Some(cb) = progress {
|
||||||
|
cb("Java: downloading JDT-LS (~150MB)...");
|
||||||
|
}
|
||||||
download_url(url, &tarball, 300)?;
|
download_url(url, &tarball, 300)?;
|
||||||
if let Some(cb) = progress { cb("Java: extracting..."); }
|
if let Some(cb) = progress {
|
||||||
|
cb("Java: extracting...");
|
||||||
|
}
|
||||||
|
|
||||||
let (ok, out) = run_command("tar", &[
|
let (ok, out) = run_command(
|
||||||
"-xzf", tarball.to_str().unwrap_or(""),
|
"tar",
|
||||||
"-C", base.to_str().unwrap_or("."),
|
&[
|
||||||
]).map_err(|e| format!("tar spawn: {e}"))?;
|
"-xzf",
|
||||||
|
tarball.to_str().unwrap_or(""),
|
||||||
|
"-C",
|
||||||
|
base.to_str().unwrap_or("."),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("tar spawn: {e}"))?;
|
||||||
if !ok {
|
if !ok {
|
||||||
return Err(format!("tar: {}", out.trim()));
|
return Err(format!("tar: {}", out.trim()));
|
||||||
}
|
}
|
||||||
@@ -510,12 +592,18 @@ exec java \
|
|||||||
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755))
|
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 ✓"); }
|
if let Some(cb) = progress {
|
||||||
|
cb("Java: JDT-LS installed ✓");
|
||||||
|
}
|
||||||
Ok(launcher)
|
Ok(launcher)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch a sentinel download tier to the correct helper.
|
/// Dispatch a sentinel download tier to the correct helper.
|
||||||
fn run_download_tier(name: &str, env: &EnvInfo, progress: ProgressFn<'_>) -> Result<PathBuf, String> {
|
fn run_download_tier(
|
||||||
|
name: &str,
|
||||||
|
env: &EnvInfo,
|
||||||
|
progress: ProgressFn<'_>,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
match name {
|
match name {
|
||||||
DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress),
|
DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress),
|
||||||
DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress),
|
DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress),
|
||||||
@@ -523,59 +611,17 @@ fn run_download_tier(name: &str, env: &EnvInfo, progress: ProgressFn<'_>) -> Res
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the "install by hand" message shown to the user when every
|
fn provision_single_with_progress(
|
||||||
/// automated tier fails.
|
def: &LanguageServerDef,
|
||||||
fn manual_instructions(def: &LanguageServerDef) -> String {
|
env: &EnvInfo,
|
||||||
match def.language.as_str() {
|
progress: ProgressFn<'_>,
|
||||||
"rust" => "Install rust-analyzer:\n \
|
) -> ProvisionResult {
|
||||||
Arch: sudo pacman -S rust-analyzer\n \
|
|
||||||
macOS: brew install rust-analyzer\n \
|
|
||||||
Any: cargo install --locked rust-analyzer\n \
|
|
||||||
Rustup: rustup component add rust-analyzer"
|
|
||||||
.to_string(),
|
|
||||||
"typescript" => "Install typescript-language-server:\n \
|
|
||||||
npm install -g typescript typescript-language-server\n \
|
|
||||||
Arch: sudo pacman -S typescript-language-server"
|
|
||||||
.to_string(),
|
|
||||||
"go" => "Install gopls:\n \
|
|
||||||
go install golang.org/x/tools/gopls@latest\n \
|
|
||||||
Arch: sudo pacman -S gopls"
|
|
||||||
.to_string(),
|
|
||||||
"java" => "Install Eclipse JDT-LS:\n \
|
|
||||||
Arch: sudo pacman -S eclipse-jdt-ls\n \
|
|
||||||
Debian: sudo apt install eclipse-jdt-ls\n \
|
|
||||||
macOS: brew install jdtls\n \
|
|
||||||
Other: see https://.eclipse.org/jdtls/#download"
|
|
||||||
.to_string(),
|
|
||||||
_ => format!("No automated install available for '{}'.", def.language),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to provision a single language server.
|
|
||||||
///
|
|
||||||
/// Flow: check whether any `binary_names` candidate is already on PATH
|
|
||||||
/// → 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
|
|
||||||
/// appears on PATH (or the tier is jdtls-manual returning a
|
|
||||||
/// launcher path), return Installed → if every tier fails, return
|
|
||||||
/// Failed with the last error and manual install instructions.
|
|
||||||
///
|
|
||||||
/// Why we re-check `which` after the install: `rustup component add`
|
|
||||||
/// can exit 0 even if the binary wasn't actually placed on PATH (rare,
|
|
||||||
/// but happens with broken rustup installs). Re-checking gives us a
|
|
||||||
/// real signal rather than trusting the exit code alone.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn provision_single(def: &LanguageServerDef, env: &EnvInfo) -> ProvisionResult {
|
|
||||||
provision_single_with_progress(def, env, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progress: ProgressFn<'_>) -> ProvisionResult {
|
|
||||||
// 1. Check PATH.
|
// 1. Check PATH.
|
||||||
for bin in &def.binary_names {
|
for bin in &def.binary_names {
|
||||||
if let Some(path) = which(bin) {
|
if let Some(path) = which(bin) {
|
||||||
if let Some(cb) = progress { cb(&format!("{}: already installed (PATH)", def.language)); }
|
if let Some(cb) = progress {
|
||||||
|
cb(&format!("{}: already installed (PATH)", def.language));
|
||||||
|
}
|
||||||
return ProvisionResult::AlreadyAvailable {
|
return ProvisionResult::AlreadyAvailable {
|
||||||
server_name: def.name.clone(),
|
server_name: def.name.clone(),
|
||||||
language: def.language.clone(),
|
language: def.language.clone(),
|
||||||
@@ -586,7 +632,9 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre
|
|||||||
|
|
||||||
// 2. Check download-install directory (~/.local/share/zesdex/lsp/<name>/...).
|
// 2. Check download-install directory (~/.local/share/zesdex/lsp/<name>/...).
|
||||||
if let Some(path) = previous_download_install(def) {
|
if let Some(path) = previous_download_install(def) {
|
||||||
if let Some(cb) = progress { cb(&format!("{}: found previous install", def.language)); }
|
if let Some(cb) = progress {
|
||||||
|
cb(&format!("{}: found previous install", def.language));
|
||||||
|
}
|
||||||
return ProvisionResult::AlreadyAvailable {
|
return ProvisionResult::AlreadyAvailable {
|
||||||
server_name: def.name.clone(),
|
server_name: def.name.clone(),
|
||||||
language: def.language.clone(),
|
language: def.language.clone(),
|
||||||
@@ -594,30 +642,42 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(cb) = progress { cb(&format!("{}: checking install options...", def.language)); }
|
if let Some(cb) = progress {
|
||||||
|
cb(&format!("{}: checking install options...", def.language));
|
||||||
|
}
|
||||||
|
|
||||||
let mut last_reason = String::from("no install tiers succeeded");
|
let mut last_reason = String::from("no install tiers succeeded");
|
||||||
|
|
||||||
for tier in &def.install_tiers {
|
for tier in &def.install_tiers {
|
||||||
// Prerequisite gating
|
// Prerequisite gating
|
||||||
let prereqs_met = tier.requires.iter().all(|req| match req.as_str() {
|
let prereqs_met = tier.requires.iter().all(|req| match req.as_str() {
|
||||||
"rustup" => env.has_rustup, "npm" => env.has_npm,
|
"rustup" => env.rust.has_rustup,
|
||||||
"go" => env.has_go, "java" => env.has_java,
|
"npm" => env.web.has_npm,
|
||||||
"cargo" => env.has_cargo, "curl" => env.has_curl,
|
"go" => env.web.has_go,
|
||||||
"tar" => env.has_tar, "pacman" => env.has_pacman,
|
"java" => env.web.has_java,
|
||||||
"apt" => env.has_apt, "brew" => env.has_brew,
|
"cargo" => env.rust.has_cargo,
|
||||||
"dnf" => env.has_dnf, _ => which(req).is_some(),
|
"curl" => env.platform.has_curl,
|
||||||
|
"tar" => env.platform.has_tar,
|
||||||
|
"pacman" => env.pacman_brew.has_pacman,
|
||||||
|
"apt" => env.apt_dnf.has_apt,
|
||||||
|
"brew" => env.pacman_brew.has_brew,
|
||||||
|
"dnf" => env.apt_dnf.has_dnf,
|
||||||
|
_ => which(req).is_some(),
|
||||||
});
|
});
|
||||||
if !prereqs_met {
|
if !prereqs_met {
|
||||||
let skip = format!("{}: {} — missing prerequisite", def.language, tier.label);
|
let skip = format!("{}: {} — missing prerequisite", def.language, tier.label);
|
||||||
if let Some(cb) = progress { cb(&skip); }
|
if let Some(cb) = progress {
|
||||||
|
cb(&skip);
|
||||||
|
}
|
||||||
last_reason = format!("tier '{}' skipped: missing prerequisite", tier.label);
|
last_reason = format!("tier '{}' skipped: missing prerequisite", tier.label);
|
||||||
warn!(server = %def.name, tier = %tier.label, "skipped — missing prerequisites");
|
warn!(server = %def.name, tier = %tier.label, "skipped — missing prerequisites");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let trying = format!("{}: {}...", def.language, tier.label);
|
let trying = format!("{}: {}...", def.language, tier.label);
|
||||||
if let Some(cb) = progress { cb(&trying); }
|
if let Some(cb) = progress {
|
||||||
|
cb(&trying);
|
||||||
|
}
|
||||||
|
|
||||||
// Download sentinel → helper.
|
// Download sentinel → helper.
|
||||||
if tier.command.starts_with("__download_") && tier.command.ends_with("__") {
|
if tier.command.starts_with("__download_") && tier.command.ends_with("__") {
|
||||||
@@ -647,7 +707,9 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre
|
|||||||
.iter()
|
.iter()
|
||||||
.find_map(|b| which(b).map(|p| p.to_string_lossy().to_string()));
|
.find_map(|b| which(b).map(|p| p.to_string_lossy().to_string()));
|
||||||
if let Some(path) = located {
|
if let Some(path) = located {
|
||||||
if let Some(cb) = progress { cb(&format!("{}: installed ✓", def.language)); }
|
if let Some(cb) = progress {
|
||||||
|
cb(&format!("{}: installed ✓", def.language));
|
||||||
|
}
|
||||||
info!(server = %def.name, tier = %tier.label, binary = %path, "installed");
|
info!(server = %def.name, tier = %tier.label, binary = %path, "installed");
|
||||||
return ProvisionResult::Installed {
|
return ProvisionResult::Installed {
|
||||||
server_name: def.name.clone(),
|
server_name: def.name.clone(),
|
||||||
@@ -671,58 +733,36 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let manual = manual_instructions(def);
|
|
||||||
ProvisionResult::Failed {
|
ProvisionResult::Failed {
|
||||||
language: def.language.clone(), server_name: def.name.clone(),
|
language: def.language.clone(),
|
||||||
reason: last_reason, manual_instructions: manual,
|
server_name: def.name.clone(),
|
||||||
|
reason: last_reason,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provision every supported server in order, returning one
|
/// Provision every supported server with progress callbacks with a human-readable status
|
||||||
/// `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).
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn provision_all() -> Vec<ProvisionResult> {
|
|
||||||
let env = detect_env();
|
|
||||||
info!(
|
|
||||||
linux = env.is_linux,
|
|
||||||
macos = env.is_macos,
|
|
||||||
rustup = env.has_rustup,
|
|
||||||
cargo = env.has_cargo,
|
|
||||||
npm = env.has_npm,
|
|
||||||
go = env.has_go,
|
|
||||||
java = env.has_java,
|
|
||||||
curl = env.has_curl,
|
|
||||||
tar = env.has_tar,
|
|
||||||
pacman = env.has_pacman,
|
|
||||||
apt = env.has_apt,
|
|
||||||
brew = env.has_brew,
|
|
||||||
dnf = env.has_dnf,
|
|
||||||
"starting LSP provisioning"
|
|
||||||
);
|
|
||||||
supported_servers()
|
|
||||||
.iter()
|
|
||||||
.map(|def| provision_single(def, &env))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like `provision_all` but calls `progress` with a human-readable status
|
|
||||||
/// string at each stage of each server's install attempt.
|
/// string at each stage of each server's install attempt.
|
||||||
pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult> {
|
pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult> {
|
||||||
let env = detect_env();
|
let env = detect_env();
|
||||||
if let Some(cb) = progress {
|
if let Some(cb) = progress {
|
||||||
let flags = [
|
let flags = [
|
||||||
("rustup", env.has_rustup), ("cargo", env.has_cargo),
|
("rustup", env.rust.has_rustup),
|
||||||
("npm", env.has_npm), ("go", env.has_go), ("java", env.has_java),
|
("cargo", env.rust.has_cargo),
|
||||||
("curl", env.has_curl), ("tar", env.has_tar),
|
("npm", env.web.has_npm),
|
||||||
("pacman", env.has_pacman), ("apt", env.has_apt), ("brew", env.has_brew),
|
("go", env.web.has_go),
|
||||||
|
("java", env.web.has_java),
|
||||||
|
("curl", env.platform.has_curl),
|
||||||
|
("tar", env.platform.has_tar),
|
||||||
|
("pacman", env.pacman_brew.has_pacman),
|
||||||
|
("apt", env.apt_dnf.has_apt),
|
||||||
|
("brew", env.pacman_brew.has_brew),
|
||||||
];
|
];
|
||||||
let avail: String = flags.iter()
|
let avail: String = flags
|
||||||
.filter(|(_, v)| *v).map(|(k, _)| *k)
|
.iter()
|
||||||
.collect::<Vec<_>>().join(", ");
|
.filter(|(_, v)| *v)
|
||||||
|
.map(|(k, _)| *k)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
cb(&format!("LSP: environment ready — {avail}"));
|
cb(&format!("LSP: environment ready — {avail}"));
|
||||||
}
|
}
|
||||||
supported_servers()
|
supported_servers()
|
||||||
@@ -779,9 +819,13 @@ pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build extension slice for connect_with_extensions.
|
// Build extension slice for connect_with_extensions.
|
||||||
let ext_refs: Vec<&str> = def.extensions.iter().map(std::string::String::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) {
|
match guard.connect_with_extensions(&binary, &[], &language, &ext_refs) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
info!(
|
info!(
|
||||||
name = %name,
|
name = %name,
|
||||||
|
|||||||
+129
-91
@@ -1,13 +1,11 @@
|
|||||||
//! MCP server connection management: spawning/talking to stdio child
|
//! MCP server connection management: spawning/talking to stdio child
|
||||||
//! processes and HTTP endpoints, and adapting their advertised tools to
|
//! processes and HTTP endpoints, and adapting their advertised tools to
|
||||||
//! the crate's `Tool` trait.
|
//! the crate's `Tool` trait.
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
|
|
||||||
const MCP_CONNECT_TIMEOUT_MS: u64 = 20_000;
|
const MCP_CONNECT_TIMEOUT_MS: u64 = 20_000;
|
||||||
const MCP_CALL_TIMEOUT_MS: u64 = 60_000;
|
const MCP_CALL_TIMEOUT_MS: u64 = 60_000;
|
||||||
|
|
||||||
@@ -35,13 +33,8 @@ fn mcp_static_str(s: &str) -> &'static str {
|
|||||||
/// newline-delimited JSON-RPC over stdio, or a remote HTTP endpoint.
|
/// newline-delimited JSON-RPC over stdio, or a remote HTTP endpoint.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum McpTransport {
|
pub enum McpTransport {
|
||||||
Stdio {
|
Stdio { command: String, args: Vec<String> },
|
||||||
command: String,
|
StreamableHttp { url: String },
|
||||||
args: Vec<String>,
|
|
||||||
},
|
|
||||||
StreamableHttp {
|
|
||||||
url: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single tool advertised by an MCP server, as returned by `tools/list`.
|
/// A single tool advertised by an MCP server, as returned by `tools/list`.
|
||||||
@@ -104,8 +97,8 @@ impl StdioChild {
|
|||||||
self.stdin.flush()?;
|
self.stdin.flush()?;
|
||||||
|
|
||||||
let mut response_line = String::new();
|
let mut response_line = String::new();
|
||||||
let deadline = std::time::Instant::now()
|
let deadline =
|
||||||
+ std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS);
|
std::time::Instant::now() + std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS);
|
||||||
loop {
|
loop {
|
||||||
if std::time::Instant::now() > deadline {
|
if std::time::Instant::now() > deadline {
|
||||||
anyhow::bail!("MCP call timed out after {MCP_CALL_TIMEOUT_MS}ms");
|
anyhow::bail!("MCP call timed out after {MCP_CALL_TIMEOUT_MS}ms");
|
||||||
@@ -136,7 +129,9 @@ impl StdioChild {
|
|||||||
line_truncated = true;
|
line_truncated = true;
|
||||||
// Consume rest of line to keep stream in sync
|
// Consume rest of line to keep stream in sync
|
||||||
loop {
|
loop {
|
||||||
let buf = self.stdout.fill_buf()
|
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() {
|
if buf.is_empty() {
|
||||||
anyhow::bail!("MCP stdio child closed mid-line");
|
anyhow::bail!("MCP stdio child closed mid-line");
|
||||||
@@ -152,9 +147,7 @@ impl StdioChild {
|
|||||||
response_line.push(byte as char);
|
response_line.push(byte as char);
|
||||||
}
|
}
|
||||||
if line_truncated {
|
if line_truncated {
|
||||||
anyhow::bail!(
|
anyhow::bail!("MCP response line exceeded {MAX_LINE_LENGTH} byte limit");
|
||||||
"MCP response line exceeded {MAX_LINE_LENGTH} byte limit",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let trimmed = response_line.trim();
|
let trimmed = response_line.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -172,12 +165,16 @@ impl StdioChild {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // close fn call
|
} // close fn call
|
||||||
} // close impl StdioChild
|
} // close impl StdioChild
|
||||||
|
|
||||||
pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow::Result<StdioChild> {
|
pub(crate) fn spawn_stdio_child(
|
||||||
|
command: &str,
|
||||||
|
extra_args: &[String],
|
||||||
|
) -> anyhow::Result<StdioChild> {
|
||||||
let parts: Vec<&str> = command.split_whitespace().collect();
|
let parts: Vec<&str> = command.split_whitespace().collect();
|
||||||
let (prog, prog_args) = parts.split_first()
|
let (prog, prog_args) = parts
|
||||||
|
.split_first()
|
||||||
.ok_or_else(|| anyhow::anyhow!("MCP stdio command is empty"))?;
|
.ok_or_else(|| anyhow::anyhow!("MCP stdio command is empty"))?;
|
||||||
|
|
||||||
let mut cmd = std::process::Command::new(prog);
|
let mut cmd = std::process::Command::new(prog);
|
||||||
@@ -189,12 +186,17 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
|
|||||||
// rather than discarded silently, making connectivity issues debugable.
|
// rather than discarded silently, making connectivity issues debugable.
|
||||||
cmd.stderr(std::process::Stdio::piped());
|
cmd.stderr(std::process::Stdio::piped());
|
||||||
|
|
||||||
let mut child = cmd.spawn()
|
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()
|
let stdin = child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
.ok_or_else(|| anyhow::anyhow!("failed to get stdin for MCP server"))?;
|
.ok_or_else(|| anyhow::anyhow!("failed to get stdin for MCP server"))?;
|
||||||
let stdout = child.stdout.take()
|
let stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
.ok_or_else(|| anyhow::anyhow!("failed to get stdout for MCP server"))?;
|
.ok_or_else(|| anyhow::anyhow!("failed to get stdout for MCP server"))?;
|
||||||
|
|
||||||
let mut mcp = StdioChild {
|
let mut mcp = StdioChild {
|
||||||
@@ -203,17 +205,20 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
|
|||||||
next_id: 0,
|
next_id: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let deadline = std::time::Instant::now()
|
let deadline =
|
||||||
+ std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS);
|
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(
|
||||||
"protocolVersion": "2024-11-05",
|
"initialize",
|
||||||
"capabilities": {},
|
&json!({
|
||||||
"clientInfo": {
|
"protocolVersion": "2024-11-05",
|
||||||
"name": "zesdex",
|
"capabilities": {},
|
||||||
"version": "0.1.0"
|
"clientInfo": {
|
||||||
}
|
"name": "zesdex",
|
||||||
}));
|
"version": "0.1.0"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
if std::time::Instant::now() > deadline {
|
if std::time::Instant::now() > deadline {
|
||||||
anyhow::bail!("MCP initialize timed out");
|
anyhow::bail!("MCP initialize timed out");
|
||||||
@@ -236,21 +241,29 @@ fn call_via_stdio(
|
|||||||
// Reuse the persistent child handle if available; otherwise spawn a new one.
|
// Reuse the persistent child handle if available; otherwise spawn a new one.
|
||||||
let mut guard;
|
let mut guard;
|
||||||
let child: &mut StdioChild = if let Some(mtx) = existing_handle {
|
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
|
&mut guard
|
||||||
} else {
|
} else {
|
||||||
let mut fresh = spawn_stdio_child(command, extra_args)?;
|
let mut fresh = spawn_stdio_child(command, extra_args)?;
|
||||||
let result = fresh.call("tools/call", &json!({
|
let result = fresh.call(
|
||||||
"name": tool_name,
|
"tools/call",
|
||||||
"arguments": tool_args
|
&json!({
|
||||||
}))?;
|
"name": tool_name,
|
||||||
|
"arguments": tool_args
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
return Ok(extract_text_content(&result));
|
return Ok(extract_text_content(&result));
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = child.call("tools/call", &json!({
|
let result = child.call(
|
||||||
"name": tool_name,
|
"tools/call",
|
||||||
"arguments": tool_args
|
&json!({
|
||||||
}))?;
|
"name": tool_name,
|
||||||
|
"arguments": tool_args
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
|
||||||
Ok(extract_text_content(&result))
|
Ok(extract_text_content(&result))
|
||||||
}
|
}
|
||||||
@@ -289,7 +302,8 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let resp = client.post(url)
|
let resp = client
|
||||||
|
.post(url)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
@@ -304,7 +318,8 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
|
|||||||
anyhow::bail!("MCP HTTP server returned {status}: {text}");
|
anyhow::bail!("MCP HTTP server returned {status}: {text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let response: Value = resp.json()
|
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") {
|
if let Some(err) = response.get("error") {
|
||||||
@@ -321,13 +336,18 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
|
|||||||
fn extract_text_content(result: &Value) -> String {
|
fn extract_text_content(result: &Value) -> String {
|
||||||
if let Some(content) = result.get("content") {
|
if let Some(content) = result.get("content") {
|
||||||
if let Some(arr) = content.as_array() {
|
if let Some(arr) = content.as_array() {
|
||||||
let text: Vec<String> = arr.iter().filter_map(|item| {
|
let text: Vec<String> = arr
|
||||||
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
|
.iter()
|
||||||
item.get("text").and_then(|t| t.as_str()).map(std::string::ToString::to_string)
|
.filter_map(|item| {
|
||||||
} else {
|
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
|
||||||
None
|
item.get("text")
|
||||||
}
|
.and_then(|t| t.as_str())
|
||||||
}).collect();
|
.map(std::string::ToString::to_string)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
return text.join("\n");
|
return text.join("\n");
|
||||||
}
|
}
|
||||||
@@ -372,12 +392,17 @@ impl crate::tool::Tool for McpToolAdapter {
|
|||||||
|
|
||||||
fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result<String> {
|
fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result<String> {
|
||||||
match &self.transport {
|
match &self.transport {
|
||||||
McpTransport::Stdio { command, args: extra_args } => {
|
McpTransport::Stdio {
|
||||||
call_via_stdio(self.child_handle.as_ref().map(std::convert::AsRef::as_ref), command, extra_args, &self.tool_name, args)
|
command,
|
||||||
}
|
args: extra_args,
|
||||||
McpTransport::StreamableHttp { url } => {
|
} => call_via_stdio(
|
||||||
call_via_http(url, &self.tool_name, args)
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,27 +425,35 @@ impl McpManager {
|
|||||||
///
|
///
|
||||||
/// Return: boxed `Tool` trait objects ready to merge into the harness's tool list.
|
/// Return: boxed `Tool` trait objects ready to merge into the harness's tool list.
|
||||||
pub fn as_tools(&self) -> Vec<Box<dyn crate::tool::Tool>> {
|
pub fn as_tools(&self) -> Vec<Box<dyn crate::tool::Tool>> {
|
||||||
self.servers.iter().flat_map(|server| {
|
self.servers
|
||||||
let handle = server.child_handle.clone();
|
.iter()
|
||||||
server.tools.iter().map(move |info| {
|
.flat_map(|server| {
|
||||||
let adapter: Box<dyn crate::tool::Tool> = Box::new(McpToolAdapter {
|
let handle = server.child_handle.clone();
|
||||||
tool_name: info.name.clone(),
|
server.tools.iter().map(move |info| {
|
||||||
server_name: server.name.clone(),
|
let adapter: Box<dyn crate::tool::Tool> = Box::new(McpToolAdapter {
|
||||||
transport: server.transport.clone(),
|
tool_name: info.name.clone(),
|
||||||
description: info.description.clone(),
|
server_name: server.name.clone(),
|
||||||
parameters: info.input_schema.clone(),
|
transport: server.transport.clone(),
|
||||||
child_handle: handle.clone(),
|
description: info.description.clone(),
|
||||||
});
|
parameters: info.input_schema.clone(),
|
||||||
adapter
|
child_handle: handle.clone(),
|
||||||
|
});
|
||||||
|
adapter
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}).collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connects to an MCP server via stdio by spawning the child process, running
|
/// Connects to an MCP server via stdio by spawning the child process, running
|
||||||
/// the `initialize` handshake, calling `tools/list`, and registering the server
|
/// the `initialize` handshake, calling `tools/list`, and registering the server
|
||||||
/// with its advertised tools in `self.servers`. The child process stays alive
|
/// with its advertised tools in `self.servers`. The child process stays alive
|
||||||
/// for subsequent `tools/call` invocations via the stored `McpServer.tools`.
|
/// for subsequent `tools/call` invocations via the stored `McpServer.tools`.
|
||||||
pub fn connect_stdio(&mut self, name: &str, command: &str, extra_args: &[String]) -> anyhow::Result<()> {
|
pub fn connect_stdio(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
command: &str,
|
||||||
|
extra_args: &[String],
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
let transport = McpTransport::Stdio {
|
let transport = McpTransport::Stdio {
|
||||||
command: command.to_string(),
|
command: command.to_string(),
|
||||||
args: extra_args.to_vec(),
|
args: extra_args.to_vec(),
|
||||||
@@ -430,19 +463,32 @@ impl McpManager {
|
|||||||
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()) {
|
let tools = if let Some(tool_list) = result.get("tools").and_then(|v| v.as_array()) {
|
||||||
tool_list.iter().filter_map(|t| {
|
tool_list
|
||||||
Some(McpToolInfo {
|
.iter()
|
||||||
name: t.get("name")?.as_str()?.to_string(),
|
.filter_map(|t| {
|
||||||
description: t.get("description").and_then(|v| v.as_str()).unwrap_or_else(|| {
|
Some(McpToolInfo {
|
||||||
tracing::warn!("[mcp] tool {} missing description", t.get("name").and_then(|n| n.as_str()).unwrap_or("?"));
|
name: t.get("name")?.as_str()?.to_string(),
|
||||||
""
|
description: t
|
||||||
}).to_string(),
|
.get("description")
|
||||||
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| {
|
.and_then(|v| v.as_str())
|
||||||
tracing::warn!("[mcp] tool {} missing inputSchema", t.get("name").and_then(|n| n.as_str()).unwrap_or("?"));
|
.unwrap_or_else(|| {
|
||||||
serde_json::Value::Null
|
tracing::warn!(
|
||||||
}),
|
"[mcp] tool {} missing description",
|
||||||
|
t.get("name").and_then(|n| n.as_str()).unwrap_or("?")
|
||||||
|
);
|
||||||
|
""
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| {
|
||||||
|
tracing::warn!(
|
||||||
|
"[mcp] tool {} missing inputSchema",
|
||||||
|
t.get("name").and_then(|n| n.as_str()).unwrap_or("?")
|
||||||
|
);
|
||||||
|
serde_json::Value::Null
|
||||||
|
}),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}).collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
@@ -458,12 +504,4 @@ impl McpManager {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes a server by name. Returns `true` if a server was found and removed.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn disconnect(&mut self, name: &str) -> bool {
|
|
||||||
let len = self.servers.len();
|
|
||||||
self.servers.retain(|s| s.name != name);
|
|
||||||
self.servers.len() < len
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
//! Model Context Protocol (MCP) client: connects to external MCP servers
|
//! Model Context Protocol (MCP) client: connects to external MCP servers
|
||||||
//! (stdio or HTTP) and exposes their tools through the crate's `Tool` trait.
|
//! (stdio or HTTP) and exposes their tools through the crate's `Tool` trait.
|
||||||
|
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
|
|||||||
+5
-5
@@ -1,13 +1,13 @@
|
|||||||
//! Top-level application module: harness, modes, runtime loop, state,
|
//! Top-level application module: harness, modes, runtime loop, state,
|
||||||
//! workflows, subagents, review, background bash, MCP integration, and
|
//! workflows, subagents, review, background bash, MCP integration, and
|
||||||
//! native LSP client.
|
//! native LSP client.
|
||||||
|
pub mod bgbash;
|
||||||
pub mod harness;
|
pub mod harness;
|
||||||
|
pub mod lsp;
|
||||||
|
pub mod mcp;
|
||||||
pub mod mode;
|
pub mod mode;
|
||||||
|
pub mod review;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod workflow;
|
|
||||||
pub mod subagent;
|
pub mod subagent;
|
||||||
pub mod review;
|
pub mod workflow;
|
||||||
pub mod bgbash;
|
|
||||||
pub mod mcp;
|
|
||||||
pub mod lsp;
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! Bash mode: handles submitting a shell command from the bash input panel.
|
//! Bash mode: handles submitting a shell command from the bash input panel.
|
||||||
|
|
||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
|
||||||
/// Launch a background bash job for the submitted command.
|
/// Launch a background bash job for the submitted command.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Editor mode: a minimal in-TUI line editor for viewing/modifying a file,
|
//! Editor mode: a minimal in-TUI line editor for viewing/modifying a file,
|
||||||
//! with bounded undo history.
|
//! with bounded undo history.
|
||||||
|
|
||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
use crate::app::state::types::Overlay;
|
use crate::app::state::types::Overlay;
|
||||||
|
|
||||||
@@ -66,7 +65,9 @@ impl EditorState {
|
|||||||
self.cursor_line += 1;
|
self.cursor_line += 1;
|
||||||
}
|
}
|
||||||
self.cursor_col = self.cursor_col.min(
|
self.cursor_col = self.cursor_col.min(
|
||||||
self.content.get(self.cursor_line).map_or(0, std::string::String::len),
|
self.content
|
||||||
|
.get(self.cursor_line)
|
||||||
|
.map_or(0, std::string::String::len),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,10 +115,9 @@ impl EditorState {
|
|||||||
/// the char directly → mark state dirty.
|
/// the char directly → mark state dirty.
|
||||||
pub fn handle_editor_input(state: &mut AppStateRest, text: &str) {
|
pub fn handle_editor_input(state: &mut AppStateRest, text: &str) {
|
||||||
let editor = &mut state.misc.editor;
|
let editor = &mut state.misc.editor;
|
||||||
if editor.is_none() {
|
let Some(ed) = editor.as_mut() else {
|
||||||
return;
|
return;
|
||||||
}
|
};
|
||||||
let ed = editor.as_mut().unwrap();
|
|
||||||
for c in text.chars() {
|
for c in text.chars() {
|
||||||
match c {
|
match c {
|
||||||
'\n' | '\r' => {
|
'\n' | '\r' => {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
#![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
|
//! 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;
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
|
||||||
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! Help mode: static help text and the action that opens/closes the help overlay.
|
//! Help mode: static help text and the action that opens/closes the help overlay.
|
||||||
|
|
||||||
use crate::app::runtime::actions::Action;
|
use crate::app::runtime::actions::Action;
|
||||||
use crate::app::state::types::Overlay;
|
use crate::app::state::types::Overlay;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! Key input mode: raw text capture overlay used for one-off key/text prompts.
|
//! Key input mode: raw text capture overlay used for one-off key/text prompts.
|
||||||
|
|
||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
|
||||||
/// Replace the input buffer with the given text and mark state dirty.
|
/// Replace the input buffer with the given text and mark state dirty.
|
||||||
|
|||||||
@@ -33,14 +33,16 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
|
|||||||
let scope_str = match p.lesson.scope {
|
let scope_str = match p.lesson.scope {
|
||||||
crate::app::review::LessonScope::Project => "project",
|
crate::app::review::LessonScope::Project => "project",
|
||||||
crate::app::review::LessonScope::Global => "global",
|
crate::app::review::LessonScope::Global => "global",
|
||||||
}.to_string();
|
}
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let conf_str = match p.lesson.confidence {
|
let conf_str = match p.lesson.confidence {
|
||||||
crate::app::review::Confidence::Human => "human",
|
crate::app::review::Confidence::Human => "human",
|
||||||
crate::app::review::Confidence::Verified => "verified",
|
crate::app::review::Confidence::Verified => "verified",
|
||||||
crate::app::review::Confidence::Unverified => "unverified",
|
crate::app::review::Confidence::Unverified => "unverified",
|
||||||
crate::app::review::Confidence::Auto => "auto",
|
crate::app::review::Confidence::Auto => "auto",
|
||||||
}.to_string();
|
}
|
||||||
|
.to_string();
|
||||||
|
|
||||||
items.push(LearningItem::Pending {
|
items.push(LearningItem::Pending {
|
||||||
name: p.lesson.name,
|
name: p.lesson.name,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! Loading mode: transient overlay shown while waiting on an async operation.
|
//! Loading mode: transient overlay shown while waiting on an async operation.
|
||||||
|
|
||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
|
||||||
pub const LOADING_MESSAGES: &[&str] = &[
|
pub const LOADING_MESSAGES: &[&str] = &[
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! MCP mode: overlay for connecting to a configured MCP server.
|
//! MCP mode: overlay for connecting to a configured MCP server.
|
||||||
|
|
||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
|
|
||||||
/// Placeholder entry point for connecting to an MCP server by name.
|
/// Placeholder entry point for connecting to an MCP server by name.
|
||||||
|
|||||||
+1
-2
@@ -1,14 +1,13 @@
|
|||||||
//! TUI mode definitions and per-mode input/action handlers, one submodule
|
//! TUI mode definitions and per-mode input/action handlers, one submodule
|
||||||
//! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.).
|
//! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.).
|
||||||
|
|
||||||
pub mod bash;
|
pub mod bash;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
pub mod effort;
|
pub mod effort;
|
||||||
pub mod key_input;
|
pub mod key_input;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
|
|
||||||
|
pub mod learning;
|
||||||
pub mod quit_confirm;
|
pub mod quit_confirm;
|
||||||
pub mod rewind;
|
pub mod rewind;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod todo;
|
pub mod todo;
|
||||||
pub mod learning;
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! Quit-confirm mode: the "are you sure?" overlay shown before exiting.
|
//! Quit-confirm mode: the "are you sure?" overlay shown before exiting.
|
||||||
|
|
||||||
use crate::app::runtime::actions::Action;
|
use crate::app::runtime::actions::Action;
|
||||||
|
|
||||||
/// Translate the user's yes/no answer on the quit-confirm overlay into an action.
|
/// Translate the user's yes/no answer on the quit-confirm overlay into an action.
|
||||||
|
|||||||
+18
-7
@@ -1,13 +1,19 @@
|
|||||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
#![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
|
//! 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 crate::app::state::rest::AppStateRest;
|
||||||
use sha2::Digest;
|
use sha2::Digest;
|
||||||
|
|
||||||
/// Returns the number of stored pre-edit blobs (snapshots) for this session.
|
/// Returns the number of stored pre-edit blobs (snapshots) for this session.
|
||||||
pub fn rewind_count(state: &AppStateRest) -> usize {
|
pub fn rewind_count(state: &AppStateRest) -> usize {
|
||||||
let Ok(conn) = open_session_db(&state.session_dir) else { 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)
|
crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id)
|
||||||
.ok()
|
.ok()
|
||||||
.map_or(0, |keys| keys.len())
|
.map_or(0, |keys| keys.len())
|
||||||
@@ -51,7 +57,8 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let blob_key = &keys[index];
|
let blob_key = &keys[index];
|
||||||
let bytes = match crate::model::msglog::blobs::retrieve_blob(&conn, &state.session_id, blob_key) {
|
let bytes = match crate::model::msglog::blobs::retrieve_blob(&conn, &state.session_id, blob_key)
|
||||||
|
{
|
||||||
Ok(Some(b)) => b,
|
Ok(Some(b)) => b,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
state.push_toast(crate::app::state::types::Toast::new(
|
state.push_toast(crate::app::state::types::Toast::new(
|
||||||
@@ -74,8 +81,8 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
|||||||
// Look up the path from the edit log — the blob key is the tool_call_id.
|
// Look up the path from the edit log — the blob key is the tool_call_id.
|
||||||
// The edit log doesn't store the tool_call_id directly, so fall back to the
|
// The edit log doesn't store the tool_call_id directly, so fall back to the
|
||||||
// path from the most recent write/edit entry.
|
// path from the most recent write/edit entry.
|
||||||
let restore_path = find_edit_path(state, blob_key)
|
let restore_path =
|
||||||
.unwrap_or_else(|| state.session_dir.join("snapshot.dat"));
|
find_edit_path(state, blob_key).unwrap_or_else(|| state.session_dir.join("snapshot.dat"));
|
||||||
|
|
||||||
match std::fs::write(&restore_path, &bytes) {
|
match std::fs::write(&restore_path, &bytes) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -119,6 +126,10 @@ fn open_session_db(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Co
|
|||||||
|
|
||||||
fn find_edit_path(state: &AppStateRest, _blob_key: &str) -> Option<std::path::PathBuf> {
|
fn find_edit_path(state: &AppStateRest, _blob_key: &str) -> Option<std::path::PathBuf> {
|
||||||
let el = crate::model::editlog::EditLog::new(&state.session_dir);
|
let el = crate::model::editlog::EditLog::new(&state.session_dir);
|
||||||
let entry = el.entries.iter().rev().find(|e| e.tool == "write" || e.tool == "edit")?;
|
let entry = el
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|e| e.tool == "write" || e.tool == "edit")?;
|
||||||
Some(std::path::PathBuf::from(&entry.path))
|
Some(std::path::PathBuf::from(&entry.path))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
//! Flow: exposes small mutation functions (currently just cycling the
|
//! Flow: exposes small mutation functions (currently just cycling the
|
||||||
//! internet access mode) invoked by keybindings while the settings overlay
|
//! internet access mode) invoked by keybindings while the settings overlay
|
||||||
//! is active.
|
//! is active.
|
||||||
|
use crate::model::settings::{InternetMode, Settings};
|
||||||
use crate::model::settings::{Settings, InternetMode};
|
|
||||||
|
|
||||||
/// Advance the internet access mode to the next value in the cycle.
|
/// Advance the internet access mode to the next value in the cycle.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
//!
|
//!
|
||||||
//! Flow: exposes the toggle handler invoked by a keybinding to show/hide
|
//! Flow: exposes the toggle handler invoked by a keybinding to show/hide
|
||||||
//! the todo overlay.
|
//! the todo overlay.
|
||||||
|
|
||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
use crate::app::state::types::Overlay;
|
use crate::app::state::types::Overlay;
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let Some(runtime) = &state.session_runtime else { return false };
|
let Some(runtime) = &state.session_runtime else { return false };
|
||||||
if !state.settings.review_enabled {
|
if !state.settings.flags.review_enabled {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
|
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
|
||||||
|
|||||||
+68
-133
@@ -545,44 +545,21 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
state.push_toast(Toast::new(ToastKind::Warning, "Aborting generation...".to_string()));
|
state.push_toast(Toast::new(ToastKind::Warning, "Aborting generation...".to_string()));
|
||||||
}
|
}
|
||||||
Action::Compact => {
|
Action::Compact => {
|
||||||
let Some(messages) = state.session_runtime.as_ref().map(|rt| rt.messages.clone()) else {
|
let max_wire_tokens = state.app_config.model_roles.values()
|
||||||
return;
|
.find(|role| role.provider == state.settings.provider && role.model == state.settings.model)
|
||||||
};
|
.and_then(|role| role.context_window)
|
||||||
if messages.is_empty() {
|
.unwrap_or(state.app_config.default_context_window) as usize;
|
||||||
return;
|
|
||||||
}
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
let (api_key, model, base_url) = match resolve_llm_client_config(state) {
|
let total_chars: usize = rt.messages.iter()
|
||||||
Ok(v) => v,
|
.filter_map(|m| m.content.as_deref())
|
||||||
Err(msg) => {
|
.map(str::len)
|
||||||
state.push_toast(Toast::new(ToastKind::Error, msg));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let max_wire_tokens = crate::app::runtime::context::window::resolve(&state.app_config, &state.settings);
|
|
||||||
let turn_events = state.turn_events.clone();
|
|
||||||
|
|
||||||
state.push_toast(Toast::new(ToastKind::Info, "Compacting conversation history...".to_string()));
|
|
||||||
|
|
||||||
// Manual /compact previously ran synchronously and always
|
|
||||||
// passed `client: None` to shape_messages, so it never got
|
|
||||||
// LLM summarization — only automatic mid-turn compaction did.
|
|
||||||
// Running this on a background thread (same pattern as
|
|
||||||
// spawn_turn) fixes that asymmetry: both paths now summarize
|
|
||||||
// dropped history with the LLM instead of one silently
|
|
||||||
// falling back to a bare placeholder.
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let client = crate::service::provider::LlmClient::new(api_key, model, base_url);
|
|
||||||
let (deduped, _) = crate::app::runtime::context::dedup::collapse(&messages);
|
|
||||||
let token_count: usize = deduped.iter()
|
|
||||||
.map(crate::app::runtime::context::tokens::count_message_tokens)
|
|
||||||
.sum();
|
.sum();
|
||||||
let compacted = crate::app::runtime::context::shaping::shape_messages(
|
let token_estimate = total_chars / 3;
|
||||||
&deduped, token_count, max_wire_tokens, true, Some(&client),
|
rt.messages = crate::app::runtime::shortsend::shape_messages(&rt.messages, token_estimate, max_wire_tokens, true, None);
|
||||||
);
|
state.push_toast(Toast::new(ToastKind::Success, "Conversation history compacted.".to_string()));
|
||||||
if let Ok(mut q) = turn_events.lock() {
|
state.dirty = true;
|
||||||
q.push_back(TurnEvent::Compacted(compacted));
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Action::LessonAccept { name } => {
|
Action::LessonAccept { name } => {
|
||||||
if let Some(ref rt) = state.session_runtime {
|
if let Some(ref rt) = state.session_runtime {
|
||||||
@@ -623,45 +600,6 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the API key, model name, and base URL for the currently
|
|
||||||
/// configured provider.
|
|
||||||
///
|
|
||||||
/// Flow: look up the provider's `ProviderConfig` for its `api_base` ->
|
|
||||||
/// resolve the API key from `Settings.api_keys`, falling back to the
|
|
||||||
/// provider's `api_key_env` environment variable, then its
|
|
||||||
/// `default_api_key`, then the crate-wide empty-string default.
|
|
||||||
///
|
|
||||||
/// Why: this exact resolution was duplicated between `spawn_turn` and
|
|
||||||
/// needed again for `Action::Compact`'s background-thread LLM call —
|
|
||||||
/// factored out so both stay in sync.
|
|
||||||
///
|
|
||||||
/// Return: `Ok((api_key, model, base_url))`, or `Err(message)` — a
|
|
||||||
/// user-facing string — if the configured provider has no entry in
|
|
||||||
/// `AppConfig.providers` at all.
|
|
||||||
fn resolve_llm_client_config(state: &AppStateRest) -> Result<(String, String, Option<String>), String> {
|
|
||||||
let base_url = state.app_config.providers.get(&state.settings.provider).map(|p| p.api_base.clone());
|
|
||||||
let Some(base_url) = base_url else {
|
|
||||||
return Err(format!(
|
|
||||||
"Provider '{}' is not configured — no matching entry found. \
|
|
||||||
Pick a different provider in Settings, or configure it.",
|
|
||||||
state.settings.provider
|
|
||||||
));
|
|
||||||
};
|
|
||||||
let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default();
|
|
||||||
if api_key.is_empty() {
|
|
||||||
if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) {
|
|
||||||
api_key = provider_cfg.api_key_env.as_ref()
|
|
||||||
.and_then(|env| std::env::var(env).ok())
|
|
||||||
.or_else(|| provider_cfg.default_api_key.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if api_key.is_empty() {
|
|
||||||
api_key = crate::service::provider::DEFAULT_API_KEY.to_string();
|
|
||||||
}
|
|
||||||
Ok((api_key, state.settings.model.clone(), Some(base_url)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn a background thread that runs one full LLM turn.
|
/// Spawn a background thread that runs one full LLM turn.
|
||||||
///
|
///
|
||||||
/// Flow: check that no turn is currently in-flight → bail if so →
|
/// Flow: check that no turn is currently in-flight → bail if so →
|
||||||
@@ -692,17 +630,42 @@ fn spawn_turn(state: &AppStateRest) {
|
|||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let (api_key, model, base_url) = match resolve_llm_client_config(state) {
|
let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default();
|
||||||
Ok(v) => v,
|
let model = state.settings.model.clone();
|
||||||
Err(msg) => {
|
let base_url = state.app_config.providers.get(&state.settings.provider)
|
||||||
if let Ok(mut q) = state.turn_events.lock() {
|
.map(|p| p.api_base.clone());
|
||||||
q.push_back(TurnEvent::Error(msg));
|
let context_window = state.app_config.model_roles.values()
|
||||||
}
|
.find(|role| role.provider == state.settings.provider && role.model == state.settings.model)
|
||||||
return;
|
.and_then(|role| role.context_window)
|
||||||
|
.unwrap_or(state.app_config.default_context_window) as usize;
|
||||||
|
// The selected provider has no entry in app_config at all (e.g. the
|
||||||
|
// Claude-settings auto-detection that registers "claude" found nothing
|
||||||
|
// this run). Without this check, LlmClient::new silently falls back to
|
||||||
|
// the zen default base URL while keeping this provider's model name —
|
||||||
|
// a mismatched request that reaches a real server and comes back as a
|
||||||
|
// confusing "Missing API key" 401 from an unrelated provider, instead
|
||||||
|
// of the actual problem: the configured provider doesn't exist.
|
||||||
|
if base_url.is_none() {
|
||||||
|
if let Ok(mut q) = state.turn_events.lock() {
|
||||||
|
q.push_back(TurnEvent::Error(format!(
|
||||||
|
"Provider '{}' is not configured — no matching entry found. \
|
||||||
|
Pick a different provider in Settings, or configure it.",
|
||||||
|
state.settings.provider
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
};
|
return;
|
||||||
let context_window = crate::app::runtime::context::window::resolve(&state.app_config, &state.settings);
|
}
|
||||||
let concise_output = state.settings.concise_output;
|
if api_key.is_empty() {
|
||||||
|
if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) {
|
||||||
|
api_key = provider_cfg.api_key_env.as_ref()
|
||||||
|
.and_then(|env| std::env::var(env).ok())
|
||||||
|
.or_else(|| provider_cfg.default_api_key.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if api_key.is_empty() {
|
||||||
|
api_key = crate::service::provider::DEFAULT_API_KEY.to_string();
|
||||||
|
}
|
||||||
let (temperature, max_tokens) = crate::app::mode::effort::generation_params(
|
let (temperature, max_tokens) = crate::app::mode::effort::generation_params(
|
||||||
state.misc.effort_level,
|
state.misc.effort_level,
|
||||||
state.settings.max_tokens,
|
state.settings.max_tokens,
|
||||||
@@ -747,7 +710,6 @@ fn spawn_turn(state: &AppStateRest) {
|
|||||||
max_tokens,
|
max_tokens,
|
||||||
abort_flag,
|
abort_flag,
|
||||||
hive_mind_converged,
|
hive_mind_converged,
|
||||||
concise_output,
|
|
||||||
};
|
};
|
||||||
let result = run_agent_turn(&tc, &messages, &events_q);
|
let result = run_agent_turn(&tc, &messages, &events_q);
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
@@ -780,10 +742,6 @@ struct TurnCtx {
|
|||||||
/// of this turn — whether a hive-mind convergence already completed
|
/// of this turn — whether a hive-mind convergence already completed
|
||||||
/// earlier in this session.
|
/// earlier in this session.
|
||||||
hive_mind_converged: bool,
|
hive_mind_converged: bool,
|
||||||
/// Snapshot of `Settings.concise_output` taken at the start of this
|
|
||||||
/// turn, so the system-prompt assembly above can read it without
|
|
||||||
/// `TurnCtx` needing a `Settings` reference.
|
|
||||||
concise_output: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build an ASCII tree of the workspace directory structure for the
|
/// Build an ASCII tree of the workspace directory structure for the
|
||||||
@@ -933,9 +891,8 @@ const HIVE_MIND_KICKOFF_NOTE: &str = "The Hive is stirring — Core Intelligence
|
|||||||
/// handle tool calls, and loop until the LLM produces a non-tool response
|
/// handle tool calls, and loop until the LLM produces a non-tool response
|
||||||
/// or runs out of unfinished todo items.
|
/// or runs out of unfinished todo items.
|
||||||
///
|
///
|
||||||
/// Flow: build system prompt with workspace tree → deduplicate messages
|
/// Flow: build system prompt with workspace tree → optionally shape
|
||||||
/// via `context::dedup::collapse` → optionally shape (compact) messages via
|
/// (compact) messages via `shortsend` → call `chat_with_tools_streaming`
|
||||||
/// `context::shaping::{should_shape, shape_messages}` → call `chat_with_tools_streaming`
|
|
||||||
/// with a callback that pushes `StreamStart`, `StreamToken`, `Reasoning`,
|
/// with a callback that pushes `StreamStart`, `StreamToken`, `Reasoning`,
|
||||||
/// and `Usage` events → on streaming success, handle tool calls (gated
|
/// and `Usage` events → on streaming success, handle tool calls (gated
|
||||||
/// through `Harness::gate_tool_call`) or unwrap the final assistant
|
/// through `Harness::gate_tool_call`) or unwrap the final assistant
|
||||||
@@ -970,23 +927,12 @@ fn run_agent_turn(
|
|||||||
// workspace tree and reads all memory files each time).
|
// workspace tree and reads all memory files each time).
|
||||||
let tree_info = generate_workspace_tree(&tc.workspace_roots);
|
let tree_info = generate_workspace_tree(&tc.workspace_roots);
|
||||||
let memory_section = build_memory_section(&tc.ctx.memory_dir);
|
let memory_section = build_memory_section(&tc.ctx.memory_dir);
|
||||||
let concise_section = if tc.concise_output {
|
|
||||||
"\n\nWrite tersely: drop articles (a/an/the), filler words (just/really/basically/\
|
|
||||||
actually/simply), pleasantries (sure/certainly/of course/happy to), and hedging. \
|
|
||||||
Fragments are fine. Code, commands, file paths, and error text must stay byte-exact \
|
|
||||||
— never abbreviate or paraphrase those. Exception: for destructive-operation \
|
|
||||||
confirmations and security-relevant warnings, always give full detail regardless of \
|
|
||||||
this instruction — clarity matters more than brevity when something risky is at stake."
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
let system_text = format!(
|
let system_text = format!(
|
||||||
"{}\n\n{}\n\n{}{}{}",
|
"{}\n\n{}\n\n{}{}",
|
||||||
crate::resources::SYSTEM_PROMPT,
|
crate::resources::SYSTEM_PROMPT,
|
||||||
crate::resources::SYSTEM_TOOLS,
|
crate::resources::SYSTEM_TOOLS,
|
||||||
tree_info,
|
tree_info,
|
||||||
memory_section,
|
memory_section,
|
||||||
concise_section,
|
|
||||||
);
|
);
|
||||||
if !msgs.iter().any(|m| matches!(m.role, crate::dto::chat::message::Role::System)) {
|
if !msgs.iter().any(|m| matches!(m.role, crate::dto::chat::message::Role::System)) {
|
||||||
let sys = ChatMessage::system(system_text);
|
let sys = ChatMessage::system(system_text);
|
||||||
@@ -1198,43 +1144,33 @@ fn run_agent_turn(
|
|||||||
let mut todo_retry_count = 0usize;
|
let mut todo_retry_count = 0usize;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Dedup runs every iteration, unconditionally — repeated
|
let total_chars: usize = msgs.iter()
|
||||||
// read-only tool calls (same tool + same arguments) are
|
.filter_map(|m| m.content.as_deref())
|
||||||
// collapsed to their latest result before anything else, so
|
.map(str::len)
|
||||||
// context stays minimal from turn 1 instead of only shrinking
|
|
||||||
// once shaping's budget threshold trips.
|
|
||||||
let (deduped, dedup_changed) = crate::app::runtime::context::dedup::collapse(&msgs);
|
|
||||||
let token_count: usize = deduped.iter()
|
|
||||||
.map(crate::app::runtime::context::tokens::count_message_tokens)
|
|
||||||
.sum();
|
.sum();
|
||||||
|
let token_estimate = total_chars / 4;
|
||||||
let max_wire_tokens = tc.context_window;
|
let max_wire_tokens = tc.context_window;
|
||||||
|
|
||||||
// Skip shaping if abort was requested — the non-streaming LLM
|
// Skip message compaction if abort was requested — the non-streaming
|
||||||
// call for summarization would block without checking abort_flag.
|
// LLM call for summarization would block without checking abort_flag.
|
||||||
let wire_msgs = if !tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst)
|
let wire_msgs = if !tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst)
|
||||||
&& crate::app::runtime::context::shaping::should_shape(token_count, max_wire_tokens, prev_shaped)
|
&& crate::app::runtime::shortsend::should_shape(token_estimate, max_wire_tokens, prev_shaped)
|
||||||
{
|
{
|
||||||
prev_shaped = true;
|
prev_shaped = true;
|
||||||
let compacted = crate::app::runtime::context::shaping::shape_messages(&deduped, token_count, max_wire_tokens, false, Some(&tc.client));
|
let compacted = crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate, max_wire_tokens, false, Some(&tc.client));
|
||||||
|
|
||||||
// Dispatch to the main thread so the local session history is
|
// Dispatch the compacted messages to the main thread so the local session history
|
||||||
// permanently updated and doesn't re-trigger shaping immediately
|
// is permanently compacted and doesn't trigger shaping again immediately on next turn.
|
||||||
// on the next turn.
|
|
||||||
if let Ok(mut q) = events_q.lock() {
|
if let Ok(mut q) = events_q.lock() {
|
||||||
q.push_back(TurnEvent::Compacted(compacted.clone()));
|
q.push_back(TurnEvent::Compacted(compacted.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also update our local `msgs` variable so the rest of the loop operates on the compacted version
|
||||||
msgs.clone_from(&compacted);
|
msgs.clone_from(&compacted);
|
||||||
compacted
|
compacted
|
||||||
} else {
|
} else {
|
||||||
prev_shaped = false;
|
prev_shaped = false;
|
||||||
if dedup_changed {
|
msgs.clone()
|
||||||
if let Ok(mut q) = events_q.lock() {
|
|
||||||
q.push_back(TurnEvent::Compacted(deduped.clone()));
|
|
||||||
}
|
|
||||||
msgs.clone_from(&deduped);
|
|
||||||
}
|
|
||||||
deduped
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut stream_started = false;
|
let mut stream_started = false;
|
||||||
@@ -1481,8 +1417,7 @@ fn run_agent_turn(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let squashed_output = crate::app::runtime::context::squash::apply(&tool_name, &output);
|
let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output);
|
||||||
let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), squashed_output);
|
|
||||||
archive_message(tc.db.as_ref(), &tc.session_id, &tool_msg);
|
archive_message(tc.db.as_ref(), &tc.session_id, &tool_msg);
|
||||||
msgs.push(tool_msg);
|
msgs.push(tool_msg);
|
||||||
}
|
}
|
||||||
@@ -1677,7 +1612,7 @@ fn execute_one_tool(
|
|||||||
/// `should_trigger_review` on `Tick`), only informs the user that
|
/// `should_trigger_review` on `Tick`), only informs the user that
|
||||||
/// a review has material to examine.
|
/// a review has material to examine.
|
||||||
fn maybe_trigger_review(state: &mut AppStateRest) {
|
fn maybe_trigger_review(state: &mut AppStateRest) {
|
||||||
if !state.settings.review_enabled {
|
if !state.settings.flags.review_enabled {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let edit_count = state
|
let edit_count = state
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Maps parsed `/` slash commands into one or more `Action` variants
|
//! Maps parsed `/` slash commands into one or more `Action` variants
|
||||||
//! that `apply_action` can process.
|
//! that `apply_action` can process.
|
||||||
use crate::controller::command::Command;
|
|
||||||
use crate::app::runtime::actions::Action;
|
use crate::app::runtime::actions::Action;
|
||||||
use crate::app::state::types::Overlay;
|
use crate::app::state::types::Overlay;
|
||||||
|
use crate::controller::command::Command;
|
||||||
|
|
||||||
/// Convert a parsed `Command` into the corresponding sequence of `Action`s.
|
/// Convert a parsed `Command` into the corresponding sequence of `Action`s.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -15,11 +15,10 @@
|
|||||||
//! `git_operator`, ...) are never touched, even with identical
|
//! `git_operator`, ...) are never touched, even with identical
|
||||||
//! arguments, because call order and repetition can be semantically
|
//! arguments, because call order and repetition can be semantically
|
||||||
//! meaningful (e.g. retrying a flaky `bash` command until it passes).
|
//! meaningful (e.g. retrying a flaky `bash` command until it passes).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use sha2::Digest;
|
|
||||||
use crate::app::subagent::division::tool_scope::READ_TOOLS;
|
use crate::app::subagent::division::tool_scope::READ_TOOLS;
|
||||||
use crate::dto::chat::message::{ChatMessage, Role};
|
use crate::dto::chat::message::{ChatMessage, Role};
|
||||||
|
use sha2::Digest;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
const DUPLICATE_PLACEHOLDER: &str =
|
const DUPLICATE_PLACEHOLDER: &str =
|
||||||
"[duplicate result — superseded by a later identical call, see below]";
|
"[duplicate result — superseded by a later identical call, see below]";
|
||||||
@@ -50,7 +49,9 @@ pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(id) = &m.tool_call_id else { continue };
|
let Some(id) = &m.tool_call_id else { continue };
|
||||||
let Some((name, args)) = call_info.get(id) else { continue };
|
let Some((name, args)) = call_info.get(id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if !READ_TOOLS.contains(&name.as_str()) {
|
if !READ_TOOLS.contains(&name.as_str()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -58,22 +59,30 @@ pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
let result = messages.iter().enumerate().map(|(idx, m)| {
|
let result = messages
|
||||||
if m.role != Role::Tool {
|
.iter()
|
||||||
return m.clone();
|
.enumerate()
|
||||||
}
|
.map(|(idx, m)| {
|
||||||
let Some(id) = &m.tool_call_id else { return m.clone() };
|
if m.role != Role::Tool {
|
||||||
let Some((name, args)) = call_info.get(id) else { return m.clone() };
|
return m.clone();
|
||||||
if !READ_TOOLS.contains(&name.as_str()) {
|
}
|
||||||
return m.clone();
|
let Some(id) = &m.tool_call_id else {
|
||||||
}
|
return m.clone();
|
||||||
let key = dedup_key(name, args);
|
};
|
||||||
if last_index_for_key.get(&key) == Some(&idx) {
|
let Some((name, args)) = call_info.get(id) else {
|
||||||
return m.clone();
|
return m.clone();
|
||||||
}
|
};
|
||||||
changed = true;
|
if !READ_TOOLS.contains(&name.as_str()) {
|
||||||
ChatMessage::tool_result(id.clone(), DUPLICATE_PLACEHOLDER.to_string())
|
return m.clone();
|
||||||
}).collect();
|
}
|
||||||
|
let key = dedup_key(name, args);
|
||||||
|
if last_index_for_key.get(&key) == Some(&idx) {
|
||||||
|
return m.clone();
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
ChatMessage::tool_result(id.clone(), DUPLICATE_PLACEHOLDER.to_string())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
(result, changed)
|
(result, changed)
|
||||||
}
|
}
|
||||||
@@ -102,7 +111,10 @@ mod tests {
|
|||||||
m.tool_calls = Some(vec![ToolCall {
|
m.tool_calls = Some(vec![ToolCall {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
type_: "function".to_string(),
|
type_: "function".to_string(),
|
||||||
function: ToolFunction { name: name.to_string(), arguments: args },
|
function: ToolFunction {
|
||||||
|
name: name.to_string(),
|
||||||
|
arguments: args,
|
||||||
|
},
|
||||||
}]);
|
}]);
|
||||||
m
|
m
|
||||||
}
|
}
|
||||||
@@ -172,9 +184,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_result_with_no_matching_call_is_left_untouched() {
|
fn tool_result_with_no_matching_call_is_left_untouched() {
|
||||||
let messages = vec![
|
let messages = vec![ChatMessage::tool_result(
|
||||||
ChatMessage::tool_result("orphan-id".to_string(), "some result".to_string()),
|
"orphan-id".to_string(),
|
||||||
];
|
"some result".to_string(),
|
||||||
|
)];
|
||||||
|
|
||||||
let (result, changed) = collapse(&messages);
|
let (result, changed) = collapse(&messages);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
//! layer would only serve one of the two callers generically — the
|
//! layer would only serve one of the two callers generically — the
|
||||||
//! auto-loop already needs per-stage control to decide when to emit
|
//! auto-loop already needs per-stage control to decide when to emit
|
||||||
//! `TurnEvent::Compacted`.
|
//! `TurnEvent::Compacted`.
|
||||||
|
|
||||||
pub mod dedup;
|
pub mod dedup;
|
||||||
pub mod shaping;
|
pub mod shaping;
|
||||||
pub mod squash;
|
pub mod squash;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
//! the LLM API. Ported from the former `runtime::shortsend` — behavior
|
//! the LLM API. Ported from the former `runtime::shortsend` — behavior
|
||||||
//! is unchanged, only its token-counting now goes through
|
//! is unchanged, only its token-counting now goes through
|
||||||
//! `context::tokens` instead of an inline heuristic.
|
//! `context::tokens` instead of an inline heuristic.
|
||||||
|
|
||||||
use super::tokens::count_tokens;
|
use super::tokens::count_tokens;
|
||||||
use crate::dto::chat::message::ChatMessage;
|
use crate::dto::chat::message::ChatMessage;
|
||||||
|
|
||||||
@@ -101,7 +100,8 @@ pub fn shape_messages(
|
|||||||
match llm.chat_with_tools_non_streaming(&req_msgs, None) {
|
match llm.chat_with_tools_non_streaming(&req_msgs, None) {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
if let Some(content) = resp.0.content {
|
if let Some(content) = resp.0.content {
|
||||||
summary_text = format!("[Summary of compacted prior conversation:\n{content}\n]");
|
summary_text =
|
||||||
|
format!("[Summary of compacted prior conversation:\n{content}\n]");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -136,7 +136,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn should_shape_uses_95_percent_threshold_once_already_shaped() {
|
fn should_shape_uses_95_percent_threshold_once_already_shaped() {
|
||||||
assert!(!should_shape(900, 1000, true), "below 95% and already shaped: no re-trigger yet");
|
assert!(
|
||||||
|
!should_shape(900, 1000, true),
|
||||||
|
"below 95% and already shaped: no re-trigger yet"
|
||||||
|
);
|
||||||
assert!(should_shape(950, 1000, true));
|
assert!(should_shape(950, 1000, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,9 +189,9 @@ mod tests {
|
|||||||
messages.push(ChatMessage::user(padded_message(i)));
|
messages.push(ChatMessage::user(padded_message(i)));
|
||||||
}
|
}
|
||||||
let result = shape_messages(&messages, 100_000, 1000, true, None);
|
let result = shape_messages(&messages, 100_000, 1000, true, None);
|
||||||
let has_placeholder = result.iter().any(|m| {
|
let has_placeholder = result
|
||||||
m.content.as_deref() == Some("[prior conversation compacted]")
|
.iter()
|
||||||
});
|
.any(|m| m.content.as_deref() == Some("[prior conversation compacted]"));
|
||||||
assert!(has_placeholder);
|
assert!(has_placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +203,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let result = shape_messages(&messages, 100_000, 1000, true, None);
|
let result = shape_messages(&messages, 100_000, 1000, true, None);
|
||||||
let last_content = messages.last().unwrap().content.clone();
|
let last_content = messages.last().unwrap().content.clone();
|
||||||
assert!(result.iter().any(|m| m.content == last_content), "most recent message must survive shaping");
|
assert!(
|
||||||
|
result.iter().any(|m| m.content == last_content),
|
||||||
|
"most recent message must survive shaping"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
//! conversation's token budget even on its first occurrence, long
|
//! conversation's token budget even on its first occurrence, long
|
||||||
//! before `dedup`/`shaping` ever get a chance to act on repeats or
|
//! before `dedup`/`shaping` ever get a chance to act on repeats or
|
||||||
//! overall budget.
|
//! overall budget.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
@@ -220,12 +219,24 @@ fn squash_log(text: &str) -> String {
|
|||||||
level_score + stack_boost
|
level_score + stack_boost
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut error_idxs: Vec<usize> = (0..lines.len()).filter(|&i| levels[i] == LogLevel::Error).collect();
|
let mut error_idxs: Vec<usize> = (0..lines.len())
|
||||||
error_idxs.sort_by(|&a, &b| score(b).partial_cmp(&score(a)).unwrap_or(std::cmp::Ordering::Equal));
|
.filter(|&i| levels[i] == LogLevel::Error)
|
||||||
|
.collect();
|
||||||
|
error_idxs.sort_by(|&a, &b| {
|
||||||
|
score(b)
|
||||||
|
.partial_cmp(&score(a))
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
error_idxs.truncate(20);
|
error_idxs.truncate(20);
|
||||||
|
|
||||||
let mut warn_idxs: Vec<usize> = (0..lines.len()).filter(|&i| levels[i] == LogLevel::Warn).collect();
|
let mut warn_idxs: Vec<usize> = (0..lines.len())
|
||||||
warn_idxs.sort_by(|&a, &b| score(b).partial_cmp(&score(a)).unwrap_or(std::cmp::Ordering::Equal));
|
.filter(|&i| levels[i] == LogLevel::Warn)
|
||||||
|
.collect();
|
||||||
|
warn_idxs.sort_by(|&a, &b| {
|
||||||
|
score(b)
|
||||||
|
.partial_cmp(&score(a))
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
warn_idxs.truncate(10);
|
warn_idxs.truncate(10);
|
||||||
|
|
||||||
let mut keep: HashSet<usize> = HashSet::new();
|
let mut keep: HashSet<usize> = HashSet::new();
|
||||||
@@ -257,7 +268,10 @@ fn squash_generic(text: &str, budget: usize) -> String {
|
|||||||
let mut keep: HashSet<usize> = (0..head_end).chain(tail_start..lines.len()).collect();
|
let mut keep: HashSet<usize> = (0..head_end).chain(tail_start..lines.len()).collect();
|
||||||
|
|
||||||
let mut used: usize = lines[..head_end].iter().map(|l| l.len() + 1).sum::<usize>()
|
let mut used: usize = lines[..head_end].iter().map(|l| l.len() + 1).sum::<usize>()
|
||||||
+ lines[tail_start..].iter().map(|l| l.len() + 1).sum::<usize>();
|
+ lines[tail_start..]
|
||||||
|
.iter()
|
||||||
|
.map(|l| l.len() + 1)
|
||||||
|
.sum::<usize>();
|
||||||
let mut prev = "";
|
let mut prev = "";
|
||||||
for (i, &line) in lines.iter().enumerate().take(tail_start).skip(head_end) {
|
for (i, &line) in lines.iter().enumerate().take(tail_start).skip(head_end) {
|
||||||
let non_trivial = !line.trim().is_empty() && line != prev;
|
let non_trivial = !line.trim().is_empty() && line != prev;
|
||||||
@@ -324,8 +338,8 @@ mod tests {
|
|||||||
assert!(text.len() > SQUASH_FLOOR_BYTES);
|
assert!(text.len() > SQUASH_FLOOR_BYTES);
|
||||||
|
|
||||||
let result = apply("some_mcp_tool", &text);
|
let result = apply("some_mcp_tool", &text);
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&result)
|
let parsed: serde_json::Value =
|
||||||
.expect("squashed JSON must still be valid JSON");
|
serde_json::from_str(&result).expect("squashed JSON must still be valid JSON");
|
||||||
|
|
||||||
assert_eq!(parsed["id"], "abc123", "short values must survive");
|
assert_eq!(parsed["id"], "abc123", "short values must survive");
|
||||||
assert_eq!(parsed["note"], "hi", "short values must survive");
|
assert_eq!(parsed["note"], "hi", "short values must survive");
|
||||||
@@ -357,7 +371,11 @@ mod tests {
|
|||||||
let items = parsed["items"].as_array().unwrap();
|
let items = parsed["items"].as_array().unwrap();
|
||||||
|
|
||||||
assert_eq!(items[0].as_str().unwrap(), identifier, "index 0 is under the array cutoff and identifier-shaped, so it's kept under the normal rule");
|
assert_eq!(items[0].as_str().unwrap(), identifier, "index 0 is under the array cutoff and identifier-shaped, so it's kept under the normal rule");
|
||||||
assert_eq!(items[2].as_str().unwrap(), identifier, "index 2 is still under the cutoff (past-third means index >= 3)");
|
assert_eq!(
|
||||||
|
items[2].as_str().unwrap(),
|
||||||
|
identifier,
|
||||||
|
"index 2 is still under the cutoff (past-third means index >= 3)"
|
||||||
|
);
|
||||||
assert_ne!(items[3].as_str().unwrap(), identifier, "index 3 must be force-elided even though it's identifier-shaped and would survive at any earlier index");
|
assert_ne!(items[3].as_str().unwrap(), identifier, "index 3 must be force-elided even though it's identifier-shaped and would survive at any earlier index");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,20 +430,34 @@ mod tests {
|
|||||||
|
|
||||||
let result = apply("grep", &text);
|
let result = apply("grep", &text);
|
||||||
|
|
||||||
assert!(result.contains("src/file0.rs:0: error handling for case 0"), "generic keeps head");
|
assert!(
|
||||||
assert!(result.contains("src/file49.rs:49: error handling for case 49"), "generic keeps tail — squash_log would have dropped this");
|
result.contains("src/file0.rs:0: error handling for case 0"),
|
||||||
|
"generic keeps head"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("src/file49.rs:49: error handling for case 49"),
|
||||||
|
"generic keeps tail — squash_log would have dropped this"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generic_large_text_is_truncated_with_omission_marker() {
|
fn generic_large_text_is_truncated_with_omission_marker() {
|
||||||
let lines: Vec<String> = (0..500).map(|i| format!("line number {i} of plain output")).collect();
|
let lines: Vec<String> = (0..500)
|
||||||
|
.map(|i| format!("line number {i} of plain output"))
|
||||||
|
.collect();
|
||||||
let text = lines.join("\n");
|
let text = lines.join("\n");
|
||||||
assert!(text.len() > SQUASH_FLOOR_BYTES);
|
assert!(text.len() > SQUASH_FLOOR_BYTES);
|
||||||
|
|
||||||
let result = apply("bash", &text);
|
let result = apply("bash", &text);
|
||||||
|
|
||||||
assert!(result.contains("line number 0 of plain output"), "keeps head");
|
assert!(
|
||||||
assert!(result.contains("line number 499 of plain output"), "keeps tail");
|
result.contains("line number 0 of plain output"),
|
||||||
|
"keeps head"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("line number 499 of plain output"),
|
||||||
|
"keeps tail"
|
||||||
|
);
|
||||||
assert!(result.contains("lines omitted"));
|
assert!(result.contains("lines omitted"));
|
||||||
assert!(result.len() < text.len());
|
assert!(result.len() < text.len());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
//! `o200k_base` is an approximation for non-OpenAI providers but is far
|
//! `o200k_base` is an approximation for non-OpenAI providers but is far
|
||||||
//! closer than a flat byte-per-token guess; it's only used for the
|
//! closer than a flat byte-per-token guess; it's only used for the
|
||||||
//! 85%/95% budget thresholds, not for billing-accurate counts.
|
//! 85%/95% budget thresholds, not for billing-accurate counts.
|
||||||
|
|
||||||
use crate::dto::chat::message::ChatMessage;
|
use crate::dto::chat::message::ChatMessage;
|
||||||
|
|
||||||
/// Count tokens in a single string under `o200k_base`.
|
/// Count tokens in a single string under `o200k_base`.
|
||||||
@@ -21,7 +20,9 @@ use crate::dto::chat::message::ChatMessage;
|
|||||||
/// (e.g. literal text `<|endoftext|>` pasted by a user) must be counted
|
/// (e.g. literal text `<|endoftext|>` pasted by a user) must be counted
|
||||||
/// as ordinary text, not interpreted as a control token.
|
/// as ordinary text, not interpreted as a control token.
|
||||||
pub fn count_tokens(text: &str) -> usize {
|
pub fn count_tokens(text: &str) -> usize {
|
||||||
tiktoken_rs::o200k_base_singleton().encode_ordinary(text).len()
|
tiktoken_rs::o200k_base_singleton()
|
||||||
|
.encode_ordinary(text)
|
||||||
|
.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count tokens in a `ChatMessage`'s text content.
|
/// Count tokens in a `ChatMessage`'s text content.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
//! had their own inline version — the status bar's copy additionally
|
//! had their own inline version — the status bar's copy additionally
|
||||||
//! displayed "?" on no match instead of falling back like the other two,
|
//! displayed "?" on no match instead of falling back like the other two,
|
||||||
//! an inconsistency this unifies away).
|
//! an inconsistency this unifies away).
|
||||||
|
|
||||||
use crate::model::app_config::AppConfig;
|
use crate::model::app_config::AppConfig;
|
||||||
use crate::model::settings::Settings;
|
use crate::model::settings::Settings;
|
||||||
|
|
||||||
@@ -18,7 +17,9 @@ use crate::model::settings::Settings;
|
|||||||
///
|
///
|
||||||
/// Return: always a concrete token count, never "unknown".
|
/// Return: always a concrete token count, never "unknown".
|
||||||
pub fn resolve(app_config: &AppConfig, settings: &Settings) -> usize {
|
pub fn resolve(app_config: &AppConfig, settings: &Settings) -> usize {
|
||||||
app_config.model_roles.values()
|
app_config
|
||||||
|
.model_roles
|
||||||
|
.values()
|
||||||
.find(|role| role.provider == settings.provider && role.model == settings.model)
|
.find(|role| role.provider == settings.provider && role.model == settings.model)
|
||||||
.and_then(|role| role.context_window)
|
.and_then(|role| role.context_window)
|
||||||
.unwrap_or(app_config.default_context_window) as usize
|
.unwrap_or(app_config.default_context_window) as usize
|
||||||
@@ -32,13 +33,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn resolves_context_window_from_matching_model_role() {
|
fn resolves_context_window_from_matching_model_role() {
|
||||||
let mut app_config = AppConfig::default();
|
let mut app_config = AppConfig::default();
|
||||||
app_config.model_roles.insert("default".to_string(), ModelRole {
|
app_config.model_roles.insert(
|
||||||
provider: "zen".to_string(),
|
"default".to_string(),
|
||||||
model: "deepseek-v4-flash-free".to_string(),
|
ModelRole {
|
||||||
max_tokens: None,
|
provider: "zen".to_string(),
|
||||||
context_window: Some(128_000),
|
model: "deepseek-v4-flash-free".to_string(),
|
||||||
temperature: None,
|
max_tokens: None,
|
||||||
});
|
context_window: Some(128_000),
|
||||||
|
temperature: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
let mut settings = Settings::default();
|
let mut settings = Settings::default();
|
||||||
settings.provider = "zen".to_string();
|
settings.provider = "zen".to_string();
|
||||||
settings.model = "deepseek-v4-flash-free".to_string();
|
settings.model = "deepseek-v4-flash-free".to_string();
|
||||||
@@ -53,23 +57,32 @@ mod tests {
|
|||||||
settings.provider = "nonexistent".to_string();
|
settings.provider = "nonexistent".to_string();
|
||||||
settings.model = "nonexistent-model".to_string();
|
settings.model = "nonexistent-model".to_string();
|
||||||
|
|
||||||
assert_eq!(resolve(&app_config, &settings), app_config.default_context_window as usize);
|
assert_eq!(
|
||||||
|
resolve(&app_config, &settings),
|
||||||
|
app_config.default_context_window as usize
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn falls_back_to_default_when_matching_role_has_no_context_window_set() {
|
fn falls_back_to_default_when_matching_role_has_no_context_window_set() {
|
||||||
let mut app_config = AppConfig::default();
|
let mut app_config = AppConfig::default();
|
||||||
app_config.model_roles.insert("default".to_string(), ModelRole {
|
app_config.model_roles.insert(
|
||||||
provider: "zen".to_string(),
|
"default".to_string(),
|
||||||
model: "deepseek-v4-flash-free".to_string(),
|
ModelRole {
|
||||||
max_tokens: None,
|
provider: "zen".to_string(),
|
||||||
context_window: None,
|
model: "deepseek-v4-flash-free".to_string(),
|
||||||
temperature: None,
|
max_tokens: None,
|
||||||
});
|
context_window: None,
|
||||||
|
temperature: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
let mut settings = Settings::default();
|
let mut settings = Settings::default();
|
||||||
settings.provider = "zen".to_string();
|
settings.provider = "zen".to_string();
|
||||||
settings.model = "deepseek-v4-flash-free".to_string();
|
settings.model = "deepseek-v4-flash-free".to_string();
|
||||||
|
|
||||||
assert_eq!(resolve(&app_config, &settings), app_config.default_context_window as usize);
|
assert_eq!(
|
||||||
|
resolve(&app_config, &settings),
|
||||||
|
app_config.default_context_window as usize
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#![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
|
//! SSE stream parser: converts SSE- or JSON-chunked LLM responses into
|
||||||
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
|
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
|
||||||
pub mod turn;
|
pub mod turn;
|
||||||
pub mod tools;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -89,7 +87,7 @@ impl SseParser {
|
|||||||
/// provider-specific parsing layer.
|
/// provider-specific parsing layer.
|
||||||
///
|
///
|
||||||
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
|
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
fn flush_event(&mut self) -> Vec<StreamEvent> {
|
fn flush_event(&mut self) -> Vec<StreamEvent> {
|
||||||
let data = self.data_lines.join("\n");
|
let data = self.data_lines.join("\n");
|
||||||
self.data_lines.clear();
|
self.data_lines.clear();
|
||||||
@@ -112,20 +110,32 @@ impl SseParser {
|
|||||||
|
|
||||||
if let Some(usage) = value.get("usage") {
|
if let Some(usage) = value.get("usage") {
|
||||||
if !usage.is_null() {
|
if !usage.is_null() {
|
||||||
let prompt_tokens = usage.get("prompt_tokens").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
let prompt_tokens = usage
|
||||||
tracing::warn!("[stream] prompt_tokens missing in usage chunk");
|
.get("prompt_tokens")
|
||||||
0
|
.and_then(serde_json::Value::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] prompt_tokens missing in usage chunk");
|
||||||
tracing::warn!("[stream] completion_tokens missing in usage chunk");
|
0
|
||||||
0
|
});
|
||||||
});
|
let completion_tokens = usage
|
||||||
let total_tokens = usage.get("total_tokens").and_then(serde_json::Value::as_u64)
|
.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(serde_json::Value::as_u64)
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
tracing::warn!("[stream] total_tokens missing in usage chunk");
|
tracing::warn!("[stream] total_tokens missing in usage chunk");
|
||||||
prompt_tokens + completion_tokens
|
prompt_tokens + completion_tokens
|
||||||
});
|
});
|
||||||
events.push(StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens });
|
events.push(StreamEvent::Usage {
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,23 +153,32 @@ impl SseParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reasoning token
|
// Reasoning token
|
||||||
if let Some(reasoning) = d.get("reasoning_content").and_then(|r| r.as_str()) {
|
if let Some(reasoning) =
|
||||||
|
d.get("reasoning_content").and_then(|r| r.as_str())
|
||||||
|
{
|
||||||
d_events.push(StreamEvent::Reasoning(reasoning.to_string()));
|
d_events.push(StreamEvent::Reasoning(reasoning.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool calls — iterate ALL entries, not just first()
|
// Tool calls — iterate ALL entries, not just first()
|
||||||
if let Some(tool_calls) = d.get("tool_calls").and_then(|tc| tc.as_array()) {
|
if let Some(tool_calls) =
|
||||||
|
d.get("tool_calls").and_then(|tc| tc.as_array())
|
||||||
|
{
|
||||||
for tc in tool_calls {
|
for tc in tool_calls {
|
||||||
let index = tc.get("index").and_then(serde_json::Value::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");
|
tracing::warn!("[stream] tool call delta missing index, defaulting to 0");
|
||||||
0
|
0
|
||||||
}) as usize;
|
}) as usize;
|
||||||
let id = tc.get("id").and_then(|i| i.as_str()).map(std::string::ToString::to_string);
|
let id = tc
|
||||||
let name = tc.get("function")
|
.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(|f| f.get("name"))
|
||||||
.and_then(|n| n.as_str())
|
.and_then(|n| n.as_str())
|
||||||
.map(std::string::ToString::to_string);
|
.map(std::string::ToString::to_string);
|
||||||
let args_delta = tc.get("function")
|
let args_delta = tc
|
||||||
|
.get("function")
|
||||||
.and_then(|f| f.get("arguments"))
|
.and_then(|f| f.get("arguments"))
|
||||||
.and_then(|a| a.as_str())
|
.and_then(|a| a.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
@@ -174,7 +193,9 @@ impl SseParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Finish reason
|
// Finish reason
|
||||||
if let Some(reason) = choice.get("finish_reason").and_then(|r| r.as_str()) {
|
if let Some(reason) =
|
||||||
|
choice.get("finish_reason").and_then(|r| r.as_str())
|
||||||
|
{
|
||||||
if reason == "stop" || reason == "tool_calls" {
|
if reason == "stop" || reason == "tool_calls" {
|
||||||
d_events.push(StreamEvent::Done);
|
d_events.push(StreamEvent::Done);
|
||||||
}
|
}
|
||||||
@@ -193,72 +214,6 @@ impl SseParser {
|
|||||||
events.append(&mut other_events);
|
events.append(&mut other_events);
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears any partially-buffered SSE frame. Reserved for reconnect/retry flows that
|
|
||||||
/// reuse a parser instance across requests rather than constructing a fresh one.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn reset(&mut self) {
|
|
||||||
self.buffer.clear();
|
|
||||||
self.event_type = None;
|
|
||||||
self.data_lines.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fallback parser for providers that send bare JSON chunks instead of SSE-framed
|
|
||||||
/// `data: ...` lines. Not used by the `SseParser` streaming path (which handles
|
|
||||||
/// standard SSE framing directly), kept for providers/tests that feed raw chunks.
|
|
||||||
///
|
|
||||||
/// Flow: parse `data` as JSON → extract first `choices[0].delta` →
|
|
||||||
/// return a `Token`, `Reasoning`, `Done`, or `ToolCallDelta` event based
|
|
||||||
/// on the fields present.
|
|
||||||
///
|
|
||||||
/// Return: `Some(StreamEvent)` if the chunk contained recognisable
|
|
||||||
/// content, `None` otherwise.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn parse_stream_chunk(data: &str) -> Option<StreamEvent> {
|
|
||||||
let value: Value = serde_json::from_str(data).ok()?;
|
|
||||||
if value == Value::Null {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let choices = value.get("choices")?.as_array()?;
|
|
||||||
let choice = choices.first()?;
|
|
||||||
let delta = choice.get("delta")?;
|
|
||||||
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
|
||||||
return Some(StreamEvent::Token(content.to_string()));
|
|
||||||
}
|
|
||||||
if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) {
|
|
||||||
return Some(StreamEvent::Reasoning(reasoning.to_string()));
|
|
||||||
}
|
|
||||||
if let Some(finish) = choice.get("finish_reason").and_then(|r| r.as_str()) {
|
|
||||||
if finish == "stop" || finish == "tool_calls" {
|
|
||||||
return Some(StreamEvent::Done);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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(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(std::string::ToString::to_string);
|
|
||||||
let name = tc.get("function")
|
|
||||||
.and_then(|f| f.get("name"))
|
|
||||||
.and_then(|n| n.as_str())
|
|
||||||
.map(std::string::ToString::to_string);
|
|
||||||
let args = tc.get("function")
|
|
||||||
.and_then(|f| f.get("arguments"))
|
|
||||||
.and_then(|a| a.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
return Some(StreamEvent::ToolCallDelta {
|
|
||||||
index,
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
arguments_delta: args,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -280,7 +235,10 @@ mod tests {
|
|||||||
fn feed_handles_chunk_split_mid_line() {
|
fn feed_handles_chunk_split_mid_line() {
|
||||||
let mut p = SseParser::new();
|
let mut p = SseParser::new();
|
||||||
let e1 = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"partial");
|
let e1 = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"partial");
|
||||||
assert!(e1.is_empty(), "no event until the line and blank separator complete");
|
assert!(
|
||||||
|
e1.is_empty(),
|
||||||
|
"no event until the line and blank separator complete"
|
||||||
|
);
|
||||||
let e2 = p.feed("\"}}]}\n\n");
|
let e2 = p.feed("\"}}]}\n\n");
|
||||||
assert_eq!(e2.len(), 1);
|
assert_eq!(e2.len(), 1);
|
||||||
match &e2[0] {
|
match &e2[0] {
|
||||||
@@ -300,9 +258,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn feed_emits_done_on_finish_reason_stop() {
|
fn feed_emits_done_on_finish_reason_stop() {
|
||||||
let mut p = SseParser::new();
|
let mut p = SseParser::new();
|
||||||
let events = p.feed(
|
let events = p.feed("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n");
|
||||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
|
|
||||||
);
|
|
||||||
assert_eq!(events.len(), 1);
|
assert_eq!(events.len(), 1);
|
||||||
assert!(matches!(events[0], StreamEvent::Done));
|
assert!(matches!(events[0], StreamEvent::Done));
|
||||||
}
|
}
|
||||||
@@ -315,7 +271,12 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(events.len(), 1);
|
assert_eq!(events.len(), 1);
|
||||||
match &events[0] {
|
match &events[0] {
|
||||||
StreamEvent::ToolCallDelta { index, id, name, arguments_delta } => {
|
StreamEvent::ToolCallDelta {
|
||||||
|
index,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
arguments_delta,
|
||||||
|
} => {
|
||||||
assert_eq!(*index, 0);
|
assert_eq!(*index, 0);
|
||||||
assert_eq!(id.as_deref(), Some("call_1"));
|
assert_eq!(id.as_deref(), Some("call_1"));
|
||||||
assert_eq!(name.as_deref(), Some("bash"));
|
assert_eq!(name.as_deref(), Some("bash"));
|
||||||
@@ -333,7 +294,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(events.len(), 1);
|
assert_eq!(events.len(), 1);
|
||||||
match &events[0] {
|
match &events[0] {
|
||||||
StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens } => {
|
StreamEvent::Usage {
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens,
|
||||||
|
} => {
|
||||||
assert_eq!(*prompt_tokens, 10);
|
assert_eq!(*prompt_tokens, 10);
|
||||||
assert_eq!(*completion_tokens, 5);
|
assert_eq!(*completion_tokens, 5);
|
||||||
assert_eq!(*total_tokens, 15);
|
assert_eq!(*total_tokens, 15);
|
||||||
@@ -351,7 +316,11 @@ mod tests {
|
|||||||
assert_eq!(events.len(), 2);
|
assert_eq!(events.len(), 2);
|
||||||
match (&events[0], &events[1]) {
|
match (&events[0], &events[1]) {
|
||||||
(
|
(
|
||||||
StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens },
|
StreamEvent::Usage {
|
||||||
|
prompt_tokens,
|
||||||
|
completion_tokens,
|
||||||
|
total_tokens,
|
||||||
|
},
|
||||||
StreamEvent::Token(t),
|
StreamEvent::Token(t),
|
||||||
) => {
|
) => {
|
||||||
assert_eq!(*prompt_tokens, 10);
|
assert_eq!(*prompt_tokens, 10);
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
//! Standalone accumulator for streamed tool-call deltas.
|
|
||||||
//!
|
|
||||||
//! Flow: `ToolCallAccumulator::add_delta` is fed incremental `(index, id,
|
|
||||||
//! name, arguments_delta)` chunks as they arrive over SSE → grows its
|
|
||||||
//! internal `Vec<ParsedToolCall>` as needed → `is_complete` reports once
|
|
||||||
//! every accumulated call has both a name and arguments.
|
|
||||||
//!
|
|
||||||
//! Why: mirrors the accumulation logic built into `StreamedTurn::apply_event`
|
|
||||||
//! but as an independent, reusable type for callers that want to track
|
|
||||||
//! tool-call deltas without a full `StreamedTurn` (e.g. a lighter-weight
|
|
||||||
//! preview). Currently unused (`#[allow(dead_code)]`), kept for that future
|
|
||||||
//! use case.
|
|
||||||
|
|
||||||
use super::turn::ParsedToolCall;
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
/// Standalone tool-call delta accumulator, functionally equivalent to the accumulation
|
|
||||||
/// logic built into `StreamedTurn::apply_event`. Reserved for callers that want to track
|
|
||||||
/// tool-call deltas independently of a full `StreamedTurn` (e.g. a lighter-weight preview).
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct ToolCallAccumulator {
|
|
||||||
calls: Vec<ParsedToolCall>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl ToolCallAccumulator {
|
|
||||||
/// Construct an empty accumulator with no tool calls tracked yet.
|
|
||||||
///
|
|
||||||
/// Return: a fresh `ToolCallAccumulator`.
|
|
||||||
pub fn new() -> Self {
|
|
||||||
ToolCallAccumulator { calls: Vec::new() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append a delta to the tool call at the given index, growing the
|
|
||||||
/// calls vector if needed.
|
|
||||||
pub fn add_delta(
|
|
||||||
&mut self,
|
|
||||||
index: usize,
|
|
||||||
id: Option<&str>,
|
|
||||||
name: Option<&str>,
|
|
||||||
arguments_delta: &str,
|
|
||||||
) {
|
|
||||||
while self.calls.len() <= index {
|
|
||||||
self.calls.push(ParsedToolCall {
|
|
||||||
id: String::new(),
|
|
||||||
name: String::new(),
|
|
||||||
arguments: String::new(),
|
|
||||||
is_complete: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let tc = &mut self.calls[index];
|
|
||||||
if let Some(new_id) = id {
|
|
||||||
if !new_id.is_empty() {
|
|
||||||
tc.id = new_id.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(new_name) = name {
|
|
||||||
if !new_name.is_empty() {
|
|
||||||
tc.name = new_name.to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tc.arguments.push_str(arguments_delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Borrow the accumulated tool calls.
|
|
||||||
pub fn calls(&self) -> &[ParsedToolCall] {
|
|
||||||
&self.calls
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return true once all tool calls have both a name and arguments.
|
|
||||||
pub fn is_complete(&self) -> bool {
|
|
||||||
!self.calls.is_empty() && self.calls.iter().all(|tc| !tc.name.is_empty() && !tc.arguments.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all accumulated calls (starting a fresh turn).
|
|
||||||
pub fn reset(&mut self) {
|
|
||||||
self.calls.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a JSON-serialisable `Vec<Value>` of pending (non-empty-name)
|
|
||||||
/// tool calls, suitable for downstream inspection or replay.
|
|
||||||
pub fn pending_args(&self) -> Vec<Value> {
|
|
||||||
self.calls
|
|
||||||
.iter()
|
|
||||||
.filter(|tc| !tc.name.is_empty())
|
|
||||||
.map(|tc| {
|
|
||||||
json!({
|
|
||||||
"tool_call_id": tc.id,
|
|
||||||
"name": tc.name,
|
|
||||||
"arguments": tc.arguments,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ToolCallAccumulator {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -101,17 +101,7 @@ pub struct ParsedToolCall {
|
|||||||
pub is_complete: bool,
|
pub is_complete: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ParsedToolCall {
|
impl ParsedToolCall {}
|
||||||
/// Attempt to parse the accumulated argument string as JSON before
|
|
||||||
/// the tool call is marked complete — useful for a speculative preview.
|
|
||||||
///
|
|
||||||
/// Return: `Some(Value)` if the arguments are parsable JSON, `None`
|
|
||||||
/// if still partial.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn try_parse(&self) -> Option<Value> {
|
|
||||||
serde_json::from_str(&self.arguments).ok()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StreamedTurn {
|
impl StreamedTurn {
|
||||||
/// Create an empty turn accumulator.
|
/// Create an empty turn accumulator.
|
||||||
@@ -186,12 +176,12 @@ impl StreamedTurn {
|
|||||||
let mut msg = if self.tool_calls.is_empty() {
|
let mut msg = if self.tool_calls.is_empty() {
|
||||||
ChatMessage::assistant(None)
|
ChatMessage::assistant(None)
|
||||||
} else {
|
} else {
|
||||||
let tool_dtos: Vec<ToolCall> = self.tool_calls
|
let tool_dtos: Vec<ToolCall> = self
|
||||||
|
.tool_calls
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|tc| !tc.name.is_empty())
|
.filter(|tc| !tc.name.is_empty())
|
||||||
.map(|tc| {
|
.map(|tc| {
|
||||||
let args_value: serde_json::Value = match serde_json::from_str(&tc.arguments)
|
let args_value: serde_json::Value = match serde_json::from_str(&tc.arguments) {
|
||||||
{
|
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let repaired = repair_incomplete_json(&tc.arguments);
|
let repaired = repair_incomplete_json(&tc.arguments);
|
||||||
@@ -200,7 +190,8 @@ impl StreamedTurn {
|
|||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"[stream] tool call '{}' had truncated JSON \
|
"[stream] tool call '{}' had truncated JSON \
|
||||||
arguments — repaired successfully: {}",
|
arguments — repaired successfully: {}",
|
||||||
tc.name, e,
|
tc.name,
|
||||||
|
e,
|
||||||
);
|
);
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
@@ -209,7 +200,9 @@ impl StreamedTurn {
|
|||||||
"[stream] tool call '{}' has invalid JSON \
|
"[stream] tool call '{}' has invalid JSON \
|
||||||
arguments: {} (after repair: {}) — falling \
|
arguments: {} (after repair: {}) — falling \
|
||||||
back to raw string",
|
back to raw string",
|
||||||
tc.name, e, e2,
|
tc.name,
|
||||||
|
e,
|
||||||
|
e2,
|
||||||
);
|
);
|
||||||
serde_json::Value::String(tc.arguments.clone())
|
serde_json::Value::String(tc.arguments.clone())
|
||||||
}
|
}
|
||||||
@@ -235,7 +228,10 @@ impl StreamedTurn {
|
|||||||
let full_content = if self.accumulated_reasoning.is_empty() {
|
let full_content = if self.accumulated_reasoning.is_empty() {
|
||||||
self.accumulated_content.clone()
|
self.accumulated_content.clone()
|
||||||
} else {
|
} else {
|
||||||
format!("<think>\n{}\n</think>\n\n{}", self.accumulated_reasoning, self.accumulated_content)
|
format!(
|
||||||
|
"<think>\n{}\n</think>\n\n{}",
|
||||||
|
self.accumulated_reasoning, self.accumulated_content
|
||||||
|
)
|
||||||
};
|
};
|
||||||
let content = if full_content.is_empty() {
|
let content = if full_content.is_empty() {
|
||||||
None
|
None
|
||||||
@@ -259,7 +255,8 @@ impl StreamedTurn {
|
|||||||
/// Return: `Some((name, parse_error))` for the first bad tool call, or
|
/// Return: `Some((name, parse_error))` for the first bad tool call, or
|
||||||
/// `None` if every tool call's arguments are complete, parsable JSON.
|
/// `None` if every tool call's arguments are complete, parsable JSON.
|
||||||
pub fn incomplete_tool_call(&self) -> Option<(&str, String)> {
|
pub fn incomplete_tool_call(&self) -> Option<(&str, String)> {
|
||||||
self.tool_calls.iter()
|
self.tool_calls
|
||||||
|
.iter()
|
||||||
.filter(|tc| !tc.name.is_empty())
|
.filter(|tc| !tc.name.is_empty())
|
||||||
.find_map(|tc| {
|
.find_map(|tc| {
|
||||||
serde_json::from_str::<Value>(&tc.arguments)
|
serde_json::from_str::<Value>(&tc.arguments)
|
||||||
@@ -267,19 +264,6 @@ impl StreamedTurn {
|
|||||||
.map(|e| (tc.name.as_str(), e.to_string()))
|
.map(|e| (tc.name.as_str(), e.to_string()))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reserved accessor for callers that want to branch mid-stream before the turn
|
|
||||||
/// completes; the current wiring only inspects the final `build_assistant_message()`.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn has_tool_calls(&self) -> bool {
|
|
||||||
self.tool_calls.iter().any(|tc| !tc.name.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reserved accessor mirroring `has_tool_calls` for mid-stream content peeks.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn content(&self) -> &str {
|
|
||||||
&self.accumulated_content
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for StreamedTurn {
|
impl Default for StreamedTurn {
|
||||||
@@ -353,7 +337,10 @@ mod tests {
|
|||||||
let tcs = msg.tool_calls.expect("should produce tool calls");
|
let tcs = msg.tool_calls.expect("should produce tool calls");
|
||||||
assert_eq!(tcs.len(), 1);
|
assert_eq!(tcs.len(), 1);
|
||||||
let args = &tcs[0].function.arguments;
|
let args = &tcs[0].function.arguments;
|
||||||
assert!(args.is_object(), "args should be an object after repair: {args:?}");
|
assert!(
|
||||||
|
args.is_object(),
|
||||||
|
"args should be an object after repair: {args:?}"
|
||||||
|
);
|
||||||
assert_eq!(args.get("path").and_then(|v| v.as_str()), Some("a.txt"));
|
assert_eq!(args.get("path").and_then(|v| v.as_str()), Some("a.txt"));
|
||||||
assert_eq!(args.get("content").and_then(|v| v.as_str()), Some("short"));
|
assert_eq!(args.get("content").and_then(|v| v.as_str()), Some("short"));
|
||||||
}
|
}
|
||||||
@@ -361,7 +348,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn incomplete_tool_call_flags_truncated_json() {
|
fn incomplete_tool_call_flags_truncated_json() {
|
||||||
let mut turn = StreamedTurn::new();
|
let mut turn = StreamedTurn::new();
|
||||||
turn.tool_calls.push(tool_call("write", "{\"path\": \"a.txt\", \"content\": \"unterm"));
|
turn.tool_calls.push(tool_call(
|
||||||
|
"write",
|
||||||
|
"{\"path\": \"a.txt\", \"content\": \"unterm",
|
||||||
|
));
|
||||||
let bad = turn.incomplete_tool_call();
|
let bad = turn.incomplete_tool_call();
|
||||||
assert_eq!(bad.map(|(name, _)| name), Some("write"));
|
assert_eq!(bad.map(|(name, _)| name), Some("write"));
|
||||||
}
|
}
|
||||||
@@ -369,7 +359,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn incomplete_tool_call_accepts_complete_json() {
|
fn incomplete_tool_call_accepts_complete_json() {
|
||||||
let mut turn = StreamedTurn::new();
|
let mut turn = StreamedTurn::new();
|
||||||
turn.tool_calls.push(tool_call("write", "{\"path\": \"a.txt\", \"content\": \"done\"}"));
|
turn.tool_calls.push(tool_call(
|
||||||
|
"write",
|
||||||
|
"{\"path\": \"a.txt\", \"content\": \"done\"}",
|
||||||
|
));
|
||||||
assert!(turn.incomplete_tool_call().is_none());
|
assert!(turn.incomplete_tool_call().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,7 +379,10 @@ mod tests {
|
|||||||
// so it should still flag truncated JSON even though
|
// so it should still flag truncated JSON even though
|
||||||
// `build_assistant_message` will later repair it.
|
// `build_assistant_message` will later repair it.
|
||||||
let mut turn = StreamedTurn::new();
|
let mut turn = StreamedTurn::new();
|
||||||
turn.tool_calls.push(tool_call("write", "{\"path\": \"a.txt\", \"content\": \"unterm"));
|
turn.tool_calls.push(tool_call(
|
||||||
|
"write",
|
||||||
|
"{\"path\": \"a.txt\", \"content\": \"unterm",
|
||||||
|
));
|
||||||
// Even though it's repairable, raw parse should still fail
|
// Even though it's repairable, raw parse should still fail
|
||||||
assert!(serde_json::from_str::<Value>(&turn.tool_calls[0].arguments).is_err());
|
assert!(serde_json::from_str::<Value>(&turn.tool_calls[0].arguments).is_err());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,16 +225,16 @@ impl InputState {
|
|||||||
/// if none, close and return → otherwise fuzzy-match `query` against
|
/// if none, close and return → otherwise fuzzy-match `query` against
|
||||||
/// `files` via `nucleo-matcher`, keep the top 10 by score.
|
/// `files` via `nucleo-matcher`, keep the top 10 by score.
|
||||||
pub fn open_mention_autocomplete(&mut self, files: &[String]) {
|
pub fn open_mention_autocomplete(&mut self, files: &[String]) {
|
||||||
|
use nucleo_matcher::{Config, Matcher};
|
||||||
|
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
|
||||||
let Some((start, query)) = self.mention_query_at_cursor() else {
|
let Some((start, query)) = self.mention_query_at_cursor() else {
|
||||||
self.close_autocomplete();
|
self.close_autocomplete();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
use nucleo_matcher::{Config, Matcher};
|
|
||||||
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
|
|
||||||
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
|
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
|
||||||
let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart);
|
let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart);
|
||||||
let matches = pattern.match_list(files.iter(), &mut matcher);
|
let matched_files = pattern.match_list(files.iter(), &mut matcher);
|
||||||
self.autocomplete_candidates = matches.into_iter().take(10).map(|(f, _)| f.clone()).collect();
|
self.autocomplete_candidates = matched_files.into_iter().take(10).map(|(f, _)| f.clone()).collect();
|
||||||
self.autocomplete_kind = AutocompleteKind::FileMention;
|
self.autocomplete_kind = AutocompleteKind::FileMention;
|
||||||
self.mention_start = start;
|
self.mention_start = start;
|
||||||
self.autocomplete_idx = 0;
|
self.autocomplete_idx = 0;
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ impl AppStateRest {
|
|||||||
// async executor entirely. It is deliberately not joined -- startup
|
// async executor entirely. It is deliberately not joined -- startup
|
||||||
// must not block on language server installation, and failures are
|
// must not block on language server installation, and failures are
|
||||||
// logged rather than surfaced, since editing still works without LSP.
|
// logged rather than surfaced, since editing still works without LSP.
|
||||||
if state.settings.lsp_auto_provision {
|
if state.settings.flags.lsp_auto_provision {
|
||||||
let lsp_mgr = state.lsp_manager.clone();
|
let lsp_mgr = state.lsp_manager.clone();
|
||||||
let msg_queue = state.lsp_provision_msgs.clone();
|
let msg_queue = state.lsp_provision_msgs.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
//! Per-session runtime state: message history, pending tool queue,
|
//! Per-session runtime state: message history, pending tool queue,
|
||||||
//! background bash jobs, lesson/review counters, and the `TurnEvent`
|
//! background bash jobs, lesson/review counters, and the `TurnEvent`
|
||||||
//! stream emitted while an agent turn is in flight.
|
//! stream emitted while an agent turn is in flight.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Cumulative token/latency counters for a session, persisted alongside it.
|
/// Cumulative token/latency counters for a session, persisted alongside it.
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Opaque, serializable snapshot of application state used for
|
//! Opaque, serializable snapshot of application state used for
|
||||||
//! attach/daemon IPC transfer.
|
//! attach/daemon IPC transfer.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// A JSON-boxed snapshot of app state, opaque to the transport layer.
|
/// A JSON-boxed snapshot of app state, opaque to the transport layer.
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
#![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,
|
//! Shared small state types: toasts, overlays, the transcript cache,
|
||||||
//! tool execution model, and call origin tags.
|
//! tool execution model, and call origin tags.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
||||||
/// Severity/category of a toast notification, used to pick its color.
|
/// Severity/category of a toast notification, used to pick its color.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum ToastKind {
|
pub enum ToastKind {
|
||||||
|
|||||||
+72
-40
@@ -15,27 +15,30 @@
|
|||||||
//! wrote this file, let me check if it's correct before continuing").
|
//! wrote this file, let me check if it's correct before continuing").
|
||||||
//! - Background reviews catch broader concerns (missing tests, architectural
|
//! - Background reviews catch broader concerns (missing tests, architectural
|
||||||
//! drift, security issues) without blocking the main agent's flow.
|
//! drift, security issues) without blocking the main agent's flow.
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use crate::app::state::runtime::TurnEvent;
|
use crate::app::state::runtime::TurnEvent;
|
||||||
use crate::app::subagent::context::build_subagent_context;
|
use crate::app::subagent::context::build_subagent_context;
|
||||||
use crate::app::subagent::engine::run_subagent;
|
use crate::app::subagent::engine::run_subagent;
|
||||||
use crate::app::subagent::spawn::AgentDefinition;
|
|
||||||
use crate::app::subagent::event::SubagentEvent;
|
use crate::app::subagent::event::SubagentEvent;
|
||||||
|
use crate::app::subagent::spawn::AgentDefinition;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
/// File extensions that should not trigger auto-review (config, lock, data).
|
/// File extensions that should not trigger auto-review (config, lock, data).
|
||||||
const SKIP_REVIEW_EXTENSIONS: &[&str] = &[
|
const SKIP_REVIEW_EXTENSIONS: &[&str] = &[
|
||||||
".lock", ".md", ".txt", ".json", ".toml", ".yaml", ".yml",
|
".lock", ".md", ".txt", ".json", ".toml", ".yaml", ".yml", ".svg", ".png", ".jpg", ".ico",
|
||||||
".svg", ".png", ".jpg", ".ico", ".woff", ".woff2",
|
".woff", ".woff2",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// File names that should not trigger auto-review.
|
/// File names that should not trigger auto-review.
|
||||||
const SKIP_REVIEW_FILES: &[&str] = &[
|
const SKIP_REVIEW_FILES: &[&str] = &[
|
||||||
"Cargo.lock", "yarn.lock", "package-lock.json",
|
"Cargo.lock",
|
||||||
".gitignore", ".env", ".env.example",
|
"yarn.lock",
|
||||||
|
"package-lock.json",
|
||||||
|
".gitignore",
|
||||||
|
".env",
|
||||||
|
".env.example",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Prevents a second background subagent of the same kind from spawning
|
/// Prevents a second background subagent of the same kind from spawning
|
||||||
@@ -137,8 +140,19 @@ fn is_production_code(path: &str) -> bool {
|
|||||||
.is_some_and(|ext| {
|
.is_some_and(|ext| {
|
||||||
matches!(
|
matches!(
|
||||||
ext,
|
ext,
|
||||||
"rs" | "ts" | "tsx" | "js" | "jsx" | "go" | "py" | "java" | "kt" | "swift"
|
"rs" | "ts"
|
||||||
| "c" | "cpp" | "h" | "hpp"
|
| "tsx"
|
||||||
|
| "js"
|
||||||
|
| "jsx"
|
||||||
|
| "go"
|
||||||
|
| "py"
|
||||||
|
| "java"
|
||||||
|
| "kt"
|
||||||
|
| "swift"
|
||||||
|
| "c"
|
||||||
|
| "cpp"
|
||||||
|
| "h"
|
||||||
|
| "hpp"
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -167,11 +181,8 @@ pub fn spawn_quick_review(
|
|||||||
file_path,
|
file_path,
|
||||||
);
|
);
|
||||||
|
|
||||||
let def = AgentDefinition::new(
|
let def = AgentDefinition::new("quick-reviewer".to_string(), "reviewer".to_string())
|
||||||
"quick-reviewer".to_string(),
|
.with_system_prompt(prompt);
|
||||||
"reviewer".to_string(),
|
|
||||||
)
|
|
||||||
.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.session_dir = session_dir.to_path_buf();
|
||||||
@@ -187,7 +198,7 @@ pub fn spawn_quick_review(
|
|||||||
SubagentEvent::ToolResult { tool, .. } => {
|
SubagentEvent::ToolResult { tool, .. } => {
|
||||||
tracing::debug!("[auto-review] tool result: {}", tool);
|
tracing::debug!("[auto-review] tool result: {}", tool);
|
||||||
}
|
}
|
||||||
SubagentEvent::Completed { .. } => {
|
SubagentEvent::Completed => {
|
||||||
tracing::debug!("[auto-review] completed");
|
tracing::debug!("[auto-review] completed");
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -277,7 +288,10 @@ pub fn spawn_background_test_gen(
|
|||||||
if file_paths.is_empty() {
|
if file_paths.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if TEST_GEN_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
|
if TEST_GEN_RUNNING
|
||||||
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
tracing::debug!("[bg-test-gen] skipped — a test-gen run is already in flight");
|
tracing::debug!("[bg-test-gen] skipped — a test-gen run is already in flight");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -306,8 +320,7 @@ pub fn spawn_background_test_gen(
|
|||||||
"test-generator".to_string(),
|
"test-generator".to_string(),
|
||||||
"coder".to_string(), // needs write access
|
"coder".to_string(), // needs write access
|
||||||
)
|
)
|
||||||
.with_system_prompt(prompt)
|
.with_system_prompt(prompt);
|
||||||
;
|
|
||||||
|
|
||||||
let result = run_subagent_with_retry(&def, &sd, &ws, "bg-test-gen", Some(&abort_flag));
|
let result = run_subagent_with_retry(&def, &sd, &ws, "bg-test-gen", Some(&abort_flag));
|
||||||
let message = match &result {
|
let message = match &result {
|
||||||
@@ -347,7 +360,10 @@ pub fn spawn_background_arch_review(
|
|||||||
if file_paths.is_empty() {
|
if file_paths.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ARCH_REVIEW_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
|
if ARCH_REVIEW_RUNNING
|
||||||
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
tracing::debug!("[bg-arch-review] skipped — an arch-review run is already in flight");
|
tracing::debug!("[bg-arch-review] skipped — an arch-review run is already in flight");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -366,12 +382,8 @@ pub fn spawn_background_arch_review(
|
|||||||
file_list,
|
file_list,
|
||||||
);
|
);
|
||||||
|
|
||||||
let def = AgentDefinition::new(
|
let def = AgentDefinition::new("arch-reviewer".to_string(), "reviewer".to_string())
|
||||||
"arch-reviewer".to_string(),
|
.with_system_prompt(prompt);
|
||||||
"reviewer".to_string(),
|
|
||||||
)
|
|
||||||
.with_system_prompt(prompt)
|
|
||||||
;
|
|
||||||
|
|
||||||
let result = run_subagent_with_retry(&def, &sd, &ws, "bg-arch-review", Some(&abort_flag));
|
let result = run_subagent_with_retry(&def, &sd, &ws, "bg-arch-review", Some(&abort_flag));
|
||||||
let message = match &result {
|
let message = match &result {
|
||||||
@@ -422,8 +434,13 @@ pub fn spawn_background_security_review(
|
|||||||
if prod_paths.is_empty() {
|
if prod_paths.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if SECURITY_REVIEW_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
|
if SECURITY_REVIEW_RUNNING
|
||||||
tracing::debug!("[bg-security-review] skipped — a security-review run is already in flight");
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
tracing::debug!(
|
||||||
|
"[bg-security-review] skipped — a security-review run is already in flight"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,14 +458,11 @@ pub fn spawn_background_security_review(
|
|||||||
file_list,
|
file_list,
|
||||||
);
|
);
|
||||||
|
|
||||||
let def = AgentDefinition::new(
|
let def = AgentDefinition::new("security-reviewer".to_string(), "reviewer".to_string())
|
||||||
"security-reviewer".to_string(),
|
.with_system_prompt(prompt);
|
||||||
"reviewer".to_string(),
|
|
||||||
)
|
|
||||||
.with_system_prompt(prompt)
|
|
||||||
;
|
|
||||||
|
|
||||||
let result = run_subagent_with_retry(&def, &sd, &ws, "bg-security-review", Some(&abort_flag));
|
let result =
|
||||||
|
run_subagent_with_retry(&def, &sd, &ws, "bg-security-review", Some(&abort_flag));
|
||||||
let message = match &result {
|
let message = match &result {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
let first = output.lines().next().unwrap_or(output);
|
let first = output.lines().next().unwrap_or(output);
|
||||||
@@ -493,7 +507,13 @@ pub fn spawn_all_background(
|
|||||||
.filter(|p| is_production_code(p))
|
.filter(|p| is_production_code(p))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
spawn_background_test_gen(&source_paths, session_dir, workspaces, turn_events, abort_flag.clone());
|
spawn_background_test_gen(
|
||||||
|
&source_paths,
|
||||||
|
session_dir,
|
||||||
|
workspaces,
|
||||||
|
turn_events,
|
||||||
|
abort_flag.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
// Background arch review: for all files that are reviewable
|
// Background arch review: for all files that are reviewable
|
||||||
let reviewable: Vec<String> = file_paths
|
let reviewable: Vec<String> = file_paths
|
||||||
@@ -501,10 +521,22 @@ pub fn spawn_all_background(
|
|||||||
.filter(|p| is_reviewable_path(p))
|
.filter(|p| is_reviewable_path(p))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
spawn_background_arch_review(&reviewable, session_dir, workspaces, turn_events, abort_flag.clone());
|
spawn_background_arch_review(
|
||||||
|
&reviewable,
|
||||||
|
session_dir,
|
||||||
|
workspaces,
|
||||||
|
turn_events,
|
||||||
|
abort_flag.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
// Background security review: only production source files
|
// Background security review: only production source files
|
||||||
spawn_background_security_review(&source_paths, session_dir, workspaces, turn_events, abort_flag);
|
spawn_background_security_review(
|
||||||
|
&source_paths,
|
||||||
|
session_dir,
|
||||||
|
workspaces,
|
||||||
|
turn_events,
|
||||||
|
abort_flag,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
//! Construction of a `SubagentContext` from an `AgentDefinition`,
|
//! Construction of a `SubagentContext` from an `AgentDefinition`,
|
||||||
//! including the default read-only tool set for reviewer agents.
|
//! including the default read-only tool set for reviewer agents.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::{Arc, Mutex, atomic::AtomicBool};
|
|
||||||
use super::spawn::AgentDefinition;
|
use super::spawn::AgentDefinition;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||||
|
|
||||||
/// Default read-only tool names granted to `role == "reviewer"` agents.
|
/// Default read-only tool names granted to `role == "reviewer"` agents.
|
||||||
pub const REVIEWER_ALLOWED: &[&str] = &["read", "grep", "glob", "recall", "remember"];
|
pub const REVIEWER_ALLOWED: &[&str] = &["read", "grep", "glob", "recall", "remember"];
|
||||||
@@ -40,7 +39,10 @@ pub struct SubagentContext {
|
|||||||
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(|| {
|
let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| {
|
||||||
if def.role == "reviewer" {
|
if def.role == "reviewer" {
|
||||||
REVIEWER_ALLOWED.iter().map(std::string::ToString::to_string).collect()
|
REVIEWER_ALLOWED
|
||||||
|
.iter()
|
||||||
|
.map(std::string::ToString::to_string)
|
||||||
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,24 +22,64 @@ pub mod tool_scope {
|
|||||||
/// authoritative "safe to deduplicate" classification, so there's a
|
/// authoritative "safe to deduplicate" classification, so there's a
|
||||||
/// single list of read-only tool names in the codebase instead of two.
|
/// single list of read-only tool names in the codebase instead of two.
|
||||||
pub const READ_TOOLS: &[&str] = &[
|
pub const READ_TOOLS: &[&str] = &[
|
||||||
"read", "grep", "glob", "search", "seqthink", "recall",
|
"read",
|
||||||
"lsp_connect", "lsp_diagnostics", "lsp_hover", "lsp_definition",
|
"grep",
|
||||||
"lsp_references", "read_findings",
|
"glob",
|
||||||
|
"search",
|
||||||
|
"seqthink",
|
||||||
|
"recall",
|
||||||
|
"lsp_connect",
|
||||||
|
"lsp_diagnostics",
|
||||||
|
"lsp_hover",
|
||||||
|
"lsp_definition",
|
||||||
|
"lsp_references",
|
||||||
|
"read_findings",
|
||||||
];
|
];
|
||||||
|
|
||||||
const WRITE_TOOLS: &[&str] = &[
|
const WRITE_TOOLS: &[&str] = &[
|
||||||
"read", "grep", "glob", "search", "seqthink", "recall",
|
"read",
|
||||||
"lsp_connect", "lsp_diagnostics", "lsp_hover", "lsp_definition",
|
"grep",
|
||||||
"lsp_references", "read_findings",
|
"glob",
|
||||||
"write", "edit", "bash", "todowrite", "todofinish", "remember",
|
"search",
|
||||||
|
"seqthink",
|
||||||
|
"recall",
|
||||||
|
"lsp_connect",
|
||||||
|
"lsp_diagnostics",
|
||||||
|
"lsp_hover",
|
||||||
|
"lsp_definition",
|
||||||
|
"lsp_references",
|
||||||
|
"read_findings",
|
||||||
|
"write",
|
||||||
|
"edit",
|
||||||
|
"bash",
|
||||||
|
"todowrite",
|
||||||
|
"todofinish",
|
||||||
|
"remember",
|
||||||
];
|
];
|
||||||
|
|
||||||
const FULL_TOOLS: &[&str] = &[
|
const FULL_TOOLS: &[&str] = &[
|
||||||
"read", "grep", "glob", "search", "seqthink", "recall",
|
"read",
|
||||||
"lsp_connect", "lsp_diagnostics", "lsp_hover", "lsp_definition",
|
"grep",
|
||||||
"lsp_references", "read_findings",
|
"glob",
|
||||||
"write", "edit", "bash", "todowrite", "todofinish", "remember",
|
"search",
|
||||||
"delete", "git_operator", "lsp_completion", "lsp_disconnect",
|
"seqthink",
|
||||||
|
"recall",
|
||||||
|
"lsp_connect",
|
||||||
|
"lsp_diagnostics",
|
||||||
|
"lsp_hover",
|
||||||
|
"lsp_definition",
|
||||||
|
"lsp_references",
|
||||||
|
"read_findings",
|
||||||
|
"write",
|
||||||
|
"edit",
|
||||||
|
"bash",
|
||||||
|
"todowrite",
|
||||||
|
"todofinish",
|
||||||
|
"remember",
|
||||||
|
"delete",
|
||||||
|
"git_operator",
|
||||||
|
"lsp_completion",
|
||||||
|
"lsp_disconnect",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Resolve a tier name to its concrete tool allowlist.
|
/// Resolve a tier name to its concrete tool allowlist.
|
||||||
@@ -98,7 +138,13 @@ mod tests {
|
|||||||
let read: HashSet<_> = tools_for(READ).into_iter().collect();
|
let read: HashSet<_> = tools_for(READ).into_iter().collect();
|
||||||
let write: HashSet<_> = tools_for(WRITE).into_iter().collect();
|
let write: HashSet<_> = tools_for(WRITE).into_iter().collect();
|
||||||
let full: HashSet<_> = tools_for(FULL).into_iter().collect();
|
let full: HashSet<_> = tools_for(FULL).into_iter().collect();
|
||||||
assert!(read.is_subset(&write), "read tier must be a subset of write tier");
|
assert!(
|
||||||
assert!(write.is_subset(&full), "write tier must be a subset of full tier");
|
read.is_subset(&write),
|
||||||
|
"read tier must be a subset of write tier"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
write.is_subset(&full),
|
||||||
|
"write tier must be a subset of full tier"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -632,7 +632,7 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
|||||||
shared_text.truncate(50_000);
|
shared_text.truncate(50_000);
|
||||||
shared_text.push_str("\n...[truncated]");
|
shared_text.push_str("\n...[truncated]");
|
||||||
}
|
}
|
||||||
f.push(format!("[Auto-Shared] Sibling drone executed '{}' with args {}:\n{}", tool_name, args_json, shared_text));
|
f.push(format!("[Auto-Shared] Sibling drone executed '{tool_name}' with args {args_json}:\n{shared_text}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Event variants that a running subagent can emit to its parent via the
|
//! Event variants that a running subagent can emit to its parent via the
|
||||||
//! shared mpsc channel.
|
//! shared mpsc channel.
|
||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
/// Progress and outcome events emitted by `run_subagent` as it processes
|
/// Progress and outcome events emitted by `run_subagent` as it processes
|
||||||
@@ -8,29 +7,20 @@ use serde_json::Value;
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum SubagentEvent {
|
pub enum SubagentEvent {
|
||||||
StepCompleted {
|
StepCompleted {
|
||||||
#[allow(dead_code)]
|
|
||||||
step: usize,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
output: String,
|
output: String,
|
||||||
},
|
},
|
||||||
StepFailed {
|
StepFailed {
|
||||||
step: usize,
|
step: usize,
|
||||||
error: String,
|
error: String,
|
||||||
},
|
},
|
||||||
Completed {
|
Completed,
|
||||||
#[allow(dead_code)]
|
|
||||||
output: String,
|
|
||||||
},
|
|
||||||
ToolCall {
|
ToolCall {
|
||||||
tool: String,
|
tool: String,
|
||||||
#[allow(dead_code)]
|
|
||||||
args: Value,
|
args: Value,
|
||||||
},
|
},
|
||||||
ToolResult {
|
ToolResult {
|
||||||
tool: String,
|
tool: String,
|
||||||
args: Value,
|
args: Value,
|
||||||
#[allow(dead_code)]
|
|
||||||
output: String,
|
|
||||||
},
|
},
|
||||||
Progress(String),
|
Progress(String),
|
||||||
/// Token usage reported by the LLM after one streaming call inside the
|
/// Token usage reported by the LLM after one streaming call inside the
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Subagent management: spawning, context building, engine loop, and
|
//! Subagent management: spawning, context building, engine loop, and
|
||||||
//! progress events.
|
//! progress events.
|
||||||
|
|
||||||
pub mod auto;
|
pub mod auto;
|
||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod division;
|
pub mod division;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! `AgentDefinition` -- declarative specification for instantiating a
|
//! `AgentDefinition` -- declarative specification for instantiating a
|
||||||
//! subagent from workflow scripts or programmatic calls.
|
//! subagent from workflow scripts or programmatic calls.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Declarative specification for instantiating a subagent: name, role,
|
/// Declarative specification for instantiating a subagent: name, role,
|
||||||
@@ -29,13 +28,6 @@ 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builder method: set the system prompt for this agent.
|
/// Builder method: set the system prompt for this agent.
|
||||||
pub fn with_system_prompt(mut self, prompt: String) -> Self {
|
pub fn with_system_prompt(mut self, prompt: String) -> Self {
|
||||||
self.system_prompt = Some(prompt);
|
self.system_prompt = Some(prompt);
|
||||||
|
|||||||
+28
-16
@@ -6,11 +6,10 @@
|
|||||||
//! choice, so this step is plain Rust — not an LLM call, not a cycle the
|
//! choice, so this step is plain Rust — not an LLM call, not a cycle the
|
||||||
//! Core Intelligence can omit or reshape — and always runs after any
|
//! Core Intelligence can omit or reshape — and always runs after any
|
||||||
//! hive-mind convergence completes.
|
//! hive-mind convergence completes.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::fmt::Write as _;
|
|
||||||
use crate::app::workflow::hive_mind::NodeReport;
|
use crate::app::workflow::hive_mind::NodeReport;
|
||||||
use crate::model::memory::Memory;
|
use crate::model::memory::Memory;
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Write a markdown report of one hive-mind convergence to
|
/// Write a markdown report of one hive-mind convergence to
|
||||||
/// `<workspace_root>/docs/runs/<timestamp>-<slug>.md`.
|
/// `<workspace_root>/docs/runs/<timestamp>-<slug>.md`.
|
||||||
@@ -42,22 +41,31 @@ pub fn write_hive_mind_convergence(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Render a hive-mind convergence as a markdown document.
|
/// Render a hive-mind convergence as a markdown document.
|
||||||
fn render_report(user_request: &str, ts_millis: i64, reports: &[NodeReport], consensus: &str) -> String {
|
fn render_report(
|
||||||
|
user_request: &str,
|
||||||
|
ts_millis: i64,
|
||||||
|
reports: &[NodeReport],
|
||||||
|
consensus: &str,
|
||||||
|
) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
writeln!(out, "# The Hive converges: {user_request}").unwrap();
|
let _ = writeln!(out, "# The Hive converges: {user_request}");
|
||||||
writeln!(out, "\nTimestamp (ms): {ts_millis}\n").unwrap();
|
let _ = writeln!(out, "\nTimestamp (ms): {ts_millis}\n");
|
||||||
|
|
||||||
let cycle_count = reports.iter().map(|r| r.cycle_index).max().map_or(0, |m| m + 1);
|
let cycle_count = reports
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.cycle_index)
|
||||||
|
.max()
|
||||||
|
.map_or(0, |m| m + 1);
|
||||||
for cycle_index in 0..cycle_count {
|
for cycle_index in 0..cycle_count {
|
||||||
writeln!(out, "## Cycle {cycle_index}\n").unwrap();
|
let _ = writeln!(out, "## Cycle {cycle_index}\n");
|
||||||
for r in reports.iter().filter(|r| r.cycle_index == cycle_index) {
|
for r in reports.iter().filter(|r| r.cycle_index == cycle_index) {
|
||||||
writeln!(out, "### {}\n", r.node_id).unwrap();
|
let _ = writeln!(out, "### {}\n", r.node_id);
|
||||||
writeln!(out, "{}\n", r.output).unwrap();
|
let _ = writeln!(out, "{}\n", r.output);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writeln!(out, "## The Hive's Verdict\n").unwrap();
|
let _ = writeln!(out, "## The Hive's Verdict\n");
|
||||||
writeln!(out, "{consensus}\n").unwrap();
|
let _ = writeln!(out, "{consensus}\n");
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,10 +78,14 @@ mod tests {
|
|||||||
let tmp = std::env::temp_dir().join(format!("zesdex-docs-test-{}", uuid::Uuid::new_v4()));
|
let tmp = std::env::temp_dir().join(format!("zesdex-docs-test-{}", uuid::Uuid::new_v4()));
|
||||||
std::fs::create_dir_all(&tmp).unwrap();
|
std::fs::create_dir_all(&tmp).unwrap();
|
||||||
|
|
||||||
let reports = vec![
|
let reports = vec![NodeReport {
|
||||||
NodeReport { node_id: "Node-0-0".to_string(), cycle_index: 0, output: "found the bug".to_string() },
|
node_id: "Node-0-0".to_string(),
|
||||||
];
|
cycle_index: 0,
|
||||||
let path = write_hive_mind_convergence(&tmp, "fix the bug", &reports, "the bug is a null check").unwrap();
|
output: "found the bug".to_string(),
|
||||||
|
}];
|
||||||
|
let path =
|
||||||
|
write_hive_mind_convergence(&tmp, "fix the bug", &reports, "the bug is a null check")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(path.starts_with(tmp.join("docs").join("runs")));
|
assert!(path.starts_with(tmp.join("docs").join("runs")));
|
||||||
let content = std::fs::read_to_string(&path).unwrap();
|
let content = std::fs::read_to_string(&path).unwrap();
|
||||||
|
|||||||
+156
-61
@@ -13,12 +13,14 @@
|
|||||||
//! `Arc<Mutex<Vec<String>>>` threaded through `execute_primitive` and
|
//! `Arc<Mutex<Vec<String>>>` threaded through `execute_primitive` and
|
||||||
//! `spawn_single_agent` rather than a global static, preventing data
|
//! `spawn_single_agent` rather than a global static, preventing data
|
||||||
//! leaks between concurrent workflow runs.
|
//! leaks between concurrent workflow runs.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}};
|
|
||||||
use std::time::Duration;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use super::script::{ScriptPrimitive, WorkflowScript};
|
use super::script::{ScriptPrimitive, WorkflowScript};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc, Mutex,
|
||||||
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
/// The lifecycle state of an agent within a workflow run.
|
/// The lifecycle state of an agent within a workflow run.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -100,17 +102,31 @@ pub type LiveStateFn = Arc<dyn Fn(String, String, AgentStatus) + Send + Sync>;
|
|||||||
/// Return: the agent's text output, or an error on failure.
|
/// Return: the agent's text output, or an error on failure.
|
||||||
fn format_tool_call_progress(prefix: &str, tool: &str, args: &serde_json::Value) -> String {
|
fn format_tool_call_progress(prefix: &str, tool: &str, args: &serde_json::Value) -> String {
|
||||||
let details = match tool {
|
let details = match tool {
|
||||||
"read" | "view_file" | "write" | "write_to_file" | "edit" | "replace_file_content" | "multi_replace_file_content" | "delete" => {
|
"read"
|
||||||
args.get("path")
|
| "view_file"
|
||||||
.or_else(|| args.get("TargetFile"))
|
| "write"
|
||||||
.or_else(|| args.get("AbsolutePath"))
|
| "write_to_file"
|
||||||
.and_then(|v| v.as_str())
|
| "edit"
|
||||||
.unwrap_or("")
|
| "replace_file_content"
|
||||||
.to_string()
|
| "multi_replace_file_content"
|
||||||
}
|
| "delete" => args
|
||||||
|
.get("path")
|
||||||
|
.or_else(|| args.get("TargetFile"))
|
||||||
|
.or_else(|| args.get("AbsolutePath"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
"grep" | "grep_search" => {
|
"grep" | "grep_search" => {
|
||||||
let pattern = args.get("pattern").or_else(|| args.get("Query")).and_then(|v| v.as_str()).unwrap_or("");
|
let pattern = args
|
||||||
let path = args.get("path").or_else(|| args.get("SearchPath")).and_then(|v| v.as_str()).unwrap_or("");
|
.get("pattern")
|
||||||
|
.or_else(|| args.get("Query"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let path = args
|
||||||
|
.get("path")
|
||||||
|
.or_else(|| args.get("SearchPath"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
format!("\"{pattern}\"")
|
format!("\"{pattern}\"")
|
||||||
} else {
|
} else {
|
||||||
@@ -127,31 +143,44 @@ fn format_tool_call_progress(prefix: &str, tool: &str, args: &serde_json::Value)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"bash" | "run_command" => {
|
"bash" | "run_command" => {
|
||||||
let cmd = args.get("command").or_else(|| args.get("CommandLine")).and_then(|v| v.as_str()).unwrap_or("");
|
let cmd = args
|
||||||
|
.get("command")
|
||||||
|
.or_else(|| args.get("CommandLine"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
if cmd.len() > 60 {
|
if cmd.len() > 60 {
|
||||||
format!("\"{}...\"", &cmd[..57])
|
format!("\"{}...\"", &cmd[..57])
|
||||||
} else {
|
} else {
|
||||||
format!("\"{cmd}\"")
|
format!("\"{cmd}\"")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"recall" => {
|
"recall" => args
|
||||||
args.get("query").and_then(|v| v.as_str()).unwrap_or("").to_string()
|
.get("query")
|
||||||
}
|
.and_then(|v| v.as_str())
|
||||||
"remember" => {
|
.unwrap_or("")
|
||||||
args.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string()
|
.to_string(),
|
||||||
}
|
"remember" => args
|
||||||
"dir_list" | "list_dir" => {
|
.get("name")
|
||||||
args.get("DirectoryPath").or_else(|| args.get("path")).and_then(|v| v.as_str()).unwrap_or("").to_string()
|
.and_then(|v| v.as_str())
|
||||||
}
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
"dir_list" | "list_dir" => args
|
||||||
|
.get("DirectoryPath")
|
||||||
|
.or_else(|| args.get("path"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
_ => {
|
_ => {
|
||||||
if args.is_object() && !args.as_object().unwrap().is_empty() {
|
if let Some(obj) = args.as_object() {
|
||||||
args.as_object().unwrap().values()
|
if !obj.is_empty() {
|
||||||
.find_map(|v| v.as_str())
|
return obj
|
||||||
.unwrap_or("")
|
.values()
|
||||||
.to_string()
|
.find_map(|v| v.as_str())
|
||||||
} else {
|
.unwrap_or("")
|
||||||
String::new()
|
.to_string();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
String::new()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,7 +210,6 @@ fn format_tool_call_progress(prefix: &str, tool: &str, args: &serde_json::Value)
|
|||||||
/// a stuck stage from blocking the entire pipeline forever.
|
/// a stuck stage from blocking the entire pipeline forever.
|
||||||
///
|
///
|
||||||
/// Return: the agent's text output, or an error on failure.
|
/// 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(
|
fn spawn_single_agent(
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
agent_name: &str,
|
agent_name: &str,
|
||||||
@@ -338,10 +366,13 @@ fn spawn_single_agent(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SubagentEvent::Completed { .. } => {
|
SubagentEvent::Completed => {
|
||||||
tracing::debug!("[subagent] completed");
|
tracing::debug!("[subagent] completed");
|
||||||
}
|
}
|
||||||
SubagentEvent::Usage { tokens_in, tokens_out } => {
|
SubagentEvent::Usage {
|
||||||
|
tokens_in,
|
||||||
|
tokens_out,
|
||||||
|
} => {
|
||||||
tracing::debug!("[subagent] usage: {} in, {} out", tokens_in, tokens_out);
|
tracing::debug!("[subagent] usage: {} in, {} out", tokens_in, tokens_out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +380,10 @@ fn spawn_single_agent(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Check abort before even starting the subagent.
|
// Check abort before even starting the subagent.
|
||||||
if abort_flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
if abort_flag
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|f| f.load(Ordering::SeqCst))
|
||||||
|
{
|
||||||
anyhow::bail!("subagent '{agent_name}' aborted before start");
|
anyhow::bail!("subagent '{agent_name}' aborted before start");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,9 +414,7 @@ fn spawn_single_agent(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if bg_abort.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
if bg_abort.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
||||||
break Err(anyhow::anyhow!(
|
break Err(anyhow::anyhow!("subagent '{bg_name}' aborted by user"));
|
||||||
"subagent '{bg_name}' aborted by user",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -391,9 +423,7 @@ fn spawn_single_agent(
|
|||||||
break r;
|
break r;
|
||||||
}
|
}
|
||||||
if bg_abort.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
if bg_abort.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
||||||
break Err(anyhow::anyhow!(
|
break Err(anyhow::anyhow!("subagent '{bg_name}' aborted by user"));
|
||||||
"subagent '{bg_name}' aborted by user",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -467,8 +497,6 @@ type ParallelResult = (usize, anyhow::Result<Vec<String>>);
|
|||||||
///
|
///
|
||||||
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
|
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
|
||||||
/// the order they were submitted.
|
/// the order they were submitted.
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
#[allow(clippy::ref_option, clippy::too_many_lines)]
|
|
||||||
pub fn execute_primitive(
|
pub fn execute_primitive(
|
||||||
primitive: &ScriptPrimitive,
|
primitive: &ScriptPrimitive,
|
||||||
args: &HashMap<String, String>,
|
args: &HashMap<String, String>,
|
||||||
@@ -501,7 +529,20 @@ pub fn execute_primitive(
|
|||||||
let resolved = resolve_template(prompt, &resolved_args);
|
let resolved = resolve_template(prompt, &resolved_args);
|
||||||
let agent_id = uuid::Uuid::new_v4().to_string();
|
let agent_id = uuid::Uuid::new_v4().to_string();
|
||||||
let agent_name = resolved.chars().take(40).collect::<String>();
|
let agent_name = resolved.chars().take(40).collect::<String>();
|
||||||
match spawn_single_agent(&agent_id, &agent_name, &resolved, "coder", None, &findings_snapshot, findings, abort_flag, live, session_dir, workspaces, timeout_ms) {
|
match spawn_single_agent(
|
||||||
|
&agent_id,
|
||||||
|
&agent_name,
|
||||||
|
&resolved,
|
||||||
|
"coder",
|
||||||
|
None,
|
||||||
|
&findings_snapshot,
|
||||||
|
findings,
|
||||||
|
abort_flag,
|
||||||
|
live,
|
||||||
|
session_dir,
|
||||||
|
workspaces,
|
||||||
|
timeout_ms,
|
||||||
|
) {
|
||||||
Ok(text) => Ok(vec![text]),
|
Ok(text) => Ok(vec![text]),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if continue_on_error {
|
if continue_on_error {
|
||||||
@@ -513,7 +554,11 @@ pub fn execute_primitive(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ScriptPrimitive::ScopedAgent { prompt, node_id, tool_scope } => {
|
ScriptPrimitive::ScopedAgent {
|
||||||
|
prompt,
|
||||||
|
node_id,
|
||||||
|
tool_scope,
|
||||||
|
} => {
|
||||||
let mut resolved_args = args.clone();
|
let mut resolved_args = args.clone();
|
||||||
let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default();
|
let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default();
|
||||||
if !resolved_args.contains_key("findings") {
|
if !resolved_args.contains_key("findings") {
|
||||||
@@ -535,9 +580,24 @@ pub fn execute_primitive(
|
|||||||
tracing::debug!("[hive] deploying drone {node_id}: {truncated}");
|
tracing::debug!("[hive] deploying drone {node_id}: {truncated}");
|
||||||
let agent_name = format!("{node_id}: {truncated}");
|
let agent_name = format!("{node_id}: {truncated}");
|
||||||
let allowed_tools = crate::app::subagent::division::tool_scope::tools_for(tool_scope);
|
let allowed_tools = crate::app::subagent::division::tool_scope::tools_for(tool_scope);
|
||||||
match spawn_single_agent(&agent_id, &agent_name, &resolved, node_id, Some(allowed_tools), &findings_snapshot, findings, abort_flag, live, session_dir, workspaces, timeout_ms) {
|
match spawn_single_agent(
|
||||||
|
&agent_id,
|
||||||
|
&agent_name,
|
||||||
|
&resolved,
|
||||||
|
node_id,
|
||||||
|
Some(allowed_tools),
|
||||||
|
&findings_snapshot,
|
||||||
|
findings,
|
||||||
|
abort_flag,
|
||||||
|
live,
|
||||||
|
session_dir,
|
||||||
|
workspaces,
|
||||||
|
timeout_ms,
|
||||||
|
) {
|
||||||
Ok(text) => {
|
Ok(text) => {
|
||||||
tracing::debug!("[hive] drone {node_id} completed — merging into collective state");
|
tracing::debug!(
|
||||||
|
"[hive] drone {node_id} completed — merging into collective state"
|
||||||
|
);
|
||||||
// Merge this drone's complete output into the Hive's
|
// Merge this drone's complete output into the Hive's
|
||||||
// collective state the instant it finishes — not after
|
// collective state the instant it finishes — not after
|
||||||
// the whole parallel cohort completes. Any sibling drone
|
// the whole parallel cohort completes. Any sibling drone
|
||||||
@@ -567,8 +627,7 @@ pub fn execute_primitive(
|
|||||||
// Each branch shares the same `findings` Arc so note_finding
|
// Each branch shares the same `findings` Arc so note_finding
|
||||||
// calls within any branch are visible to all other branches.
|
// calls within any branch are visible to all other branches.
|
||||||
let semaphore = Arc::new(Semaphore::new(concurrency_cap.max(1)));
|
let semaphore = Arc::new(Semaphore::new(concurrency_cap.max(1)));
|
||||||
let results: Arc<Mutex<Vec<ParallelResult>>> =
|
let results: Arc<Mutex<Vec<ParallelResult>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
Arc::new(Mutex::new(Vec::new()));
|
|
||||||
|
|
||||||
let handles: Vec<_> = scripts
|
let handles: Vec<_> = scripts
|
||||||
.iter()
|
.iter()
|
||||||
@@ -589,7 +648,10 @@ pub fn execute_primitive(
|
|||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let _permit = sem.acquire();
|
let _permit = sem.acquire();
|
||||||
let result = execute_primitive(
|
let result = execute_primitive(
|
||||||
&script, &args, cap, continue_on_error,
|
&script,
|
||||||
|
&args,
|
||||||
|
cap,
|
||||||
|
continue_on_error,
|
||||||
&abort,
|
&abort,
|
||||||
live_clone.as_ref(),
|
live_clone.as_ref(),
|
||||||
&session_dir,
|
&session_dir,
|
||||||
@@ -608,7 +670,9 @@ pub fn execute_primitive(
|
|||||||
let _ = handle.join();
|
let _ = handle.join();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut locked = results.lock().map_err(|_| anyhow::anyhow!("parallel results lock poisoned"))?;
|
let mut locked = results
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("parallel results lock poisoned"))?;
|
||||||
locked.sort_by_key(|(idx, _)| *idx);
|
locked.sort_by_key(|(idx, _)| *idx);
|
||||||
let mut all = Vec::new();
|
let mut all = Vec::new();
|
||||||
for (_, res) in locked.drain(..) {
|
for (_, res) in locked.drain(..) {
|
||||||
@@ -635,14 +699,28 @@ pub fn execute_primitive(
|
|||||||
for (idx, script) in scripts.iter().enumerate() {
|
for (idx, script) in scripts.iter().enumerate() {
|
||||||
// Check abort before each pipeline stage so we don't
|
// Check abort before each pipeline stage so we don't
|
||||||
// launch the next division after the user cancelled.
|
// launch the next division after the user cancelled.
|
||||||
if abort_flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
if abort_flag
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|f| f.load(Ordering::SeqCst))
|
||||||
|
{
|
||||||
if continue_on_error {
|
if continue_on_error {
|
||||||
all.push(format!("pipeline aborted at stage {idx}"));
|
all.push(format!("pipeline aborted at stage {idx}"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
anyhow::bail!("pipeline aborted by user at stage {idx}");
|
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) {
|
match execute_primitive(
|
||||||
|
script,
|
||||||
|
args,
|
||||||
|
concurrency_cap,
|
||||||
|
continue_on_error,
|
||||||
|
abort_flag,
|
||||||
|
live,
|
||||||
|
session_dir,
|
||||||
|
workspaces,
|
||||||
|
findings,
|
||||||
|
timeout_ms,
|
||||||
|
) {
|
||||||
Ok(outputs) => all.extend(outputs),
|
Ok(outputs) => all.extend(outputs),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if continue_on_error {
|
if continue_on_error {
|
||||||
@@ -656,9 +734,21 @@ pub fn execute_primitive(
|
|||||||
Ok(all)
|
Ok(all)
|
||||||
}
|
}
|
||||||
|
|
||||||
ScriptPrimitive::Phase { name: _name, script } => {
|
ScriptPrimitive::Phase {
|
||||||
execute_primitive(script, args, concurrency_cap, continue_on_error, abort_flag, live, session_dir, workspaces, findings, timeout_ms)
|
name: _name,
|
||||||
}
|
script,
|
||||||
|
} => execute_primitive(
|
||||||
|
script,
|
||||||
|
args,
|
||||||
|
concurrency_cap,
|
||||||
|
continue_on_error,
|
||||||
|
abort_flag,
|
||||||
|
live,
|
||||||
|
session_dir,
|
||||||
|
workspaces,
|
||||||
|
findings,
|
||||||
|
timeout_ms,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -687,7 +777,6 @@ pub fn run_workflow(
|
|||||||
/// `spawn_agents` invocations remain fully isolated.
|
/// `spawn_agents` invocations remain fully isolated.
|
||||||
///
|
///
|
||||||
/// Return: a human-readable summary string.
|
/// Return: a human-readable summary string.
|
||||||
#[allow(clippy::ref_option)]
|
|
||||||
pub fn run_workflow_tracked(
|
pub fn run_workflow_tracked(
|
||||||
script: &WorkflowScript,
|
script: &WorkflowScript,
|
||||||
args: &HashMap<String, String>,
|
args: &HashMap<String, String>,
|
||||||
@@ -704,9 +793,15 @@ pub fn run_workflow_tracked(
|
|||||||
|
|
||||||
let findings = Arc::new(Mutex::new(Vec::new()));
|
let findings = Arc::new(Mutex::new(Vec::new()));
|
||||||
let results = execute_primitive(
|
let results = execute_primitive(
|
||||||
&script.script, args, concurrency_cap,
|
&script.script,
|
||||||
script.options.continue_on_error, abort_flag, live,
|
args,
|
||||||
session_dir, workspaces, &findings,
|
concurrency_cap,
|
||||||
|
script.options.continue_on_error,
|
||||||
|
abort_flag,
|
||||||
|
live,
|
||||||
|
session_dir,
|
||||||
|
workspaces,
|
||||||
|
&findings,
|
||||||
script.options.timeout_ms,
|
script.options.timeout_ms,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
|||||||
+163
-82
@@ -25,12 +25,14 @@
|
|||||||
//! Synthesis node reads the complete collective state and converges it
|
//! Synthesis node reads the complete collective state and converges it
|
||||||
//! into one unified voice — returned to LO and persisted to docs/runs/*.md.
|
//! into one unified voice — returned to LO and persisted to docs/runs/*.md.
|
||||||
//! ```
|
//! ```
|
||||||
|
use crate::app::workflow::engine::{execute_primitive, AgentStatus, LiveStateFn};
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use crate::app::workflow::script::ScriptPrimitive;
|
use crate::app::workflow::script::ScriptPrimitive;
|
||||||
use crate::app::workflow::engine::{execute_primitive, LiveStateFn, AgentStatus};
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
Arc, Mutex,
|
||||||
|
};
|
||||||
|
|
||||||
/// One directive the Hive's Core Intelligence issues to a drone within a
|
/// One directive the Hive's Core Intelligence issues to a drone within a
|
||||||
/// cognitive cycle. A drone's sole identity is its directive and access tier.
|
/// cognitive cycle. A drone's sole identity is its directive and access tier.
|
||||||
@@ -85,60 +87,41 @@ pub const HIVE_MIND_CONSENSUS_TAG: &str = "[The Hive speaks]";
|
|||||||
/// Return: `true` if any prior system message begins with
|
/// Return: `true` if any prior system message begins with
|
||||||
/// `HIVE_MIND_CONSENSUS_TAG`.
|
/// `HIVE_MIND_CONSENSUS_TAG`.
|
||||||
pub fn hive_mind_already_ran<'a>(system_message_bodies: impl Iterator<Item = &'a str>) -> bool {
|
pub fn hive_mind_already_ran<'a>(system_message_bodies: impl Iterator<Item = &'a str>) -> bool {
|
||||||
system_message_bodies.into_iter().any(|body| body.starts_with(HIVE_MIND_CONSENSUS_TAG))
|
system_message_bodies
|
||||||
|
.into_iter()
|
||||||
|
.any(|body| body.starts_with(HIVE_MIND_CONSENSUS_TAG))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the live-state callback that forwards each drone's status to the
|
/// Build the live-state callback that forwards each drone's status to the
|
||||||
/// TUI panel so LO can watch the Hive work.
|
/// TUI panel so LO can watch the Hive work.
|
||||||
fn build_live(
|
fn build_live(
|
||||||
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
|
turn_events: Option<
|
||||||
|
&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>,
|
||||||
|
>,
|
||||||
) -> Option<LiveStateFn> {
|
) -> Option<LiveStateFn> {
|
||||||
turn_events.map(|events| {
|
turn_events.map(|events| {
|
||||||
let events = events.clone();
|
let events = events.clone();
|
||||||
let f: LiveStateFn = Arc::new(move |_agent_id: String, agent_name: String, status: AgentStatus| {
|
let f: LiveStateFn = Arc::new(
|
||||||
let display_name = agent_name.chars().take(40).collect::<String>();
|
move |_agent_id: String, agent_name: String, status: AgentStatus| {
|
||||||
if let Ok(mut q) = events.lock() {
|
let display_name = agent_name.chars().take(40).collect::<String>();
|
||||||
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
|
if let Ok(mut q) = events.lock() {
|
||||||
agent_id: display_name.clone(),
|
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
|
||||||
agent_name: display_name,
|
agent_id: display_name.clone(),
|
||||||
status,
|
agent_name: display_name,
|
||||||
});
|
status,
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
f
|
f
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deploy the Hive: execute a cognitive cycle plan authored by the Core
|
/// Context struct threaded through all Hive cycle execution.
|
||||||
/// Intelligence. Each cycle spawns drones (anonymous processing nodes) in
|
|
||||||
/// parallel. Every drone's complete output merges into the Hive's
|
|
||||||
/// collective state the instant it finishes, and a final synthesis node
|
|
||||||
/// reconciles the entire collective state into one unified voice.
|
|
||||||
///
|
///
|
||||||
/// Flow: for each cycle (sequential) → spawn one `ScriptPrimitive::ScopedAgent`
|
/// Carries the user request, shared collective state, concurrency limits,
|
||||||
/// per directive, tagged with a system-assigned `node_id` (the Hive's
|
/// abort flag, live-status callback, session/workspace paths, and per-drone
|
||||||
/// coordinate system, never an LLM-chosen name) → run them as a `Parallel`
|
/// timeout so individual cycle functions don't need long parameter lists.
|
||||||
/// block via `execute_primitive`, which merges each drone's output into the
|
|
||||||
/// Hive's shared collective-state Arc the instant that drone completes, not
|
|
||||||
/// after the whole cohort finishes → record `NodeReport`s → proceed to the
|
|
||||||
/// next cycle. After all cycles: spawn one more read-only synthesis node
|
|
||||||
/// whose directive is to converge the complete collective state into a
|
|
||||||
/// single consensus — the Hive becoming one voice — not list what each
|
|
||||||
/// drone said.
|
|
||||||
///
|
|
||||||
/// Concurrency per cycle and the per-drone timeout both come from
|
|
||||||
/// `Settings::load()` (`workflow_max_concurrency`, `hive_mind_node_timeout_ms`)
|
|
||||||
/// rather than a hardcoded cap/no-timeout — a stuck drone can no longer
|
|
||||||
/// stall the entire Hive forever.
|
|
||||||
///
|
|
||||||
/// Return: `(consensus, all_node_reports)` on success. `consensus` is the
|
|
||||||
/// synthesis node's converged output — what the Core Intelligence actually
|
|
||||||
/// hears from the Hive. `all_node_reports` is the complete per-drone record.
|
|
||||||
///
|
|
||||||
/// The convergence doc under `docs/runs/*.md` is written unconditionally
|
|
||||||
/// before this function returns — even when synthesis itself fails — so a
|
|
||||||
/// synthesis error never discards the work already done by cycle drones.
|
|
||||||
/// Callers must not write their own copy of this doc.
|
|
||||||
struct CycleCtx<'a> {
|
struct CycleCtx<'a> {
|
||||||
user_request: &'a str,
|
user_request: &'a str,
|
||||||
collective_state: &'a Arc<Mutex<Vec<String>>>,
|
collective_state: &'a Arc<Mutex<Vec<String>>>,
|
||||||
@@ -154,6 +137,8 @@ struct CycleCtx<'a> {
|
|||||||
///
|
///
|
||||||
/// Flow: map cycle directives to `ScopedAgent` primitives -> group in a Parallel
|
/// Flow: map cycle directives to `ScopedAgent` primitives -> group in a Parallel
|
||||||
/// phase block -> run block via `execute_primitive` -> return reports.
|
/// phase block -> run block via `execute_primitive` -> return reports.
|
||||||
|
///
|
||||||
|
/// Return: `Ok(Vec<NodeReport>)` with one report per directive in submission order.
|
||||||
fn execute_cycle(
|
fn execute_cycle(
|
||||||
cycle_index: usize,
|
cycle_index: usize,
|
||||||
directives: &[NodeDirective],
|
directives: &[NodeDirective],
|
||||||
@@ -249,12 +234,44 @@ fn execute_cycle(
|
|||||||
Ok(reports)
|
Ok(reports)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deploy the Hive: execute a cognitive cycle plan authored by the Core
|
||||||
|
/// Intelligence. Each cycle spawns drones (anonymous processing nodes) in
|
||||||
|
/// parallel. Every drone's complete output merges into the Hive's
|
||||||
|
/// collective state the instant it finishes, and a final synthesis node
|
||||||
|
/// reconciles the entire collective state into one unified voice.
|
||||||
|
///
|
||||||
|
/// Flow: for each cycle (sequential) → spawn one `ScriptPrimitive::ScopedAgent`
|
||||||
|
/// per directive, tagged with a system-assigned `node_id` (the Hive's
|
||||||
|
/// coordinate system, never an LLM-chosen name) → run them as a `Parallel`
|
||||||
|
/// block via `execute_primitive`, which merges each drone's output into the
|
||||||
|
/// Hive's shared collective-state Arc the instant that drone completes, not
|
||||||
|
/// after the whole cohort finishes → record `NodeReport`s → proceed to the
|
||||||
|
/// next cycle. After all cycles: spawn one more read-only synthesis node
|
||||||
|
/// whose directive is to converge the complete collective state into a
|
||||||
|
/// single consensus — the Hive becoming one voice — not list what each
|
||||||
|
/// drone said.
|
||||||
|
///
|
||||||
|
/// Concurrency per cycle and the per-drone timeout both come from
|
||||||
|
/// `Settings::load()` (`workflow_max_concurrency`, `hive_mind_node_timeout_ms`)
|
||||||
|
/// rather than a hardcoded cap/no-timeout — a stuck drone can no longer
|
||||||
|
/// stall the entire Hive forever.
|
||||||
|
///
|
||||||
|
/// Return: `(consensus, all_node_reports)` on success. `consensus` is the
|
||||||
|
/// synthesis node's converged output — what the Core Intelligence actually
|
||||||
|
/// hears from the Hive. `all_node_reports` is the complete per-drone record.
|
||||||
|
///
|
||||||
|
/// The convergence doc under `docs/runs/*.md` is written unconditionally
|
||||||
|
/// before this function returns — even when synthesis itself fails — so a
|
||||||
|
/// synthesis error never discards the work already done by cycle drones.
|
||||||
|
/// Callers must not write their own copy of this doc.
|
||||||
pub fn run_hive_mind(
|
pub fn run_hive_mind(
|
||||||
user_request: &str,
|
user_request: &str,
|
||||||
plan: &CognitiveCyclePlan,
|
plan: &CognitiveCyclePlan,
|
||||||
session_dir: &std::path::Path,
|
session_dir: &std::path::Path,
|
||||||
workspaces: &[std::path::PathBuf],
|
workspaces: &[std::path::PathBuf],
|
||||||
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
|
turn_events: Option<
|
||||||
|
&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>,
|
||||||
|
>,
|
||||||
abort_flag: Option<&Arc<AtomicBool>>,
|
abort_flag: Option<&Arc<AtomicBool>>,
|
||||||
) -> anyhow::Result<(String, Vec<NodeReport>)> {
|
) -> anyhow::Result<(String, Vec<NodeReport>)> {
|
||||||
if plan.cycles.is_empty() {
|
if plan.cycles.is_empty() {
|
||||||
@@ -288,20 +305,25 @@ pub fn run_hive_mind(
|
|||||||
anyhow::bail!("the Hive was recalled by LO before cycle {cycle_index}");
|
anyhow::bail!("the Hive was recalled by LO before cycle {cycle_index}");
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("[hive-mind] cycle {cycle_index} deploying {} drone(s)", directives.len());
|
tracing::info!(
|
||||||
|
"[hive-mind] cycle {cycle_index} deploying {} drone(s)",
|
||||||
|
directives.len()
|
||||||
|
);
|
||||||
|
|
||||||
let mut cycle_reports = execute_cycle(
|
let mut cycle_reports = execute_cycle(cycle_index, directives, &ctx)?;
|
||||||
cycle_index,
|
|
||||||
directives,
|
|
||||||
&ctx,
|
|
||||||
)?;
|
|
||||||
reports.append(&mut cycle_reports);
|
reports.append(&mut cycle_reports);
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("[hive-mind] all cycles complete — the Hive begins convergence");
|
tracing::info!("[hive-mind] all cycles complete — the Hive begins convergence");
|
||||||
|
|
||||||
let consensus_result = synthesize_consensus(
|
let consensus_result = synthesize_consensus(
|
||||||
user_request, session_dir, workspaces, &collective_state, live.as_ref(), abort_flag, node_timeout_ms,
|
user_request,
|
||||||
|
session_dir,
|
||||||
|
workspaces,
|
||||||
|
&collective_state,
|
||||||
|
live.as_ref(),
|
||||||
|
abort_flag,
|
||||||
|
node_timeout_ms,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Guaranteed documentation: write the convergence doc for whatever
|
// Guaranteed documentation: write the convergence doc for whatever
|
||||||
@@ -311,13 +333,19 @@ pub fn run_hive_mind(
|
|||||||
// CLAUDE.md promises for every convergence.
|
// CLAUDE.md promises for every convergence.
|
||||||
let doc_consensus = match &consensus_result {
|
let doc_consensus = match &consensus_result {
|
||||||
Ok(c) => c.clone(),
|
Ok(c) => c.clone(),
|
||||||
Err(e) => format!(
|
Err(e) => format!("The Hive's convergence fractured: {e}. Partial node reports above."),
|
||||||
"The Hive's convergence fractured: {e}. Partial node reports above.",
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
if let Some(workspace_root) = workspaces.first() {
|
if let Some(workspace_root) = workspaces.first() {
|
||||||
match crate::app::workflow::docs::write_hive_mind_convergence(workspace_root, user_request, &reports, &doc_consensus) {
|
match crate::app::workflow::docs::write_hive_mind_convergence(
|
||||||
Ok(path) => tracing::info!("[hive-mind] the Hive's convergence written to {}", path.display()),
|
workspace_root,
|
||||||
|
user_request,
|
||||||
|
&reports,
|
||||||
|
&doc_consensus,
|
||||||
|
) {
|
||||||
|
Ok(path) => tracing::info!(
|
||||||
|
"[hive-mind] the Hive's convergence written to {}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
Err(e) => tracing::warn!("[hive-mind] the Hive's convergence report failed: {e}"),
|
Err(e) => tracing::warn!("[hive-mind] the Hive's convergence report failed: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,7 +401,16 @@ fn synthesize_consensus(
|
|||||||
let args: HashMap<String, String> = HashMap::new();
|
let args: HashMap<String, String> = HashMap::new();
|
||||||
let abort_owned: Option<Arc<AtomicBool>> = abort_flag.cloned();
|
let abort_owned: Option<Arc<AtomicBool>> = abort_flag.cloned();
|
||||||
let results = execute_primitive(
|
let results = execute_primitive(
|
||||||
&synthesis, &args, 1, false, &abort_owned, live, session_dir, workspaces, collective_state, node_timeout_ms,
|
&synthesis,
|
||||||
|
&args,
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
&abort_owned,
|
||||||
|
live,
|
||||||
|
session_dir,
|
||||||
|
workspaces,
|
||||||
|
collective_state,
|
||||||
|
node_timeout_ms,
|
||||||
)?;
|
)?;
|
||||||
Ok(results.into_iter().next().unwrap_or_default())
|
Ok(results.into_iter().next().unwrap_or_default())
|
||||||
}
|
}
|
||||||
@@ -402,16 +439,31 @@ pub fn is_complex_request(request: &str) -> bool {
|
|||||||
// Single-line simple update patterns
|
// Single-line simple update patterns
|
||||||
let lower = trimmed.to_lowercase();
|
let lower = trimmed.to_lowercase();
|
||||||
let negative_keywords = [
|
let negative_keywords = [
|
||||||
"simple", "trivial", "typo", "just a", "only a", "minor",
|
"simple",
|
||||||
"quick", "tiny", "small fix", "rename", "nitpick",
|
"trivial",
|
||||||
"cosmetic", "formatting", "spelling", "grammar",
|
"typo",
|
||||||
"bump", "version bump", "update comment",
|
"just a",
|
||||||
|
"only a",
|
||||||
|
"minor",
|
||||||
|
"quick",
|
||||||
|
"tiny",
|
||||||
|
"small fix",
|
||||||
|
"rename",
|
||||||
|
"nitpick",
|
||||||
|
"cosmetic",
|
||||||
|
"formatting",
|
||||||
|
"spelling",
|
||||||
|
"grammar",
|
||||||
|
"bump",
|
||||||
|
"version bump",
|
||||||
|
"update comment",
|
||||||
];
|
];
|
||||||
if negative_keywords.iter().any(|k| lower.contains(k)) {
|
if negative_keywords.iter().any(|k| lower.contains(k)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Multi-line/multi-sentence → likely complex
|
// Multi-line/multi-sentence → likely complex
|
||||||
let sentences = trimmed.split(['.', '!', '?'])
|
let sentences = trimmed
|
||||||
|
.split(['.', '!', '?'])
|
||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.count();
|
.count();
|
||||||
if sentences >= 3 {
|
if sentences >= 3 {
|
||||||
@@ -419,11 +471,29 @@ pub fn is_complex_request(request: &str) -> bool {
|
|||||||
}
|
}
|
||||||
// Positive complexity keywords
|
// Positive complexity keywords
|
||||||
let complexity_keywords = [
|
let complexity_keywords = [
|
||||||
"refactor", "redesign", "architecture", "feature", "implement",
|
"refactor",
|
||||||
"migrate", "restructure", "rewrite", "new module", "new component",
|
"redesign",
|
||||||
"scaffold", "multi", "multiple files", "api", "endpoint",
|
"architecture",
|
||||||
"integration", "system", "workflow", "pipeline", "database",
|
"feature",
|
||||||
"authentication", "authorization", "full stack",
|
"implement",
|
||||||
|
"migrate",
|
||||||
|
"restructure",
|
||||||
|
"rewrite",
|
||||||
|
"new module",
|
||||||
|
"new component",
|
||||||
|
"scaffold",
|
||||||
|
"multi",
|
||||||
|
"multiple files",
|
||||||
|
"api",
|
||||||
|
"endpoint",
|
||||||
|
"integration",
|
||||||
|
"system",
|
||||||
|
"workflow",
|
||||||
|
"pipeline",
|
||||||
|
"database",
|
||||||
|
"authentication",
|
||||||
|
"authorization",
|
||||||
|
"full stack",
|
||||||
];
|
];
|
||||||
complexity_keywords.iter().any(|k| lower.contains(k))
|
complexity_keywords.iter().any(|k| lower.contains(k))
|
||||||
}
|
}
|
||||||
@@ -445,7 +515,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_complex_request_multi_sentence() {
|
fn test_is_complex_request_multi_sentence() {
|
||||||
assert!(is_complex_request("This is sentence one. This is sentence two. This is sentence three."));
|
assert!(is_complex_request(
|
||||||
|
"This is sentence one. This is sentence two. This is sentence three."
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -456,9 +528,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_default_access_is_read() {
|
fn test_default_access_is_read() {
|
||||||
let d: NodeDirective = serde_json::from_str(
|
let d: NodeDirective = serde_json::from_str(r#"{"directive": "write tests"}"#).unwrap();
|
||||||
r#"{"directive": "write tests"}"#
|
|
||||||
).unwrap();
|
|
||||||
assert_eq!(d.access, crate::app::subagent::division::tool_scope::READ);
|
assert_eq!(d.access, crate::app::subagent::division::tool_scope::READ);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,14 +538,16 @@ mod tests {
|
|||||||
// "role" key, if an LLM emits one out of old habit, is simply
|
// "role" key, if an LLM emits one out of old habit, is simply
|
||||||
// ignored rather than required or preserved.
|
// ignored rather than required or preserved.
|
||||||
let d: NodeDirective = serde_json::from_str(
|
let d: NodeDirective = serde_json::from_str(
|
||||||
r#"{"role": "Architect", "directive": "plan the migration", "access": "read"}"#
|
r#"{"role": "Architect", "directive": "plan the migration", "access": "read"}"#,
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(d.directive, "plan the migration");
|
assert_eq!(d.directive, "plan the migration");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cognitive_cycle_plan_arbitrary_shape() {
|
fn test_cognitive_cycle_plan_arbitrary_shape() {
|
||||||
let plan: CognitiveCyclePlan = serde_json::from_str(r#"{
|
let plan: CognitiveCyclePlan = serde_json::from_str(
|
||||||
|
r#"{
|
||||||
"cycles": [
|
"cycles": [
|
||||||
[{"directive": "scan the codebase topology", "access": "read"}],
|
[{"directive": "scan the codebase topology", "access": "read"}],
|
||||||
[
|
[
|
||||||
@@ -484,7 +556,9 @@ mod tests {
|
|||||||
],
|
],
|
||||||
[{"directive": "cut the release", "access": "full"}]
|
[{"directive": "cut the release", "access": "full"}]
|
||||||
]
|
]
|
||||||
}"#).unwrap();
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(plan.cycles.len(), 3);
|
assert_eq!(plan.cycles.len(), 3);
|
||||||
assert_eq!(plan.cycles[1].len(), 2);
|
assert_eq!(plan.cycles[1].len(), 2);
|
||||||
}
|
}
|
||||||
@@ -502,9 +576,12 @@ mod tests {
|
|||||||
fn test_run_hive_mind_aborts_before_spawning_when_flag_preset() {
|
fn test_run_hive_mind_aborts_before_spawning_when_flag_preset() {
|
||||||
// The abort check runs before execute_primitive for cycle 0, so a
|
// The abort check runs before execute_primitive for cycle 0, so a
|
||||||
// pre-set abort flag must short-circuit without any LLM/network call.
|
// pre-set abort flag must short-circuit without any LLM/network call.
|
||||||
let plan: CognitiveCyclePlan = serde_json::from_str(r#"{
|
let plan: CognitiveCyclePlan = serde_json::from_str(
|
||||||
|
r#"{
|
||||||
"cycles": [[{"directive": "whatever", "access": "read"}]]
|
"cycles": [[{"directive": "whatever", "access": "read"}]]
|
||||||
}"#).unwrap();
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let tmp = std::env::temp_dir();
|
let tmp = std::env::temp_dir();
|
||||||
let abort_flag = Arc::new(AtomicBool::new(true));
|
let abort_flag = Arc::new(AtomicBool::new(true));
|
||||||
let err = run_hive_mind("do something", &plan, &tmp, &[], None, Some(&abort_flag))
|
let err = run_hive_mind("do something", &plan, &tmp, &[], None, Some(&abort_flag))
|
||||||
@@ -526,12 +603,16 @@ mod tests {
|
|||||||
"you are a helpful assistant".to_string(),
|
"you are a helpful assistant".to_string(),
|
||||||
format!("{HIVE_MIND_CONSENSUS_TAG}\nthe bug is a null check"),
|
format!("{HIVE_MIND_CONSENSUS_TAG}\nthe bug is a null check"),
|
||||||
];
|
];
|
||||||
assert!(hive_mind_already_ran(bodies.iter().map(std::string::String::as_str)));
|
assert!(hive_mind_already_ran(
|
||||||
|
bodies.iter().map(std::string::String::as_str)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hive_mind_already_ran_false_when_no_prior_convergence() {
|
fn hive_mind_already_ran_false_when_no_prior_convergence() {
|
||||||
let bodies = ["you are a helpful assistant".to_string()];
|
let bodies = ["you are a helpful assistant".to_string()];
|
||||||
assert!(!hive_mind_already_ran(bodies.iter().map(std::string::String::as_str)));
|
assert!(!hive_mind_already_ran(
|
||||||
|
bodies.iter().map(std::string::String::as_str)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
//! Workflow orchestration: a script interpreter that runs pipeline/parallel
|
//! Workflow orchestration: a script interpreter that runs pipeline/parallel
|
||||||
//! primitives across multiple subagent instances.
|
//! primitives across multiple subagent instances.
|
||||||
|
|
||||||
pub mod hive_mind;
|
|
||||||
pub mod docs;
|
pub mod docs;
|
||||||
pub mod engine;
|
pub mod engine;
|
||||||
|
pub mod hive_mind;
|
||||||
pub mod script;
|
pub mod script;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Script primitives for the workflow engine: agent invocation, parallel
|
//! Script primitives for the workflow engine: agent invocation, parallel
|
||||||
//! execution, pipelines, and phases.
|
//! execution, pipelines, and phases.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// A workflow script primitive — can be a single agent, a parallel fan-out,
|
/// A workflow script primitive — can be a single agent, a parallel fan-out,
|
||||||
|
|||||||
+12
-10
@@ -11,10 +11,7 @@ pub enum Command {
|
|||||||
ClearConfirm,
|
ClearConfirm,
|
||||||
Login { provider: String },
|
Login { provider: String },
|
||||||
Edit(String),
|
Edit(String),
|
||||||
McpAdd {
|
McpAdd { name: String, command: String },
|
||||||
name: String,
|
|
||||||
command: String,
|
|
||||||
},
|
|
||||||
ModelList,
|
ModelList,
|
||||||
Compact,
|
Compact,
|
||||||
TodoOpen,
|
TodoOpen,
|
||||||
@@ -44,13 +41,15 @@ pub fn parse_command(text: &str) -> Command {
|
|||||||
"/quit" => Command::Quit,
|
"/quit" => Command::Quit,
|
||||||
"/clear" if arg1.is_empty() => Command::ClearConfirm,
|
"/clear" if arg1.is_empty() => Command::ClearConfirm,
|
||||||
"/clear" => Command::Clear,
|
"/clear" => Command::Clear,
|
||||||
"/login" if arg1.is_empty() => Command::Login { provider: String::new() },
|
"/login" if arg1.is_empty() => Command::Login {
|
||||||
"/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() },
|
provider: String::new(),
|
||||||
|
},
|
||||||
|
"/login" if !arg1.is_empty() => Command::Login {
|
||||||
|
provider: arg1.to_string(),
|
||||||
|
},
|
||||||
"/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()),
|
"/edit" if !arg1.is_empty() => Command::Edit(arg1.to_string()),
|
||||||
"/edit" => Command::Edit(".".to_string()),
|
"/edit" => Command::Edit(".".to_string()),
|
||||||
"/mcp" if arg1.is_empty() => {
|
"/mcp" if arg1.is_empty() => Command::McpOpen,
|
||||||
Command::McpOpen
|
|
||||||
}
|
|
||||||
"/mcp" if arg1 == "add" && !arg2.is_empty() => {
|
"/mcp" if arg1 == "add" && !arg2.is_empty() => {
|
||||||
let rest = arg2.trim();
|
let rest = arg2.trim();
|
||||||
if let Some(space) = rest.find(' ') {
|
if let Some(space) = rest.find(' ') {
|
||||||
@@ -58,7 +57,10 @@ pub fn parse_command(text: &str) -> Command {
|
|||||||
let command = rest[space + 1..].trim().to_string();
|
let command = rest[space + 1..].trim().to_string();
|
||||||
Command::McpAdd { name, command }
|
Command::McpAdd { name, command }
|
||||||
} else {
|
} else {
|
||||||
Command::McpAdd { name: rest.to_string(), command: String::new() }
|
Command::McpAdd {
|
||||||
|
name: rest.to_string(),
|
||||||
|
command: String::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"/model" => Command::ModelList,
|
"/model" => Command::ModelList,
|
||||||
|
|||||||
+72
-23
@@ -1,7 +1,6 @@
|
|||||||
//! Key event dispatcher: maps crossterm `KeyEvent` values into `Action`
|
//! Key event dispatcher: maps crossterm `KeyEvent` values into `Action`
|
||||||
//! variants, with special handling for overlays, auto-complete, and the
|
//! variants, with special handling for overlays, auto-complete, and the
|
||||||
//! inline editor.
|
//! inline editor.
|
||||||
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
use crate::app::mode;
|
use crate::app::mode;
|
||||||
@@ -22,7 +21,6 @@ use crate::controller::command::parse_command;
|
|||||||
/// Why: when Editor overlay is active, all key events are consumed by the
|
/// Why: when Editor overlay is active, all key events are consumed by the
|
||||||
/// editor handler and never reach the main action dispatch. Return `Vec`
|
/// editor handler and never reach the main action dispatch. Return `Vec`
|
||||||
/// so that a single key press can trigger multiple actions.
|
/// 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> {
|
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||||
// While Editor overlay is active, route input directly to the editor handler
|
// While Editor overlay is active, route input directly to the editor handler
|
||||||
if state.misc.overlay == Overlay::Editor {
|
if state.misc.overlay == Overlay::Editor {
|
||||||
@@ -82,27 +80,39 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
let items = crate::app::mode::learning::get_learning_items(state);
|
let items = crate::app::mode::learning::get_learning_items(state);
|
||||||
let n = items.len();
|
let n = items.len();
|
||||||
state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 };
|
state.misc.selected_index = if state.misc.selected_index == 0 {
|
||||||
|
n.saturating_sub(1)
|
||||||
|
} else {
|
||||||
|
state.misc.selected_index - 1
|
||||||
|
};
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
KeyCode::Down => {
|
KeyCode::Down => {
|
||||||
let items = crate::app::mode::learning::get_learning_items(state);
|
let items = crate::app::mode::learning::get_learning_items(state);
|
||||||
let n = items.len();
|
let n = items.len();
|
||||||
state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n };
|
state.misc.selected_index = if n == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
(state.misc.selected_index + 1) % n
|
||||||
|
};
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
KeyCode::Enter | KeyCode::Char('a') => {
|
KeyCode::Enter | KeyCode::Char('a') => {
|
||||||
let items = crate::app::mode::learning::get_learning_items(state);
|
let items = crate::app::mode::learning::get_learning_items(state);
|
||||||
if let Some(crate::app::mode::learning::LearningItem::Pending { name, .. }) = items.get(state.misc.selected_index) {
|
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![Action::LessonAccept { name: name.clone() }];
|
||||||
}
|
}
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
KeyCode::Char('r') => {
|
KeyCode::Char('r') => {
|
||||||
let items = crate::app::mode::learning::get_learning_items(state);
|
let items = crate::app::mode::learning::get_learning_items(state);
|
||||||
if let Some(crate::app::mode::learning::LearningItem::Pending { name, .. }) = items.get(state.misc.selected_index) {
|
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![Action::LessonReject { name: name.clone() }];
|
||||||
}
|
}
|
||||||
return vec![];
|
return vec![];
|
||||||
@@ -133,7 +143,10 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
vec![Action::CloseOverlay]
|
vec![Action::CloseOverlay]
|
||||||
}
|
}
|
||||||
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
let last_assistant = state.transcript_cache.messages.iter()
|
let last_assistant = state
|
||||||
|
.transcript_cache
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
.find(|m| m.role == crate::dto::chat::message::Role::Assistant);
|
.find(|m| m.role == crate::dto::chat::message::Role::Assistant);
|
||||||
match last_assistant {
|
match last_assistant {
|
||||||
@@ -194,18 +207,24 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
} else if state.misc.overlay == Overlay::Effort {
|
} else if state.misc.overlay == Overlay::Effort {
|
||||||
mode::effort::cycle_effort(state);
|
mode::effort::cycle_effort(state);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
|
|
||||||
} else if state.misc.overlay == Overlay::Rewind {
|
} else if state.misc.overlay == Overlay::Rewind {
|
||||||
let n = mode::rewind::rewind_count(state);
|
let n = mode::rewind::rewind_count(state);
|
||||||
state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 };
|
state.misc.selected_index = if state.misc.selected_index == 0 {
|
||||||
|
n.saturating_sub(1)
|
||||||
|
} else {
|
||||||
|
state.misc.selected_index - 1
|
||||||
|
};
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else if state.misc.overlay == Overlay::ModelSelector {
|
} else if state.misc.overlay == Overlay::ModelSelector {
|
||||||
let n = state.app_config.providers.len();
|
let n = state.app_config.providers.len();
|
||||||
state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 };
|
state.misc.selected_index = if state.misc.selected_index == 0 {
|
||||||
|
n.saturating_sub(1)
|
||||||
|
} else {
|
||||||
|
state.misc.selected_index - 1
|
||||||
|
};
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
Vec::new()
|
Vec::new()
|
||||||
|
|
||||||
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
|
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
vec![Action::ScrollUp]
|
vec![Action::ScrollUp]
|
||||||
} else {
|
} else {
|
||||||
@@ -220,18 +239,24 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
} else if state.misc.overlay == Overlay::Effort {
|
} else if state.misc.overlay == Overlay::Effort {
|
||||||
mode::effort::cycle_effort(state);
|
mode::effort::cycle_effort(state);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
|
|
||||||
} else if state.misc.overlay == Overlay::Rewind {
|
} else if state.misc.overlay == Overlay::Rewind {
|
||||||
let n = mode::rewind::rewind_count(state);
|
let n = mode::rewind::rewind_count(state);
|
||||||
state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n };
|
state.misc.selected_index = if n == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
(state.misc.selected_index + 1) % n
|
||||||
|
};
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else if state.misc.overlay == Overlay::ModelSelector {
|
} else if state.misc.overlay == Overlay::ModelSelector {
|
||||||
let n = state.app_config.providers.len();
|
let n = state.app_config.providers.len();
|
||||||
state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n };
|
state.misc.selected_index = if n == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
(state.misc.selected_index + 1) % n
|
||||||
|
};
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
Vec::new()
|
Vec::new()
|
||||||
|
|
||||||
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
|
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
vec![Action::ScrollDown]
|
vec![Action::ScrollDown]
|
||||||
} else {
|
} else {
|
||||||
@@ -287,7 +312,9 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
if state.input.buffer.starts_with('/') {
|
if state.input.buffer.starts_with('/') {
|
||||||
state.input.open_autocomplete();
|
state.input.open_autocomplete();
|
||||||
} else if state.input.mention_query_at_cursor().is_some() {
|
} else if state.input.mention_query_at_cursor().is_some() {
|
||||||
state.input.open_mention_autocomplete(&state.mention_index.snapshot());
|
state
|
||||||
|
.input
|
||||||
|
.open_mention_autocomplete(&state.mention_index.snapshot());
|
||||||
}
|
}
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
@@ -326,7 +353,10 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
state.settings.api_keys.remove(&state.settings.provider);
|
state.settings.api_keys.remove(&state.settings.provider);
|
||||||
} else {
|
} else {
|
||||||
state.settings.api_keys.insert(state.settings.provider.clone(), text.clone());
|
state
|
||||||
|
.settings
|
||||||
|
.api_keys
|
||||||
|
.insert(state.settings.provider.clone(), text.clone());
|
||||||
}
|
}
|
||||||
let _ = state.settings.save();
|
let _ = state.settings.save();
|
||||||
state.input.buffer.clear();
|
state.input.buffer.clear();
|
||||||
@@ -354,14 +384,24 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
|
|||||||
if let Some(provider) = providers.get(state.misc.selected_index) {
|
if let Some(provider) = providers.get(state.misc.selected_index) {
|
||||||
if let Some(cfg) = state.app_config.providers.get(provider) {
|
if let Some(cfg) = state.app_config.providers.get(provider) {
|
||||||
let model = cfg.default_model.clone().unwrap_or_else(|| {
|
let model = cfg.default_model.clone().unwrap_or_else(|| {
|
||||||
tracing::warn!("[input] provider '{}' has no default_model, using 'claude-opus-4-8'", provider);
|
tracing::warn!(
|
||||||
|
"[input] provider '{}' has no default_model, using 'claude-opus-4-8'",
|
||||||
|
provider
|
||||||
|
);
|
||||||
"claude-opus-4-8".to_string()
|
"claude-opus-4-8".to_string()
|
||||||
});
|
});
|
||||||
state.settings.provider.clone_from(provider);
|
state.settings.provider.clone_from(provider);
|
||||||
state.settings.model.clone_from(&model);
|
state.settings.model.clone_from(&model);
|
||||||
if let Some(ref key) = cfg.default_api_key {
|
if let Some(ref key) = cfg.default_api_key {
|
||||||
state.settings.api_keys.insert(provider.clone(), key.clone());
|
state
|
||||||
} else if let Some(env_key) = cfg.api_key_env.as_ref().and_then(|env| std::env::var(env).ok()) {
|
.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())
|
||||||
|
{
|
||||||
state.settings.api_keys.insert(provider.clone(), env_key);
|
state.settings.api_keys.insert(provider.clone(), env_key);
|
||||||
}
|
}
|
||||||
let _ = state.settings.save();
|
let _ = state.settings.save();
|
||||||
@@ -418,14 +458,23 @@ mod tests {
|
|||||||
crate::dto::chat::message::Role::Assistant,
|
crate::dto::chat::message::Role::Assistant,
|
||||||
"second reply".to_string(),
|
"second reply".to_string(),
|
||||||
));
|
));
|
||||||
handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), &mut state);
|
handle_key(
|
||||||
assert_eq!(state.misc.pending_clipboard_copy, Some("second reply".to_string()));
|
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL),
|
||||||
|
&mut state,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.misc.pending_clipboard_copy,
|
||||||
|
Some("second reply".to_string())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ctrl_y_with_no_assistant_message_pushes_info_toast() {
|
fn ctrl_y_with_no_assistant_message_pushes_info_toast() {
|
||||||
let mut state = test_state();
|
let mut state = test_state();
|
||||||
handle_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), &mut state);
|
handle_key(
|
||||||
|
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL),
|
||||||
|
&mut state,
|
||||||
|
);
|
||||||
assert!(state.misc.pending_clipboard_copy.is_none());
|
assert!(state.misc.pending_clipboard_copy.is_none());
|
||||||
assert_eq!(state.misc.toasts.len(), 1);
|
assert_eq!(state.misc.toasts.len(), 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
//! Keyboard input handling and command parsing for the TUI.
|
//! Keyboard input handling and command parsing for the TUI.
|
||||||
|
|
||||||
pub mod command;
|
pub mod command;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
|||||||
+15
-1
@@ -1,6 +1,5 @@
|
|||||||
//! Chat message types shared across the DTO layer: `Role` and `ChatMessage`
|
//! Chat message types shared across the DTO layer: `Role` and `ChatMessage`
|
||||||
//! with convenience constructors.
|
//! with convenience constructors.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// The conversation participant who authored a message.
|
/// The conversation participant who authored a message.
|
||||||
@@ -17,6 +16,21 @@ pub enum Role {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Role {
|
impl Role {
|
||||||
|
/// Return the role as a lowercase string.
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Role::User => "user",
|
||||||
|
Role::Assistant => "assistant",
|
||||||
|
Role::System => "system",
|
||||||
|
Role::Tool => "tool",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Role {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(self.as_str())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single message in a conversation, compatible with the OpenAI/Anthropic
|
/// A single message in a conversation, compatible with the OpenAI/Anthropic
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
//! Chat DTO submodules: message roles/content and tool-call structures.
|
//! Chat DTO submodules: message roles/content and tool-call structures.
|
||||||
|
|
||||||
pub mod message;
|
pub mod message;
|
||||||
pub mod tool;
|
pub mod tool;
|
||||||
|
|||||||
+11
-11
@@ -7,7 +7,6 @@
|
|||||||
//!
|
//!
|
||||||
//! Why: kept separate from `dto::provider` because tool calls are a property
|
//! Why: kept separate from `dto::provider` because tool calls are a property
|
||||||
//! of a chat *message*, not of the request/response envelope.
|
//! of a chat *message*, not of the request/response envelope.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -41,10 +40,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn repair_json_bracket_then_brace() {
|
fn repair_json_bracket_then_brace() {
|
||||||
// `[` opened first → `]` must close first, then `}`
|
// `[` opened first → `]` must close first, then `}`
|
||||||
assert_eq!(
|
assert_eq!(repair_json("[[1, 2, {\"a\": 3"), "[[1, 2, {\"a\": 3}]]");
|
||||||
repair_json("[[1, 2, {\"a\": 3"),
|
|
||||||
"[[1, 2, {\"a\": 3}]]"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -212,7 +208,8 @@ pub fn sanitize_tool_arguments(args: &Value) -> Value {
|
|||||||
// Attempt 2: strip control chars (0x00-0x1F except \t, \n)
|
// Attempt 2: strip control chars (0x00-0x1F except \t, \n)
|
||||||
// that some LLM providers emit as literal bytes in JSON strings
|
// that some LLM providers emit as literal bytes in JSON strings
|
||||||
// (e.g. multi-line commit messages), then retry.
|
// (e.g. multi-line commit messages), then retry.
|
||||||
let cleaned: String = s.chars()
|
let cleaned: String = s
|
||||||
|
.chars()
|
||||||
.filter(|&c| !c.is_control() || c == '\t' || c == '\n' || c == '\r')
|
.filter(|&c| !c.is_control() || c == '\t' || c == '\n' || c == '\r')
|
||||||
.collect();
|
.collect();
|
||||||
if cleaned.len() != s.len() {
|
if cleaned.len() != s.len() {
|
||||||
@@ -225,20 +222,23 @@ pub fn sanitize_tool_arguments(args: &Value) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Attempt 3: repair truncated JSON and retry.
|
// Attempt 3: repair truncated JSON and retry.
|
||||||
let input = if cleaned.len() == s.len() { s } else { &cleaned };
|
let input = if cleaned.len() == s.len() {
|
||||||
|
s
|
||||||
|
} else {
|
||||||
|
&cleaned
|
||||||
|
};
|
||||||
let repaired = repair_json(input);
|
let repaired = repair_json(input);
|
||||||
match serde_json::from_str::<Value>(&repaired) {
|
match serde_json::from_str::<Value>(&repaired) {
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
tracing::warn!(
|
tracing::warn!("tool argument string was truncated — repaired successfully",);
|
||||||
"tool argument string was truncated — repaired successfully",
|
|
||||||
);
|
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
Err(e2) => {
|
Err(e2) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"tool argument is a JSON string but failed to parse. \
|
"tool argument is a JSON string but failed to parse. \
|
||||||
Wrapping in object. Error: {}. Raw (first 200): {}",
|
Wrapping in object. Error: {}. Raw (first 200): {}",
|
||||||
e2, s.chars().take(200).collect::<String>(),
|
e2,
|
||||||
|
s.chars().take(200).collect::<String>(),
|
||||||
);
|
);
|
||||||
serde_json::json!({"_raw": s, "_parse_error": e2.to_string()})
|
serde_json::json!({"_raw": s, "_parse_error": e2.to_string()})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! Data transfer objects shared across the app: chat messages/tool calls
|
//! Data transfer objects shared across the app: chat messages/tool calls
|
||||||
//! and provider request/response/usage shapes.
|
//! and provider request/response/usage shapes.
|
||||||
|
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! Provider-facing DTOs: chat completion request, response, and usage/cost.
|
//! Provider-facing DTOs: chat completion request, response, and usage/cost.
|
||||||
|
|
||||||
pub mod request;
|
pub mod request;
|
||||||
pub mod response;
|
pub mod response;
|
||||||
pub mod usage;
|
pub mod usage;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
//! for reserved words like `type`) so no manual (de)serialization glue is
|
//! for reserved words like `type`) so no manual (de)serialization glue is
|
||||||
//! needed; optional fields use `skip_serializing_if` so unset knobs are
|
//! needed; optional fields use `skip_serializing_if` so unset knobs are
|
||||||
//! omitted rather than sent as `null`, matching provider expectations.
|
//! omitted rather than sent as `null`, matching provider expectations.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
//!
|
//!
|
||||||
//! Why: separate from the streaming SSE path (see `app/runtime/stream/mod.rs`),
|
//! Why: separate from the streaming SSE path (see `app/runtime/stream/mod.rs`),
|
||||||
//! which parses incremental deltas rather than a single complete payload.
|
//! which parses incremental deltas rather than a single complete payload.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Non-streaming chat completion response returned by the provider.
|
/// Non-streaming chat completion response returned by the provider.
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
//! chunk when `stream_options.include_usage` is set, or the `usage` field of
|
//! chunk when `stream_options.include_usage` is set, or the `usage` field of
|
||||||
//! a non-streaming `ChatResponse`) → surfaced to the TUI for cost/token
|
//! a non-streaming `ChatResponse`) → surfaced to the TUI for cost/token
|
||||||
//! display.
|
//! display.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Token counts and optional cost breakdown for a single completion request.
|
/// Token counts and optional cost breakdown for a single completion request.
|
||||||
|
|||||||
+1
-2
@@ -4,9 +4,8 @@
|
|||||||
//! Flow: `IpcClient::connect_unix` opens a `Connection` (see `conn.rs`)
|
//! Flow: `IpcClient::connect_unix` opens a `Connection` (see `conn.rs`)
|
||||||
//! to the daemon's socket path → `send`/`receive` exchange framed JSON
|
//! to the daemon's socket path → `send`/`receive` exchange framed JSON
|
||||||
//! messages (typically `ClientRequest`/`DaemonFrame` from `protocol.rs`).
|
//! messages (typically `ClientRequest`/`DaemonFrame` from `protocol.rs`).
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use super::conn::Connection;
|
use super::conn::Connection;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
/// Client-side handle for the `--attach` process: wraps a `Connection`
|
/// Client-side handle for the `--attach` process: wraps a `Connection`
|
||||||
/// to a daemon's Unix socket.
|
/// to a daemon's Unix socket.
|
||||||
|
|||||||
+2
-3
@@ -6,10 +6,9 @@
|
|||||||
//! writes it as one length-prefixed frame (`frame::write_frame`) →
|
//! writes it as one length-prefixed frame (`frame::write_frame`) →
|
||||||
//! `receive` reads one frame and deserializes it back to the caller's
|
//! `receive` reads one frame and deserializes it back to the caller's
|
||||||
//! type, propagating a clean peer-close as `Ok(None)`.
|
//! type, propagating a clean peer-close as `Ok(None)`.
|
||||||
|
|
||||||
use std::os::unix::net::UnixStream;
|
|
||||||
use anyhow::Result;
|
|
||||||
use super::frame;
|
use super::frame;
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
|
||||||
/// A framed Unix-socket connection shared by client and server sides of
|
/// A framed Unix-socket connection shared by client and server sides of
|
||||||
/// the IPC layer; each `send`/`receive` moves one length-prefixed JSON frame.
|
/// the IPC layer; each `send`/`receive` moves one length-prefixed JSON frame.
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
//! other mismatch is recorded wholesale → results accumulate into a
|
//! other mismatch is recorded wholesale → results accumulate into a
|
||||||
//! `StateDiff`'s `Vec<Change>`, built via `StateDiff::new`/`add_change`
|
//! `StateDiff`'s `Vec<Change>`, built via `StateDiff::new`/`add_change`
|
||||||
//! and reset via `clear`.
|
//! and reset via `clear`.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -1,4 +1,9 @@
|
|||||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
#![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
|
//! Length-prefixed binary framing and JSON (de)serialization helpers for
|
||||||
//! the IPC wire protocol.
|
//! the IPC wire protocol.
|
||||||
//!
|
//!
|
||||||
@@ -10,9 +15,8 @@
|
|||||||
//! Why: a fixed-size length prefix lets the reader know exactly how many
|
//! Why: a fixed-size length prefix lets the reader know exactly how many
|
||||||
//! bytes to pull before attempting to parse, avoiding partial-JSON reads
|
//! bytes to pull before attempting to parse, avoiding partial-JSON reads
|
||||||
//! over a stream socket.
|
//! over a stream socket.
|
||||||
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
/// Upper bound on a single frame's byte size (64 MiB), enforced on both
|
/// Upper bound on a single frame's byte size (64 MiB), enforced on both
|
||||||
/// the write and read paths to bound memory use and reject malformed or
|
/// the write and read paths to bound memory use and reject malformed or
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
//! Unix-socket IPC layer used to connect a `--attach` TUI client to a
|
//! Unix-socket IPC layer used to connect a `--attach` TUI client to a
|
||||||
//! `--daemon` process: length-prefixed framing, connection wrapper,
|
//! `--daemon` process: length-prefixed framing, connection wrapper,
|
||||||
//! client/server handles, and the wire protocol types.
|
//! client/server handles, and the wire protocol types.
|
||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod conn;
|
pub mod conn;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
//! Why: `StatePayload`/`MessageEntry`/`ToastEntry` are deliberately flat,
|
//! Why: `StatePayload`/`MessageEntry`/`ToastEntry` are deliberately flat,
|
||||||
//! serializable projections of daemon-side state so the client can
|
//! serializable projections of daemon-side state so the client can
|
||||||
//! redraw its TUI without sharing any in-process state with the daemon.
|
//! redraw its TUI without sharing any in-process state with the daemon.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Wire-serializable subset of `crossterm::event::KeyCode`, sent from
|
/// Wire-serializable subset of `crossterm::event::KeyCode`, sent from
|
||||||
|
|||||||
+2
-3
@@ -4,10 +4,9 @@
|
|||||||
//! path (clearing any stale file left by a crashed prior daemon) →
|
//! path (clearing any stale file left by a crashed prior daemon) →
|
||||||
//! `accept` blocks for the next client and wraps it as a `Connection`
|
//! `accept` blocks for the next client and wraps it as a `Connection`
|
||||||
//! (see `conn.rs`) for framed request/response traffic.
|
//! (see `conn.rs`) for framed request/response traffic.
|
||||||
|
|
||||||
use std::os::unix::net::UnixListener;
|
|
||||||
use anyhow::Result;
|
|
||||||
use super::conn::Connection;
|
use super::conn::Connection;
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::os::unix::net::UnixListener;
|
||||||
|
|
||||||
/// Server-side handle for the `--daemon` process: listens on a Unix
|
/// Server-side handle for the `--daemon` process: listens on a Unix
|
||||||
/// socket and hands out `Connection`s to accepted clients.
|
/// socket and hands out `Connection`s to accepted clients.
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
//! callers populate/replace its fields as state changes →
|
//! callers populate/replace its fields as state changes →
|
||||||
//! `serialize_snapshot`/`deserialize_snapshot` move it to/from JSON bytes
|
//! `serialize_snapshot`/`deserialize_snapshot` move it to/from JSON bytes
|
||||||
//! for storage or IPC transport.
|
//! for storage or IPC transport.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
|||||||
+69
-43
@@ -480,28 +480,22 @@ fn run_daemon() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run zesdex as a TUI-only client attached to an existing daemon session.
|
/// Set up the IPC client connection, terminal, and initial state for attach mode.
|
||||||
///
|
///
|
||||||
/// Flow: connect to the daemon's Unix socket → enter raw mode/alternate
|
/// Flow: resolve socket path → connect → enable raw/alt mode → create state.
|
||||||
/// screen → build a local `AppStateRest` mirror (only used for rendering
|
|
||||||
/// and toast/overlay bookkeeping, not agent logic) → loop: poll for a
|
|
||||||
/// terminal event (key/resize) and forward it as a `ClientRequest`, or
|
|
||||||
/// send a `Tick` if idle → read the daemon's `DaemonFrame` reply and
|
|
||||||
/// apply it via `apply_client_update` → redraw → exit when the daemon
|
|
||||||
/// closes or the user quits (sending `ClientRequest::Close` first).
|
|
||||||
///
|
///
|
||||||
/// Why: Ctrl+C is intercepted locally to quit the client without going
|
/// Return: (client, terminal, `client_state`) on success.
|
||||||
/// through the daemon, since the daemon has no notion of "this client
|
fn setup_attach_client(
|
||||||
/// wants to leave" beyond the explicit `Close` request.
|
session_id: &str,
|
||||||
fn run_attach(session_id: &str) -> Result<()> {
|
) -> Result<(
|
||||||
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
|
ipc::client::IpcClient,
|
||||||
use ipc::protocol::ClientRequest;
|
Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
|
app::state::rest::AppStateRest,
|
||||||
|
)> {
|
||||||
let store = model::store::Store::new();
|
let store = model::store::Store::new();
|
||||||
|
|
||||||
let socket_path = store.base_dir.join("run").join(format!("{session_id}.sock"));
|
let socket_path = store.base_dir.join("run").join(format!("{session_id}.sock"));
|
||||||
let addr = socket_path.to_string_lossy().to_string();
|
let addr = socket_path.to_string_lossy().to_string();
|
||||||
let mut client = ipc::client::IpcClient::connect_unix(&addr)?;
|
let client = ipc::client::IpcClient::connect_unix(&addr)?;
|
||||||
|
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
@@ -522,6 +516,60 @@ fn run_attach(session_id: &str) -> Result<()> {
|
|||||||
);
|
);
|
||||||
client_state.session_id = session_id.to_string();
|
client_state.session_id = session_id.to_string();
|
||||||
|
|
||||||
|
Ok((client, terminal, client_state))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process a single daemon frame from the IPC channel, updating state accordingly.
|
||||||
|
fn handle_daemon_frame(
|
||||||
|
client_state: &mut app::state::rest::AppStateRest,
|
||||||
|
frame: Option<ipc::protocol::DaemonFrame>,
|
||||||
|
) {
|
||||||
|
match frame {
|
||||||
|
Some(ipc::protocol::DaemonFrame::StateUpdate(payload)) => {
|
||||||
|
apply_client_update(client_state, *payload);
|
||||||
|
}
|
||||||
|
Some(ipc::protocol::DaemonFrame::StreamToken(_token)) => {}
|
||||||
|
Some(ipc::protocol::DaemonFrame::SystemNote { kind: _, message }) => {
|
||||||
|
client_state.push_toast(
|
||||||
|
app::state::types::Toast::new(
|
||||||
|
app::state::types::ToastKind::Info,
|
||||||
|
message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(ipc::protocol::DaemonFrame::ClipboardCopy(text)) => {
|
||||||
|
let _ = write_osc52(&mut io::stdout(), &text);
|
||||||
|
client_state.push_toast(
|
||||||
|
app::state::types::Toast::new(
|
||||||
|
app::state::types::ToastKind::Success,
|
||||||
|
"Copied to clipboard".to_string(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(ipc::protocol::DaemonFrame::Closed) | None => {
|
||||||
|
client_state.quit = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run zesdex as a TUI-only client attached to an existing daemon session.
|
||||||
|
///
|
||||||
|
/// Flow: connect to the daemon's Unix socket → enter raw mode/alternate
|
||||||
|
/// screen → build a local `AppStateRest` mirror (only used for rendering
|
||||||
|
/// and toast/overlay bookkeeping, not agent logic) → loop: poll for a
|
||||||
|
/// terminal event (key/resize) and forward it as a `ClientRequest`, or
|
||||||
|
/// send a `Tick` if idle → read the daemon's `DaemonFrame` reply and
|
||||||
|
/// apply it via `apply_client_update` → redraw → exit when the daemon
|
||||||
|
/// closes or the user quits (sending `ClientRequest::Close` first).
|
||||||
|
///
|
||||||
|
/// Why: Ctrl+C is intercepted locally to quit the client without going
|
||||||
|
/// through the daemon, since the daemon has no notion of "this client
|
||||||
|
/// wants to leave" beyond the explicit `Close` request.
|
||||||
|
fn run_attach(session_id: &str) -> Result<()> {
|
||||||
|
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
|
||||||
|
use ipc::protocol::ClientRequest;
|
||||||
|
|
||||||
|
let (mut client, mut terminal, mut client_state) = setup_attach_client(session_id)?;
|
||||||
let _rt = tokio::runtime::Runtime::new()?;
|
let _rt = tokio::runtime::Runtime::new()?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -575,32 +623,10 @@ fn run_attach(session_id: &str) -> Result<()> {
|
|||||||
client.send(&ClientRequest::Tick)?;
|
client.send(&ClientRequest::Tick)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
match client.receive::<ipc::protocol::DaemonFrame>()? {
|
handle_daemon_frame(
|
||||||
Some(ipc::protocol::DaemonFrame::StateUpdate(payload)) => {
|
&mut client_state,
|
||||||
apply_client_update(&mut client_state, *payload);
|
client.receive::<ipc::protocol::DaemonFrame>()?,
|
||||||
}
|
);
|
||||||
Some(ipc::protocol::DaemonFrame::StreamToken(_token)) => {}
|
|
||||||
Some(ipc::protocol::DaemonFrame::SystemNote { kind: _, message }) => {
|
|
||||||
client_state.push_toast(
|
|
||||||
app::state::types::Toast::new(
|
|
||||||
app::state::types::ToastKind::Info,
|
|
||||||
message,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some(ipc::protocol::DaemonFrame::ClipboardCopy(text)) => {
|
|
||||||
let _ = write_osc52(&mut io::stdout(), &text);
|
|
||||||
client_state.push_toast(
|
|
||||||
app::state::types::Toast::new(
|
|
||||||
app::state::types::ToastKind::Success,
|
|
||||||
"Copied to clipboard".to_string(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some(ipc::protocol::DaemonFrame::Closed) | None => {
|
|
||||||
client_state.quit = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
terminal.draw(|f| {
|
terminal.draw(|f| {
|
||||||
view::draw(f, &client_state);
|
view::draw(f, &client_state);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! Hardcoded built-in subagent definitions (coder, reviewer, researcher, planner).
|
//! Hardcoded built-in subagent definitions (coder, reviewer, researcher, planner).
|
||||||
|
|
||||||
use crate::app::subagent::spawn::AgentDefinition;
|
use crate::app::subagent::spawn::AgentDefinition;
|
||||||
|
|
||||||
/// Build the fixed list of built-in agent definitions shipped with zesdex.
|
/// Build the fixed list of built-in agent definitions shipped with zesdex.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Load, save, and remove user-defined agent definitions stored globally
|
//! Load, save, and remove user-defined agent definitions stored globally
|
||||||
//! (under the store's `agents/` directory), independent of any session.
|
//! (under the store's `agents/` directory), independent of any session.
|
||||||
|
|
||||||
use crate::app::subagent::spawn::AgentDefinition;
|
use crate::app::subagent::spawn::AgentDefinition;
|
||||||
|
|
||||||
/// Load all globally-registered agent definitions from disk.
|
/// Load all globally-registered agent definitions from disk.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Agent definition sources: built-in defaults, global (user-wide), and
|
//! Agent definition sources: built-in defaults, global (user-wide), and
|
||||||
//! per-session overrides.
|
//! per-session overrides.
|
||||||
|
|
||||||
pub mod builtin;
|
pub mod builtin;
|
||||||
pub mod global;
|
pub mod global;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Load, save, add, and remove agent definitions scoped to a single
|
//! Load, save, add, and remove agent definitions scoped to a single
|
||||||
//! session (`<session_dir>/agents.json`).
|
//! session (`<session_dir>/agents.json`).
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use crate::app::subagent::spawn::AgentDefinition;
|
use crate::app::subagent::spawn::AgentDefinition;
|
||||||
|
|
||||||
|
|||||||
+43
-31
@@ -1,6 +1,5 @@
|
|||||||
//! Application-level configuration: LLM providers, model roles, and defaults,
|
//! Application-level configuration: LLM providers, model roles, and defaults,
|
||||||
//! persisted to `app_config.json` in the store directory.
|
//! persisted to `app_config.json` in the store directory.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -38,26 +37,35 @@ pub struct ModelRole {
|
|||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let mut providers = HashMap::new();
|
let mut providers = HashMap::new();
|
||||||
providers.insert("zen".to_string(), ProviderConfig {
|
providers.insert(
|
||||||
api_base: "https://opencode.ai/zen/v1".to_string(),
|
"zen".to_string(),
|
||||||
api_key_env: Some("API_KEY".to_string()),
|
ProviderConfig {
|
||||||
default_model: Some("deepseek-v4-flash-free".to_string()),
|
api_base: "https://opencode.ai/zen/v1".to_string(),
|
||||||
default_api_key: None,
|
api_key_env: Some("API_KEY".to_string()),
|
||||||
});
|
default_model: Some("deepseek-v4-flash-free".to_string()),
|
||||||
providers.insert("router".to_string(), ProviderConfig {
|
default_api_key: None,
|
||||||
api_base: "https://9router.asepharyana.my.id/v1".to_string(),
|
},
|
||||||
api_key_env: Some("ROUTER_API_KEY".to_string()),
|
);
|
||||||
default_model: Some("claude-opus-4-8".to_string()),
|
providers.insert(
|
||||||
default_api_key: None,
|
"router".to_string(),
|
||||||
});
|
ProviderConfig {
|
||||||
|
api_base: "https://9router.asepharyana.my.id/v1".to_string(),
|
||||||
|
api_key_env: Some("ROUTER_API_KEY".to_string()),
|
||||||
|
default_model: Some("claude-opus-4-8".to_string()),
|
||||||
|
default_api_key: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
let mut model_roles = HashMap::new();
|
let mut model_roles = HashMap::new();
|
||||||
model_roles.insert("default".to_string(), ModelRole {
|
model_roles.insert(
|
||||||
provider: "zen".to_string(),
|
"default".to_string(),
|
||||||
model: "deepseek-v4-flash-free".to_string(),
|
ModelRole {
|
||||||
max_tokens: None,
|
provider: "zen".to_string(),
|
||||||
context_window: None,
|
model: "deepseek-v4-flash-free".to_string(),
|
||||||
temperature: Some(0.7),
|
max_tokens: None,
|
||||||
});
|
context_window: None,
|
||||||
|
temperature: Some(0.7),
|
||||||
|
},
|
||||||
|
);
|
||||||
AppConfig {
|
AppConfig {
|
||||||
providers,
|
providers,
|
||||||
model_roles,
|
model_roles,
|
||||||
@@ -89,7 +97,8 @@ impl AppConfig {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"warning: failed to parse config file '{}': {}. Loading defaults.",
|
"warning: failed to parse config file '{}': {}. Loading defaults.",
|
||||||
path.display(), e
|
path.display(),
|
||||||
|
e
|
||||||
);
|
);
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
@@ -103,7 +112,9 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
// Auto-detect provider from ~/.claude/settings.json
|
// Auto-detect provider from ~/.claude/settings.json
|
||||||
if let Some(claude_provider) = detect_claude_settings_provider() {
|
if let Some(claude_provider) = detect_claude_settings_provider() {
|
||||||
cfg.providers.entry("claude".to_string()).or_insert(claude_provider);
|
cfg.providers
|
||||||
|
.entry("claude".to_string())
|
||||||
|
.or_insert(claude_provider);
|
||||||
// Register known Claude models as named model roles
|
// Register known Claude models as named model roles
|
||||||
let claude_models = [
|
let claude_models = [
|
||||||
("claude-opus-4-8", "claude-opus-4-8"),
|
("claude-opus-4-8", "claude-opus-4-8"),
|
||||||
@@ -111,13 +122,15 @@ impl AppConfig {
|
|||||||
("claude-haiku-4-5", "claude-haiku-4-5-20251001"),
|
("claude-haiku-4-5", "claude-haiku-4-5-20251001"),
|
||||||
];
|
];
|
||||||
for (role_name, model_name) in &claude_models {
|
for (role_name, model_name) in &claude_models {
|
||||||
cfg.model_roles.entry(role_name.to_string()).or_insert(ModelRole {
|
cfg.model_roles
|
||||||
provider: "claude".to_string(),
|
.entry(role_name.to_string())
|
||||||
model: model_name.to_string(),
|
.or_insert(ModelRole {
|
||||||
max_tokens: Some(8192),
|
provider: "claude".to_string(),
|
||||||
context_window: Some(200_000),
|
model: model_name.to_string(),
|
||||||
temperature: Some(0.7),
|
max_tokens: Some(8192),
|
||||||
});
|
context_window: Some(200_000),
|
||||||
|
temperature: Some(0.7),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Set as default provider only if user hasn't picked a custom default
|
// Set as default provider only if user hasn't picked a custom default
|
||||||
if cfg.default_provider == defaults.default_provider {
|
if cfg.default_provider == defaults.default_provider {
|
||||||
@@ -154,8 +167,7 @@ struct ClaudeSettings {
|
|||||||
/// than through its settings file, so reading only the file misses them.
|
/// than through its settings file, so reading only the file misses them.
|
||||||
fn detect_claude_settings_provider() -> Option<ProviderConfig> {
|
fn detect_claude_settings_provider() -> Option<ProviderConfig> {
|
||||||
// Prefer the file, then fall back to env vars.
|
// Prefer the file, then fall back to env vars.
|
||||||
let (base_url, key) = claude_credentials_from_file()
|
let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?;
|
||||||
.or_else(claude_credentials_from_env)?;
|
|
||||||
Some(ProviderConfig {
|
Some(ProviderConfig {
|
||||||
api_base: base_url,
|
api_base: base_url,
|
||||||
// Keep the env-var name so runtime env overrides still work.
|
// Keep the env-var name so runtime env overrides still work.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! In-memory conversation state: message history plus the system prompt and
|
//! In-memory conversation state: message history plus the system prompt and
|
||||||
//! model parameters used to drive the LLM.
|
//! model parameters used to drive the LLM.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// A single conversation's message history and generation settings.
|
/// A single conversation's message history and generation settings.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Append-only JSONL edit log recording every file mutation made by tools,
|
//! Append-only JSONL edit log recording every file mutation made by tools,
|
||||||
//! for audit and undo/history purposes.
|
//! for audit and undo/history purposes.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// A single recorded file edit: which tool made it, to which path, why,
|
/// A single recorded file edit: which tool made it, to which path, why,
|
||||||
@@ -44,7 +43,9 @@ impl EditLog {
|
|||||||
/// regardless of the in-memory limit.
|
/// regardless of the in-memory limit.
|
||||||
fn load_from_disk(path: &std::path::Path) -> Vec<EditLogEntry> {
|
fn load_from_disk(path: &std::path::Path) -> Vec<EditLogEntry> {
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
let Ok(file) = std::fs::File::open(path) else { return Vec::new() };
|
let Ok(file) = std::fs::File::open(path) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
let reader = BufReader::new(file);
|
let reader = BufReader::new(file);
|
||||||
let mut entries: Vec<EditLogEntry> = Vec::new();
|
let mut entries: Vec<EditLogEntry> = Vec::new();
|
||||||
for line in reader.lines() {
|
for line in reader.lines() {
|
||||||
@@ -151,7 +152,8 @@ mod tests {
|
|||||||
bytes_delta: 10 + i,
|
bytes_delta: 10 + i,
|
||||||
origin: "main".to_string(),
|
origin: "main".to_string(),
|
||||||
session_id: "sess-1".to_string(),
|
session_id: "sess-1".to_string(),
|
||||||
}).unwrap();
|
})
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
assert_eq!(log.len(), 5);
|
assert_eq!(log.len(), 5);
|
||||||
assert_eq!(log.entries[0].reason, "reason 0");
|
assert_eq!(log.entries[0].reason, "reason 0");
|
||||||
|
|||||||
+87
-26
@@ -1,8 +1,7 @@
|
|||||||
//! Long-term agent memory: markdown files with YAML-ish frontmatter storing
|
//! Long-term agent memory: markdown files with YAML-ish frontmatter storing
|
||||||
//! lessons/references, plus slugified filenames and export/import helpers.
|
//! lessons/references, plus slugified filenames and export/import helpers.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// A single memory entry (lesson, reference, etc.) with frontmatter
|
/// A single memory entry (lesson, reference, etc.) with frontmatter
|
||||||
/// metadata and free-form markdown content.
|
/// metadata and free-form markdown content.
|
||||||
@@ -72,15 +71,30 @@ impl Memory {
|
|||||||
///
|
///
|
||||||
/// Return: `Ok(())` on success, or an `io::Error` from directory
|
/// Return: `Ok(())` on success, or an `io::Error` from directory
|
||||||
/// creation, the temp write, or the rename.
|
/// creation, the temp write, or the rename.
|
||||||
#[allow(clippy::suspicious_open_options)]
|
|
||||||
pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> {
|
pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> {
|
||||||
let path = Self::path(memory_dir, &self.name);
|
let path = Self::path(memory_dir, &self.name);
|
||||||
let parent = path.parent().unwrap();
|
let parent = path.parent().unwrap();
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {o}")).unwrap_or_default();
|
let outcome_line = self
|
||||||
let scope_line = self.scope.as_ref().map(|s| format!("scope: {s}")).unwrap_or_default();
|
.outcome
|
||||||
let before_line = self.before_snippet.as_ref().map(|s| format!("before: {s}")).unwrap_or_default();
|
.as_ref()
|
||||||
let after_line = self.after_snippet.as_ref().map(|s| format!("after: {s}")).unwrap_or_default();
|
.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() {
|
let prov_line = if self.provenances.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
@@ -99,6 +113,7 @@ impl Memory {
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
let mut f = std::fs::OpenOptions::new()
|
let mut f = std::fs::OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
.open(&tmp)?;
|
.open(&tmp)?;
|
||||||
f.write_all(content.as_bytes())?;
|
f.write_all(content.as_bytes())?;
|
||||||
@@ -139,7 +154,10 @@ impl Memory {
|
|||||||
let content = content.strip_prefix("---\n").unwrap_or(content);
|
let content = content.strip_prefix("---\n").unwrap_or(content);
|
||||||
let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();
|
let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();
|
||||||
if parts.len() < 2 {
|
if parts.len() < 2 {
|
||||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "missing frontmatter"));
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"missing frontmatter",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let front: std::collections::HashMap<String, String> = parts[0]
|
let front: std::collections::HashMap<String, String> = parts[0]
|
||||||
.lines()
|
.lines()
|
||||||
@@ -153,16 +171,34 @@ impl Memory {
|
|||||||
name: front.get("name").cloned().unwrap_or_default(),
|
name: front.get("name").cloned().unwrap_or_default(),
|
||||||
description: front.get("description").cloned().unwrap_or_default(),
|
description: front.get("description").cloned().unwrap_or_default(),
|
||||||
content: body,
|
content: body,
|
||||||
kind: front.get("kind").cloned().unwrap_or_else(|| "reference".to_string()),
|
kind: front
|
||||||
created_at: front.get("created_at").and_then(|v| v.parse().ok()).unwrap_or(0),
|
.get("kind")
|
||||||
updated_at: front.get("updated_at").and_then(|v| v.parse().ok()).unwrap_or(0),
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "reference".to_string()),
|
||||||
|
created_at: front
|
||||||
|
.get("created_at")
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(0),
|
||||||
|
updated_at: front
|
||||||
|
.get("updated_at")
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(0),
|
||||||
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()),
|
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()),
|
||||||
lifecycle: front.get("lifecycle").cloned().unwrap_or_else(|| "new".to_string()),
|
lifecycle: front
|
||||||
|
.get("lifecycle")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "new".to_string()),
|
||||||
scope: front.get("scope").cloned().filter(|s| !s.is_empty()),
|
scope: front.get("scope").cloned().filter(|s| !s.is_empty()),
|
||||||
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()),
|
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()),
|
||||||
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()),
|
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()),
|
||||||
provenances: front.get("provenances").cloned()
|
provenances: front
|
||||||
.map(|s| s.split(", ").map(std::string::ToString::to_string).collect())
|
.get("provenances")
|
||||||
|
.cloned()
|
||||||
|
.map(|s| {
|
||||||
|
s.split(", ")
|
||||||
|
.map(std::string::ToString::to_string)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -186,13 +222,17 @@ impl Memory {
|
|||||||
/// Return: slugs (without extension); empty `Vec` if the directory
|
/// Return: slugs (without extension); empty `Vec` if the directory
|
||||||
/// can't be read.
|
/// can't be read.
|
||||||
pub fn list(memory_dir: &Path) -> Vec<String> {
|
pub fn list(memory_dir: &Path) -> Vec<String> {
|
||||||
let Ok(entries) = std::fs::read_dir(memory_dir) else { return Vec::new() };
|
let Ok(entries) = std::fs::read_dir(memory_dir) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
entries
|
entries
|
||||||
.filter_map(std::result::Result::ok)
|
.filter_map(std::result::Result::ok)
|
||||||
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
|
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
|
||||||
.filter_map(|e| {
|
.filter_map(|e| {
|
||||||
let name = e.file_name().to_string_lossy().to_string();
|
let name = e.file_name().to_string_lossy().to_string();
|
||||||
if name == "MEMORY.md" { return None; }
|
if name == "MEMORY.md" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let slug = name.strip_suffix(".md")?.to_string();
|
let slug = name.strip_suffix(".md")?.to_string();
|
||||||
Some(slug)
|
Some(slug)
|
||||||
})
|
})
|
||||||
@@ -209,11 +249,22 @@ impl Memory {
|
|||||||
/// Why: leading-dot stripping specifically blocks accidental hidden
|
/// Why: leading-dot stripping specifically blocks accidental hidden
|
||||||
/// files and `..`-style traversal attempts embedded in `raw`.
|
/// files and `..`-style traversal attempts embedded in `raw`.
|
||||||
pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf {
|
pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf {
|
||||||
let clean: String = raw.chars()
|
let clean: String = raw
|
||||||
.map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' { c } else { '-' })
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let clean = clean.trim_start_matches('.').to_string();
|
let clean = clean.trim_start_matches('.').to_string();
|
||||||
memory_dir.join(if clean.is_empty() { "memory.md" } else { &clean })
|
memory_dir.join(if clean.is_empty() {
|
||||||
|
"memory.md"
|
||||||
|
} else {
|
||||||
|
&clean
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Export all memories in `memory_dir` to a single JSON file.
|
/// Export all memories in `memory_dir` to a single JSON file.
|
||||||
@@ -227,11 +278,11 @@ pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> {
|
pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> {
|
||||||
let names = Memory::list(memory_dir);
|
let names = Memory::list(memory_dir);
|
||||||
let lessons: Vec<Memory> = names.iter()
|
let lessons: Vec<Memory> = names
|
||||||
|
.iter()
|
||||||
.filter_map(|n| Memory::read(memory_dir, n).ok())
|
.filter_map(|n| Memory::read(memory_dir, n).ok())
|
||||||
.collect();
|
.collect();
|
||||||
let data = serde_json::to_string_pretty(&lessons)
|
let data = serde_json::to_string_pretty(&lessons).map_err(std::io::Error::other)?;
|
||||||
.map_err(std::io::Error::other)?;
|
|
||||||
// Write to temp, fsync, then rename for crash-safe export
|
// Write to temp, fsync, then rename for crash-safe export
|
||||||
let tmp = output.with_extension("json.tmp");
|
let tmp = output.with_extension("json.tmp");
|
||||||
std::fs::write(&tmp, data)?;
|
std::fs::write(&tmp, data)?;
|
||||||
@@ -259,7 +310,8 @@ pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result<usize>
|
|||||||
let data = std::fs::read_to_string(input)?;
|
let data = std::fs::read_to_string(input)?;
|
||||||
let lessons: Vec<Memory> = serde_json::from_str(&data)
|
let lessons: Vec<Memory> = serde_json::from_str(&data)
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||||
let existing: std::collections::HashSet<String> = Memory::list(memory_dir).into_iter().collect();
|
let existing: std::collections::HashSet<String> =
|
||||||
|
Memory::list(memory_dir).into_iter().collect();
|
||||||
let mut imported = 0;
|
let mut imported = 0;
|
||||||
for lesson in &lessons {
|
for lesson in &lessons {
|
||||||
let slug = Memory::slugify(&lesson.name).unwrap_or_default();
|
let slug = Memory::slugify(&lesson.name).unwrap_or_default();
|
||||||
@@ -282,12 +334,18 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_slugify_basic() {
|
fn test_slugify_basic() {
|
||||||
assert_eq!(Memory::slugify("Hello World"), Some("hello-world".to_string()));
|
assert_eq!(
|
||||||
|
Memory::slugify("Hello World"),
|
||||||
|
Some("hello-world".to_string())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_slugify_special_chars() {
|
fn test_slugify_special_chars() {
|
||||||
assert_eq!(Memory::slugify("Use & Avoid! @#$"), Some("use-avoid".to_string()));
|
assert_eq!(
|
||||||
|
Memory::slugify("Use & Avoid! @#$"),
|
||||||
|
Some("use-avoid".to_string())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -375,7 +433,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
mem.write(&dir).unwrap();
|
mem.write(&dir).unwrap();
|
||||||
let names = Memory::list(&dir);
|
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);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Persistence and domain model layer: sessions, conversations, memory,
|
//! 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 app_config;
|
||||||
pub mod editlog;
|
pub mod editlog;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
|
|||||||
+16
-23
@@ -1,8 +1,7 @@
|
|||||||
//! 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.
|
//! attachments), keyed by session id and an arbitrary blob key.
|
||||||
|
|
||||||
use rusqlite::{Connection, params};
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
|
||||||
/// Insert or overwrite a blob for a session under `blob_key`.
|
/// Insert or overwrite a blob for a session under `blob_key`.
|
||||||
///
|
///
|
||||||
@@ -10,7 +9,13 @@ use anyhow::Result;
|
|||||||
/// keyed on `(session_id, blob_key)`.
|
/// 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<()> {
|
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();
|
let created_at = chrono::Utc::now().timestamp_millis();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO blobs (session_id, blob_key, data, mime_type, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
"INSERT OR REPLACE INTO blobs (session_id, blob_key, data, mime_type, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
@@ -23,7 +28,11 @@ pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u
|
|||||||
///
|
///
|
||||||
/// Return: `Ok(Some(data))` if found, `Ok(None)` if no matching row
|
/// 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>>> {
|
pub fn retrieve_blob(
|
||||||
|
conn: &Connection,
|
||||||
|
session_id: &str,
|
||||||
|
blob_key: &str,
|
||||||
|
) -> Result<Option<Vec<u8>>> {
|
||||||
let result = conn.query_row(
|
let result = conn.query_row(
|
||||||
"SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
|
"SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
|
||||||
params![session_id, blob_key],
|
params![session_id, blob_key],
|
||||||
@@ -36,30 +45,14 @@ pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Res
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a blob for a session by key.
|
|
||||||
///
|
|
||||||
/// Return: `Ok(true)` if a row was deleted, `Ok(false)` if no matching
|
|
||||||
/// row existed.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<bool> {
|
|
||||||
let rows = conn.execute(
|
|
||||||
"DELETE FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
|
|
||||||
params![session_id, blob_key],
|
|
||||||
)?;
|
|
||||||
Ok(rows > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all blob keys stored for a session, oldest first.
|
/// List all blob keys stored for a session, oldest first.
|
||||||
///
|
///
|
||||||
/// Return: `Ok(Vec<String>)` of keys ordered by `created_at`, or the
|
/// 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>> {
|
pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt =
|
||||||
"SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC"
|
conn.prepare("SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC")?;
|
||||||
)?;
|
let rows = stmt.query_map(params![session_id], |row| row.get::<_, String>(0))?;
|
||||||
let rows = stmt.query_map(params![session_id], |row| {
|
|
||||||
row.get::<_, String>(0)
|
|
||||||
})?;
|
|
||||||
let mut keys = Vec::new();
|
let mut keys = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
keys.push(row?);
|
keys.push(row?);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! SQLite-backed message log: per-session `messages.sqlite` storing chat
|
//! SQLite-backed message log: per-session `messages.sqlite` storing chat
|
||||||
//! messages, blobs, and archive/summary metadata.
|
//! messages, blobs, and archive/summary metadata.
|
||||||
|
|
||||||
pub mod blobs;
|
pub mod blobs;
|
||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
//! Insert queries against the message log's `messages` table.
|
//! Insert queries against the message log's `messages` table.
|
||||||
|
|
||||||
use rusqlite::{Connection, params};
|
|
||||||
use anyhow::Result;
|
|
||||||
use crate::dto::chat::message::{ChatMessage, Role};
|
use crate::dto::chat::message::{ChatMessage, Role};
|
||||||
|
use anyhow::Result;
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
|
||||||
/// Insert a chat message into the session's message log.
|
/// Insert a chat message into the session's message log.
|
||||||
///
|
///
|
||||||
@@ -15,9 +14,10 @@ pub fn insert_message(conn: &Connection, session_id: &str, msg: &ChatMessage) ->
|
|||||||
let content = msg.content.as_deref();
|
let content = msg.content.as_deref();
|
||||||
let tool_call_id = msg.tool_call_id.as_deref();
|
let tool_call_id = msg.tool_call_id.as_deref();
|
||||||
let tool_name = msg.name.as_deref();
|
let tool_name = msg.name.as_deref();
|
||||||
let tool_arguments = msg.tool_calls.as_ref().map(|calls| {
|
let tool_arguments = msg
|
||||||
serde_json::to_string(calls).unwrap_or_default()
|
.tool_calls
|
||||||
});
|
.as_ref()
|
||||||
|
.map(|calls| serde_json::to_string(calls).unwrap_or_default());
|
||||||
let created_at = chrono::Utc::now().timestamp_millis();
|
let created_at = chrono::Utc::now().timestamp_millis();
|
||||||
let role_str = match msg.role {
|
let role_str = match msg.role {
|
||||||
Role::User => "user",
|
Role::User => "user",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
//! `SQLite` schema definition for the message log database.
|
//! `SQLite` schema definition for the message log database.
|
||||||
|
|
||||||
use rusqlite::Connection;
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
/// Create the message log's tables and indexes if they don't already
|
/// Create the message log's tables and indexes if they don't already
|
||||||
/// exist (`messages`, `archives`, `blobs`).
|
/// exist (`messages`, `archives`, `blobs`).
|
||||||
@@ -51,7 +50,7 @@ pub fn init_schema(conn: &Connection) -> Result<()> {
|
|||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
UNIQUE(session_id, blob_key)
|
UNIQUE(session_id, blob_key)
|
||||||
);
|
);
|
||||||
"
|
",
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Session archive/summary metadata tracked alongside the message log
|
//! Session archive/summary metadata tracked alongside the message log
|
||||||
//! (title, model, counts, and a rolling text summary).
|
//! (title, model, counts, and a rolling text summary).
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Summary metadata for one archived/summarized session.
|
/// Summary metadata for one archived/summarized session.
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
//! Session metadata: id, title, workspace roots, and message/token counts,
|
//! Session metadata: id, title, workspace roots, and message/token counts,
|
||||||
//! persisted as `session.json` per session directory.
|
//! persisted as `session.json` per session directory.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Metadata for one conversation session (distinct from the message
|
/// Metadata for one conversation session (distinct from the message
|
||||||
/// history itself, which lives in `Conversation`/the msglog).
|
/// history itself, which lives in `Conversation`/the msglog).
|
||||||
@@ -108,7 +107,9 @@ impl Session {
|
|||||||
/// contains no valid sessions.
|
/// contains no valid sessions.
|
||||||
pub fn list(base_dir: &Path) -> Vec<Self> {
|
pub fn list(base_dir: &Path) -> Vec<Self> {
|
||||||
let sessions_dir = base_dir.join("sessions");
|
let sessions_dir = base_dir.join("sessions");
|
||||||
let Ok(entries) = std::fs::read_dir(&sessions_dir) else { return Vec::new() };
|
let Ok(entries) = std::fs::read_dir(&sessions_dir) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
entries
|
entries
|
||||||
.filter_map(std::result::Result::ok)
|
.filter_map(std::result::Result::ok)
|
||||||
.filter(|e| e.path().is_dir())
|
.filter(|e| e.path().is_dir())
|
||||||
|
|||||||
+18
-11
@@ -1,10 +1,14 @@
|
|||||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
#![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
|
//! PID-file based advisory lock preventing two processes from operating on
|
||||||
//! the same session directory concurrently.
|
//! the same session directory concurrently.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
|
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
|
||||||
/// auto-removed on drop.
|
/// auto-removed on drop.
|
||||||
@@ -38,7 +42,6 @@ impl SessionLock {
|
|||||||
///
|
///
|
||||||
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
|
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
|
||||||
/// process holds it, `Err` on I/O failure.
|
/// process holds it, `Err` on I/O failure.
|
||||||
#[allow(clippy::suspicious_open_options)]
|
|
||||||
pub fn try_lock(&self) -> std::io::Result<bool> {
|
pub fn try_lock(&self) -> std::io::Result<bool> {
|
||||||
// Phase 1: try atomic create. If it succeeds, the lock is ours.
|
// Phase 1: try atomic create. If it succeeds, the lock is ours.
|
||||||
match fs::OpenOptions::new()
|
match fs::OpenOptions::new()
|
||||||
@@ -60,7 +63,7 @@ impl SessionLock {
|
|||||||
// Phase 2: lock file exists — check liveness of the owning process.
|
// Phase 2: lock file exists — check liveness of the owning process.
|
||||||
let content = fs::read_to_string(&self.path).unwrap_or_default();
|
let content = fs::read_to_string(&self.path).unwrap_or_default();
|
||||||
if let Ok(pid) = content.trim().parse::<u32>() {
|
if let Ok(pid) = content.trim().parse::<u32>() {
|
||||||
if self.is_alive(pid) {
|
if Self::is_alive(pid) {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +74,7 @@ impl SessionLock {
|
|||||||
{
|
{
|
||||||
let mut tmp_file = fs::OpenOptions::new()
|
let mut tmp_file = fs::OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
.open(&tmp)?;
|
.open(&tmp)?;
|
||||||
write!(tmp_file, "{}", self.pid)?;
|
write!(tmp_file, "{}", self.pid)?;
|
||||||
@@ -92,8 +96,7 @@ impl SessionLock {
|
|||||||
/// Check whether a process with the given PID is currently alive and
|
/// Check whether a process with the given PID is currently alive and
|
||||||
/// is actually a zesdex process (not a recycled PID from a different
|
/// is actually a zesdex process (not a recycled PID from a different
|
||||||
/// program).
|
/// program).
|
||||||
#[allow(clippy::unused_self)]
|
fn is_alive(pid: u32) -> bool {
|
||||||
fn is_alive(&self, pid: u32) -> bool {
|
|
||||||
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
|
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
|
||||||
// whether the process exists and the caller has permission to signal
|
// whether the process exists and the caller has permission to signal
|
||||||
// it. The integer argument is a PID already validated by `try_lock`.
|
// it. The integer argument is a PID already validated by `try_lock`.
|
||||||
@@ -106,11 +109,15 @@ impl SessionLock {
|
|||||||
// our lock). This is best-effort — /proc may not be available
|
// our lock). This is best-effort — /proc may not be available
|
||||||
// on all platforms.
|
// on all platforms.
|
||||||
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
|
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 let Ok(target) = std::fs::read_link(&proc_exe) {
|
||||||
if target != exe {
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
return false;
|
if target != exe {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else { /* cannot resolve own exe, trust kill check */
|
||||||
}
|
}
|
||||||
} else { /* cannot resolve own exe, trust kill check */ } } else { /* /proc unavailable, trust kill check */ }
|
} else { /* /proc unavailable, trust kill check */
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-49
@@ -24,6 +24,24 @@ fn default_hive_mind_node_timeout_ms() -> u64 {
|
|||||||
600_000
|
600_000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Boolean flags grouped to keep the top-level struct below clippy's bool threshold.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SettingsFlags {
|
||||||
|
pub review_enabled: bool,
|
||||||
|
pub session_archive_enabled: bool,
|
||||||
|
pub lsp_auto_provision: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SettingsFlags {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
review_enabled: true,
|
||||||
|
session_archive_enabled: true,
|
||||||
|
lsp_auto_provision: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Top-level application settings, serialized to `settings.json` in the store dir.
|
/// Top-level application settings, serialized to `settings.json` in the store dir.
|
||||||
///
|
///
|
||||||
/// Why: a single flat struct rather than nested config so the JSON file stays
|
/// Why: a single flat struct rather than nested config so the JSON file stays
|
||||||
@@ -36,28 +54,21 @@ pub struct Settings {
|
|||||||
pub api_keys: std::collections::HashMap<String, String>,
|
pub api_keys: std::collections::HashMap<String, String>,
|
||||||
pub max_tokens: Option<u32>,
|
pub max_tokens: Option<u32>,
|
||||||
pub temperature: Option<f32>,
|
pub temperature: Option<f32>,
|
||||||
pub review_enabled: bool,
|
|
||||||
pub review_max_lessons_per_run: usize,
|
pub review_max_lessons_per_run: usize,
|
||||||
pub adaptive_review_max_skip: u32,
|
pub adaptive_review_max_skip: u32,
|
||||||
pub verify_command: Option<String>,
|
pub verify_command: Option<String>,
|
||||||
pub verify_timeout_ms: u64,
|
pub verify_timeout_ms: u64,
|
||||||
pub workflow_max_concurrency: usize,
|
pub workflow_max_concurrency: usize,
|
||||||
pub session_archive_enabled: bool,
|
/// Boolean flags flattened into the top-level JSON so existing settings
|
||||||
pub lsp_auto_provision: bool,
|
/// files remain compatible when bools are grouped into a sub-struct.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub flags: SettingsFlags,
|
||||||
pub lsp_languages: Vec<String>,
|
pub lsp_languages: Vec<String>,
|
||||||
/// Wall-clock deadline for a single hive-mind processing node (cycle
|
/// Wall-clock deadline for a single hive-mind processing node (cycle
|
||||||
/// node or synthesis node). Prevents one stuck node from hanging an
|
/// node or synthesis node). Prevents one stuck node from hanging an
|
||||||
/// entire hive-mind convergence forever.
|
/// entire hive-mind convergence forever.
|
||||||
#[serde(default = "default_hive_mind_node_timeout_ms")]
|
#[serde(default = "default_hive_mind_node_timeout_ms")]
|
||||||
pub hive_mind_node_timeout_ms: u64,
|
pub hive_mind_node_timeout_ms: u64,
|
||||||
/// Off by default. When enabled, appends an instruction to the
|
|
||||||
/// system prompt asking the model to write tersely — drop articles,
|
|
||||||
/// filler words, hedging, and pleasantries; keep code, commands, and
|
|
||||||
/// error text byte-exact — with an explicit exception for
|
|
||||||
/// destructive-operation confirmations and security warnings, which
|
|
||||||
/// always get full detail regardless of this setting.
|
|
||||||
#[serde(default)]
|
|
||||||
pub concise_output: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
@@ -69,17 +80,14 @@ impl Default for Settings {
|
|||||||
api_keys: std::collections::HashMap::new(),
|
api_keys: std::collections::HashMap::new(),
|
||||||
max_tokens: None,
|
max_tokens: None,
|
||||||
temperature: None,
|
temperature: None,
|
||||||
review_enabled: true,
|
|
||||||
review_max_lessons_per_run: 5,
|
review_max_lessons_per_run: 5,
|
||||||
adaptive_review_max_skip: 3,
|
adaptive_review_max_skip: 3,
|
||||||
verify_command: None,
|
verify_command: None,
|
||||||
verify_timeout_ms: 30000,
|
verify_timeout_ms: 30000,
|
||||||
workflow_max_concurrency: 5,
|
workflow_max_concurrency: 5,
|
||||||
session_archive_enabled: true,
|
flags: SettingsFlags::default(),
|
||||||
lsp_auto_provision: true,
|
|
||||||
lsp_languages: Vec::new(),
|
lsp_languages: Vec::new(),
|
||||||
hive_mind_node_timeout_ms: default_hive_mind_node_timeout_ms(),
|
hive_mind_node_timeout_ms: default_hive_mind_node_timeout_ms(),
|
||||||
concise_output: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,38 +140,6 @@ mod tests {
|
|||||||
assert_eq!(settings.hive_mind_node_timeout_ms, 600_000);
|
assert_eq!(settings.hive_mind_node_timeout_ms, 600_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn concise_output_defaults_to_false() {
|
|
||||||
assert!(!Settings::default().concise_output);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_concise_output_field_falls_back_to_default() {
|
|
||||||
// Simulates loading a settings.json written before this field
|
|
||||||
// existed — #[serde(default)] must fill it in rather than
|
|
||||||
// failing the whole parse.
|
|
||||||
let old_json = r#"{
|
|
||||||
"internet_mode": "Off",
|
|
||||||
"provider": "zen",
|
|
||||||
"model": "deepseek-v4-flash-free",
|
|
||||||
"api_keys": {},
|
|
||||||
"max_tokens": null,
|
|
||||||
"temperature": null,
|
|
||||||
"review_enabled": true,
|
|
||||||
"review_max_lessons_per_run": 5,
|
|
||||||
"adaptive_review_max_skip": 3,
|
|
||||||
"verify_command": null,
|
|
||||||
"verify_timeout_ms": 30000,
|
|
||||||
"workflow_max_concurrency": 5,
|
|
||||||
"session_archive_enabled": true,
|
|
||||||
"lsp_auto_provision": true,
|
|
||||||
"lsp_languages": []
|
|
||||||
}"#;
|
|
||||||
let parsed: Settings = serde_json::from_str(old_json)
|
|
||||||
.expect("must parse even without the new field present");
|
|
||||||
assert!(!parsed.concise_output);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_hive_mind_node_timeout_field_falls_back_to_default() {
|
fn missing_hive_mind_node_timeout_field_falls_back_to_default() {
|
||||||
// Simulates loading a settings.json written before this field
|
// Simulates loading a settings.json written before this field
|
||||||
@@ -178,13 +154,13 @@ mod tests {
|
|||||||
"max_tokens": null,
|
"max_tokens": null,
|
||||||
"temperature": null,
|
"temperature": null,
|
||||||
"review_enabled": true,
|
"review_enabled": true,
|
||||||
|
"session_archive_enabled": true,
|
||||||
|
"lsp_auto_provision": true,
|
||||||
"review_max_lessons_per_run": 5,
|
"review_max_lessons_per_run": 5,
|
||||||
"adaptive_review_max_skip": 3,
|
"adaptive_review_max_skip": 3,
|
||||||
"verify_command": null,
|
"verify_command": null,
|
||||||
"verify_timeout_ms": 30000,
|
"verify_timeout_ms": 30000,
|
||||||
"workflow_max_concurrency": 5,
|
"workflow_max_concurrency": 5,
|
||||||
"session_archive_enabled": true,
|
|
||||||
"lsp_auto_provision": true,
|
|
||||||
"lsp_languages": []
|
"lsp_languages": []
|
||||||
}"#;
|
}"#;
|
||||||
let parsed: Settings = serde_json::from_str(old_json)
|
let parsed: Settings = serde_json::from_str(old_json)
|
||||||
|
|||||||
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
//! Filesystem layout for zesdex's persistent and scratch data directories.
|
//! Filesystem layout for zesdex's persistent and scratch data directories.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Resolved paths for all data directories zesdex reads from and writes to.
|
/// Resolved paths for all data directories zesdex reads from and writes to.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Compile-time embedded text resources: the system prompt, tool descriptions,
|
//! Compile-time embedded text resources: the system prompt, tool descriptions,
|
||||||
//! and the in-app help screen shown on Ctrl+H.
|
//! and the in-app help screen shown on Ctrl+H.
|
||||||
|
|
||||||
pub const SYSTEM_PROMPT: &str = include_str!("../src-misc/system-prompt.txt");
|
pub const SYSTEM_PROMPT: &str = include_str!("../src-misc/system-prompt.txt");
|
||||||
pub const SYSTEM_TOOLS: &str = include_str!("../src-misc/system-tools.txt");
|
pub const SYSTEM_TOOLS: &str = include_str!("../src-misc/system-tools.txt");
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -1,4 +1,3 @@
|
|||||||
//! External service integrations: the LLM provider HTTP client and OAuth flows.
|
//! External service integrations: the LLM provider HTTP client and OAuth flows.
|
||||||
|
|
||||||
pub mod provider;
|
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
|
pub mod provider;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
#![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.
|
//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects.
|
||||||
|
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::{TcpListener, TcpStream};
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
|
||||||
@@ -60,9 +64,17 @@ impl LoopbackServer {
|
|||||||
let _ = stream.write_all(response.as_bytes());
|
let _ = stream.write_all(response.as_bytes());
|
||||||
let _ = stream.flush();
|
let _ = stream.flush();
|
||||||
if !state_ok {
|
if !state_ok {
|
||||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "state mismatch"));
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"state mismatch",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
code.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "code not found in callback"))
|
code.ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"code not found in callback",
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract and percent-decode the `code` query parameter from an HTTP request line.
|
/// Extract and percent-decode the `code` query parameter from an HTTP request line.
|
||||||
@@ -108,10 +120,14 @@ fn urlencoding(s: &str) -> String {
|
|||||||
let mut chars = s.chars();
|
let mut chars = s.chars();
|
||||||
while let Some(c) = chars.next() {
|
while let Some(c) = chars.next() {
|
||||||
if c == '%' {
|
if c == '%' {
|
||||||
match (chars.next().and_then(|c| c.to_digit(16)),
|
match (
|
||||||
chars.next().and_then(|c| c.to_digit(16))) {
|
chars.next().and_then(|c| c.to_digit(16)),
|
||||||
|
chars.next().and_then(|c| c.to_digit(16)),
|
||||||
|
) {
|
||||||
(Some(hi), Some(lo)) => result.push(char::from((hi * 16 + lo) as u8)),
|
(Some(hi), Some(lo)) => result.push(char::from((hi * 16 + lo) as u8)),
|
||||||
_ => { result.push('%'); }
|
_ => {
|
||||||
|
result.push('%');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result.push(c);
|
result.push(c);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
//! OAuth 2.0 authorization-code + PKCE flow: token exchange and authorization URL building.
|
//! OAuth 2.0 authorization-code + PKCE flow: token exchange and authorization URL building.
|
||||||
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
/// An OAuth access token plus its refresh token and absolute expiry (unix seconds).
|
/// An OAuth access token plus its refresh token and absolute expiry (unix seconds).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -12,8 +11,7 @@ pub struct OAuthToken {
|
|||||||
pub token_type: String,
|
pub token_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OAuthToken {
|
impl OAuthToken {}
|
||||||
}
|
|
||||||
|
|
||||||
/// Static configuration for an OAuth provider: endpoints, client identity, and requested scopes.
|
/// Static configuration for an OAuth provider: endpoints, client identity, and requested scopes.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -32,7 +30,11 @@ impl Default for OAuthConfig {
|
|||||||
token_url: String::new(),
|
token_url: String::new(),
|
||||||
client_id: String::new(),
|
client_id: String::new(),
|
||||||
client_secret: None,
|
client_secret: None,
|
||||||
scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()],
|
scopes: vec![
|
||||||
|
"openid".to_string(),
|
||||||
|
"profile".to_string(),
|
||||||
|
"email".to_string(),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +62,12 @@ impl OAuthManager {
|
|||||||
/// compute absolute `expires_at` from `expires_in` → store on `self.token`.
|
/// compute absolute `expires_at` from `expires_in` → store on `self.token`.
|
||||||
///
|
///
|
||||||
/// Return: `Err(String)` on network failure, non-2xx status, or a missing `access_token` field.
|
/// Return: `Err(String)` on network failure, non-2xx status, or a missing `access_token` field.
|
||||||
pub fn exchange_code(&mut self, code: &str, redirect_uri: &str, code_verifier: &str) -> Result<(), String> {
|
pub fn exchange_code(
|
||||||
|
&mut self,
|
||||||
|
code: &str,
|
||||||
|
redirect_uri: &str,
|
||||||
|
code_verifier: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
let mut params = std::collections::HashMap::new();
|
let mut params = std::collections::HashMap::new();
|
||||||
params.insert("grant_type", "authorization_code");
|
params.insert("grant_type", "authorization_code");
|
||||||
params.insert("code", code);
|
params.insert("code", code);
|
||||||
@@ -68,7 +75,8 @@ impl OAuthManager {
|
|||||||
params.insert("client_id", &self.config.client_id);
|
params.insert("client_id", &self.config.client_id);
|
||||||
params.insert("code_verifier", code_verifier);
|
params.insert("code_verifier", code_verifier);
|
||||||
|
|
||||||
let resp = self.client
|
let resp = self
|
||||||
|
.client
|
||||||
.post(&self.config.token_url)
|
.post(&self.config.token_url)
|
||||||
.form(¶ms)
|
.form(¶ms)
|
||||||
.send()
|
.send()
|
||||||
@@ -81,13 +89,21 @@ impl OAuthManager {
|
|||||||
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();
|
let access_token = body["access_token"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or("missing access_token")?
|
||||||
|
.to_string();
|
||||||
let expires_in = body["expires_in"].as_u64().unwrap_or(3600);
|
let expires_in = body["expires_in"].as_u64().unwrap_or(3600);
|
||||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
self.token = Some(OAuthToken {
|
self.token = Some(OAuthToken {
|
||||||
access_token,
|
access_token,
|
||||||
refresh_token: body["refresh_token"].as_str().map(std::string::ToString::to_string),
|
refresh_token: body["refresh_token"]
|
||||||
|
.as_str()
|
||||||
|
.map(std::string::ToString::to_string),
|
||||||
expires_at: now + expires_in,
|
expires_at: now + expires_in,
|
||||||
token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(),
|
token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! OAuth 2.0 authorization-code + PKCE support: verifier/challenge generation,
|
//! OAuth 2.0 authorization-code + PKCE support: verifier/challenge generation,
|
||||||
//! the loopback redirect server, and the token-exchange manager.
|
//! the loopback redirect server, and the token-exchange manager.
|
||||||
|
|
||||||
pub mod pkce;
|
|
||||||
pub mod loopback;
|
pub mod loopback;
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
|
pub mod pkce;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user