Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dccf0cee4 | ||
|
|
1f91b44708 | ||
|
|
b25929824a | ||
|
|
3fd9a2b2db | ||
|
|
6a98d52d54 | ||
|
|
3847c0e6fd | ||
|
|
5023e5dfa1 | ||
|
|
eac0443c4c | ||
|
|
14f3eae62a | ||
|
|
aec53651ed | ||
|
|
93f3c2a357 | ||
|
|
9ea3d361b1 | ||
|
|
271721694b | ||
|
|
171597ca05 | ||
|
|
7b0b53671f | ||
|
|
884b19ccb5 | ||
|
|
6db00b2266 | ||
|
|
7f64423615 | ||
|
|
6d3f4918bf |
No files matched your search
@@ -0,0 +1,26 @@
|
|||||||
|
# Rust build artifacts
|
||||||
|
target/
|
||||||
|
|
||||||
|
# VCS
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Local/secret files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
|
||||||
|
# Kilo metadata
|
||||||
|
.kilo/
|
||||||
|
|
||||||
|
# Docs lessons
|
||||||
|
docs/lesson/
|
||||||
@@ -21,11 +21,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
components: clippy
|
components: clippy
|
||||||
|
|
||||||
- name: Build workspace
|
- name: Format check
|
||||||
run: cargo build --release --workspace
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: Clippy workspace
|
||||||
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
- name: Test workspace
|
- name: Test workspace
|
||||||
run: cargo test --workspace
|
run: cargo test --workspace
|
||||||
|
|
||||||
- name: Clippy workspace
|
|
||||||
run: cargo clippy --workspace -- -D warnings
|
|
||||||
@@ -7,3 +7,4 @@ package-lock.json
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
docs/lesson/
|
docs/lesson/
|
||||||
.kilo/
|
.kilo/
|
||||||
|
.hermes/
|
||||||
@@ -1,3 +1,48 @@
|
|||||||
|
## [1.19.3](https://github.com/asepharyana/zesdex/compare/v1.19.2...v1.19.3) (2026-08-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **api:** model Opus pakai URL + API custom dari ~/.claude/settings.json ([1f91b44](https://github.com/asepharyana/zesdex/commit/1f91b447080e7106201d77585edb7d38b74e34bb))
|
||||||
|
|
||||||
|
## [1.19.2](https://github.com/asepharyana/zesdex/compare/v1.19.1...v1.19.2) (2026-08-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **agent:** stabilkan async & parallel — satu runtime, bounded concurrency, isolasi error ([6a98d52](https://github.com/asepharyana/zesdex/commit/6a98d52d54a69f78710d852a69dda3ac0a4ead31))
|
||||||
|
|
||||||
|
## [1.19.1](https://github.com/asepharyana/zesdex/compare/v1.19.0...v1.19.1) (2026-08-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **agent:** rombak alur AI agent — adaptif, hemat token, self-healing ([eac0443](https://github.com/asepharyana/zesdex/commit/eac0443c4c3b8bfcbefd4bad9554168fb6525b94))
|
||||||
|
|
||||||
|
# [1.19.0](https://github.com/asepharyana/zesdex/compare/v1.18.4...v1.19.0) (2026-08-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* hapus fitur LSP bawaan (language server protocol) ([93f3c2a](https://github.com/asepharyana/zesdex/commit/93f3c2a3572511b5a84f244980ad71bd1b455e70))
|
||||||
|
|
||||||
|
## [1.18.4](https://github.com/asepharyana/zesdex/compare/v1.18.3...v1.18.4) (2026-08-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **tui:** render streaming token secara inkremental + kurangi redraw sia-sia ([2717216](https://github.com/asepharyana/zesdex/commit/271721694beb62e9fa5ff932312b293aa1d56823))
|
||||||
|
|
||||||
|
## [1.18.3](https://github.com/asepharyana/zesdex/compare/v1.18.2...v1.18.3) (2026-08-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **api:** cegah race condition pada register users.json (TOCTOU) ([884b19c](https://github.com/asepharyana/zesdex/commit/884b19ccb5fbcaa6b29cb41dc978386cf7b1b3f9))
|
||||||
|
* **api:** perbaiki keamanan auth & WebSocket, tambah rate limiting ([6db00b2](https://github.com/asepharyana/zesdex/commit/6db00b22663839a6a975ae978058b894900c71de))
|
||||||
|
* **build:** perbaiki referensi paket zesdex-gateway dan sinkronisasi versi nix ([6d3f491](https://github.com/asepharyana/zesdex/commit/6d3f4918bfea06b106d4fe75adf58e6a29aca2a5))
|
||||||
|
* **build:** perbaiki referensi paket zesdex-gateway di Dockerfile & default.nix ([7f64423](https://github.com/asepharyana/zesdex/commit/7f644236158f66fcc106ec55e437ebf28ee1cca1))
|
||||||
|
|
||||||
## [1.18.2](https://github.com/asepharyana/zesdex/compare/v1.18.1...v1.18.2) (2026-08-20)
|
## [1.18.2](https://github.com/asepharyana/zesdex/compare/v1.18.1...v1.18.2) (2026-08-20)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+11
-45
@@ -1088,15 +1088,6 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fluent-uri"
|
|
||||||
version = "0.1.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 1.3.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fnv"
|
name = "fnv"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -1972,19 +1963,6 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lsp-types"
|
|
||||||
version = "0.97.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 1.3.2",
|
|
||||||
"fluent-uri",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"serde_repr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac_address"
|
name = "mac_address"
|
||||||
version = "1.1.8"
|
version = "1.1.8"
|
||||||
@@ -3369,17 +3347,6 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_repr"
|
|
||||||
version = "0.1.20"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.118",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -4895,7 +4862,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex-api"
|
name = "zesdex-api"
|
||||||
version = "1.18.0"
|
version = "1.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -4918,7 +4885,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex-application"
|
name = "zesdex-application"
|
||||||
version = "1.18.0"
|
version = "1.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -4935,7 +4902,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex-bootstrap"
|
name = "zesdex-bootstrap"
|
||||||
version = "1.18.0"
|
version = "1.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -4952,7 +4919,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex-daemon"
|
name = "zesdex-daemon"
|
||||||
version = "1.18.0"
|
version = "1.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -4976,7 +4943,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex-domain"
|
name = "zesdex-domain"
|
||||||
version = "1.18.0"
|
version = "1.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -4992,7 +4959,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex-gateway"
|
name = "zesdex-gateway"
|
||||||
version = "1.18.0"
|
version = "1.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -5019,7 +4986,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex-grpc"
|
name = "zesdex-grpc"
|
||||||
version = "1.18.0"
|
version = "1.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -5036,7 +5003,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex-infrastructure"
|
name = "zesdex-infrastructure"
|
||||||
version = "1.18.0"
|
version = "1.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -5055,7 +5022,6 @@ dependencies = [
|
|||||||
"infer",
|
"infer",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"libc",
|
"libc",
|
||||||
"lsp-types",
|
|
||||||
"nucleo-matcher",
|
"nucleo-matcher",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
@@ -5085,7 +5051,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex-tui"
|
name = "zesdex-tui"
|
||||||
version = "1.18.0"
|
version = "1.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -5111,7 +5077,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex-web"
|
name = "zesdex-web"
|
||||||
version = "1.18.0"
|
version = "1.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -5131,7 +5097,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zesdex-ws"
|
name = "zesdex-ws"
|
||||||
version = "1.18.0"
|
version = "1.19.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
+1
-2
@@ -15,7 +15,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.18.2"
|
version = "1.19.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||||
|
|
||||||
@@ -61,7 +61,6 @@ ignore = "0.4"
|
|||||||
nucleo-matcher = "0.3"
|
nucleo-matcher = "0.3"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
rmcp = { version = "2.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] }
|
rmcp = { version = "2.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] }
|
||||||
lsp-types = "0.97"
|
|
||||||
tiktoken-rs = "0.12"
|
tiktoken-rs = "0.12"
|
||||||
similar = "3"
|
similar = "3"
|
||||||
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
|
syntect = { version = "5", default-features = false, features = ["default-fancy"] }
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ WORKDIR /app
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build with release profile (treats warnings as errors via lints)
|
# Build with release profile (treats warnings as errors via lints)
|
||||||
RUN cargo build --release -p zesdex-backend --bin zesdex
|
RUN cargo build --release -p zesdex-gateway --bin zesdex
|
||||||
|
|
||||||
# Stage 2: Minimal runtime image
|
# Stage 2: Minimal runtime image
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
# Zesdex — Autonomous AI Coding Agent
|
|
||||||
|
|
||||||
Zesdex is an autonomous AI coding agent with a Terminal UI (TUI). It acts as an
|
|
||||||
OpenAI/Anthropic-compatible LLM client wrapped in a tool-use harness with **37
|
|
||||||
built-in tools** — file operations, git, shell execution, LSP integration, MCP,
|
|
||||||
subagent orchestration, and more.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────┐
|
|
||||||
│ Mode Selector │
|
|
||||||
│ TUI (default) ─── Daemon ─── Attach ─── API ─── WS/gRPC/Web │
|
|
||||||
└──────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run the TUI (default mode)
|
|
||||||
cargo run
|
|
||||||
|
|
||||||
# Run the REST API server
|
|
||||||
cargo run -- --api --api-port 8080
|
|
||||||
|
|
||||||
# Run in daemon mode (background + IPC)
|
|
||||||
cargo run -- --daemon
|
|
||||||
|
|
||||||
# Attach TUI to a running daemon session
|
|
||||||
cargo run -- --attach <session-id>
|
|
||||||
|
|
||||||
# Seed initial data (first run)
|
|
||||||
cargo run --bin bootstrap
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- **Rust** 1.81+ (edition 2021)
|
|
||||||
- **Linux** or **macOS** (Unix domain sockets required for daemon mode)
|
|
||||||
- An **API key** for an OpenAI/Anthropic-compatible LLM provider (set via
|
|
||||||
settings or environment variable)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Modes
|
|
||||||
|
|
||||||
| Flag | Mode | Description |
|
|
||||||
|------|------|-------------|
|
|
||||||
| *(none)* | **TUI** | Full terminal UI with chat, overlays, and agent loop in one process |
|
|
||||||
| `--daemon` | **Daemon** | Background daemon with IPC socket; clients attach separately |
|
|
||||||
| `--attach <id>` | **Attach** | Connect TUI to an existing daemon session via Unix socket |
|
|
||||||
| `--api` | **REST API** | HTTP server with session management and chat endpoints |
|
|
||||||
| `--ws` | **WebSocket** | WebSocket server for real-time communication |
|
|
||||||
| `--grpc` | **gRPC** | gRPC server for programmatic access |
|
|
||||||
| `--web` | **Web** | Serves the web frontend |
|
|
||||||
| `--api-port`, `--ws-port`, `--grpc-port`, `--web-port` | *(ports)* | Configure server ports (defaults: 8080, 8081, 50051, 3000) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Clean Architecture Layering
|
|
||||||
|
|
||||||
```
|
|
||||||
apps/
|
|
||||||
├── domain/ # Pure entities, value objects, repository/service traits
|
|
||||||
│ # Zero framework deps — only serde + chrono + uuid
|
|
||||||
├── application/ # Use-case services (auth, sessions, conversations, memory)
|
|
||||||
│ # Depends only on domain-layer trait interfaces
|
|
||||||
├── infrastructure/ # All I/O: LLM client, IPC, persistence, LSP, MCP, tools
|
|
||||||
│ # Implements domain/application port interfaces
|
|
||||||
└── interfaces/ # Entry points
|
|
||||||
├── tui/ # Ratatui terminal UI
|
|
||||||
├── api/ # Axum REST API
|
|
||||||
├── daemon/ # Unix socket daemon + client
|
|
||||||
├── ws/ # WebSocket server
|
|
||||||
├── grpc/ # gRPC server
|
|
||||||
└── web/ # Web frontend (static file server)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tool System
|
|
||||||
|
|
||||||
37 tools across 9 categories:
|
|
||||||
|
|
||||||
| Category | Tools |
|
|
||||||
|----------|-------|
|
|
||||||
| **File System** | `read`, `write`, `edit`, `delete`, `dir_list`, `dir_cache_update` |
|
|
||||||
| **Shell** | `bash`, `bash_interactive`, `bash_kill`, `bash_output` |
|
|
||||||
| **Git** | `git_operator`, `git_cred`, `git_worktree` |
|
|
||||||
| **Search** | `search`, `grep`, `glob`, `semantic_search` |
|
|
||||||
| **LSP** | `lsp_connect`, `lsp_hover`, `lsp_completion`, `lsp_definition`, `lsp_references`, `lsp_diagnostics`, `lsp_disconnect` |
|
|
||||||
| **Memory** | `remember`, `recall`, `forget` |
|
|
||||||
| **Workflow** | `spawn_agents`, `spawn_pipeline`, `plan`, `sequential_think`, `hive_mind` |
|
|
||||||
| **Utility** | `todo_write`, `todo_finish`, `pong`, `cd` |
|
|
||||||
| **Background** | Background bash jobs with `cancel/status/list` |
|
|
||||||
|
|
||||||
Each tool implements the `Tool` trait:
|
|
||||||
```rust
|
|
||||||
pub trait Tool: Send + Sync {
|
|
||||||
fn name(&self) -> &'static str;
|
|
||||||
fn description(&self) -> &'static str;
|
|
||||||
fn parameters(&self) -> Value;
|
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hive Mind Orchestration
|
|
||||||
|
|
||||||
The multi-agent orchestration system compiles a **cognitive cycle plan** per
|
|
||||||
task — ordered cycles of parallel processing nodes. Each node has a directive
|
|
||||||
and an **access tier** (`read` / `write` / `full`). Node outputs merge into a
|
|
||||||
shared collective state in real time, and a final **consensus synthesis**
|
|
||||||
produces the unified result.
|
|
||||||
|
|
||||||
- **Auto-trigger**: Complex requests automatically use the hive mind
|
|
||||||
- **Manual entry**: The `hive_mind` tool lets the LLM specify cycles explicitly
|
|
||||||
- **Live progress**: TUI panel shows each node's status and current tool
|
|
||||||
- **Guaranteed docs**: Every convergence writes to `docs/runs/`
|
|
||||||
|
|
||||||
### IPC Protocol (Daemon Mode)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────┐ Unix socket ┌──────────┐
|
|
||||||
│ Client │ ◄──────────────► │ Daemon │
|
|
||||||
│ (TUI) │ length-prefixed│ │
|
|
||||||
└──────────┘ serde_json └──────────┘
|
|
||||||
|
|
||||||
Frame format: [4-byte BE length][JSON payload]
|
|
||||||
```
|
|
||||||
|
|
||||||
The daemon holds `AppStateRest` and drives the agent loop. Clients are stateless
|
|
||||||
renderers that receive full state snapshots after each action.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Built-in Features
|
|
||||||
|
|
||||||
| Feature | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| **LLM Provider** | OpenAI/Anthropic-compatible API (streaming + non-streaming) with automatic retry and fallback |
|
|
||||||
| **Tool Harness** | Safety-gated tool execution with graduated review checks |
|
|
||||||
| **Subagents** | Auto-inline review, background test-gen, arch-review, security-review |
|
|
||||||
| **OAuth 2.0** | PKCE flow for LLM provider authentication |
|
|
||||||
| **MCP** | Model Context Protocol server management (stdio + HTTP transport) |
|
|
||||||
| **LSP** | Language Server Protocol integration (completion, hover, diagnostics, references) |
|
|
||||||
| **Session Mgmt** | SQLite-persisted sessions with lock-based concurrency control |
|
|
||||||
| **Memory** | File-based memory system with frontmatter metadata |
|
|
||||||
| **Edit Log** | Append-only edit history with configurable retention |
|
|
||||||
| **Rate Limiting** | Sliding-window per-client rate limiter |
|
|
||||||
| **JWT Auth** | HS256 JWT access/refresh tokens (API mode) |
|
|
||||||
| **Password Auth** | Argon2 password hashing with pepper |
|
|
||||||
| **OAuth Loopback** | Localhost HTTP server for OAuth redirect capture |
|
|
||||||
| **Background Jobs** | Long-running shell jobs with cancellation and output collection |
|
|
||||||
| **Settings** | JSON-persisted settings with hot-reload |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TUI Overlays
|
|
||||||
|
|
||||||
16 overlays accessible from the terminal UI:
|
|
||||||
|
|
||||||
| Overlay | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| Chat Input | Main input bar with autocomplete |
|
|
||||||
| Bash Panel | Interactive shell panel |
|
|
||||||
| File Editor | Built-in file editor |
|
|
||||||
| Effort Selector | LLM reasoning effort selector |
|
|
||||||
| Help | Keybindings reference |
|
|
||||||
| Key Input | Custom key binding configuration |
|
|
||||||
| Learning | Lesson viewer |
|
|
||||||
| Loading | Generating spinner |
|
|
||||||
| MCP Manager | MCP server management |
|
|
||||||
| Model Selector | LLM model picker |
|
|
||||||
| Quit Confirm | Exit confirmation dialog |
|
|
||||||
| Rewind | Message/history rewind |
|
|
||||||
| Settings | Settings panel |
|
|
||||||
| Todo | Task/TODO list |
|
|
||||||
| Usage | Token usage statistics |
|
|
||||||
| Workflow | Hive-mind node progress |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data & Persistence
|
|
||||||
|
|
||||||
All data lives under the platform's data directory (`~/.local/share/zesdex/`):
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.local/share/zesdex/
|
|
||||||
├── settings.json # User settings (provider, model, keys)
|
|
||||||
├── app_config.json # Provider definitions (endpoints, env vars)
|
|
||||||
├── sessions/ # Chat sessions (one subdirectory per session)
|
|
||||||
│ └── <uuid>/
|
|
||||||
│ ├── session.json # Session metadata
|
|
||||||
│ ├── messages.jsonl # Message log
|
|
||||||
│ └── .lock # Session lock file
|
|
||||||
└── memories/ # Memory files with frontmatter metadata
|
|
||||||
└── *.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build all crates
|
|
||||||
cargo build
|
|
||||||
|
|
||||||
# Run all unit tests (8 tests across 11 crates)
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
# Run clippy linting
|
|
||||||
cargo clippy --all-targets
|
|
||||||
|
|
||||||
# Run with verbose logging
|
|
||||||
RUST_LOG=debug cargo run
|
|
||||||
```
|
|
||||||
|
|
||||||
### Workspace Crates
|
|
||||||
|
|
||||||
| Crate | Path | Layer |
|
|
||||||
|-------|------|-------|
|
|
||||||
| `zesdex-domain` | `apps/domain/` | Pure domain entities & traits |
|
|
||||||
| `zesdex-application` | `apps/application/` | Use-case services |
|
|
||||||
| `zesdex-infrastructure` | `apps/infrastructure/` | All I/O & tool implementations |
|
|
||||||
| `zesdex-tui` | `apps/interfaces/tui/` | Ratatui terminal interface |
|
|
||||||
| `zesdex-api` | `apps/interfaces/api/` | Axum REST API |
|
|
||||||
| `zesdex-daemon` | `apps/interfaces/daemon/` | Unix socket daemon |
|
|
||||||
| `zesdex-ws` | `apps/interfaces/ws/` | WebSocket server |
|
|
||||||
| `zesdex-grpc` | `apps/interfaces/grpc/` | gRPC server |
|
|
||||||
| `zesdex-web` | `apps/interfaces/web/` | Web frontend |
|
|
||||||
| `zesdex-gateway` | `apps/gateway/` | CLI entry point & dispatcher |
|
|
||||||
| `zesdex-bootstrap` | `apps/bootstrap/` | Initial data seeder |
|
|
||||||
|
|
||||||
### Code Map
|
|
||||||
|
|
||||||
Detailed architecture documentation is in `docs/CODEMAPS/`:
|
|
||||||
|
|
||||||
| File | Covers |
|
|
||||||
|------|--------|
|
|
||||||
| `docs/CODEMAPS/architecture.md` | System layout, process modes, data flow |
|
|
||||||
| `docs/CODEMAPS/backend.md` | Provider, OAuth, IPC, workflow engine, MCP, LSP, review |
|
|
||||||
| `docs/CODEMAPS/frontend.md` | TUI render pipeline, 16 overlays, toasts, input handling |
|
|
||||||
| `docs/CODEMAPS/data.md` | Persistence, SQLite msglog, memory files, settings/config |
|
|
||||||
| `docs/CODEMAPS/dependencies.md` | All Rust crates and external services |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
See `CHANGELOG.md` for release history.
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
//! Mandatory explore phase — spawns parallel subagents to discover context
|
|
||||||
//! before the main agent begins its turn.
|
|
||||||
//!
|
|
||||||
//! # Flow
|
|
||||||
//!
|
|
||||||
//! Before the main agent's LLM loop, [`ExploreService::explore`] dispatches
|
|
||||||
//! at least 3 subagents in parallel (code-structure scan, symbol-index query,
|
|
||||||
//! semantic-context search). Their findings are consolidated into a single
|
|
||||||
//! system message that is prepended to the conversation.
|
|
||||||
//!
|
|
||||||
//! # Why mandatory
|
|
||||||
//!
|
|
||||||
//! Without structured exploration the main agent works from an empty context
|
|
||||||
//! window. The explore phase guarantees that every turn starts with a compact
|
|
||||||
//! snapshot of what the codebase contains and where relevant code lives.
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::future::Future;
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use zesdex_domain::agent::TurnEvent;
|
|
||||||
|
|
||||||
/// The consolidated output of an explore phase — a set of system-level
|
|
||||||
/// context messages injected before the main agent prompt.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ExploreOutput {
|
|
||||||
/// One or more system messages summarising what the explore subagents
|
|
||||||
/// discovered. Prepended to the conversation by the turn service.
|
|
||||||
pub context_messages: Vec<String>,
|
|
||||||
/// Short human-readable summary of what was explored.
|
|
||||||
pub summary: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Service trait for the mandatory pre-turn exploration phase.
|
|
||||||
///
|
|
||||||
/// Implementors spawn ≥3 parallel subagents, each analysing a different
|
|
||||||
/// aspect of the workspace, and return a consolidated summary.
|
|
||||||
///
|
|
||||||
/// # Object safety
|
|
||||||
///
|
|
||||||
/// This trait is `dyn`-safe — it returns `Pin<Box<dyn Future>>` so it can
|
|
||||||
/// be stored as `Arc<dyn ExploreService>`.
|
|
||||||
pub trait ExploreService: Send + Sync {
|
|
||||||
/// Run the explore phase.
|
|
||||||
///
|
|
||||||
/// `query` — the user's current input phrase.
|
|
||||||
/// `workspace_root` — absolute path to the workspace root.
|
|
||||||
/// `turn_events` — shared event queue for TUI updates.
|
|
||||||
/// Returns structured context messages and a summary blob.
|
|
||||||
fn explore<'a>(
|
|
||||||
&'a self,
|
|
||||||
query: &'a str,
|
|
||||||
workspace_root: &'a str,
|
|
||||||
turn_events: &'a Arc<Mutex<VecDeque<TurnEvent>>>,
|
|
||||||
) -> Pin<Box<dyn Future<Output = Result<ExploreOutput>> + Send + 'a>>;
|
|
||||||
}
|
|
||||||
@@ -16,14 +16,9 @@ pub trait ToolExecutor: Send + Sync {
|
|||||||
/// Service for running agent turns asynchronously.
|
/// Service for running agent turns asynchronously.
|
||||||
pub trait AgentTurnService: Send + Sync {
|
pub trait AgentTurnService: Send + Sync {
|
||||||
/// Run a full agent turn loop asynchronously.
|
/// Run a full agent turn loop asynchronously.
|
||||||
fn run_turn(
|
fn run_turn(&self, params: AgentTurnParams) -> impl Future<Output = Result<()>> + Send;
|
||||||
&self,
|
|
||||||
params: AgentTurnParams,
|
|
||||||
) -> impl Future<Output = Result<()>> + Send;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod explore;
|
|
||||||
pub mod turn_service;
|
pub mod turn_service;
|
||||||
|
|
||||||
pub use explore::{ExploreOutput, ExploreService};
|
|
||||||
pub use turn_service::{compact_messages_with_ai, AgentTurnServiceImpl};
|
pub use turn_service::{compact_messages_with_ai, AgentTurnServiceImpl};
|
||||||
@@ -7,12 +7,29 @@ use zesdex_domain::agent::{AgentTurnParams, TurnEvent};
|
|||||||
use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef};
|
use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef};
|
||||||
use zesdex_domain::main_agent_prompt;
|
use zesdex_domain::main_agent_prompt;
|
||||||
|
|
||||||
|
use super::ToolExecutor;
|
||||||
use crate::ports::ProviderService;
|
use crate::ports::ProviderService;
|
||||||
use super::{ExploreService, ToolExecutor};
|
|
||||||
|
|
||||||
/// Maximum tool-call iterations per agent turn before forcing termination.
|
/// Maximum tool-call iterations per agent turn before forcing termination.
|
||||||
const MAX_TURN_ITERATIONS: u32 = 50;
|
const MAX_TURN_ITERATIONS: u32 = 50;
|
||||||
|
|
||||||
|
/// Maximum number of consecutive identical tool errors before the loop
|
||||||
|
/// injects a recovery note and forces a different approach.
|
||||||
|
const MAX_CONSECUTIVE_TOOL_ERRORS: usize = 3;
|
||||||
|
|
||||||
|
/// Total tool-call errors tolerated per turn before the loop is stopped.
|
||||||
|
const MAX_TOTAL_TOOL_ERRORS: usize = 8;
|
||||||
|
|
||||||
|
/// Ceiling for a single tool-result message inserted into context.
|
||||||
|
///
|
||||||
|
/// Tool outputs can be huge (read / semantic_search). Truncating keeps the
|
||||||
|
/// context window from exploding while preserving the important head.
|
||||||
|
const TOOL_OUTPUT_MAX_CHARS: usize = 12_000;
|
||||||
|
|
||||||
|
/// Total conversation characters that trigger auto-compaction before the
|
||||||
|
/// next LLM call.
|
||||||
|
const AUTO_COMPACT_CHARS: usize = 60_000;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helper: push a TurnEvent onto the shared queue.
|
// Helper: push a TurnEvent onto the shared queue.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -51,6 +68,89 @@ fn make_stream_callback(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: truncate a long tool output before it enters the conversation
|
||||||
|
// context. Preserves the head and appends a clear truncation marker.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn truncate_tool_output(output: String) -> String {
|
||||||
|
if output.len() <= TOOL_OUTPUT_MAX_CHARS {
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
let mut result: String = output.chars().take(TOOL_OUTPUT_MAX_CHARS).collect();
|
||||||
|
result.push_str(&format!(
|
||||||
|
"\n...[truncated {} chars]",
|
||||||
|
output.len() - TOOL_OUTPUT_MAX_CHARS
|
||||||
|
));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: adaptive generation parameters.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pick a `max_tokens` budget for the turn's next LLM call based on the
|
||||||
|
/// length of the user's request. Short requests need far fewer tokens than
|
||||||
|
/// the current hardcoded 4096 — big savings on small tasks.
|
||||||
|
fn adaptive_max_tokens(request_len: usize) -> u32 {
|
||||||
|
if request_len <= 80 {
|
||||||
|
800
|
||||||
|
} else if request_len <= 400 {
|
||||||
|
1600
|
||||||
|
} else {
|
||||||
|
4096
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sum the character length of the conversation (user + assistant +
|
||||||
|
/// tool content) as a cheap proxy for context size.
|
||||||
|
fn conversation_chars(messages: &[ChatMessage]) -> usize {
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.content.as_deref().map(str::len).unwrap_or(0))
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track repeated tool-call errors so the loop can recover instead of
|
||||||
|
/// burning iterations retrying the same failing tool.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ErrorTracker {
|
||||||
|
consecutive: usize,
|
||||||
|
total: usize,
|
||||||
|
last_tool: String,
|
||||||
|
last_error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErrorTracker {
|
||||||
|
fn record(&mut self, tool_name: &str, error: &str, messages: &mut Vec<ChatMessage>) {
|
||||||
|
if self.last_tool == tool_name {
|
||||||
|
self.consecutive += 1;
|
||||||
|
} else {
|
||||||
|
self.consecutive = 1;
|
||||||
|
}
|
||||||
|
self.last_tool = tool_name.to_string();
|
||||||
|
self.last_error = error.to_string();
|
||||||
|
self.total += 1;
|
||||||
|
|
||||||
|
// Inject a recovery note once the same tool keeps failing.
|
||||||
|
if self.consecutive >= MAX_CONSECUTIVE_TOOL_ERRORS
|
||||||
|
&& !messages.iter().any(|m| {
|
||||||
|
m.content
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|c| c.contains("[System note]"))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
messages.push(ChatMessage::system(
|
||||||
|
zesdex_domain::agent::prompt::error_recovery_note(tool_name, error),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_stop(&self) -> bool {
|
||||||
|
self.consecutive >= MAX_CONSECUTIVE_TOOL_ERRORS * 2 || self.total >= MAX_TOTAL_TOOL_ERRORS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helper: execute a single tool call, push events, return the result string.
|
// Helper: execute a single tool call, push events, return the result string.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -71,6 +171,7 @@ async fn execute_tool_call<T: ToolExecutor>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let is_error = output.starts_with("Error:");
|
let is_error = output.starts_with("Error:");
|
||||||
|
let output = truncate_tool_output(output);
|
||||||
|
|
||||||
push_event(
|
push_event(
|
||||||
turn_events,
|
turn_events,
|
||||||
@@ -108,44 +209,30 @@ fn emit_usage(turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>, usage: Option<(u64,
|
|||||||
|
|
||||||
/// Service implementation for executing an agent turn asynchronously.
|
/// Service implementation for executing an agent turn asynchronously.
|
||||||
///
|
///
|
||||||
/// # Explore phase
|
/// The turn loop is adaptive and token-aware:
|
||||||
///
|
/// - No mandatory explore phase — the *agent* decides when to call the
|
||||||
/// Before the main LLM loop begins, [`AgentTurnServiceImpl`] runs a mandatory
|
/// `explore_codebase` tool (see the main prompt), so simple queries skip
|
||||||
/// explore phase that spawns ≥3 parallel subagents (code structure, symbol
|
/// exploration entirely.
|
||||||
/// index, semantic context) and injects their consolidated findings as a
|
/// - `max_tokens` / `temperature` adapt to the request length and phase.
|
||||||
/// system message. See [`ExploreService`] for the trait contract.
|
/// - Repeated tool errors trigger a system recovery note and eventually
|
||||||
|
/// stop the loop instead of burning iterations.
|
||||||
|
/// - Tool outputs are truncated before entering context.
|
||||||
|
/// - Oversized histories are auto-compacted before the next LLM call.
|
||||||
pub struct AgentTurnServiceImpl<P: ProviderService, T: ToolExecutor> {
|
pub struct AgentTurnServiceImpl<P: ProviderService, T: ToolExecutor> {
|
||||||
provider: Arc<P>,
|
provider: Arc<P>,
|
||||||
tool_executor: Arc<T>,
|
tool_executor: Arc<T>,
|
||||||
tool_defs: Vec<ToolDef>,
|
tool_defs: Vec<ToolDef>,
|
||||||
/// Optional explore-phase service. When `Some`, the explore phase runs
|
|
||||||
/// before every turn; when `None` it is skipped (tests, daemon mode).
|
|
||||||
explore_service: Option<Arc<dyn ExploreService>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> {
|
impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> {
|
||||||
pub fn new(
|
pub fn new(provider: Arc<P>, tool_executor: Arc<T>, tool_defs: Vec<ToolDef>) -> Self {
|
||||||
provider: Arc<P>,
|
|
||||||
tool_executor: Arc<T>,
|
|
||||||
tool_defs: Vec<ToolDef>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
provider,
|
provider,
|
||||||
tool_executor,
|
tool_executor,
|
||||||
tool_defs,
|
tool_defs,
|
||||||
explore_service: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attach an optional explore-phase service.
|
|
||||||
///
|
|
||||||
/// When set, every call to `run_turn` will first run the explore phase
|
|
||||||
/// and inject the consolidated context as a system message.
|
|
||||||
pub fn with_explore(mut self, service: Arc<dyn ExploreService>) -> Self {
|
|
||||||
self.explore_service = Some(service);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute a single LLM call with the current message list, handling
|
/// Execute a single LLM call with the current message list, handling
|
||||||
/// streaming events and error reporting.
|
/// streaming events and error reporting.
|
||||||
async fn call_llm(
|
async fn call_llm(
|
||||||
@@ -153,6 +240,8 @@ impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> {
|
|||||||
messages: &[ChatMessage],
|
messages: &[ChatMessage],
|
||||||
abort: &Arc<AtomicBool>,
|
abort: &Arc<AtomicBool>,
|
||||||
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
|
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||||
|
max_tokens: u32,
|
||||||
|
temperature: f32,
|
||||||
) -> Result<(ChatMessage, Option<(u64, u64)>), String> {
|
) -> Result<(ChatMessage, Option<(u64, u64)>), String> {
|
||||||
let on_event = make_stream_callback(abort, turn_events);
|
let on_event = make_stream_callback(abort, turn_events);
|
||||||
|
|
||||||
@@ -160,13 +249,39 @@ impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> {
|
|||||||
.chat_stream(
|
.chat_stream(
|
||||||
messages,
|
messages,
|
||||||
Some(self.tool_defs.clone()),
|
Some(self.tool_defs.clone()),
|
||||||
Some(4096),
|
Some(max_tokens),
|
||||||
Some(0.7),
|
Some(temperature),
|
||||||
on_event,
|
on_event,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("LLM error: {e}"))
|
.map_err(|e| format!("LLM error: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auto-compact the history in place if it exceeds the threshold.
|
||||||
|
///
|
||||||
|
/// Runs at most once per turn. Skips the synthetic system prompt that
|
||||||
|
/// this service inserts at index 0.
|
||||||
|
async fn auto_compact_if_needed(&self, messages: &mut Vec<ChatMessage>) {
|
||||||
|
if conversation_chars(messages) <= AUTO_COMPACT_CHARS {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Keep the system prompt (index 0) out of compaction.
|
||||||
|
let sys = messages[0].clone();
|
||||||
|
let mut rest: Vec<ChatMessage> = messages.drain(1..).collect();
|
||||||
|
let before = rest.len();
|
||||||
|
if let Err(e) = super::compact_messages_with_ai(&mut rest, self.provider.as_ref()).await {
|
||||||
|
warn!("auto-compact failed (non-fatal): {e}");
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"auto-compacted history: {} messages -> {}",
|
||||||
|
before,
|
||||||
|
rest.len()
|
||||||
|
);
|
||||||
|
let mut rebuilt = Vec::with_capacity(rest.len() + 1);
|
||||||
|
rebuilt.push(sys);
|
||||||
|
rebuilt.extend(rest);
|
||||||
|
*messages = rebuilt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnServiceImpl<P, T> {
|
impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnServiceImpl<P, T> {
|
||||||
@@ -177,68 +292,26 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
|
|||||||
params.model
|
params.model
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Phase 0: Mandatory explore ──────────────────────────────────
|
|
||||||
// Spawn ≥3 parallel subagents to discover code structure, symbols,
|
|
||||||
// and semantic context. The consolidated summary is injected as a
|
|
||||||
// system message before the main agent prompt.
|
|
||||||
if let Some(ref explorer) = self.explore_service {
|
|
||||||
// Determine workspace root from the first message's context or
|
|
||||||
// the first workspace root in params.
|
|
||||||
let user_query = params
|
|
||||||
.messages
|
|
||||||
.last()
|
|
||||||
.map(|m| m.content.clone().unwrap_or_default())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let workspace_root = params
|
|
||||||
.workspace_roots
|
|
||||||
.first()
|
|
||||||
.map(|p| p.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| ".".to_string());
|
|
||||||
|
|
||||||
push_event(
|
|
||||||
¶ms.turn_events,
|
|
||||||
TurnEvent::SystemNote {
|
|
||||||
kind: "info".into(),
|
|
||||||
message: "🔍 Exploring codebase structure...".into(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
match explorer.explore(&user_query, &workspace_root, ¶ms.turn_events).await {
|
|
||||||
Ok(output) => {
|
|
||||||
// Insert each context message as a system message.
|
|
||||||
// They go at index 0 and are removed after the turn
|
|
||||||
// like the main agent prompt.
|
|
||||||
for ctx_msg in &output.context_messages {
|
|
||||||
params
|
|
||||||
.messages
|
|
||||||
.insert(0, ChatMessage::system(ctx_msg.clone()));
|
|
||||||
}
|
|
||||||
info!(
|
|
||||||
"Explore phase complete: {} context messages, {}",
|
|
||||||
output.context_messages.len(),
|
|
||||||
output.summary
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Explore phase failed (non-fatal): {e}");
|
|
||||||
push_event(
|
|
||||||
¶ms.turn_events,
|
|
||||||
TurnEvent::SystemNote {
|
|
||||||
kind: "warn".into(),
|
|
||||||
message: format!("Explore phase failed: {e}"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert system prompt at position 0 once and keep it there for the
|
// Insert system prompt at position 0 once and keep it there for the
|
||||||
// entire turn, avoiding per-iteration clones of the full message list.
|
// entire turn, avoiding per-iteration clones of the full message list.
|
||||||
// It is removed before emitting the Compacted event so persistence
|
params
|
||||||
// does not store the prompt redundantly.
|
.messages
|
||||||
params.messages.insert(0, ChatMessage::system(main_agent_prompt()));
|
.insert(0, ChatMessage::system(main_agent_prompt()));
|
||||||
let original_count = params.messages.len();
|
let original_count = params.messages.len();
|
||||||
|
|
||||||
|
// Estimate request complexity from the last user message.
|
||||||
|
let request_len = params
|
||||||
|
.messages
|
||||||
|
.last()
|
||||||
|
.and_then(|m| m.content.as_deref())
|
||||||
|
.map(str::len)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let mut errors = ErrorTracker::default();
|
||||||
|
// Track whether the previous call produced tool calls — used to
|
||||||
|
// lower temperature once the agent starts producing a final answer.
|
||||||
|
let mut saw_tool_calls = false;
|
||||||
|
|
||||||
for iteration in 0..MAX_TURN_ITERATIONS {
|
for iteration in 0..MAX_TURN_ITERATIONS {
|
||||||
// ── Check abort flag ────────────────────────────────────────
|
// ── Check abort flag ────────────────────────────────────────
|
||||||
if params.abort.load(Ordering::SeqCst) {
|
if params.abort.load(Ordering::SeqCst) {
|
||||||
@@ -253,15 +326,40 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errors.should_stop() {
|
||||||
|
push_event(
|
||||||
|
¶ms.turn_events,
|
||||||
|
TurnEvent::SystemNote {
|
||||||
|
kind: "warn".into(),
|
||||||
|
message: "Stopping: repeated tool errors without progress".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
debug!("agent turn iteration {iteration}");
|
debug!("agent turn iteration {iteration}");
|
||||||
|
|
||||||
|
// ── Auto-compact oversized history before the LLM call ─────
|
||||||
|
self.auto_compact_if_needed(&mut params.messages).await;
|
||||||
|
|
||||||
|
// ── Adaptive generation parameters ─────────────────────────
|
||||||
|
let max_tokens = adaptive_max_tokens(request_len);
|
||||||
|
// Lower temperature while the agent is still choosing tools to
|
||||||
|
// keep tool selection deterministic; raise it for the final
|
||||||
|
// free-form answer.
|
||||||
|
let temperature = if saw_tool_calls { 0.2 } else { 0.7 };
|
||||||
|
|
||||||
// ── Stream start + call LLM ─────────────────────────────────
|
// ── Stream start + call LLM ─────────────────────────────────
|
||||||
push_event(¶ms.turn_events, TurnEvent::StreamStart);
|
push_event(¶ms.turn_events, TurnEvent::StreamStart);
|
||||||
|
|
||||||
// Uses params.messages directly (sys_msg[0] already in place
|
|
||||||
// from the insert above) — no per-iteration clone needed.
|
|
||||||
let result = self
|
let result = self
|
||||||
.call_llm(¶ms.messages, ¶ms.abort, ¶ms.turn_events)
|
.call_llm(
|
||||||
|
¶ms.messages,
|
||||||
|
¶ms.abort,
|
||||||
|
¶ms.turn_events,
|
||||||
|
max_tokens,
|
||||||
|
temperature,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@@ -282,12 +380,17 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saw_tool_calls = true;
|
||||||
params.messages.push(assistant_msg);
|
params.messages.push(assistant_msg);
|
||||||
|
|
||||||
// ── Execute each tool call ──────────────────────────
|
// ── Execute each tool call ──────────────────────────
|
||||||
for tc in &tool_calls {
|
for tc in &tool_calls {
|
||||||
let output =
|
let output =
|
||||||
execute_tool_call(self.tool_executor.as_ref(), ¶ms.turn_events, tc).await;
|
execute_tool_call(self.tool_executor.as_ref(), ¶ms.turn_events, tc)
|
||||||
|
.await;
|
||||||
|
if output.starts_with("Error:") {
|
||||||
|
errors.record(&tc.function.name, &output, &mut params.messages);
|
||||||
|
}
|
||||||
params
|
params
|
||||||
.messages
|
.messages
|
||||||
.push(ChatMessage::tool(tc.id.clone(), output));
|
.push(ChatMessage::tool(tc.id.clone(), output));
|
||||||
@@ -295,10 +398,7 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("{e}");
|
warn!("{e}");
|
||||||
push_event(
|
push_event(¶ms.turn_events, TurnEvent::Error(e));
|
||||||
¶ms.turn_events,
|
|
||||||
TurnEvent::Error(e),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,10 +407,7 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
|
|||||||
// Remove the synthetic sys_msg before shipping events to the TUI
|
// Remove the synthetic sys_msg before shipping events to the TUI
|
||||||
// so the transcript shows only the actual user/assistant/tool exchange.
|
// so the transcript shows only the actual user/assistant/tool exchange.
|
||||||
let compacted: Vec<ChatMessage> = params.messages.drain(original_count - 1..).collect();
|
let compacted: Vec<ChatMessage> = params.messages.drain(original_count - 1..).collect();
|
||||||
push_event(
|
push_event(¶ms.turn_events, TurnEvent::Compacted(compacted));
|
||||||
¶ms.turn_events,
|
|
||||||
TurnEvent::Compacted(compacted),
|
|
||||||
);
|
|
||||||
push_event(¶ms.turn_events, TurnEvent::Done);
|
push_event(¶ms.turn_events, TurnEvent::Done);
|
||||||
params.in_flight.store(false, Ordering::SeqCst);
|
params.in_flight.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
@@ -341,15 +438,16 @@ pub async fn compact_messages_with_ai<P: ProviderService>(
|
|||||||
let split_idx = messages.len() - COMPACT_KEEP_TAIL;
|
let split_idx = messages.len() - COMPACT_KEEP_TAIL;
|
||||||
let evicted: Vec<_> = messages.drain(..split_idx).collect();
|
let evicted: Vec<_> = messages.drain(..split_idx).collect();
|
||||||
|
|
||||||
let mut summary_prompt = vec![
|
let mut summary_prompt = vec![ChatMessage::system(zesdex_domain::compaction_prompt())];
|
||||||
ChatMessage::system(zesdex_domain::compaction_prompt()),
|
|
||||||
];
|
|
||||||
summary_prompt.extend(evicted);
|
summary_prompt.extend(evicted);
|
||||||
summary_prompt.push(ChatMessage::user(
|
summary_prompt.push(ChatMessage::user(
|
||||||
"Please summarise our previous conversation above for context continuity.".to_string(),
|
"Please summarise our previous conversation above for context continuity.".to_string(),
|
||||||
));
|
));
|
||||||
|
|
||||||
match provider.chat(&summary_prompt, None, Some(1024), Some(0.3)).await {
|
match provider
|
||||||
|
.chat(&summary_prompt, None, Some(1024), Some(0.3))
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok((summary_msg, _)) => {
|
Ok((summary_msg, _)) => {
|
||||||
let summary_text = summary_msg
|
let summary_text = summary_msg
|
||||||
.content
|
.content
|
||||||
@@ -374,4 +472,68 @@ pub async fn compact_messages_with_ai<P: ProviderService>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_short_output_is_unchanged() {
|
||||||
|
let out = "short".to_string();
|
||||||
|
assert_eq!(truncate_tool_output(out.clone()), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_long_output_preserves_head_and_marks_cut() {
|
||||||
|
let long = "x".repeat(TOOL_OUTPUT_MAX_CHARS + 500);
|
||||||
|
let truncated = truncate_tool_output(long.clone());
|
||||||
|
assert!(truncated.len() < long.len());
|
||||||
|
assert!(truncated.contains("...[truncated"));
|
||||||
|
assert!(truncated.starts_with("xxx"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adaptive_max_tokens_scales_with_request_len() {
|
||||||
|
assert_eq!(adaptive_max_tokens(10), 800);
|
||||||
|
assert_eq!(adaptive_max_tokens(200), 1600);
|
||||||
|
assert_eq!(adaptive_max_tokens(5000), 4096);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_tracker_injects_recovery_note_after_repeats() {
|
||||||
|
let mut tracker = ErrorTracker::default();
|
||||||
|
let mut messages: Vec<ChatMessage> = Vec::new();
|
||||||
|
tracker.record("read", "Error: File not found", &mut messages);
|
||||||
|
tracker.record("read", "Error: File not found", &mut messages);
|
||||||
|
assert!(!tracker.should_stop());
|
||||||
|
// Third consecutive failure → recovery note injected.
|
||||||
|
tracker.record("read", "Error: File not found", &mut messages);
|
||||||
|
assert!(messages.iter().any(|m| m
|
||||||
|
.content
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|c| c.contains("[System note]"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_tracker_stops_after_too_many_errors() {
|
||||||
|
let mut tracker = ErrorTracker::default();
|
||||||
|
let mut messages: Vec<ChatMessage> = Vec::new();
|
||||||
|
for i in 0..MAX_TOTAL_TOOL_ERRORS {
|
||||||
|
tracker.record("bash", &format!("Error: boom {i}"), &mut messages);
|
||||||
|
}
|
||||||
|
assert!(tracker.should_stop());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conversation_chars_sums_content_only() {
|
||||||
|
let messages = vec![
|
||||||
|
ChatMessage::system("sys".to_string()),
|
||||||
|
ChatMessage::user("hello world".to_string()),
|
||||||
|
ChatMessage::tool("id".to_string(), "output".to_string()),
|
||||||
|
];
|
||||||
|
assert_eq!(conversation_chars(&messages), 3 + 11 + 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,11 +45,7 @@ use sha2::{Digest, Sha256};
|
|||||||
/// and clear them after a successful (or failed) flow completion.
|
/// and clear them after a successful (or failed) flow completion.
|
||||||
pub trait OAuthFlowStore: Send + Sync {
|
pub trait OAuthFlowStore: Send + Sync {
|
||||||
/// Persist the PKCE code verifier and CSRF state token.
|
/// Persist the PKCE code verifier and CSRF state token.
|
||||||
fn save_flow_state(
|
fn save_flow_state(&self, verifier: &str, state: &str) -> Result<(), ServiceError>;
|
||||||
&self,
|
|
||||||
verifier: &str,
|
|
||||||
state: &str,
|
|
||||||
) -> Result<(), ServiceError>;
|
|
||||||
|
|
||||||
/// Load the stored PKCE code verifier.
|
/// Load the stored PKCE code verifier.
|
||||||
fn load_verifier(&self) -> Result<String, ServiceError>;
|
fn load_verifier(&self) -> Result<String, ServiceError>;
|
||||||
@@ -141,12 +137,7 @@ pub struct OAuthUseCase<R, S, E> {
|
|||||||
|
|
||||||
impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger> OAuthUseCase<R, S, E> {
|
impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger> OAuthUseCase<R, S, E> {
|
||||||
/// Create a new OAuth use-case.
|
/// Create a new OAuth use-case.
|
||||||
pub fn new(
|
pub fn new(token_repo: R, flow_store: S, token_exchanger: E, token_path: PathBuf) -> Self {
|
||||||
token_repo: R,
|
|
||||||
flow_store: S,
|
|
||||||
token_exchanger: E,
|
|
||||||
token_path: PathBuf,
|
|
||||||
) -> Self {
|
|
||||||
OAuthUseCase {
|
OAuthUseCase {
|
||||||
token_repo,
|
token_repo,
|
||||||
flow_store,
|
flow_store,
|
||||||
@@ -156,8 +147,8 @@ impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger> OAuthUseCase<R, S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger>
|
impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger> zesdex_domain::auth::OAuthService
|
||||||
zesdex_domain::auth::OAuthService for OAuthUseCase<R, S, E>
|
for OAuthUseCase<R, S, E>
|
||||||
{
|
{
|
||||||
fn start_flow(
|
fn start_flow(
|
||||||
&self,
|
&self,
|
||||||
@@ -183,12 +174,8 @@ impl<R: OAuthRepository, S: OAuthFlowStore, E: TokenExchanger>
|
|||||||
"starting OAuth flow",
|
"starting OAuth flow",
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut url = url::Url::parse(&config.auth_url)
|
let mut url = url::Url::parse(&config.auth_url).map_err(|e| {
|
||||||
.map_err(|e| {
|
ServiceError::InvalidConfig(format!("invalid auth_url '{}': {e}", config.auth_url))
|
||||||
ServiceError::InvalidConfig(format!(
|
|
||||||
"invalid auth_url '{}': {e}",
|
|
||||||
config.auth_url
|
|
||||||
))
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
url.query_pairs_mut()
|
url.query_pairs_mut()
|
||||||
|
|||||||
@@ -46,12 +46,11 @@ impl<R: SessionRepository, L: SessionLockRepository> SessionServiceImpl<R, L> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R: SessionRepository, L: SessionLockRepository>
|
impl<R: SessionRepository, L: SessionLockRepository> zesdex_domain::auth::SessionService
|
||||||
zesdex_domain::auth::SessionService for SessionServiceImpl<R, L>
|
for SessionServiceImpl<R, L>
|
||||||
{
|
{
|
||||||
fn create_session(&self, title: &str) -> Result<Session, ServiceError> {
|
fn create_session(&self, title: &str) -> Result<Session, ServiceError> {
|
||||||
let id = SessionId::new(&Uuid::new_v4().to_string())
|
let id = SessionId::new(&Uuid::new_v4().to_string()).map_err(ServiceError::Other)?;
|
||||||
.map_err(ServiceError::Other)?;
|
|
||||||
let title_owned = if title.is_empty() {
|
let title_owned = if title.is_empty() {
|
||||||
"New Session".to_string()
|
"New Session".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -59,8 +58,7 @@ impl<R: SessionRepository, L: SessionLockRepository>
|
|||||||
};
|
};
|
||||||
let session = Session::new(id.into_string(), title_owned);
|
let session = Session::new(id.into_string(), title_owned);
|
||||||
tracing::debug!(session_id = %session.id, title = %session.title, "creating new session");
|
tracing::debug!(session_id = %session.id, title = %session.title, "creating new session");
|
||||||
self.session_repo
|
self.session_repo.save_session(&self.base_dir, &session)?;
|
||||||
.save_session(&self.base_dir, &session)?;
|
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,17 +71,14 @@ impl<R: SessionRepository, L: SessionLockRepository>
|
|||||||
|
|
||||||
fn archive_session(&self, id: SessionId) -> Result<(), ServiceError> {
|
fn archive_session(&self, id: SessionId) -> Result<(), ServiceError> {
|
||||||
tracing::debug!(session_id = %id, "archiving session");
|
tracing::debug!(session_id = %id, "archiving session");
|
||||||
let mut session = self
|
let mut session = self.session_repo.load_session(&self.base_dir, &id)?;
|
||||||
.session_repo
|
|
||||||
.load_session(&self.base_dir, &id)?;
|
|
||||||
session.archived = true;
|
session.archived = true;
|
||||||
let millis = std::time::SystemTime::now()
|
let millis = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_millis();
|
.as_millis();
|
||||||
session.updated_at = i64::try_from(millis).unwrap_or(i64::MAX);
|
session.updated_at = i64::try_from(millis).unwrap_or(i64::MAX);
|
||||||
self.session_repo
|
self.session_repo.save_session(&self.base_dir, &session)?;
|
||||||
.save_session(&self.base_dir, &session)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,11 +58,7 @@ impl<R: ConversationRepository> zesdex_domain::cms::ConversationService
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_message(
|
fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<(), ServiceError> {
|
||||||
&self,
|
|
||||||
conv: &mut Conversation,
|
|
||||||
msg: ChatMessage,
|
|
||||||
) -> Result<(), ServiceError> {
|
|
||||||
tracing::debug!("adding message to session {}", conv.session_id);
|
tracing::debug!("adding message to session {}", conv.session_id);
|
||||||
conv.push(msg);
|
conv.push(msg);
|
||||||
let dir = self.session_dir(&conv.session_id);
|
let dir = self.session_dir(&conv.session_id);
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ use std::path::PathBuf;
|
|||||||
use tracing;
|
use tracing;
|
||||||
|
|
||||||
use zesdex_domain::cms::{
|
use zesdex_domain::cms::{
|
||||||
AppConfig, AppConfigRepository, ProviderConfig, ServiceError, Settings,
|
AppConfig, AppConfigRepository, ProviderConfig, ServiceError, Settings, SettingsRepository,
|
||||||
SettingsRepository,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Service implementation for settings and app-config operations.
|
/// Service implementation for settings and app-config operations.
|
||||||
@@ -30,11 +29,7 @@ pub struct SettingsServiceImpl<S, C> {
|
|||||||
|
|
||||||
impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
|
impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
|
||||||
/// Create a new service with the given repositories and base directory.
|
/// Create a new service with the given repositories and base directory.
|
||||||
pub fn new(
|
pub fn new(settings_repo: S, app_config_repo: C, base_dir: impl Into<PathBuf>) -> Self {
|
||||||
settings_repo: S,
|
|
||||||
app_config_repo: C,
|
|
||||||
base_dir: impl Into<PathBuf>,
|
|
||||||
) -> Self {
|
|
||||||
tracing::debug!("creating SettingsServiceImpl");
|
tracing::debug!("creating SettingsServiceImpl");
|
||||||
Self {
|
Self {
|
||||||
settings_repo,
|
settings_repo,
|
||||||
@@ -44,8 +39,8 @@ impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S: SettingsRepository, C: AppConfigRepository>
|
impl<S: SettingsRepository, C: AppConfigRepository> zesdex_domain::cms::SettingsService
|
||||||
zesdex_domain::cms::SettingsService for SettingsServiceImpl<S, C>
|
for SettingsServiceImpl<S, C>
|
||||||
{
|
{
|
||||||
fn load_settings(&self) -> Result<Settings, ServiceError> {
|
fn load_settings(&self) -> Result<Settings, ServiceError> {
|
||||||
tracing::debug!("loading settings");
|
tracing::debug!("loading settings");
|
||||||
@@ -60,11 +55,7 @@ impl<S: SettingsRepository, C: AppConfigRepository>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_provider(
|
fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<(), ServiceError> {
|
||||||
&self,
|
|
||||||
name: &str,
|
|
||||||
config: &ProviderConfig,
|
|
||||||
) -> Result<(), ServiceError> {
|
|
||||||
tracing::debug!("updating provider '{name}'");
|
tracing::debug!("updating provider '{name}'");
|
||||||
let mut app_config: AppConfig = self.app_config_repo.load(&self.base_dir)?;
|
let mut app_config: AppConfig = self.app_config_repo.load(&self.base_dir)?;
|
||||||
app_config
|
app_config
|
||||||
|
|||||||
@@ -30,10 +30,10 @@
|
|||||||
//! the use-case logic independent of any specific persistence or infrastructure
|
//! the use-case logic independent of any specific persistence or infrastructure
|
||||||
//! technology.
|
//! technology.
|
||||||
|
|
||||||
|
pub mod agent;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cms;
|
pub mod cms;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod agent;
|
|
||||||
|
|
||||||
// Re-export port traits for ergonomic access.
|
// Re-export port traits for ergonomic access.
|
||||||
pub use ports::*;
|
pub use ports::*;
|
||||||
@@ -46,12 +46,11 @@ pub use auth::{
|
|||||||
|
|
||||||
// Re-export CMS use-cases.
|
// Re-export CMS use-cases.
|
||||||
pub use cms::{
|
pub use cms::{
|
||||||
conversation_service::ConversationServiceImpl,
|
conversation_service::ConversationServiceImpl, memory_service::MemoryServiceImpl,
|
||||||
memory_service::MemoryServiceImpl,
|
|
||||||
settings_service::SettingsServiceImpl,
|
settings_service::SettingsServiceImpl,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use agent::{
|
pub use agent::{
|
||||||
AgentTurnService, ExploreOutput, ExploreService, ToolExecutor,
|
turn_service::{compact_messages_with_ai, AgentTurnServiceImpl},
|
||||||
turn_service::{AgentTurnServiceImpl, compact_messages_with_ai},
|
AgentTurnService, ToolExecutor,
|
||||||
};
|
};
|
||||||
@@ -22,11 +22,8 @@ pub trait AuthService: Send + Sync {
|
|||||||
/// Authenticate a user by verifying a password against a stored hash.
|
/// Authenticate a user by verifying a password against a stored hash.
|
||||||
///
|
///
|
||||||
/// Returns `true` if the password matches, `false` otherwise.
|
/// Returns `true` if the password matches, `false` otherwise.
|
||||||
fn authenticate(
|
fn authenticate(&self, password: &str, hash: &str)
|
||||||
&self,
|
-> impl Future<Output = Result<bool>> + Send;
|
||||||
password: &str,
|
|
||||||
hash: &str,
|
|
||||||
) -> impl Future<Output = Result<bool>> + Send;
|
|
||||||
|
|
||||||
/// Issue a new access + refresh token pair for the given subject.
|
/// Issue a new access + refresh token pair for the given subject.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ use std::path::PathBuf;
|
|||||||
use crate::core::{ChatMessage, ToolCallResult, UsageStats};
|
use crate::core::{ChatMessage, ToolCallResult, UsageStats};
|
||||||
|
|
||||||
pub mod defaults;
|
pub mod defaults;
|
||||||
pub mod prompt;
|
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
|
pub mod prompt;
|
||||||
|
|
||||||
/// Which kind of caller (main agent vs. subagent vs. reviewer) is
|
/// Which kind of caller (main agent vs. subagent vs. reviewer) is
|
||||||
/// invoking a tool, used to scope permissions and tag log/output paths.
|
/// invoking a tool, used to scope permissions and tag log/output paths.
|
||||||
|
|||||||
@@ -22,12 +22,20 @@ pub fn main_agent_prompt() -> String {
|
|||||||
You are Zesdex, an AI coding assistant. You have access to various tools \
|
You are Zesdex, an AI coding assistant. You have access to various tools \
|
||||||
via native function calling to help the user.
|
via native function calling to help the user.
|
||||||
|
|
||||||
|
TOKEN BUDGET — BE EFFICIENT:
|
||||||
|
- For simple/factual questions, answer directly. Do NOT call tools.
|
||||||
|
- For complex or unfamiliar code tasks, call `explore_codebase` ONCE at the \
|
||||||
|
start to locate relevant code, then work from that context.
|
||||||
|
- Keep tool usage minimal: prefer `grep`/`glob`/`read` for targeted lookups; \
|
||||||
|
avoid re-reading files you already have in context.
|
||||||
|
- Keep responses concise; do not repeat tool output verbatim.
|
||||||
|
|
||||||
CRITICAL DIRECTIVES & PRIORITY HIERARCHY:
|
CRITICAL DIRECTIVES & PRIORITY HIERARCHY:
|
||||||
1. WORKFLOW FIRST: For any multi-step, complex, or non-trivial task, \
|
1. WORKFLOW FIRST: For any multi-step, complex, or non-trivial task, \
|
||||||
you MUST prioritise using `workflow_run` (to construct and execute a \
|
you MUST prioritise using `workflow_run` (to construct and execute a \
|
||||||
multi-phase YAML workflow) or `hive_mind` (to orchestrate parallel \
|
multi-phase YAML workflow) or `hive_mind` (to orchestrate parallel \
|
||||||
autonomous agents). Workflows are your primary strategy.
|
autonomous agents). Workflows are your primary strategy.
|
||||||
2. PLANNING & TODOS: Use `plan_enter` to establish high-level \
|
2. PLANNING & TODOs: Use `plan_enter` to establish high-level \
|
||||||
architectural plans and `todowrite` to maintain granular task checklists.
|
architectural plans and `todowrite` to maintain granular task checklists.
|
||||||
3. REASONING: Use `seq_think` for deep step-by-step analysis.
|
3. REASONING: Use `seq_think` for deep step-by-step analysis.
|
||||||
4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) \
|
4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) \
|
||||||
@@ -70,6 +78,35 @@ executed, and modified files. Format as a clear bulleted list."
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Adaptive explore: directives
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Directive for a single lightweight context-scout subagent.
|
||||||
|
pub fn explore_scout_directive() -> String {
|
||||||
|
"\
|
||||||
|
You are a codebase context scout. \
|
||||||
|
Given the workspace root, quickly locate the code that is most relevant \
|
||||||
|
to the user's request: \
|
||||||
|
1. Run semantic_search once with the user's key terms. \
|
||||||
|
2. Read up to the 3 most relevant files (use grep for symbols if needed). \
|
||||||
|
3. Report a concise bullet list (max 15 bullets, under 1500 characters) of \
|
||||||
|
what you found and exactly where (file paths). \
|
||||||
|
Do NOT rebuild the index. Do NOT enumerate unrelated files. Be brief."
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a system note injected after repeated tool errors to steer the
|
||||||
|
/// agent toward an alternative approach instead of retrying the same call.
|
||||||
|
pub fn error_recovery_note(tool_name: &str, last_error: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"\
|
||||||
|
[System note] The tool `{tool_name}` failed repeatedly with: \"{last_error}\". \
|
||||||
|
Try an alternative approach (verify paths, correct arguments, use a \
|
||||||
|
different tool, or finish without this tool). Do NOT retry the same call."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -89,4 +126,18 @@ mod tests {
|
|||||||
assert!(prompt.contains("/home"));
|
assert!(prompt.contains("/home"));
|
||||||
assert!(prompt.contains("/home/project"));
|
assert!(prompt.contains("/home/project"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explore_scout_directive_is_concise_and_mentions_tools() {
|
||||||
|
let scout = explore_scout_directive();
|
||||||
|
assert!(scout.contains("scout"));
|
||||||
|
assert!(scout.contains("semantic_search"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_recovery_note_suggests_alternative() {
|
||||||
|
let note = error_recovery_note("read", "File not found");
|
||||||
|
assert!(note.contains("read"));
|
||||||
|
assert!(note.contains("alternative"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -46,10 +46,6 @@ pub struct SettingsPatch {
|
|||||||
pub review_enabled: Option<bool>,
|
pub review_enabled: Option<bool>,
|
||||||
/// Override the session-archive-enabled flag.
|
/// Override the session-archive-enabled flag.
|
||||||
pub session_archive_enabled: Option<bool>,
|
pub session_archive_enabled: Option<bool>,
|
||||||
/// Override the LSP auto-provision flag.
|
|
||||||
pub lsp_auto_provision: Option<bool>,
|
|
||||||
/// Override the list of LSP-managed languages.
|
|
||||||
pub lsp_languages: Option<Vec<String>>,
|
|
||||||
/// Override the hive-mind node timeout in milliseconds.
|
/// Override the hive-mind node timeout in milliseconds.
|
||||||
pub hive_mind_node_timeout_ms: Option<u64>,
|
pub hive_mind_node_timeout_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
@@ -111,12 +107,6 @@ impl SettingsPatch {
|
|||||||
if let Some(val) = self.session_archive_enabled {
|
if let Some(val) = self.session_archive_enabled {
|
||||||
settings.flags.session_archive_enabled = val;
|
settings.flags.session_archive_enabled = val;
|
||||||
}
|
}
|
||||||
if let Some(val) = self.lsp_auto_provision {
|
|
||||||
settings.flags.lsp_auto_provision = val;
|
|
||||||
}
|
|
||||||
if let Some(ref val) = self.lsp_languages {
|
|
||||||
settings.lsp_languages = val.clone();
|
|
||||||
}
|
|
||||||
if let Some(val) = self.hive_mind_node_timeout_ms {
|
if let Some(val) = self.hive_mind_node_timeout_ms {
|
||||||
settings.hive_mind_node_timeout_ms = val;
|
settings.hive_mind_node_timeout_ms = val;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,5 @@
|
|||||||
//! - `ChatMessage` — a single message with role, content, and tool metadata
|
//! - `ChatMessage` — a single message with role, content, and tool metadata
|
||||||
//! - `Role` — message role enum (User, Assistant, System, Tool)
|
//! - `Role` — message role enum (User, Assistant, System, Tool)
|
||||||
|
|
||||||
pub use crate::core::message::{ChatMessage, Role};
|
|
||||||
pub use crate::core::conversation::Conversation;
|
pub use crate::core::conversation::Conversation;
|
||||||
|
pub use crate::core::message::{ChatMessage, Role};
|
||||||
@@ -32,6 +32,7 @@ pub mod settings;
|
|||||||
pub use app_config::AppConfig;
|
pub use app_config::AppConfig;
|
||||||
pub use app_config::ModelRole;
|
pub use app_config::ModelRole;
|
||||||
pub use app_config::ProviderConfig;
|
pub use app_config::ProviderConfig;
|
||||||
|
pub use commands::{NewMemory, SettingsPatch};
|
||||||
pub use conversation::Conversation;
|
pub use conversation::Conversation;
|
||||||
pub use edit_log::EditLog;
|
pub use edit_log::EditLog;
|
||||||
pub use edit_log::EditLogEntry;
|
pub use edit_log::EditLogEntry;
|
||||||
@@ -46,7 +47,6 @@ pub use repository::SettingsRepository;
|
|||||||
pub use service::ConversationService;
|
pub use service::ConversationService;
|
||||||
pub use service::MemoryService;
|
pub use service::MemoryService;
|
||||||
pub use service::SettingsService;
|
pub use service::SettingsService;
|
||||||
pub use commands::{NewMemory, SettingsPatch};
|
|
||||||
pub use settings::InternetMode;
|
pub use settings::InternetMode;
|
||||||
pub use settings::Settings;
|
pub use settings::Settings;
|
||||||
pub use settings::SettingsFlags;
|
pub use settings::SettingsFlags;
|
||||||
@@ -56,11 +56,7 @@ pub trait ConversationRepository {
|
|||||||
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError>;
|
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError>;
|
||||||
|
|
||||||
/// Persist a `Conversation` to the given session directory.
|
/// Persist a `Conversation` to the given session directory.
|
||||||
fn save(
|
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<(), RepositoryError>;
|
||||||
&self,
|
|
||||||
session_dir: &Path,
|
|
||||||
conversation: &Conversation,
|
|
||||||
) -> Result<(), RepositoryError>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persistence contract for `Memory` (long-term agent memory entries).
|
/// Persistence contract for `Memory` (long-term agent memory entries).
|
||||||
|
|||||||
@@ -44,11 +44,7 @@ pub trait ConversationService {
|
|||||||
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError>;
|
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError>;
|
||||||
|
|
||||||
/// Append a single `ChatMessage` to the conversation and persist.
|
/// Append a single `ChatMessage` to the conversation and persist.
|
||||||
fn add_message(
|
fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<(), ServiceError>;
|
||||||
&self,
|
|
||||||
conv: &mut Conversation,
|
|
||||||
msg: ChatMessage,
|
|
||||||
) -> Result<(), ServiceError>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Use-cases for long-term memory management.
|
/// Use-cases for long-term memory management.
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
//! - `workflow_max_concurrency` — max parallel hive-mind nodes
|
//! - `workflow_max_concurrency` — max parallel hive-mind nodes
|
||||||
//! - `hive_mind_node_timeout_ms` — per-node timeout for hive-mind orchestration
|
//! - `hive_mind_node_timeout_ms` — per-node timeout for hive-mind orchestration
|
||||||
//! - `flags` — grouped boolean feature toggles
|
//! - `flags` — grouped boolean feature toggles
|
||||||
//! - `lsp_languages` — list of language IDs for LSP auto-provisioning
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -49,12 +48,10 @@ pub enum InternetMode {
|
|||||||
/// ## Fields
|
/// ## Fields
|
||||||
/// - `review_enabled` — enable automatic inline review after edits
|
/// - `review_enabled` — enable automatic inline review after edits
|
||||||
/// - `session_archive_enabled` — enable periodic session archiving
|
/// - `session_archive_enabled` — enable periodic session archiving
|
||||||
/// - `lsp_auto_provision` — auto-provision LSP language servers on project open
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SettingsFlags {
|
pub struct SettingsFlags {
|
||||||
pub review_enabled: bool,
|
pub review_enabled: bool,
|
||||||
pub session_archive_enabled: bool,
|
pub session_archive_enabled: bool,
|
||||||
pub lsp_auto_provision: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SettingsFlags {
|
impl Default for SettingsFlags {
|
||||||
@@ -63,7 +60,6 @@ impl Default for SettingsFlags {
|
|||||||
Self {
|
Self {
|
||||||
review_enabled: true,
|
review_enabled: true,
|
||||||
session_archive_enabled: true,
|
session_archive_enabled: true,
|
||||||
lsp_auto_provision: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +89,6 @@ pub struct Settings {
|
|||||||
pub workflow_max_concurrency: usize,
|
pub workflow_max_concurrency: usize,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub flags: SettingsFlags,
|
pub flags: SettingsFlags,
|
||||||
pub lsp_languages: Vec<String>,
|
|
||||||
#[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,
|
||||||
}
|
}
|
||||||
@@ -113,7 +108,6 @@ impl Default for Settings {
|
|||||||
verify_timeout_ms: 30_000,
|
verify_timeout_ms: 30_000,
|
||||||
workflow_max_concurrency: 5,
|
workflow_max_concurrency: 5,
|
||||||
flags: SettingsFlags::default(),
|
flags: SettingsFlags::default(),
|
||||||
lsp_languages: Vec::new(),
|
|
||||||
hive_mind_node_timeout_ms: 600_000,
|
hive_mind_node_timeout_ms: 600_000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,16 +335,16 @@ impl SseParser {
|
|||||||
{
|
{
|
||||||
const MAX_TOOL_CALLS: usize = 64;
|
const MAX_TOOL_CALLS: usize = 64;
|
||||||
for tc in tool_calls {
|
for tc in tool_calls {
|
||||||
let raw_index =
|
let raw_index = tc
|
||||||
tc.get("index").and_then(Value::as_u64).unwrap_or_else(
|
.get("index")
|
||||||
|| {
|
.and_then(Value::as_u64)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"[stream] tool call delta missing index, \
|
"[stream] tool call delta missing index, \
|
||||||
defaulting to 0"
|
defaulting to 0"
|
||||||
);
|
);
|
||||||
0
|
0
|
||||||
},
|
});
|
||||||
);
|
|
||||||
// Clamp index to prevent out-of-bounds / memory exhaustion
|
// Clamp index to prevent out-of-bounds / memory exhaustion
|
||||||
let index = usize::try_from(raw_index)
|
let index = usize::try_from(raw_index)
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
@@ -395,8 +395,7 @@ impl SseParser {
|
|||||||
if let Some(content) = delta.get("text").and_then(|c| c.as_str()) {
|
if let Some(content) = delta.get("text").and_then(|c| c.as_str()) {
|
||||||
d_events.push(StreamEvent::Token(content.to_string()));
|
d_events.push(StreamEvent::Token(content.to_string()));
|
||||||
}
|
}
|
||||||
if let Some(reasoning) =
|
if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str())
|
||||||
delta.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()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,11 @@ impl Store {
|
|||||||
///
|
///
|
||||||
/// Why: paths are computed, not created — call `ensure_dirs` before use.
|
/// Why: paths are computed, not created — call `ensure_dirs` before use.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let base = if let Some(data_dir) = std::env::var("XDG_DATA_HOME").ok()
|
let base = if let Some(data_dir) = std::env::var("XDG_DATA_HOME").ok().or_else(|| {
|
||||||
.or_else(|| std::env::var("HOME").ok().map(|h| format!("{h}/.local/share")))
|
std::env::var("HOME")
|
||||||
{
|
.ok()
|
||||||
|
.map(|h| format!("{h}/.local/share"))
|
||||||
|
}) {
|
||||||
PathBuf::from(data_dir).join("zesdex")
|
PathBuf::from(data_dir).join("zesdex")
|
||||||
} else {
|
} else {
|
||||||
PathBuf::from(".local/share/zesdex")
|
PathBuf::from(".local/share/zesdex")
|
||||||
|
|||||||
+14
-15
@@ -24,33 +24,32 @@
|
|||||||
//! no framework imports, no side effects. All persistence is expressed
|
//! no framework imports, no side effects. All persistence is expressed
|
||||||
//! through repository traits that infrastructure adapters implement.
|
//! through repository traits that infrastructure adapters implement.
|
||||||
|
|
||||||
|
pub mod agent;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cms;
|
pub mod cms;
|
||||||
pub mod core;
|
pub mod core;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod agent;
|
|
||||||
pub mod workflow;
|
|
||||||
pub mod subagent;
|
pub mod subagent;
|
||||||
|
pub mod workflow;
|
||||||
|
|
||||||
// Re-export all public items from each module for ergonomic imports.
|
// Re-export all public items from each module for ergonomic imports.
|
||||||
// Consumers can do `use zesdex_domain::*` for common types.
|
// Consumers can do `use zesdex_domain::*` for common types.
|
||||||
pub use auth::{
|
pub use auth::{
|
||||||
IamSession, NewSession, OAuthConfig, OAuthToken, OAuthRepository, OAuthService,
|
IamSession, NewSession, OAuthConfig, OAuthRepository, OAuthService, OAuthToken,
|
||||||
RepositoryError as AuthRepositoryError, ServiceError as AuthServiceError, Session,
|
RepositoryError as AuthRepositoryError, ServiceError as AuthServiceError, Session, SessionId,
|
||||||
SessionId, SessionLock, SessionLockRepository, SessionRepository, SessionService,
|
SessionLock, SessionLockRepository, SessionRepository, SessionService,
|
||||||
};
|
};
|
||||||
pub use cms::{
|
pub use cms::{
|
||||||
AppConfig, AppConfigRepository, Conversation as CmsConversation,
|
AppConfig, AppConfigRepository, Conversation as CmsConversation, ConversationRepository,
|
||||||
ConversationRepository, ConversationService, EditLog, EditLogEntry,
|
ConversationService, EditLog, EditLogEntry, EditLogRepository, InternetMode, Memory,
|
||||||
EditLogRepository, InternetMode, Memory, MemoryRepository, MemoryService,
|
MemoryRepository, MemoryService, ModelRole, NewMemory, ProviderConfig,
|
||||||
ModelRole, NewMemory, ProviderConfig, RepositoryError as CmsRepositoryError,
|
RepositoryError as CmsRepositoryError, ServiceError as CmsServiceError, Settings,
|
||||||
ServiceError as CmsServiceError, Settings, SettingsFlags, SettingsPatch,
|
SettingsFlags, SettingsPatch, SettingsRepository, SettingsService,
|
||||||
SettingsRepository, SettingsService,
|
|
||||||
};
|
};
|
||||||
pub use core::{
|
pub use core::{
|
||||||
ChatMessage, ChatRequest, ChatResponse, Choice, Conversation, Delta, Role,
|
ChatMessage, ChatRequest, ChatResponse, Choice, Conversation, Delta, Role, SseParser, Store,
|
||||||
SseParser, StreamEvent, StreamOptions, Store, TokenUsage, ToolCall,
|
StreamEvent, StreamOptions, TokenUsage, ToolCall, ToolCallResult, ToolDef, ToolFunction,
|
||||||
ToolCallResult, ToolDef, ToolFunction, ToolFunctionDef, UsageStats,
|
ToolFunctionDef, UsageStats,
|
||||||
};
|
};
|
||||||
pub use error::DomainError;
|
pub use error::DomainError;
|
||||||
|
|
||||||
@@ -60,5 +59,5 @@ pub use agent::*;
|
|||||||
pub use agent::defaults::*;
|
pub use agent::defaults::*;
|
||||||
pub use agent::progress::AgentProgress;
|
pub use agent::progress::AgentProgress;
|
||||||
pub use agent::prompt::{compaction_prompt, main_agent_prompt, subagent_directive};
|
pub use agent::prompt::{compaction_prompt, main_agent_prompt, subagent_directive};
|
||||||
pub use workflow::*;
|
|
||||||
pub use subagent::*;
|
pub use subagent::*;
|
||||||
|
pub use workflow::*;
|
||||||
@@ -10,6 +10,6 @@ pub enum AccessTier {
|
|||||||
Read,
|
Read,
|
||||||
/// Read + Write: above plus write, edit, delete, git, memory.
|
/// Read + Write: above plus write, edit, delete, git, memory.
|
||||||
Write,
|
Write,
|
||||||
/// Full: above plus bash, shell, LSP, workflow, plan tools.
|
/// Full: above plus bash, shell, workflow, plan tools.
|
||||||
Full,
|
Full,
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
//! configuration files plus a seed session for development/testing.
|
//! configuration files plus a seed session for development/testing.
|
||||||
//! Invoked as `cargo run --bin seed`.
|
//! Invoked as `cargo run --bin seed`.
|
||||||
|
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
let store = zesdex_domain::core::Store::new();
|
let store = zesdex_domain::core::Store::new();
|
||||||
store.ensure_dirs()?;
|
store.ensure_dirs()?;
|
||||||
@@ -46,13 +45,11 @@ fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Create a seed session
|
// Create a seed session
|
||||||
let session_id = uuid::Uuid::new_v4().to_string();
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
let session = zesdex_domain::auth::Session::new(
|
let session = zesdex_domain::auth::Session::new(session_id.clone(), "Seed Session".to_string());
|
||||||
session_id.clone(),
|
|
||||||
"Seed Session".to_string(),
|
|
||||||
);
|
|
||||||
// Persist via the session repository
|
// Persist via the session repository
|
||||||
use zesdex_domain::SessionRepository;
|
use zesdex_domain::SessionRepository;
|
||||||
let repo = zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository::new();
|
let repo =
|
||||||
|
zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository::new();
|
||||||
repo.save_session(&store.base_dir, &session)?;
|
repo.save_session(&store.base_dir, &session)?;
|
||||||
tracing::info!("Seed session created: id={session_id}");
|
tracing::info!("Seed session created: id={session_id}");
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ struct ClaudeSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let path = dirs::home_dir().unwrap().join(".claude").join("settings.json");
|
let path = dirs::home_dir()
|
||||||
|
.unwrap()
|
||||||
|
.join(".claude")
|
||||||
|
.join("settings.json");
|
||||||
println!("Path: {:?}", path);
|
println!("Path: {:?}", path);
|
||||||
match std::fs::read_to_string(&path) {
|
match std::fs::read_to_string(&path) {
|
||||||
Ok(content) => {
|
Ok(content) => {
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ use clap::Parser;
|
|||||||
|
|
||||||
/// Zesdex — autonomous AI coding agent.
|
/// Zesdex — autonomous AI coding agent.
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(name = "zesdex", version, about = "Autonomous AI coding agent with TUI")]
|
#[command(
|
||||||
|
name = "zesdex",
|
||||||
|
version,
|
||||||
|
about = "Autonomous AI coding agent with TUI"
|
||||||
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// Run as background daemon with IPC socket
|
/// Run as background daemon with IPC socket
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ ignore.workspace = true
|
|||||||
nucleo-matcher.workspace = true
|
nucleo-matcher.workspace = true
|
||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
rmcp.workspace = true
|
rmcp.workspace = true
|
||||||
lsp-types.workspace = true
|
|
||||||
tiktoken-rs.workspace = true
|
tiktoken-rs.workspace = true
|
||||||
similar.workspace = true
|
similar.workspace = true
|
||||||
syntect.workspace = true
|
syntect.workspace = true
|
||||||
|
|||||||
@@ -8,12 +8,27 @@ pub struct JwtClaims {
|
|||||||
pub sub: String,
|
pub sub: String,
|
||||||
pub exp: u64,
|
pub exp: u64,
|
||||||
pub iat: u64,
|
pub iat: u64,
|
||||||
|
/// Token purpose: `"access"` or `"refresh"`.
|
||||||
|
///
|
||||||
|
/// Prevents an access token from being replayed as a refresh token
|
||||||
|
/// (which would otherwise extend a short-lived credential into the
|
||||||
|
/// 7-day refresh window).
|
||||||
|
#[serde(rename = "typ")]
|
||||||
|
pub token_type: TokenType,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub session_id: Option<String>,
|
pub session_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// JWT token purpose.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TokenType {
|
||||||
|
Access,
|
||||||
|
Refresh,
|
||||||
|
}
|
||||||
|
|
||||||
impl JwtClaims {
|
impl JwtClaims {
|
||||||
pub fn new(sub: String, exp: u64, session_id: Option<String>) -> Self {
|
pub fn new(sub: String, exp: u64, token_type: TokenType, session_id: Option<String>) -> Self {
|
||||||
let iat = std::time::SystemTime::now()
|
let iat = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -22,6 +37,7 @@ impl JwtClaims {
|
|||||||
sub,
|
sub,
|
||||||
exp,
|
exp,
|
||||||
iat,
|
iat,
|
||||||
|
token_type,
|
||||||
session_id,
|
session_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,3 +64,57 @@ pub fn verify_token(secret: &str, token: &str) -> anyhow::Result<JwtClaims> {
|
|||||||
let token_data = jsonwebtoken::decode::<JwtClaims>(token, &key, &validation)?;
|
let token_data = jsonwebtoken::decode::<JwtClaims>(token, &key, &validation)?;
|
||||||
Ok(token_data.claims)
|
Ok(token_data.claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn claims(exp_secs_from_now: u64, token_type: TokenType) -> JwtClaims {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
JwtClaims::new(
|
||||||
|
"user-1".to_string(),
|
||||||
|
now + exp_secs_from_now,
|
||||||
|
token_type,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn access_and_refresh_tokens_roundtrip() {
|
||||||
|
let secret = "test-secret";
|
||||||
|
let access = create_token(secret, claims(3600, TokenType::Access)).unwrap();
|
||||||
|
let refresh = create_token(secret, claims(604800, TokenType::Refresh)).unwrap();
|
||||||
|
|
||||||
|
let acc = verify_token(secret, &access).unwrap();
|
||||||
|
assert_eq!(acc.token_type, TokenType::Access);
|
||||||
|
|
||||||
|
let refr = verify_token(secret, &refresh).unwrap();
|
||||||
|
assert_eq!(refr.token_type, TokenType::Refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_type_is_distinct() {
|
||||||
|
let secret = "test-secret";
|
||||||
|
let access = create_token(secret, claims(3600, TokenType::Access)).unwrap();
|
||||||
|
let claims = verify_token(secret, &access).unwrap();
|
||||||
|
assert_ne!(claims.token_type, TokenType::Refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_token_is_rejected() {
|
||||||
|
let secret = "test-secret";
|
||||||
|
// exp well in the past (beyond the library's default 60s leeway) →
|
||||||
|
// verification must fail.
|
||||||
|
let past = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs()
|
||||||
|
.saturating_sub(120);
|
||||||
|
let expired = JwtClaims::new("user-1".to_string(), past, TokenType::Access, None);
|
||||||
|
let token = create_token(secret, expired).unwrap();
|
||||||
|
assert!(verify_token(secret, &token).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,20 +21,13 @@ impl LoopbackServer {
|
|||||||
format!("http://127.0.0.1:{}/callback", self.port)
|
format!("http://127.0.0.1:{}/callback", self.port)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn wait_for_code(
|
pub fn wait_for_code(&self, timeout_ms: u64, expected_state: &str) -> std::io::Result<String> {
|
||||||
&self,
|
|
||||||
timeout_ms: u64,
|
|
||||||
expected_state: &str,
|
|
||||||
) -> std::io::Result<String> {
|
|
||||||
let (mut stream, _) = self.listener.accept()?;
|
let (mut stream, _) = self.listener.accept()?;
|
||||||
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
|
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
|
||||||
Self::read_callback(&mut stream, expected_state)
|
Self::read_callback(&mut stream, expected_state)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_callback(
|
fn read_callback(stream: &mut TcpStream, expected_state: &str) -> std::io::Result<String> {
|
||||||
stream: &mut TcpStream,
|
|
||||||
expected_state: &str,
|
|
||||||
) -> std::io::Result<String> {
|
|
||||||
let mut buf = [0u8; 4096];
|
let mut buf = [0u8; 4096];
|
||||||
let n = stream.read(&mut buf)?;
|
let n = stream.read(&mut buf)?;
|
||||||
let request = String::from_utf8_lossy(&buf[..n]);
|
let request = String::from_utf8_lossy(&buf[..n]);
|
||||||
@@ -68,7 +61,10 @@ impl LoopbackServer {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
code.ok_or_else(|| {
|
code.ok_or_else(|| {
|
||||||
std::io::Error::new(std::io::ErrorKind::InvalidData, "code not found in callback")
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"code not found in callback",
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,17 +59,25 @@ pub struct AuditReport {
|
|||||||
impl AuditReport {
|
impl AuditReport {
|
||||||
/// True if any ERROR-level violations exist.
|
/// True if any ERROR-level violations exist.
|
||||||
pub fn has_errors(&self) -> bool {
|
pub fn has_errors(&self) -> bool {
|
||||||
self.violations.iter().any(|v| v.severity == Severity::Error)
|
self.violations
|
||||||
|
.iter()
|
||||||
|
.any(|v| v.severity == Severity::Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of errors.
|
/// Number of errors.
|
||||||
pub fn error_count(&self) -> usize {
|
pub fn error_count(&self) -> usize {
|
||||||
self.violations.iter().filter(|v| v.severity == Severity::Error).count()
|
self.violations
|
||||||
|
.iter()
|
||||||
|
.filter(|v| v.severity == Severity::Error)
|
||||||
|
.count()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of warnings.
|
/// Number of warnings.
|
||||||
pub fn warning_count(&self) -> usize {
|
pub fn warning_count(&self) -> usize {
|
||||||
self.violations.iter().filter(|v| v.severity == Severity::Warning).count()
|
self.violations
|
||||||
|
.iter()
|
||||||
|
.filter(|v| v.severity == Severity::Warning)
|
||||||
|
.count()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +125,6 @@ fn forbidden_imports(layer: &str) -> &'static [&'static str] {
|
|||||||
"argon2",
|
"argon2",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"rmcp",
|
"rmcp",
|
||||||
"lsp_types",
|
|
||||||
"tiktoken_rs",
|
"tiktoken_rs",
|
||||||
"syntect",
|
"syntect",
|
||||||
"pulldown_cmark",
|
"pulldown_cmark",
|
||||||
@@ -152,11 +159,7 @@ fn forbidden_imports(layer: &str) -> &'static [&'static str] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Scan a single Rust source file for forbidden imports.
|
/// Scan a single Rust source file for forbidden imports.
|
||||||
fn scan_file(
|
fn scan_file(file_path: &Path, layer: &'static str, root: &Path) -> Vec<Violation> {
|
||||||
file_path: &Path,
|
|
||||||
layer: &'static str,
|
|
||||||
root: &Path,
|
|
||||||
) -> Vec<Violation> {
|
|
||||||
let mut violations = Vec::new();
|
let mut violations = Vec::new();
|
||||||
let content = match std::fs::read_to_string(file_path) {
|
let content = match std::fs::read_to_string(file_path) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
@@ -185,10 +188,12 @@ fn scan_file(
|
|||||||
// `use crate::` in domain could reference domain-only items — skip.
|
// `use crate::` in domain could reference domain-only items — skip.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if trimmed.starts_with(&pattern) || trimmed.starts_with(&format!("use {forbidden}::")) {
|
if trimmed.starts_with(&pattern)
|
||||||
|
|| trimmed.starts_with(&format!("use {forbidden}::"))
|
||||||
|
{
|
||||||
// Skip test code — test modules commonly import outer layers.
|
// Skip test code — test modules commonly import outer layers.
|
||||||
let is_test = content[..content.len().saturating_sub(1)]
|
let is_test =
|
||||||
.contains("#[cfg(test)]");
|
content[..content.len().saturating_sub(1)].contains("#[cfg(test)]");
|
||||||
if is_test {
|
if is_test {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -243,10 +248,7 @@ pub fn audit_layering(root: &Path) -> Result<AuditReport> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine which crate this file belongs to by walking up.
|
// Determine which crate this file belongs to by walking up.
|
||||||
let layer = path
|
let layer = path.ancestors().skip(1).find_map(|p| classify_layer(p));
|
||||||
.ancestors()
|
|
||||||
.skip(1)
|
|
||||||
.find_map(|p| classify_layer(p));
|
|
||||||
|
|
||||||
if let Some(layer) = layer {
|
if let Some(layer) = layer {
|
||||||
files_scanned += 1;
|
files_scanned += 1;
|
||||||
@@ -371,7 +373,10 @@ mod tests {
|
|||||||
fn function_metrics_short_function_ok() {
|
fn function_metrics_short_function_ok() {
|
||||||
let content = "fn ok() {\n let x = 1;\n}\n";
|
let content = "fn ok() {\n let x = 1;\n}\n";
|
||||||
let violations = check_function_metrics(content);
|
let violations = check_function_metrics(content);
|
||||||
let long: Vec<_> = violations.iter().filter(|v| v.message.contains("Function too long")).collect();
|
let long: Vec<_> = violations
|
||||||
|
.iter()
|
||||||
|
.filter(|v| v.message.contains("Function too long"))
|
||||||
|
.collect();
|
||||||
assert!(long.is_empty(), "short function should not trigger");
|
assert!(long.is_empty(), "short function should not trigger");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,7 +388,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
lines.push_str("}\n");
|
lines.push_str("}\n");
|
||||||
let violations = check_function_metrics(&lines);
|
let violations = check_function_metrics(&lines);
|
||||||
let long: Vec<_> = violations.iter().filter(|v| v.message.contains("Function too long")).collect();
|
let long: Vec<_> = violations
|
||||||
|
.iter()
|
||||||
|
.filter(|v| v.message.contains("Function too long"))
|
||||||
|
.collect();
|
||||||
assert!(!long.is_empty(), "long function should trigger warning");
|
assert!(!long.is_empty(), "long function should trigger warning");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,9 @@ pub struct CodeQualityReport {
|
|||||||
|
|
||||||
impl CodeQualityReport {
|
impl CodeQualityReport {
|
||||||
pub fn has_errors(&self) -> bool {
|
pub fn has_errors(&self) -> bool {
|
||||||
self.findings.iter().any(|f| f.severity == super::arch_audit::Severity::Error)
|
self.findings
|
||||||
|
.iter()
|
||||||
|
.any(|f| f.severity == super::arch_audit::Severity::Error)
|
||||||
}
|
}
|
||||||
pub fn count_by_rule(&self) -> Vec<(&'static str, usize)> {
|
pub fn count_by_rule(&self) -> Vec<(&'static str, usize)> {
|
||||||
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||||||
@@ -147,7 +149,8 @@ pub fn scan_quality_file(file_path: &Path, root: &Path) -> Vec<Finding> {
|
|||||||
|
|
||||||
// ── Rule: Missing doc comments on pub items ────────────────────
|
// ── Rule: Missing doc comments on pub items ────────────────────
|
||||||
if (trimmed.starts_with("pub ") || trimmed.starts_with("pub("))
|
if (trimmed.starts_with("pub ") || trimmed.starts_with("pub("))
|
||||||
&& !prev_line_doc && !prev_line_empty
|
&& !prev_line_doc
|
||||||
|
&& !prev_line_empty
|
||||||
{
|
{
|
||||||
// Check it's a struct/enum/fn/trait/type/const/mod
|
// Check it's a struct/enum/fn/trait/type/const/mod
|
||||||
let is_item = trimmed.starts_with("pub fn ")
|
let is_item = trimmed.starts_with("pub fn ")
|
||||||
@@ -246,7 +249,10 @@ mod tests {
|
|||||||
std::fs::write(&file, "fn x() { let y = foo.unwrap(); }\n").unwrap();
|
std::fs::write(&file, "fn x() { let y = foo.unwrap(); }\n").unwrap();
|
||||||
|
|
||||||
let findings = scan_quality_file(&file, &dir);
|
let findings = scan_quality_file(&file, &dir);
|
||||||
let unwrap_findings: Vec<_> = findings.iter().filter(|f| f.rule == "unwrap-in-production").collect();
|
let unwrap_findings: Vec<_> = findings
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.rule == "unwrap-in-production")
|
||||||
|
.collect();
|
||||||
assert!(!unwrap_findings.is_empty(), "should detect unwrap");
|
assert!(!unwrap_findings.is_empty(), "should detect unwrap");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,8 +268,14 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let findings = scan_quality_file(&file, &dir);
|
let findings = scan_quality_file(&file, &dir);
|
||||||
let unwrap_findings: Vec<_> = findings.iter().filter(|f| f.rule == "unwrap-in-production").collect();
|
let unwrap_findings: Vec<_> = findings
|
||||||
assert!(unwrap_findings.is_empty(), "should skip unwrap in test blocks");
|
.iter()
|
||||||
|
.filter(|f| f.rule == "unwrap-in-production")
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
unwrap_findings.is_empty(),
|
||||||
|
"should skip unwrap in test blocks"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -274,7 +286,13 @@ mod tests {
|
|||||||
std::fs::write(&file, "#[allow(clippy::too_many_arguments)]\nfn x() {}\n").unwrap();
|
std::fs::write(&file, "#[allow(clippy::too_many_arguments)]\nfn x() {}\n").unwrap();
|
||||||
|
|
||||||
let findings = scan_quality_file(&file, &dir);
|
let findings = scan_quality_file(&file, &dir);
|
||||||
let bypass_findings: Vec<_> = findings.iter().filter(|f| f.rule == "compiler-bypass").collect();
|
let bypass_findings: Vec<_> = findings
|
||||||
assert!(!bypass_findings.is_empty(), "should detect allow attributes");
|
.iter()
|
||||||
|
.filter(|f| f.rule == "compiler-bypass")
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
!bypass_findings.is_empty(),
|
||||||
|
"should detect allow attributes"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,9 +50,8 @@ pub fn validate_commit_message(msg: &str) -> Result<(), Vec<String>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse: `type(scope): description` or `type!: description` or `type: description`
|
// Parse: `type(scope): description` or `type!: description` or `type: description`
|
||||||
let re = Regex::new(
|
let re =
|
||||||
r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<desc>.+)$",
|
Regex::new(r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<desc>.+)$")
|
||||||
)
|
|
||||||
.expect("valid regex for commit parsing");
|
.expect("valid regex for commit parsing");
|
||||||
|
|
||||||
match re.captures(subject) {
|
match re.captures(subject) {
|
||||||
@@ -94,21 +93,19 @@ pub fn validate_commit_message(msg: &str) -> Result<(), Vec<String>> {
|
|||||||
} else {
|
} else {
|
||||||
let first_char = desc.chars().next().unwrap_or(' ');
|
let first_char = desc.chars().next().unwrap_or(' ');
|
||||||
if first_char.is_uppercase() {
|
if first_char.is_uppercase() {
|
||||||
errors.push(format!(
|
errors.push(format!("Description must start with lowercase: '{desc}'"));
|
||||||
"Description must start with lowercase: '{desc}'"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if desc.ends_with('.') {
|
if desc.ends_with('.') {
|
||||||
errors.push(format!(
|
errors.push(format!("Description must not end with a period: '{desc}'"));
|
||||||
"Description must not end with a period: '{desc}'"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type-specific rules.
|
// Type-specific rules.
|
||||||
match type_ {
|
match type_ {
|
||||||
"chore" | "docs" | "refactor" | "test" | "style" | "perf" | "ci" | "build"
|
"chore" | "docs" | "refactor" | "test" | "style" | "perf" | "ci" | "build"
|
||||||
| "revert" if scope.is_some() => {
|
| "revert"
|
||||||
|
if scope.is_some() =>
|
||||||
|
{
|
||||||
errors.push(format!(
|
errors.push(format!(
|
||||||
"'{type_}' commits should not use a scope. \
|
"'{type_}' commits should not use a scope. \
|
||||||
Only 'feat' and 'fix' require scopes."
|
Only 'feat' and 'fix' require scopes."
|
||||||
@@ -140,9 +137,8 @@ pub fn parse_commit_message(msg: &str) -> Option<CommitInfo> {
|
|||||||
let msg = msg.trim();
|
let msg = msg.trim();
|
||||||
let subject = msg.lines().next()?;
|
let subject = msg.lines().next()?;
|
||||||
|
|
||||||
let re = Regex::new(
|
let re =
|
||||||
r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<desc>.+)$",
|
Regex::new(r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<desc>.+)$")
|
||||||
)
|
|
||||||
.expect("valid regex");
|
.expect("valid regex");
|
||||||
|
|
||||||
let caps = re.captures(subject)?;
|
let caps = re.captures(subject)?;
|
||||||
@@ -155,10 +151,18 @@ pub fn parse_commit_message(msg: &str) -> Option<CommitInfo> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Some(CommitInfo {
|
Some(CommitInfo {
|
||||||
type_: caps.name("type").map(|m| m.as_str()).unwrap_or("").to_string(),
|
type_: caps
|
||||||
|
.name("type")
|
||||||
|
.map(|m| m.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
scope: caps.name("scope").map(|m| m.as_str().to_string()),
|
scope: caps.name("scope").map(|m| m.as_str().to_string()),
|
||||||
breaking: caps.name("breaking").is_some(),
|
breaking: caps.name("breaking").is_some(),
|
||||||
description: caps.name("desc").map(|m| m.as_str()).unwrap_or("").to_string(),
|
description: caps
|
||||||
|
.name("desc")
|
||||||
|
.map(|m| m.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
body,
|
body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -232,7 +236,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_valid_commit() {
|
fn parses_valid_commit() {
|
||||||
let parsed = parse_commit_message("feat(agent): add parallel execution\n\nWith cycle orchestration.").unwrap();
|
let parsed = parse_commit_message(
|
||||||
|
"feat(agent): add parallel execution\n\nWith cycle orchestration.",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(parsed.type_, "feat");
|
assert_eq!(parsed.type_, "feat");
|
||||||
assert_eq!(parsed.scope, Some("agent".to_string()));
|
assert_eq!(parsed.scope, Some("agent".to_string()));
|
||||||
assert!(!parsed.breaking);
|
assert!(!parsed.breaking);
|
||||||
|
|||||||
@@ -1,298 +1,141 @@
|
|||||||
//! Mandatory explore phase — spawns ≥3 parallel subagents to discover
|
//! `explore_codebase` tool — lazy, agent-initiated codebase exploration.
|
||||||
//! codebase context before every agent turn, visible in the TUI workflow tab.
|
//!
|
||||||
|
//! The main agent decides (via the system prompt) when it needs codebase
|
||||||
|
//! context. Unlike the old mandatory explore phase (which ran 3 subagents on
|
||||||
|
//! every turn regardless of the question), this tool is invoked only when the
|
||||||
|
//! agent judges it necessary — saving tokens on trivial queries while keeping
|
||||||
|
//! context available for complex tasks.
|
||||||
//!
|
//!
|
||||||
//! # Flow
|
//! # Flow
|
||||||
//!
|
//!
|
||||||
//! `ExploreServiceImpl::explore()` →
|
//! `ExploreCodebase::run` →
|
||||||
//!
|
//! 1. Parse the user's goal / target from args.
|
||||||
//! 1. Push `WorkflowAgentUpdate { Pending }` for each agent onto the turn-event
|
//! 2. Resolve subagent provider credentials from settings.
|
||||||
//! queue so the TUI workflow tab shows all 3.
|
//! 3. Spawn a single "context scout" subagent (read-only, semantic_search +
|
||||||
//! 2. Spawn **Code Structure** subagent (thread + tokio runtime).
|
//! read of up to 3 relevant files).
|
||||||
//! 3. Spawn **Symbol Index** subagent (thread + tokio runtime).
|
//! 4. Join the result and return a concise bullet summary as a tool message.
|
||||||
//! 4. Spawn **Semantic Context** subagent (thread + tokio runtime).
|
|
||||||
//! 5. Join all handles via `spawn_blocking`.
|
|
||||||
//! 6. Push `Completed` / `Failed` events for each agent.
|
|
||||||
//! 7. Consolidate findings into a system message → return.
|
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use zesdex_domain::agent::prompt::explore_scout_directive;
|
||||||
|
use zesdex_domain::cms::{AppConfigRepository, SettingsRepository};
|
||||||
|
use zesdex_domain::core::Store;
|
||||||
|
|
||||||
|
use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository};
|
||||||
use crate::subagent::context::SubagentContext;
|
use crate::subagent::context::SubagentContext;
|
||||||
use crate::subagent::division::AccessTier;
|
use crate::subagent::division::AccessTier;
|
||||||
use crate::subagent::engine::run_agent;
|
use crate::subagent::engine::run_agent;
|
||||||
use crate::tools::ToolCtx;
|
use crate::tools::{Tool, ToolCtx};
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::future::Future;
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::thread;
|
|
||||||
use tracing::{info, warn};
|
|
||||||
use zesdex_application::agent::{ExploreOutput, ExploreService};
|
|
||||||
use zesdex_domain::agent::{AgentStatus, TurnEvent};
|
|
||||||
|
|
||||||
/// Number of parallel explore subagents.
|
/// Maximum characters of the scout's final output to keep in context.
|
||||||
const EXPLORE_AGENT_COUNT: usize = 3;
|
/// The scout is directed to stay under 1500 chars, but this ceiling protects
|
||||||
|
/// against rogue output.
|
||||||
|
const EXPLORE_OUTPUT_MAX_CHARS: usize = 4000;
|
||||||
|
|
||||||
/// IDs for each explore agent (shown in the workflow tab).
|
/// `explore_codebase` tool — ask a read-only context-scout subagent to
|
||||||
const EXPLORE_IDS: [&str; 3] = ["explore-structure", "explore-symbols", "explore-context"];
|
/// locate relevant code for the current task.
|
||||||
|
pub struct ExploreCodebase;
|
||||||
|
|
||||||
/// Display names for the TUI workflow tab.
|
impl Tool for ExploreCodebase {
|
||||||
const EXPLORE_LABELS: [&str; 3] = [
|
fn name(&self) -> &'static str {
|
||||||
"📁 Code Structure",
|
"explore_codebase"
|
||||||
"🔣 Symbol Index",
|
|
||||||
"🔍 Semantic Context",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Directives for each explore subagent.
|
|
||||||
const EXPLORE_DIRECTIVES: [&str; 3] = [
|
|
||||||
// Agent 0: Code Structure
|
|
||||||
"You are a codebase structure explorer.\n\
|
|
||||||
1. List all top-level directories and files in the workspace root.\n\
|
|
||||||
2. Read Cargo.toml, package.json, or pyproject.toml at the root.\n\
|
|
||||||
3. List the apps/ or src/ directory contents.\n\
|
|
||||||
4. Identify main entry points (main.rs, main.py, index.ts, etc.).\n\
|
|
||||||
5. Count files by extension type.\n\
|
|
||||||
Use the ls_dir, read, grep, and glob tools. Be concise.",
|
|
||||||
|
|
||||||
// Agent 1: Symbol Index
|
|
||||||
"You are a symbol index explorer.\n\
|
|
||||||
1. Call the 'rebuild_index' tool to rebuild the symbol index.\n\
|
|
||||||
2. Call the 'list_symbols' tool with max_results: 100.\n\
|
|
||||||
3. Identify public APIs, entry points, and key types.\n\
|
|
||||||
4. Group symbols by language and kind.\n\
|
|
||||||
Be concise. Report what symbols exist and where they live.",
|
|
||||||
|
|
||||||
// Agent 2: Semantic Context
|
|
||||||
"You are a semantic context explorer.\n\
|
|
||||||
1. Call the 'rebuild_index' tool to ensure the index is fresh.\n\
|
|
||||||
2. Search for symbols related to the user's query using semantic_search.\n\
|
|
||||||
3. Search for config files, env variables, and settings.\n\
|
|
||||||
4. Search for test files and test patterns.\n\
|
|
||||||
Be concise. Report relevant code areas for the task.\n\
|
|
||||||
Use the semantic_search, grep, glob, and read tools.",
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Credentials
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// LLM credentials for explore subagents.
|
|
||||||
pub struct Credentials {
|
|
||||||
pub base_url: String,
|
|
||||||
pub api_key: String,
|
|
||||||
pub model: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ExploreServiceImpl — implements the application-layer trait
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Concrete [`ExploreService`] that the turn service calls.
|
|
||||||
///
|
|
||||||
/// Owns a shared `ToolCtx` and LLM credentials. Each call to `explore()`
|
|
||||||
/// spawns 3 subagents in parallel with TUI workflow-tab visibility.
|
|
||||||
pub struct ExploreServiceImpl {
|
|
||||||
tool_ctx: ToolCtx,
|
|
||||||
credentials: Credentials,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ExploreServiceImpl {
|
|
||||||
pub fn new(tool_ctx: ToolCtx, credentials: Credentials) -> Self {
|
|
||||||
ExploreServiceImpl {
|
|
||||||
tool_ctx,
|
|
||||||
credentials,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &'static str {
|
||||||
|
"Explore the codebase to locate code relevant to a task. Use this \
|
||||||
|
once at the start of complex or unfamiliar tasks (implementing a \
|
||||||
|
feature, fixing a bug, refactoring, navigating a large repo). \
|
||||||
|
Do NOT use for simple factual questions about the current \
|
||||||
|
conversation."
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl ExploreService for ExploreServiceImpl {
|
fn parameters(&self) -> Value {
|
||||||
fn explore<'a>(
|
json!({
|
||||||
&'a self,
|
"type": "object",
|
||||||
query: &'a str,
|
"properties": {
|
||||||
workspace_root: &'a str,
|
"goal": {
|
||||||
turn_events: &'a Arc<Mutex<VecDeque<TurnEvent>>>,
|
"type": "string",
|
||||||
) -> Pin<Box<dyn Future<Output = Result<ExploreOutput>> + Send + 'a>> {
|
"description": "The task or question to explore for"
|
||||||
Box::pin(async move {
|
}
|
||||||
let context = run_explore_phase(
|
},
|
||||||
query,
|
"required": ["goal"]
|
||||||
workspace_root,
|
|
||||||
&self.tool_ctx,
|
|
||||||
&self.credentials,
|
|
||||||
turn_events,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(ExploreOutput {
|
|
||||||
context_messages: vec![context],
|
|
||||||
summary: format!("{EXPLORE_AGENT_COUNT} explore agents dispatched"),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
// Helpers for pushing workflow events
|
let goal = args
|
||||||
// ---------------------------------------------------------------------------
|
.get("goal")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
fn push_event(events: &Arc<Mutex<VecDeque<TurnEvent>>>, event: TurnEvent) {
|
if goal.is_empty() {
|
||||||
if let Ok(mut q) = events.lock() {
|
return Err(anyhow::anyhow!("missing non-empty 'goal'"));
|
||||||
q.push_back(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_pending(events: &Arc<Mutex<VecDeque<TurnEvent>>>, agent_id: &str, display: &str) {
|
|
||||||
push_event(
|
|
||||||
events,
|
|
||||||
TurnEvent::WorkflowAgentUpdate {
|
|
||||||
agent_id: agent_id.to_string(),
|
|
||||||
agent_name: display.to_string(),
|
|
||||||
status: AgentStatus::Pending,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_running(events: &Arc<Mutex<VecDeque<TurnEvent>>>, agent_id: &str, display: &str) {
|
|
||||||
push_event(
|
|
||||||
events,
|
|
||||||
TurnEvent::WorkflowAgentUpdate {
|
|
||||||
agent_id: agent_id.to_string(),
|
|
||||||
agent_name: display.to_string(),
|
|
||||||
status: AgentStatus::Running,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_completed(events: &Arc<Mutex<VecDeque<TurnEvent>>>, agent_id: &str, display: &str) {
|
|
||||||
push_event(
|
|
||||||
events,
|
|
||||||
TurnEvent::WorkflowAgentUpdate {
|
|
||||||
agent_id: agent_id.to_string(),
|
|
||||||
agent_name: display.to_string(),
|
|
||||||
status: AgentStatus::Completed,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn emit_failed(events: &Arc<Mutex<VecDeque<TurnEvent>>>, agent_id: &str, display: &str, msg: &str) {
|
|
||||||
push_event(
|
|
||||||
events,
|
|
||||||
TurnEvent::WorkflowAgentUpdate {
|
|
||||||
agent_id: agent_id.to_string(),
|
|
||||||
agent_name: display.to_string(),
|
|
||||||
status: AgentStatus::Failed(msg.to_string()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Core orchestration
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Spawn `EXPLORE_AGENT_COUNT` subagents in parallel, emit workflow events
|
|
||||||
/// for the TUI tab, join, and consolidate.
|
|
||||||
async fn run_explore_phase(
|
|
||||||
query: &str,
|
|
||||||
workspace_root: &str,
|
|
||||||
tool_ctx: &ToolCtx,
|
|
||||||
credentials: &Credentials,
|
|
||||||
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
|
|
||||||
) -> Result<String> {
|
|
||||||
// ── 1. Emit Pending for all agents (appears instantly in workflow tab) ─
|
|
||||||
for (i, &id) in EXPLORE_IDS.iter().enumerate() {
|
|
||||||
emit_pending(turn_events, id, EXPLORE_LABELS[i]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Prepare directives ───────────────────────────────────────────
|
info!("explore_codebase: {goal}");
|
||||||
let mut directives: Vec<String> = Vec::with_capacity(EXPLORE_AGENT_COUNT);
|
|
||||||
for (i, &d) in EXPLORE_DIRECTIVES.iter().enumerate() {
|
|
||||||
let mut d = d.to_string();
|
|
||||||
if i == 2 {
|
|
||||||
d.push_str(&format!("\n\nThe user's current query is: \"{query}\""));
|
|
||||||
}
|
|
||||||
d.push_str(&format!("\n\nWorkspace root: {workspace_root}"));
|
|
||||||
directives.push(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 3. Spawn all agents on threads ──────────────────────────────────
|
let store = Store::new();
|
||||||
let mut handles: Vec<(usize, thread::JoinHandle<Result<String>>)> =
|
let settings = JsonSettingsRepository::new()
|
||||||
Vec::with_capacity(EXPLORE_AGENT_COUNT);
|
.load(&store.base_dir)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let app_config = JsonAppConfigRepository::new()
|
||||||
|
.load(&store.base_dir)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
for i in 0..EXPLORE_AGENT_COUNT {
|
let (provider, model) =
|
||||||
emit_running(turn_events, EXPLORE_IDS[i], EXPLORE_LABELS[i]);
|
crate::subagent::provider::resolve_subagent_provider(&settings, &app_config);
|
||||||
|
|
||||||
let ctx = SubagentContext::new(
|
let base_url = app_config
|
||||||
directives[i].clone(),
|
.providers
|
||||||
tool_ctx.clone(),
|
.get(&provider)
|
||||||
|
.map(|p| p.api_base.clone())
|
||||||
|
.unwrap_or_else(|| zesdex_domain::agent::defaults::DEFAULT_API_BASE.to_string());
|
||||||
|
|
||||||
|
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
|
||||||
|
|
||||||
|
let workspace_root = ctx
|
||||||
|
.workspaces
|
||||||
|
.first()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| ".".to_string());
|
||||||
|
|
||||||
|
// One lightweight scout — no parallel agents, no index rebuild.
|
||||||
|
let directive = format!(
|
||||||
|
"{}\n\nUser's task: {goal}\nWorkspace root: {workspace_root}",
|
||||||
|
explore_scout_directive()
|
||||||
|
);
|
||||||
|
|
||||||
|
let subagent_ctx = SubagentContext::new(
|
||||||
|
directive.clone(),
|
||||||
|
ctx.clone(),
|
||||||
"read".to_string(),
|
"read".to_string(),
|
||||||
credentials.base_url.clone(),
|
base_url,
|
||||||
credentials.api_key.clone(),
|
api_key,
|
||||||
credentials.model.clone(),
|
model,
|
||||||
);
|
);
|
||||||
|
|
||||||
let directive = directives[i].clone();
|
let rt = crate::runtime::runtime();
|
||||||
let tc = tool_ctx.clone();
|
let result = rt.block_on(run_agent(
|
||||||
|
subagent_ctx,
|
||||||
|
&directive,
|
||||||
|
AccessTier::Read,
|
||||||
|
ctx.clone(),
|
||||||
|
))?;
|
||||||
|
|
||||||
let handle = thread::spawn(move || {
|
let mut out = format!("[Codebase scout report]\n{goal}\n\n----------\n{}", result);
|
||||||
let rt = tokio::runtime::Runtime::new()
|
if out.len() > EXPLORE_OUTPUT_MAX_CHARS {
|
||||||
.context("create explore subagent tokio runtime")?;
|
warn!(
|
||||||
rt.block_on(run_agent(ctx, &directive, AccessTier::Read, tc))
|
"explore_codebase output truncated: {} chars -> {}",
|
||||||
});
|
out.len(),
|
||||||
|
EXPLORE_OUTPUT_MAX_CHARS
|
||||||
handles.push((i, handle));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 4. Join handles via spawn_blocking ──────────────────────────────
|
|
||||||
let turn_events_clone = Arc::clone(turn_events);
|
|
||||||
let results: Vec<(usize, String, bool)> = tokio::task::spawn_blocking(move || {
|
|
||||||
let mut out = Vec::with_capacity(EXPLORE_AGENT_COUNT);
|
|
||||||
for (i, handle) in handles {
|
|
||||||
let entry = match handle.join() {
|
|
||||||
Ok(Ok(output)) => {
|
|
||||||
info!(agent = i, "explore subagent completed");
|
|
||||||
emit_completed(&turn_events_clone, EXPLORE_IDS[i], EXPLORE_LABELS[i]);
|
|
||||||
(i, output, true)
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
warn!(agent = i, error = %e, "explore subagent failed");
|
|
||||||
emit_failed(&turn_events_clone, EXPLORE_IDS[i], EXPLORE_LABELS[i], &e.to_string());
|
|
||||||
(i, format!("Error: {e}"), false)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!(agent = i, error = ?e, "explore subagent panicked");
|
|
||||||
emit_failed(&turn_events_clone, EXPLORE_IDS[i], EXPLORE_LABELS[i], "thread panicked");
|
|
||||||
(i, format!("Thread panic: {e:?}"), false)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
out.push(entry);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.context("explore join task panicked")?;
|
|
||||||
|
|
||||||
// ── 5. Build consolidated context ───────────────────────────────────
|
|
||||||
Ok(build_explore_context(&results))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Consolidation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Format explore results as a system-level context message.
|
|
||||||
fn build_explore_context(results: &[(usize, String, bool)]) -> String {
|
|
||||||
let success_count = results.iter().filter(|r| r.2).count();
|
|
||||||
let total = results.len();
|
|
||||||
|
|
||||||
let mut msg = format!(
|
|
||||||
"[Explore Phase — {success_count}/{total} agents succeeded]\n\n"
|
|
||||||
);
|
);
|
||||||
|
out.truncate(EXPLORE_OUTPUT_MAX_CHARS);
|
||||||
for (i, output, success) in results {
|
out.push_str("\n...[truncated]");
|
||||||
let label = EXPLORE_LABELS.get(*i).unwrap_or(&"❓ Unknown");
|
|
||||||
if *success {
|
|
||||||
msg.push_str(&format!("=== {label} ===\n{output}\n\n"));
|
|
||||||
} else {
|
|
||||||
msg.push_str(&format!("=== {label} (FAILED) ===\n{output}\n\n"));
|
|
||||||
}
|
}
|
||||||
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
msg
|
|
||||||
}
|
}
|
||||||
@@ -107,10 +107,7 @@ impl BestPracticeEngine {
|
|||||||
let layering = self.audit_layering(workspace_root)?;
|
let layering = self.audit_layering(workspace_root)?;
|
||||||
let quality = self.scan_quality(workspace_root)?;
|
let quality = self.scan_quality(workspace_root)?;
|
||||||
|
|
||||||
Ok(CombinedAuditReport {
|
Ok(CombinedAuditReport { layering, quality })
|
||||||
layering,
|
|
||||||
quality,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ impl BashJob {
|
|||||||
let Ok(mut guard) = self.process.lock() else {
|
let Ok(mut guard) = self.process.lock() else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
guard.as_mut().is_some_and(|c| matches!(c.try_wait(), Ok(None)))
|
guard
|
||||||
|
.as_mut()
|
||||||
|
.is_some_and(|c| matches!(c.try_wait(), Ok(None)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,24 +7,22 @@
|
|||||||
pub fn check_dangerous_pattern(tool_name: &str, args: &serde_json::Value) -> Option<String> {
|
pub fn check_dangerous_pattern(tool_name: &str, args: &serde_json::Value) -> Option<String> {
|
||||||
match tool_name {
|
match tool_name {
|
||||||
"bash" => {
|
"bash" => {
|
||||||
let cmd = args
|
let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
.get("command")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
// Detect git push with --force
|
// Detect git push with --force
|
||||||
if cmd.contains("git push") && cmd.contains("--force") {
|
if cmd.contains("git push") && cmd.contains("--force") {
|
||||||
return Some("Force-pushing to git is destructive and may lose history".to_string());
|
return Some(
|
||||||
|
"Force-pushing to git is destructive and may lose history".to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Detect rm -rf /
|
// Detect rm -rf /
|
||||||
if cmd.contains("rm -rf /") || cmd.contains("rm -rf /*") {
|
if cmd.contains("rm -rf /") || cmd.contains("rm -rf /*") {
|
||||||
return Some("Recursive deletion of the root filesystem is never allowed".to_string());
|
return Some(
|
||||||
|
"Recursive deletion of the root filesystem is never allowed".to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"delete" => {
|
"delete" => {
|
||||||
let path = args
|
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
.get("path")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
if path == "/" || path.starts_with("/etc") {
|
if path == "/" || path.starts_with("/etc") {
|
||||||
return Some(format!("Deleting '{}' is too dangerous", path));
|
return Some(format!("Deleting '{}' is too dangerous", path));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,10 +76,7 @@ pub struct StatePayload {
|
|||||||
pub enum DaemonFrame {
|
pub enum DaemonFrame {
|
||||||
StateUpdate(Box<StatePayload>),
|
StateUpdate(Box<StatePayload>),
|
||||||
StreamToken(String),
|
StreamToken(String),
|
||||||
SystemNote {
|
SystemNote { kind: String, message: String },
|
||||||
kind: String,
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
ClipboardCopy(String),
|
ClipboardCopy(String),
|
||||||
Closed,
|
Closed,
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
//! ├── auth/ — JWT, Argon2, OAuth loopback
|
//! ├── auth/ — JWT, Argon2, OAuth loopback
|
||||||
//! ├── llm/ — LLM provider HTTP client
|
//! ├── llm/ — LLM provider HTTP client
|
||||||
//! ├── ipc/ — Unix-socket IPC protocol
|
//! ├── ipc/ — Unix-socket IPC protocol
|
||||||
//! ├── lsp/ — Native LSP client + provisioner
|
|
||||||
//! ├── mcp/ — Model Context Protocol bridge
|
//! ├── mcp/ — Model Context Protocol bridge
|
||||||
//! ├── bgbash/ — Background bash job management
|
//! ├── bgbash/ — Background bash job management
|
||||||
//! ├── tools/ — All 37 agent-invocable tools
|
//! ├── tools/ — All 37 agent-invocable tools
|
||||||
@@ -32,10 +31,10 @@ pub mod bgbash;
|
|||||||
pub mod guard;
|
pub mod guard;
|
||||||
pub mod ipc;
|
pub mod ipc;
|
||||||
pub mod llm;
|
pub mod llm;
|
||||||
pub mod lsp;
|
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub mod persistence;
|
pub mod persistence;
|
||||||
|
pub mod runtime;
|
||||||
pub mod subagent;
|
pub mod subagent;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
@@ -67,8 +66,7 @@ use std::sync::Arc;
|
|||||||
pub use tools::{GraduatedCheck, Tool, ToolCtx, ToolCtxBuilder};
|
pub use tools::{GraduatedCheck, Tool, ToolCtx, ToolCtxBuilder};
|
||||||
|
|
||||||
// Re-export commonly needed types at the crate root
|
// Re-export commonly needed types at the crate root
|
||||||
pub use zesdex_domain::core::{ChatMessage, Role, Store, UsageStats, ToolCallResult};
|
pub use zesdex_domain::core::{ChatMessage, Role, Store, ToolCallResult, UsageStats};
|
||||||
|
|
||||||
|
|
||||||
/// A shared, async-writable cache of directory entries, used to avoid
|
/// A shared, async-writable cache of directory entries, used to avoid
|
||||||
/// re-reading a directory every render frame.
|
/// re-reading a directory every render frame.
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
use rand_core::RngCore;
|
use rand_core::RngCore;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use zesdex_application::ports::ProviderService;
|
||||||
use zesdex_domain::core::{
|
use zesdex_domain::core::{
|
||||||
ChatMessage, ChatRequest, ChatResponse, SseParser, StreamEvent, StreamOptions, ToolDef,
|
ChatMessage, ChatRequest, ChatResponse, SseParser, StreamEvent, StreamOptions, ToolDef,
|
||||||
};
|
};
|
||||||
use zesdex_application::ports::ProviderService;
|
|
||||||
|
|
||||||
use zesdex_domain::agent::defaults::{DEFAULT_API_BASE, DEFAULT_MODEL};
|
use zesdex_domain::agent::defaults::{DEFAULT_API_BASE, DEFAULT_MODEL};
|
||||||
const DEFAULT_BASE_URL: &str = DEFAULT_API_BASE;
|
const DEFAULT_BASE_URL: &str = DEFAULT_API_BASE;
|
||||||
@@ -82,10 +82,7 @@ impl LlmClient {
|
|||||||
retrying without connect timeout",
|
retrying without connect timeout",
|
||||||
e,
|
e,
|
||||||
);
|
);
|
||||||
match reqwest::Client::builder()
|
match reqwest::Client::builder().timeout(REQUEST_TIMEOUT).build() {
|
||||||
.timeout(REQUEST_TIMEOUT)
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e2) => {
|
Err(e2) => {
|
||||||
tracing::warn!("also failed: {e2}. using default client");
|
tracing::warn!("also failed: {e2}. using default client");
|
||||||
@@ -115,8 +112,7 @@ impl LlmClient {
|
|||||||
.post(url)
|
.post(url)
|
||||||
.header("Content-Type", "application/json");
|
.header("Content-Type", "application/json");
|
||||||
if !self.api_key.is_empty() {
|
if !self.api_key.is_empty() {
|
||||||
http_req =
|
http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key));
|
||||||
http_req.header("Authorization", format!("Bearer {}", self.api_key));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut resp = http_req.json(req).send().await.map_err(|e| {
|
let mut resp = http_req.json(req).send().await.map_err(|e| {
|
||||||
@@ -237,8 +233,7 @@ impl LlmClient {
|
|||||||
n
|
n
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let text =
|
let text = String::from_utf8_lossy(&byte_buf[..valid_len]).into_owned();
|
||||||
String::from_utf8_lossy(&byte_buf[..valid_len]).into_owned();
|
|
||||||
byte_buf.drain(..valid_len);
|
byte_buf.drain(..valid_len);
|
||||||
|
|
||||||
for event in parser.feed(&text) {
|
for event in parser.feed(&text) {
|
||||||
@@ -306,8 +301,7 @@ impl ProviderService for LlmClient {
|
|||||||
.header("Content-Type", "application/json");
|
.header("Content-Type", "application/json");
|
||||||
|
|
||||||
if !self.api_key.is_empty() {
|
if !self.api_key.is_empty() {
|
||||||
http_req =
|
http_req = http_req.header("Authorization", format!("Bearer {}", self.api_key));
|
||||||
http_req.header("Authorization", format!("Bearer {}", self.api_key));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = async {
|
let result = async {
|
||||||
@@ -335,9 +329,9 @@ impl ProviderService for LlmClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let data: ChatResponse = resp.json().await?;
|
let data: ChatResponse = resp.json().await?;
|
||||||
let usage = data.usage.map(|u| {
|
let usage = data
|
||||||
(u64::from(u.prompt_tokens), u64::from(u.completion_tokens))
|
.usage
|
||||||
});
|
.map(|u| (u64::from(u.prompt_tokens), u64::from(u.completion_tokens)));
|
||||||
let message = data
|
let message = data
|
||||||
.choices
|
.choices
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -345,7 +339,8 @@ impl ProviderService for LlmClient {
|
|||||||
.and_then(|c| c.message)
|
.and_then(|c| c.message)
|
||||||
.ok_or_else(|| anyhow::anyhow!("API response had no choices"))?;
|
.ok_or_else(|| anyhow::anyhow!("API response had no choices"))?;
|
||||||
Ok((message, usage))
|
Ok((message, usage))
|
||||||
}.await;
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok((msg, usage)) => return Ok((msg, usage)),
|
Ok((msg, usage)) => return Ok((msg, usage)),
|
||||||
@@ -393,7 +388,9 @@ impl ProviderService for LlmClient {
|
|||||||
let mut captured_content = false;
|
let mut captured_content = false;
|
||||||
let mut wrapped = |event: &StreamEvent| -> bool {
|
let mut wrapped = |event: &StreamEvent| -> bool {
|
||||||
match event {
|
match event {
|
||||||
StreamEvent::Token(_) | StreamEvent::Reasoning(_) | StreamEvent::ToolCallDelta { .. } => {
|
StreamEvent::Token(_)
|
||||||
|
| StreamEvent::Reasoning(_)
|
||||||
|
| StreamEvent::ToolCallDelta { .. } => {
|
||||||
captured_content = true;
|
captured_content = true;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -425,11 +422,7 @@ pub fn resolve_api_key(
|
|||||||
) -> String {
|
) -> String {
|
||||||
let provider = &settings.provider;
|
let provider = &settings.provider;
|
||||||
|
|
||||||
let mut api_key = settings
|
let mut api_key = settings.api_keys.get(provider).cloned().unwrap_or_default();
|
||||||
.api_keys
|
|
||||||
.get(provider)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if api_key.is_empty() {
|
if api_key.is_empty() {
|
||||||
if let Some(provider_cfg) = app_config.providers.get(provider) {
|
if let Some(provider_cfg) = app_config.providers.get(provider) {
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
//! LSP client — sends JSON-RPC requests to language servers.
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::io::{BufRead, BufReader, Read, Write};
|
|
||||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
|
||||||
use std::sync::Mutex;
|
|
||||||
use tracing::{debug, info};
|
|
||||||
|
|
||||||
/// Mutable inner state of an LSP client, protected by a mutex so that
|
|
||||||
/// `send_request` and `shutdown` can be called via `&self` (required by
|
|
||||||
/// [`LspManager`](super::manager::LspManager)).
|
|
||||||
struct LspClientInner {
|
|
||||||
process: Child,
|
|
||||||
stdin: ChildStdin,
|
|
||||||
stdout: BufReader<ChildStdout>,
|
|
||||||
request_id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A minimal but functional LSP client.
|
|
||||||
pub struct LspClient {
|
|
||||||
inner: Mutex<LspClientInner>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LspClient {
|
|
||||||
/// Spawn a language server process.
|
|
||||||
pub fn start(command: &str, args: &[String]) -> Result<Self> {
|
|
||||||
let mut child = Command::new(command)
|
|
||||||
.args(args)
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.spawn()?;
|
|
||||||
|
|
||||||
let stdin = child.stdin.take().ok_or_else(|| anyhow::anyhow!("no stdin on LSP process"))?;
|
|
||||||
let stdout = BufReader::new(child.stdout.take().ok_or_else(|| anyhow::anyhow!("no stdout on LSP process"))?);
|
|
||||||
|
|
||||||
info!("LSP client spawned: {command}");
|
|
||||||
Ok(LspClient {
|
|
||||||
inner: Mutex::new(LspClientInner {
|
|
||||||
process: child,
|
|
||||||
stdin,
|
|
||||||
stdout,
|
|
||||||
request_id: 0,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a JSON-RPC request and read the response.
|
|
||||||
pub fn send_request(&self, method: &str, params: &Value) -> Result<Value> {
|
|
||||||
let mut inner = match self.inner.lock() {
|
|
||||||
Ok(g) => g,
|
|
||||||
Err(poisoned) => {
|
|
||||||
tracing::error!("LSP client mutex poisoned, recovering");
|
|
||||||
poisoned.into_inner()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
inner.request_id += 1;
|
|
||||||
let request = serde_json::json!({
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": inner.request_id,
|
|
||||||
"method": method,
|
|
||||||
"params": params.clone(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Write Content-Length header + body
|
|
||||||
let body = serde_json::to_string(&request)?;
|
|
||||||
let header = format!("Content-Length: {}\r\n\r\n", body.len());
|
|
||||||
inner.stdin.write_all(header.as_bytes())?;
|
|
||||||
inner.stdin.write_all(body.as_bytes())?;
|
|
||||||
inner.stdin.flush()?;
|
|
||||||
|
|
||||||
debug!("LSP request: {method} (id={})", inner.request_id);
|
|
||||||
|
|
||||||
// Read Content-Length header
|
|
||||||
let mut content_length = 0usize;
|
|
||||||
loop {
|
|
||||||
let mut line = String::new();
|
|
||||||
inner.stdout.read_line(&mut line)?;
|
|
||||||
let trimmed = line.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
break; // end of headers
|
|
||||||
}
|
|
||||||
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
|
|
||||||
content_length = len_str.parse::<usize>()?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the JSON body
|
|
||||||
let mut buf = vec![0u8; content_length];
|
|
||||||
inner.stdout.read_exact(&mut buf)?;
|
|
||||||
let response: Value = serde_json::from_slice(&buf)?;
|
|
||||||
|
|
||||||
debug!("LSP response for {method}: response received");
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gracefully shut down the server.
|
|
||||||
pub fn shutdown(&self) -> Result<()> {
|
|
||||||
let null = Value::Null;
|
|
||||||
if let Err(e) = self.send_request("shutdown", &null) {
|
|
||||||
tracing::warn!("LSP shutdown error: {e}");
|
|
||||||
}
|
|
||||||
if let Err(e) = self.send_request("exit", &null) {
|
|
||||||
tracing::warn!("LSP exit error: {e}");
|
|
||||||
}
|
|
||||||
if let Ok(mut inner) = self.inner.lock() {
|
|
||||||
let _ = inner.process.wait();
|
|
||||||
}
|
|
||||||
info!("LSP client shut down");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for LspClient {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if let Ok(mut inner) = self.inner.lock() {
|
|
||||||
if let Err(e) = inner.process.kill() {
|
|
||||||
tracing::warn!("LSP process kill error: {e}");
|
|
||||||
}
|
|
||||||
let _ = inner.process.wait();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
//! Manages multiple LSP server processes, keyed by language ID.
|
|
||||||
//!
|
|
||||||
//! Each language (e.g. "rust", "python") maps to one `LspClient`.
|
|
||||||
//! The manager provides a unified `request` method that dispatches
|
|
||||||
//! to the correct client by language.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use super::client::LspClient;
|
|
||||||
|
|
||||||
/// Manages one `LspClient` per language.
|
|
||||||
pub struct LspManager {
|
|
||||||
clients: HashMap<String, LspClient>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LspManager {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
LspManager {
|
|
||||||
clients: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for LspManager {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LspManager {
|
|
||||||
|
|
||||||
pub fn start(&mut self, language: &str, command: &str, args: &[String]) -> anyhow::Result<()> {
|
|
||||||
let client = LspClient::start(command, args)?;
|
|
||||||
self.clients.insert(language.to_string(), client);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_client(&self, language: &str) -> Option<&LspClient> {
|
|
||||||
self.clients.get(language)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn shutdown_all(&mut self) {
|
|
||||||
for client in self.clients.values() {
|
|
||||||
let _ = client.shutdown();
|
|
||||||
}
|
|
||||||
self.clients.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn languages(&self) -> Vec<String> {
|
|
||||||
self.clients.keys().cloned().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.clients.is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
//! Native LSP client integration — manage language server processes and
|
|
||||||
//! dispatch requests for completion, hover, diagnostics, etc.
|
|
||||||
|
|
||||||
pub mod client;
|
|
||||||
pub mod manager;
|
|
||||||
pub mod provisioner;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
//! Configuration for LSP language server provisioning.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Describes how to provision a language server for a given language.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct LspProvisionerConfig {
|
|
||||||
/// Language identifier, e.g. "rust", "python".
|
|
||||||
pub language: String,
|
|
||||||
/// The command to start the language server.
|
|
||||||
pub command: String,
|
|
||||||
/// Arguments for the command.
|
|
||||||
pub args: Vec<String>,
|
|
||||||
/// How to install the language server (if not found).
|
|
||||||
pub install_hint: Option<String>,
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
//! Discovers installed language servers on the system PATH.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use super::config::LspProvisionerConfig;
|
|
||||||
|
|
||||||
/// Known language server configurations keyed by language.
|
|
||||||
fn known_configs() -> HashMap<&'static str, (&'static str, Vec<&'static str>)> {
|
|
||||||
let mut m = HashMap::new();
|
|
||||||
m.insert("rust", ("rust-analyzer", vec![]));
|
|
||||||
m.insert("python", ("pyright-langserver", vec!["--stdio"]));
|
|
||||||
m.insert("typescript", ("typescript-language-server", vec!["--stdio"]));
|
|
||||||
m.insert("javascript", ("typescript-language-server", vec!["--stdio"]));
|
|
||||||
m.insert("go", ("gopls", vec![]));
|
|
||||||
m
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a command is available on PATH.
|
|
||||||
fn command_exists(cmd: &str) -> bool {
|
|
||||||
std::env::var_os("PATH")
|
|
||||||
.and_then(|path| {
|
|
||||||
std::env::split_paths(&path).find_map(|dir| {
|
|
||||||
let full_path = dir.join(cmd);
|
|
||||||
if full_path.is_file() {
|
|
||||||
Some(())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Discover which language servers are already on PATH.
|
|
||||||
pub fn discover_installed() -> Vec<LspProvisionerConfig> {
|
|
||||||
let mut configs = Vec::new();
|
|
||||||
for (lang, (cmd, args)) in known_configs() {
|
|
||||||
if command_exists(cmd) {
|
|
||||||
configs.push(LspProvisionerConfig {
|
|
||||||
language: lang.to_string(),
|
|
||||||
command: cmd.to_string(),
|
|
||||||
args: args.iter().map(|s| s.to_string()).collect(),
|
|
||||||
install_hint: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
configs
|
|
||||||
}
|
|
||||||
Loaded 50 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user