feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators feat(tui): create workflow panel for agent status and progress visualization feat(web): introduce web frontend interface with static file serving feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
[package]
|
||||
name = "zesdex-infrastructure"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# Infrastructure layer — concrete implementations of domain repository
|
||||
# traits, application port traits, and all platform services.
|
||||
# Depends on domain + application; NEVER on interfaces.
|
||||
[dependencies]
|
||||
zesdex-domain = { path = "../domain" }
|
||||
zesdex-application = { path = "../application" }
|
||||
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml_ng.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
reqwest.workspace = true
|
||||
rusqlite.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
hex.workspace = true
|
||||
libc.workspace = true
|
||||
dirs.workspace = true
|
||||
regex.workspace = true
|
||||
globset.workspace = true
|
||||
ignore.workspace = true
|
||||
nucleo-matcher.workspace = true
|
||||
futures-util.workspace = true
|
||||
rmcp.workspace = true
|
||||
lsp-types.workspace = true
|
||||
tiktoken-rs.workspace = true
|
||||
similar.workspace = true
|
||||
syntect.workspace = true
|
||||
pulldown-cmark.workspace = true
|
||||
infer.workspace = true
|
||||
webbrowser.workspace = true
|
||||
url.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
dom_smoothie.workspace = true
|
||||
fast_html2md.workspace = true
|
||||
scraper.workspace = true
|
||||
include_dir.workspace = true
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
axum.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
argon2.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
clap.workspace = true
|
||||
@@ -0,0 +1,50 @@
|
||||
//! JWT token utilities for HMAC-SHA256 / HS256 signing and verification.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Standard JWT claims with optional session binding.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JwtClaims {
|
||||
pub sub: String,
|
||||
pub exp: u64,
|
||||
pub iat: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl JwtClaims {
|
||||
pub fn new(sub: String, exp: u64, session_id: Option<String>) -> Self {
|
||||
let iat = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
Self {
|
||||
sub,
|
||||
exp,
|
||||
iat,
|
||||
session_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a set of claims into a JWT string using HS256.
|
||||
pub fn create_token(secret: &str, claims: JwtClaims) -> anyhow::Result<String> {
|
||||
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
|
||||
let key = jsonwebtoken::EncodingKey::from_secret(secret.as_bytes());
|
||||
let token = jsonwebtoken::encode(&header, &claims, &key)?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Verify a JWT string and return its claims.
|
||||
pub fn verify_token(secret: &str, token: &str) -> anyhow::Result<JwtClaims> {
|
||||
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
|
||||
validation.validate_exp = true;
|
||||
validation.required_spec_claims = ["sub", "exp", "iat"]
|
||||
.iter()
|
||||
.map(|&s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let key = jsonwebtoken::DecodingKey::from_secret(secret.as_bytes());
|
||||
let token_data = jsonwebtoken::decode::<JwtClaims>(token, &key, &validation)?;
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Auth service implementations: JWT signing/verification, Argon2 password
|
||||
//! hashing, and OAuth loopback server.
|
||||
|
||||
pub mod jwt;
|
||||
pub mod oauth_loopback;
|
||||
pub mod password;
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
|
||||
/// A single-use HTTP listener on `127.0.0.1` that receives the OAuth
|
||||
/// `?code=...` redirect and serves back a static confirmation page.
|
||||
pub struct LoopbackServer {
|
||||
listener: TcpListener,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl LoopbackServer {
|
||||
pub fn bind() -> std::io::Result<Self> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
Ok(LoopbackServer { listener, port })
|
||||
}
|
||||
|
||||
pub fn redirect_uri(&self) -> String {
|
||||
format!("http://127.0.0.1:{}/callback", self.port)
|
||||
}
|
||||
|
||||
pub fn wait_for_code(
|
||||
&self,
|
||||
timeout_ms: u64,
|
||||
expected_state: &str,
|
||||
) -> std::io::Result<String> {
|
||||
let (mut stream, _) = self.listener.accept()?;
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
|
||||
Self::read_callback(&mut stream, expected_state)
|
||||
}
|
||||
|
||||
fn read_callback(
|
||||
stream: &mut TcpStream,
|
||||
expected_state: &str,
|
||||
) -> std::io::Result<String> {
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = stream.read(&mut buf)?;
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
let code = Self::extract_code(&request);
|
||||
let state = Self::extract_state(&request);
|
||||
let state_ok = state.as_deref() == Some(expected_state);
|
||||
let response = match (code.as_ref(), state_ok) {
|
||||
(Some(_), true) => {
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n\
|
||||
Authorization complete. You may close this tab."
|
||||
}
|
||||
(Some(_), false) => {
|
||||
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
|
||||
State mismatch — possible CSRF attack."
|
||||
}
|
||||
(None, _) => {
|
||||
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
|
||||
Missing authorization code."
|
||||
}
|
||||
};
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
if !state_ok {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"state mismatch",
|
||||
));
|
||||
}
|
||||
code.ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, "code not found in callback")
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_code(request: &str) -> Option<String> {
|
||||
let line = request.lines().next()?;
|
||||
let path = line.split(' ').nth(1)?;
|
||||
let query = path.split('?').nth(1)?;
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
if parts.next()? == "code" {
|
||||
return parts.next().map(urlencoding);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_state(request: &str) -> Option<String> {
|
||||
let line = request.lines().next()?;
|
||||
let path = line.split(' ').nth(1)?;
|
||||
let query = path.split('?').nth(1)?;
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
if parts.next()? == "state" {
|
||||
return parts.next().map(urlencoding);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-decode a string (e.g. `%20` -> space).
|
||||
fn urlencoding(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%' {
|
||||
match (
|
||||
chars.next().and_then(|c| c.to_digit(16)),
|
||||
chars.next().and_then(|c| c.to_digit(16)),
|
||||
) {
|
||||
(Some(hi), Some(lo)) => {
|
||||
let byte: u8 = (hi as u8) * 16 + lo as u8;
|
||||
result.push(char::from(byte));
|
||||
}
|
||||
_ => {
|
||||
result.push('%');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Argon2 password hashing and verification utilities.
|
||||
|
||||
use argon2::{
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use rand_core::OsRng;
|
||||
|
||||
/// Hash a plaintext password using Argon2id with a random salt.
|
||||
pub async fn hash_password(password: &str) -> anyhow::Result<String> {
|
||||
let password = password.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?;
|
||||
Ok(hash.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))?
|
||||
}
|
||||
|
||||
/// Verify a plaintext password against a previously-hashed PHC string.
|
||||
pub async fn verify_password(password: &str, hash: &str) -> anyhow::Result<bool> {
|
||||
let password = password.to_string();
|
||||
let hash = hash.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let parsed_hash = PasswordHash::new(&hash)
|
||||
.map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?;
|
||||
let argon2 = Argon2::default();
|
||||
let valid = argon2
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok();
|
||||
Ok(valid)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))?
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Background bash control — list, cancel, and inspect background processes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::job::BashJob;
|
||||
|
||||
/// Central registry of all running background bash jobs.
|
||||
pub struct BashControl {
|
||||
jobs: Mutex<HashMap<String, Arc<BashJob>>>,
|
||||
}
|
||||
|
||||
impl BashControl {
|
||||
pub fn new() -> Self {
|
||||
BashControl {
|
||||
jobs: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new background job.
|
||||
pub fn register(&self, job: Arc<BashJob>) {
|
||||
if let Ok(mut guard) = self.jobs.lock() {
|
||||
guard.insert(job.id.clone(), job);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel a job by ID.
|
||||
pub fn cancel(&self, id: &str) -> bool {
|
||||
if let Ok(mut guard) = self.jobs.lock() {
|
||||
if let Some(job) = guard.remove(id) {
|
||||
job.cancel();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// List all active jobs.
|
||||
pub fn list(&self) -> Vec<(String, String, bool)> {
|
||||
let mut guard = self.jobs.lock().unwrap();
|
||||
guard.retain(|_, j| j.is_running());
|
||||
guard
|
||||
.iter()
|
||||
.map(|(id, job)| (id.clone(), job.command.clone(), job.is_running()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Clean up completed jobs.
|
||||
pub fn prune(&self) {
|
||||
if let Ok(mut guard) = self.jobs.lock() {
|
||||
guard.retain(|_, j| j.is_running());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Background bash job — spawns a `bash -c` subprocess and tracks its life.
|
||||
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// A handle to a spawned background bash job.
|
||||
pub struct BashJob {
|
||||
pub id: String,
|
||||
pub command: String,
|
||||
pub process: Mutex<Option<Child>>,
|
||||
pub cancelled: AtomicBool,
|
||||
}
|
||||
|
||||
/// Spawn a background bash job and return a handle.
|
||||
///
|
||||
/// The job runs until completion or until `cancel()` is called.
|
||||
pub fn spawn_bash_job(cmd: String) -> Arc<BashJob> {
|
||||
let child = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.ok();
|
||||
|
||||
let job = Arc::new(BashJob {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
command: cmd,
|
||||
process: Mutex::new(child),
|
||||
cancelled: AtomicBool::new(false),
|
||||
});
|
||||
|
||||
// Spawn a monitor thread (in production this would use an async task)
|
||||
let job_clone = Arc::clone(&job);
|
||||
std::thread::spawn(move || {
|
||||
let mut guard = job_clone.process.lock().unwrap();
|
||||
if let Some(ref mut child) = *guard {
|
||||
let _ = child.wait();
|
||||
}
|
||||
});
|
||||
|
||||
job
|
||||
}
|
||||
|
||||
impl BashJob {
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::SeqCst);
|
||||
if let Ok(mut guard) = self.process.lock() {
|
||||
if let Some(ref mut child) = *guard {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
if self.cancelled.load(Ordering::SeqCst) {
|
||||
return false;
|
||||
}
|
||||
let Ok(mut guard) = self.process.lock() else {
|
||||
return false;
|
||||
};
|
||||
guard.as_mut().map_or(false, |c| {
|
||||
matches!(c.try_wait(), Ok(None))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Background bash job management — spawn, track, and query long-running
|
||||
//! shell processes.
|
||||
|
||||
pub mod control;
|
||||
pub mod job;
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Tool gate — per-tool access control and permissions.
|
||||
|
||||
pub mod patterns;
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Tool usage patterns — detect dangerous or suspicious tool invocations.
|
||||
|
||||
/// Check whether a tool invocation matches a known dangerous pattern.
|
||||
///
|
||||
/// Returns a description of the risk if the pattern matches, or `None`
|
||||
/// if the invocation appears safe.
|
||||
pub fn check_dangerous_pattern(tool_name: &str, args: &serde_json::Value) -> Option<String> {
|
||||
match tool_name {
|
||||
"bash" => {
|
||||
let cmd = args
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
// Detect git push with --force
|
||||
if cmd.contains("git push") && cmd.contains("--force") {
|
||||
return Some("Force-pushing to git is destructive and may lose history".to_string());
|
||||
}
|
||||
// Detect rm -rf /
|
||||
if cmd.contains("rm -rf /") || cmd.contains("rm -rf /*") {
|
||||
return Some("Recursive deletion of the root filesystem is never allowed".to_string());
|
||||
}
|
||||
}
|
||||
"delete" => {
|
||||
let path = args
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if path == "/" || path.starts_with("/etc") {
|
||||
return Some(format!("Deleting '{}' is too dangerous", path));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! IPC client — connects to the daemon's Unix socket and sends/receives
|
||||
//! framed JSON messages.
|
||||
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A thread-safe IPC client connected to a Zesdex daemon over a Unix socket.
|
||||
pub struct IpcClient {
|
||||
conn: Mutex<crate::ipc::conn::Connection>,
|
||||
}
|
||||
|
||||
impl IpcClient {
|
||||
pub fn connect_unix(path: &str) -> anyhow::Result<Self> {
|
||||
let stream = UnixStream::connect(path)?;
|
||||
let conn = crate::ipc::conn::Connection::new(stream);
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn send<T: serde::Serialize>(&self, msg: &T) -> anyhow::Result<()> {
|
||||
let mut guard = self
|
||||
.conn
|
||||
.lock()
|
||||
.expect("IpcClient mutex poisoned");
|
||||
guard.send(msg)
|
||||
}
|
||||
|
||||
pub fn receive<T: serde::de::DeserializeOwned>(&self) -> anyhow::Result<Option<T>> {
|
||||
let mut guard = self
|
||||
.conn
|
||||
.lock()
|
||||
.expect("IpcClient mutex poisoned");
|
||||
guard.receive()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Connection wrapper around a Unix socket stream,
|
||||
//! pairing a buffered reader with a raw writer.
|
||||
|
||||
use std::io::BufReader;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
/// A framed JSON connection over a Unix socket.
|
||||
pub struct Connection {
|
||||
reader: BufReader<UnixStream>,
|
||||
writer: UnixStream,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
pub fn new(stream: UnixStream) -> Self {
|
||||
let reader = BufReader::new(
|
||||
stream
|
||||
.try_clone()
|
||||
.expect("UnixStream::try_clone should never fail on Linux"),
|
||||
);
|
||||
let writer = stream;
|
||||
Self { reader, writer }
|
||||
}
|
||||
|
||||
pub fn send<T: serde::Serialize>(&mut self, msg: &T) -> anyhow::Result<()> {
|
||||
let json = serde_json::to_vec(msg)?;
|
||||
crate::ipc::frame::write_frame(&mut self.writer, &json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn receive<T: serde::de::DeserializeOwned>(&mut self) -> anyhow::Result<Option<T>> {
|
||||
let raw = crate::ipc::frame::read_frame(&mut self.reader)?;
|
||||
match raw {
|
||||
None => Ok(None),
|
||||
Some(bytes) => {
|
||||
let msg: T = serde_json::from_slice(&bytes)?;
|
||||
Ok(Some(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Length-prefixed framing for Unix-socket IPC.
|
||||
//!
|
||||
//! Every message on the wire is encoded as:
|
||||
//! ```text
|
||||
//! [ 4-byte big-endian payload length ][ payload bytes (JSON) ]
|
||||
//! ```
|
||||
|
||||
use anyhow::Context;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
const MAX_PAYLOAD: u32 = 64 * 1024 * 1024;
|
||||
|
||||
/// Read one length-prefixed frame from `reader`.
|
||||
pub fn read_frame(reader: &mut impl Read) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
let mut len_buf = [0u8; 4];
|
||||
|
||||
match reader.read_exact(&mut len_buf) {
|
||||
Ok(()) => {}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(e).context("failed to read frame length prefix"),
|
||||
}
|
||||
|
||||
let payload_len = u32::from_be_bytes(len_buf) as usize;
|
||||
|
||||
if payload_len > MAX_PAYLOAD as usize {
|
||||
anyhow::bail!("frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})");
|
||||
}
|
||||
|
||||
let mut payload = vec![0u8; payload_len];
|
||||
reader.read_exact(&mut payload)?;
|
||||
|
||||
Ok(Some(payload))
|
||||
}
|
||||
|
||||
/// Write one length-prefixed frame to `writer`.
|
||||
pub fn write_frame(writer: &mut impl Write, data: &[u8]) -> anyhow::Result<()> {
|
||||
let payload_len: u32 = data.len().try_into()?;
|
||||
|
||||
if payload_len > MAX_PAYLOAD {
|
||||
anyhow::bail!("frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})");
|
||||
}
|
||||
|
||||
let len_bytes = payload_len.to_be_bytes();
|
||||
writer.write_all(&len_bytes)?;
|
||||
writer.write_all(data)?;
|
||||
writer.flush()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Unix-socket IPC layer for daemon/client communication.
|
||||
|
||||
pub mod client;
|
||||
pub mod conn;
|
||||
pub mod frame;
|
||||
pub mod protocol;
|
||||
pub mod server;
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Wire types for the Zesdex IPC protocol.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A resolved key press sent from the daemon to the client.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum KeyAction {
|
||||
Char(char),
|
||||
Enter,
|
||||
Escape,
|
||||
Backspace,
|
||||
Delete,
|
||||
Tab,
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
Home,
|
||||
End,
|
||||
PageUp,
|
||||
PageDown,
|
||||
Function(u8),
|
||||
}
|
||||
|
||||
/// A message sent from the TUI client to the daemon over the IPC socket.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ClientRequest {
|
||||
Tick,
|
||||
KeyPress {
|
||||
key: KeyAction,
|
||||
ctrl: bool,
|
||||
alt: bool,
|
||||
shift: bool,
|
||||
},
|
||||
Submit(String),
|
||||
Paste(String),
|
||||
Resize(u16, u16),
|
||||
Close,
|
||||
ScrollUp,
|
||||
ScrollDown,
|
||||
}
|
||||
|
||||
/// A single chat message within a session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageEntry {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
/// A transient toast notification sent to the client.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToastEntry {
|
||||
pub kind: String,
|
||||
pub message: String,
|
||||
pub created_at: i64,
|
||||
pub lifetime_ms: u64,
|
||||
}
|
||||
|
||||
/// Full UI state snapshot pushed from the daemon to the client.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StatePayload {
|
||||
pub session_id: String,
|
||||
pub messages: Vec<MessageEntry>,
|
||||
pub edit_count: u32,
|
||||
pub message_count: usize,
|
||||
pub overlay: Option<String>,
|
||||
pub toasts: Vec<ToastEntry>,
|
||||
pub dirty: bool,
|
||||
pub input_buffer: String,
|
||||
pub input_cursor: usize,
|
||||
}
|
||||
|
||||
/// A frame sent from the daemon to the client.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DaemonFrame {
|
||||
StateUpdate(Box<StatePayload>),
|
||||
StreamToken(String),
|
||||
SystemNote {
|
||||
kind: String,
|
||||
message: String,
|
||||
},
|
||||
ClipboardCopy(String),
|
||||
Closed,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! IPC server — binds a Unix socket and accepts incoming client connections.
|
||||
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::Path;
|
||||
|
||||
/// A Unix-socket IPC server.
|
||||
pub struct IpcServer {
|
||||
listener: UnixListener,
|
||||
}
|
||||
|
||||
impl IpcServer {
|
||||
pub fn bind_unix(path: &str) -> anyhow::Result<Self> {
|
||||
let p = Path::new(path);
|
||||
if p.exists() {
|
||||
std::fs::remove_file(p)?;
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(path)?;
|
||||
Ok(Self { listener })
|
||||
}
|
||||
|
||||
pub fn accept(&self) -> anyhow::Result<crate::ipc::conn::Connection> {
|
||||
let (stream, _addr) = self.listener.accept()?;
|
||||
Ok(crate::ipc::conn::Connection::new(stream))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! # Zesdex Infrastructure Layer
|
||||
//!
|
||||
//! ALL concrete implementations of domain repository traits, application port
|
||||
//! traits, and platform services. This is the outermost ring of the Clean
|
||||
//! Architecture onion — it depends on `zesdex-domain` and `zesdex-application`
|
||||
//! but NEVER on interface/presentation crates.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! src/
|
||||
//! ├── lib.rs — Foundational types + re-exports
|
||||
//! ├── utils.rs — CastOr, write_json_atomic, slugify
|
||||
//! ├── persistence/ — Repository implementations (IAM, CMS, SQLite)
|
||||
//! ├── auth/ — JWT, Argon2, OAuth loopback
|
||||
//! ├── llm/ — LLM provider HTTP client
|
||||
//! ├── ipc/ — Unix-socket IPC protocol
|
||||
//! ├── lsp/ — Native LSP client + provisioner
|
||||
//! ├── mcp/ — Model Context Protocol bridge
|
||||
//! ├── bgbash/ — Background bash job management
|
||||
//! ├── tools/ — All 37 agent-invocable tools
|
||||
//! ├── subagent/ — Subagent spawning & execution engine
|
||||
//! ├── workflow/ — Hive-mind orchestration engine
|
||||
//! ├── review/ — Post-edit auto-review subagent
|
||||
//! ├── guard/ — Tool-gate access control
|
||||
//! └── middleware/ — Axum HTTP middleware (auth, cors, rate-limit)
|
||||
//! ```
|
||||
|
||||
pub mod auth;
|
||||
pub mod bgbash;
|
||||
pub mod guard;
|
||||
pub mod ipc;
|
||||
pub mod llm;
|
||||
pub mod lsp;
|
||||
pub mod mcp;
|
||||
pub mod middleware;
|
||||
pub mod persistence;
|
||||
pub mod review;
|
||||
pub mod subagent;
|
||||
pub mod tools;
|
||||
pub mod utils;
|
||||
pub mod workflow;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-exports from domain
|
||||
// ---------------------------------------------------------------------------
|
||||
pub use zesdex_domain::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Foundation types — these replace `crate::app::state::*` references
|
||||
// from the legacy backend code.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Which kind of caller (main agent vs. subagent vs. reviewer) is
|
||||
/// invoking a tool, used to scope permissions and tag log/output paths.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub enum Origin {
|
||||
/// The main agent turn loop.
|
||||
Main,
|
||||
/// A spawned subagent (test-gen, arch-review, security-review, etc.).
|
||||
SubAgent,
|
||||
/// The auto-inline review step after an edit.
|
||||
Reviewer,
|
||||
}
|
||||
|
||||
impl Origin {
|
||||
/// Short string tag for this origin, used in filenames and logs.
|
||||
pub fn tag(self) -> String {
|
||||
match self {
|
||||
Origin::Main => "main",
|
||||
Origin::SubAgent => "subagent",
|
||||
Origin::Reviewer => "reviewer",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Severity/category of a toast notification, used to pick its color.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ToastKind {
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
Lesson,
|
||||
}
|
||||
|
||||
/// A transient status message shown in the TUI, auto-dismissed after `lifetime_ms`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Toast {
|
||||
pub kind: ToastKind,
|
||||
pub message: String,
|
||||
pub created_at: i64,
|
||||
pub lifetime_ms: u64,
|
||||
}
|
||||
|
||||
impl Toast {
|
||||
/// Create a toast with a default 5-second lifetime, stamped with now.
|
||||
pub fn new(kind: ToastKind, message: String) -> Self {
|
||||
Toast {
|
||||
kind,
|
||||
message,
|
||||
created_at: chrono::Utc::now().timestamp_millis(),
|
||||
lifetime_ms: 5000,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this toast's lifetime has elapsed as of `now_ms`.
|
||||
pub fn expired(&self, now_ms: i64) -> bool {
|
||||
let lifetime = self.lifetime_ms as i64;
|
||||
now_ms - self.created_at > lifetime
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared, async-writable cache of directory entries, used to avoid
|
||||
/// re-reading a directory every render frame.
|
||||
#[derive(Clone)]
|
||||
pub struct DirCache {
|
||||
entries: Arc<tokio::sync::RwLock<Vec<PathBuf>>>,
|
||||
}
|
||||
|
||||
impl DirCache {
|
||||
pub fn new() -> Self {
|
||||
DirCache {
|
||||
entries: Arc::new(tokio::sync::RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set(&self, paths: Vec<PathBuf>) {
|
||||
let mut w = self.entries.write().await;
|
||||
*w = paths;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DirCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared, whole-workspace file-path index used for `@file` mention
|
||||
/// autocomplete.
|
||||
#[derive(Clone)]
|
||||
pub struct MentionIndex {
|
||||
entries: Arc<std::sync::RwLock<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl MentionIndex {
|
||||
pub fn new() -> Self {
|
||||
MentionIndex {
|
||||
entries: Arc::new(std::sync::RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(&self, paths: Vec<String>) {
|
||||
if let Ok(mut w) = self.entries.write() {
|
||||
*w = paths;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&self, path: String) {
|
||||
if let Ok(mut w) = self.entries.write() {
|
||||
w.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Vec<String> {
|
||||
self.entries.read().map(|r| r.clone()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MentionIndex {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TurnEvent & runtime types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Events emitted onto the turn-event queue while an agent turn runs,
|
||||
/// consumed by the event loop to update state and drive re-renders.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TurnEvent {
|
||||
AssistantMessage(ChatMessage),
|
||||
ToolResult {
|
||||
tool_call_id: String,
|
||||
tool_name: String,
|
||||
output: String,
|
||||
is_error: bool,
|
||||
path: Option<String>,
|
||||
},
|
||||
SystemNote {
|
||||
kind: String,
|
||||
message: String,
|
||||
},
|
||||
StreamStart,
|
||||
StreamToken(String),
|
||||
StreamDone(ChatMessage),
|
||||
Usage {
|
||||
tokens_in: u64,
|
||||
tokens_out: u64,
|
||||
},
|
||||
ReviewUsage {
|
||||
tokens_in: u64,
|
||||
tokens_out: u64,
|
||||
},
|
||||
Compacted(Vec<ChatMessage>),
|
||||
Error(String),
|
||||
Done,
|
||||
WorkflowAgentUpdate {
|
||||
agent_id: String,
|
||||
agent_name: String,
|
||||
status: crate::AgentStatus,
|
||||
},
|
||||
}
|
||||
|
||||
/// A tool call awaiting execution, along with which execution model
|
||||
/// (inline, deferred, async) it should run under.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingTool {
|
||||
pub tool_name: String,
|
||||
pub args: serde_json::Value,
|
||||
pub execution_model: ExecutionModel,
|
||||
}
|
||||
|
||||
/// How a pending tool call should be executed when the turn resumes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExecutionModel {
|
||||
Inline,
|
||||
Deferred,
|
||||
AsyncTokio,
|
||||
}
|
||||
|
||||
/// Reference to a background bash job tracked in session state.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BashJobRef {
|
||||
pub id: String,
|
||||
pub command: String,
|
||||
pub started_at: i64,
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
/// Per-session runtime state: message history, pending tool queue,
|
||||
/// background bash jobs, lesson/review counters.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionRuntime {
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub tool_call_results: Vec<ToolCallResult>,
|
||||
pub pending_tool_queue: Vec<PendingTool>,
|
||||
pub bash_jobs: Vec<BashJobRef>,
|
||||
pub subagent_queue: usize,
|
||||
pub edit_count: u32,
|
||||
pub consecutive_empty_reviews: u32,
|
||||
pub session_start: i64,
|
||||
pub lesson_count: u32,
|
||||
pub lessons_user: u32,
|
||||
pub lessons_feedback: u32,
|
||||
pub lessons_project: u32,
|
||||
pub lessons_reference: u32,
|
||||
pub lessons_active: u32,
|
||||
pub lessons_stale: u32,
|
||||
pub lessons_contradicted: u32,
|
||||
pub lessons_human: u32,
|
||||
pub lessons_verified: u32,
|
||||
pub lessons_unverified: u32,
|
||||
pub review_count: u32,
|
||||
pub session_dir: PathBuf,
|
||||
pub usage: UsageStats,
|
||||
pub hive_mind_converged: bool,
|
||||
}
|
||||
|
||||
impl SessionRuntime {
|
||||
pub fn new(session_dir: PathBuf) -> Self {
|
||||
SessionRuntime {
|
||||
messages: Vec::new(),
|
||||
tool_call_results: Vec::new(),
|
||||
pending_tool_queue: Vec::new(),
|
||||
bash_jobs: Vec::new(),
|
||||
subagent_queue: 0,
|
||||
edit_count: 0,
|
||||
consecutive_empty_reviews: 0,
|
||||
session_start: chrono::Utc::now().timestamp_millis(),
|
||||
lesson_count: 0,
|
||||
lessons_user: 0,
|
||||
lessons_feedback: 0,
|
||||
lessons_project: 0,
|
||||
lessons_reference: 0,
|
||||
lessons_active: 0,
|
||||
lessons_stale: 0,
|
||||
lessons_contradicted: 0,
|
||||
lessons_human: 0,
|
||||
lessons_verified: 0,
|
||||
lessons_unverified: 0,
|
||||
review_count: 0,
|
||||
session_dir,
|
||||
usage: UsageStats::default(),
|
||||
hive_mind_converged: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_message(&mut self, msg: ChatMessage) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple ASCII progress display for a long-running operation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProgressState {
|
||||
pub current: u64,
|
||||
pub total: u64,
|
||||
pub message: String,
|
||||
pub start_time: i64,
|
||||
}
|
||||
|
||||
/// Agent status for workflow engine progress tracking.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum AgentStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AgentStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AgentStatus::Pending => write!(f, "pending"),
|
||||
AgentStatus::Running => write!(f, "running"),
|
||||
AgentStatus::Completed => write!(f, "completed"),
|
||||
AgentStatus::Failed(msg) => write!(f, "failed: {msg}"),
|
||||
AgentStatus::Cancelled => write!(f, "cancelled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool types — needed by all tool modules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub use tools::{GraduatedCheck, Tool, ToolCtx, ToolCtxBuilder};
|
||||
|
||||
// Re-export commonly needed types at the crate root
|
||||
pub use zesdex_domain::core::{ChatMessage, Role, Store, UsageStats, ToolCallResult};
|
||||
@@ -0,0 +1,5 @@
|
||||
//! LLM provider HTTP client for OpenAI/Anthropic-compatible chat completion APIs.
|
||||
|
||||
pub mod provider;
|
||||
|
||||
pub use provider::{resolve_api_key, LlmClient};
|
||||
@@ -0,0 +1,479 @@
|
||||
//! Blocking HTTP client for OpenAI/Anthropic-compatible chat completion APIs,
|
||||
//! supporting both non-streaming and SSE-streaming requests with automatic retry.
|
||||
|
||||
use rand_core::RngCore;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::time::Duration;
|
||||
|
||||
use zesdex_domain::core::{
|
||||
ChatMessage, ChatRequest, ChatResponse, SseParser, StreamEvent, StreamOptions, ToolDef,
|
||||
};
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://opencode.ai/zen/v1";
|
||||
const DEFAULT_MODEL: &str = "deepseek-v4-flash-free";
|
||||
pub const DEFAULT_API_KEY: &str = "";
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retry helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn backoff_seconds(attempt: u32, cap: u64) -> Duration {
|
||||
let base = 2u64.pow(attempt.saturating_sub(1));
|
||||
let delay = std::cmp::min(base, cap);
|
||||
// ±25% jitter
|
||||
let jitter_factor = 0.75 + (rand_core::OsRng.next_u32() % 51) as f64 / 100.0;
|
||||
Duration::from_secs_f64(delay as f64 * jitter_factor)
|
||||
}
|
||||
|
||||
/// Is the error an auth / billing failure that retrying won't fix?
|
||||
pub fn is_auth_error(err_str: &str) -> bool {
|
||||
let err_lower = err_str.to_lowercase();
|
||||
(err_str.contains("API error 401")
|
||||
|| err_str.contains("API error 402")
|
||||
|| err_str.contains("API error 403"))
|
||||
|| err_lower.contains("unauthorized")
|
||||
|| err_lower.contains("forbidden")
|
||||
|| err_lower.contains("authentication failed")
|
||||
}
|
||||
|
||||
fn is_rate_limit(err_str: &str) -> bool {
|
||||
err_str.contains("API error 429") || err_str.to_lowercase().contains("rate limit")
|
||||
}
|
||||
|
||||
fn backoff_for_error(attempt: u32, err_str: &str) -> Duration {
|
||||
if is_rate_limit(err_str) {
|
||||
backoff_seconds(attempt, 60)
|
||||
} else {
|
||||
backoff_seconds(attempt, 30)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Blocking HTTP client for a single LLM provider endpoint.
|
||||
pub struct LlmClient {
|
||||
pub client: reqwest::blocking::Client,
|
||||
pub api_key: String,
|
||||
pub base_url: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
impl LlmClient {
|
||||
pub fn new(mut api_key: String, model: String, base_url: Option<String>) -> Self {
|
||||
if api_key.is_empty() {
|
||||
api_key = DEFAULT_API_KEY.to_string();
|
||||
}
|
||||
let model = if model.is_empty() {
|
||||
DEFAULT_MODEL.to_string()
|
||||
} else {
|
||||
model
|
||||
};
|
||||
let client = match reqwest::blocking::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"failed to build reqwest client with connect timeout: {}. \
|
||||
retrying without connect timeout",
|
||||
e,
|
||||
);
|
||||
match reqwest::blocking::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e2) => {
|
||||
tracing::warn!("also failed: {e2}. using default client");
|
||||
reqwest::blocking::Client::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
LlmClient {
|
||||
client,
|
||||
api_key,
|
||||
base_url: base_url
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
|
||||
model,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chat_with_tools_non_streaming(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
tools: Option<Vec<ToolDef>>,
|
||||
max_tokens: Option<u32>,
|
||||
temperature: Option<f32>,
|
||||
abort_flag: Option<&AtomicBool>,
|
||||
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||
let req = ChatRequest {
|
||||
model: self.model.clone(),
|
||||
messages: messages.to_vec(),
|
||||
max_tokens: Some(max_tokens.unwrap_or(4096)),
|
||||
temperature: Some(temperature.unwrap_or(0.7)),
|
||||
tools,
|
||||
stream: Some(false),
|
||||
stop: None,
|
||||
stream_options: None,
|
||||
tool_choice: None,
|
||||
top_p: None,
|
||||
};
|
||||
|
||||
let url = format!("{}/chat/completions", self.base_url);
|
||||
let max_retries = 10;
|
||||
let mut attempt = 0u32;
|
||||
|
||||
loop {
|
||||
attempt += 1;
|
||||
|
||||
if let Some(ref flag) = abort_flag {
|
||||
if flag.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
anyhow::bail!("aborted");
|
||||
}
|
||||
}
|
||||
|
||||
let mut http_req = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json");
|
||||
|
||||
if !self.api_key.is_empty() {
|
||||
http_req =
|
||||
http_req.header("Authorization", format!("Bearer {}", self.api_key));
|
||||
}
|
||||
|
||||
let result =
|
||||
(|| -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||
let resp = http_req.json(&req).send().map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
anyhow::anyhow!(
|
||||
"API request timed out after {REQUEST_TIMEOUT:?}. \
|
||||
Check your network or try again."
|
||||
)
|
||||
} else if e.is_connect() {
|
||||
anyhow::anyhow!(
|
||||
"Could not connect to {}. \
|
||||
Is the URL correct and is the service reachable?",
|
||||
self.base_url
|
||||
)
|
||||
} else {
|
||||
anyhow::anyhow!("API request failed: {e}")
|
||||
}
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().unwrap_or_default();
|
||||
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
|
||||
}
|
||||
|
||||
let data: ChatResponse = resp.json()?;
|
||||
let usage = data.usage.map(|u| {
|
||||
(u64::from(u.prompt_tokens), u64::from(u.completion_tokens))
|
||||
});
|
||||
let message = data
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|c| c.message)
|
||||
.ok_or_else(|| anyhow::anyhow!("API response had no choices"))?;
|
||||
Ok((message, usage))
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok((msg, usage)) => return Ok((msg, usage)),
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if attempt >= max_retries || is_auth_error(&err_str) {
|
||||
return Err(e);
|
||||
}
|
||||
let delay = backoff_for_error(attempt, &err_str);
|
||||
std::thread::sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chat_with_tools_streaming(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
tools: Option<Vec<ToolDef>>,
|
||||
temperature: Option<f32>,
|
||||
max_tokens: Option<u32>,
|
||||
mut on_event: impl FnMut(&StreamEvent) -> bool,
|
||||
abort_flag: Option<&AtomicBool>,
|
||||
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||
let tools_for_fallback = tools.clone();
|
||||
let req = ChatRequest {
|
||||
model: self.model.clone(),
|
||||
messages: messages.to_vec(),
|
||||
max_tokens: Some(max_tokens.unwrap_or(4096)),
|
||||
temperature: Some(temperature.unwrap_or(0.7)),
|
||||
tools,
|
||||
stream: Some(true),
|
||||
stop: None,
|
||||
stream_options: Some(StreamOptions {
|
||||
include_usage: true,
|
||||
}),
|
||||
tool_choice: None,
|
||||
top_p: None,
|
||||
};
|
||||
|
||||
let url = format!("{}/chat/completions", self.base_url);
|
||||
let max_retries_stream = 5;
|
||||
let mut attempt = 0u32;
|
||||
let mut meaningful_content = false;
|
||||
|
||||
loop {
|
||||
attempt += 1;
|
||||
let mut captured_content = false;
|
||||
let mut wrapped = |event: &StreamEvent| -> bool {
|
||||
match event {
|
||||
StreamEvent::Token(_) | StreamEvent::Reasoning(_) => {
|
||||
captured_content = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
on_event(event)
|
||||
};
|
||||
match self.try_stream_once(&req, &url, &mut wrapped) {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
if is_auth_error(&err_str) {
|
||||
return Err(e);
|
||||
}
|
||||
if captured_content || (attempt >= max_retries_stream) {
|
||||
meaningful_content = captured_content || meaningful_content;
|
||||
break;
|
||||
}
|
||||
if attempt >= max_retries_stream {
|
||||
return Err(e);
|
||||
}
|
||||
let delay = backoff_for_error(attempt, &err_str);
|
||||
std::thread::sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if meaningful_content {
|
||||
if let Some(ref flag) = abort_flag {
|
||||
if flag.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Err(anyhow::anyhow!("aborted"));
|
||||
}
|
||||
}
|
||||
return self.chat_with_tools_non_streaming(
|
||||
messages,
|
||||
tools_for_fallback,
|
||||
max_tokens,
|
||||
temperature,
|
||||
abort_flag,
|
||||
);
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"streaming request failed after {max_retries_stream} attempts"
|
||||
))
|
||||
}
|
||||
|
||||
fn try_stream_once(
|
||||
&self,
|
||||
req: &ChatRequest,
|
||||
url: &str,
|
||||
on_event: &mut dyn FnMut(&StreamEvent) -> bool,
|
||||
) -> anyhow::Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||
use std::io::Read;
|
||||
|
||||
let mut http_req = self
|
||||
.client
|
||||
.post(url)
|
||||
.header("Content-Type", "application/json");
|
||||
if !self.api_key.is_empty() {
|
||||
http_req =
|
||||
http_req.header("Authorization", format!("Bearer {}", self.api_key));
|
||||
}
|
||||
|
||||
let resp = http_req.json(req).send().map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
anyhow::anyhow!(
|
||||
"API request timed out after {REQUEST_TIMEOUT:?}. \
|
||||
Check your network or try again."
|
||||
)
|
||||
} else if e.is_connect() {
|
||||
anyhow::anyhow!(
|
||||
"Could not connect to {}. \
|
||||
Is the URL correct and is the service reachable?",
|
||||
self.base_url
|
||||
)
|
||||
} else {
|
||||
anyhow::anyhow!("API request failed: {e}")
|
||||
}
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().unwrap_or_default();
|
||||
anyhow::bail!("API error {} from {}: {}", status, self.base_url, body);
|
||||
}
|
||||
|
||||
struct StreamedTurn {
|
||||
content: String,
|
||||
tool_calls: Vec<zesdex_domain::core::ToolCall>,
|
||||
done_received: bool,
|
||||
}
|
||||
|
||||
impl StreamedTurn {
|
||||
fn new() -> Self {
|
||||
StreamedTurn {
|
||||
content: String::new(),
|
||||
tool_calls: Vec::new(),
|
||||
done_received: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_event(&mut self, event: &StreamEvent) {
|
||||
match event {
|
||||
StreamEvent::Token(t) => self.content.push_str(t),
|
||||
StreamEvent::Reasoning(_) => {}
|
||||
StreamEvent::ToolCallDelta {
|
||||
index: _,
|
||||
id,
|
||||
name,
|
||||
arguments_delta,
|
||||
} => {
|
||||
let existing = self.tool_calls.iter_mut().find(|tc| {
|
||||
if let Some(ref id_val) = id {
|
||||
tc.id == *id_val
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
if let Some(tc) = existing {
|
||||
if let Some(ref n) = name {
|
||||
tc.function.name = n.clone();
|
||||
}
|
||||
} else {
|
||||
self.tool_calls.push(
|
||||
zesdex_domain::core::ToolCall {
|
||||
id: id.clone().unwrap_or_default(),
|
||||
type_: "function".to_string(),
|
||||
function: zesdex_domain::core::ToolFunction {
|
||||
name: name.clone().unwrap_or_default(),
|
||||
arguments: serde_json::Value::String(arguments_delta.clone()),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_assistant_message(self) -> ChatMessage {
|
||||
ChatMessage {
|
||||
role: zesdex_domain::core::Role::Assistant,
|
||||
content: if self.content.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.content)
|
||||
},
|
||||
tool_calls: if self.tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.tool_calls)
|
||||
},
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut turn = StreamedTurn::new();
|
||||
let mut usage: Option<(u64, u64)> = None;
|
||||
let mut parser = SseParser::new();
|
||||
|
||||
let mut reader = resp;
|
||||
let mut byte_buf: Vec<u8> = Vec::new();
|
||||
let mut chunk_buf = [0u8; 4096];
|
||||
|
||||
loop {
|
||||
let n = reader.read(&mut chunk_buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
byte_buf.extend_from_slice(&chunk_buf[..n]);
|
||||
let valid_len = match std::str::from_utf8(&byte_buf) {
|
||||
Ok(s) => s.len(),
|
||||
Err(e) => e.valid_up_to(),
|
||||
};
|
||||
if valid_len == 0 {
|
||||
continue;
|
||||
}
|
||||
let text =
|
||||
String::from_utf8_lossy(&byte_buf[..valid_len]).into_owned();
|
||||
byte_buf.drain(..valid_len);
|
||||
|
||||
for event in parser.feed(&text) {
|
||||
if !on_event(&event) {
|
||||
anyhow::bail!("aborted");
|
||||
}
|
||||
match &event {
|
||||
StreamEvent::Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
..
|
||||
} => {
|
||||
usage = Some((*prompt_tokens, *completion_tokens));
|
||||
}
|
||||
StreamEvent::Error(msg) => {
|
||||
anyhow::bail!("stream error: {msg}");
|
||||
}
|
||||
StreamEvent::Done => {
|
||||
turn.apply_event(&event);
|
||||
turn.done_received = true;
|
||||
return Ok((turn.build_assistant_message(), usage));
|
||||
}
|
||||
_ => turn.apply_event(&event),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((turn.build_assistant_message(), usage))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the API key for the currently configured provider, falling back
|
||||
/// through settings -> env var -> provider default.
|
||||
pub fn resolve_api_key(
|
||||
settings: &zesdex_domain::cms::Settings,
|
||||
app_config: &zesdex_domain::cms::AppConfig,
|
||||
) -> String {
|
||||
let provider = &settings.provider;
|
||||
|
||||
let mut api_key = settings
|
||||
.api_keys
|
||||
.get(provider)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
if api_key.is_empty() {
|
||||
if let Some(provider_cfg) = app_config.providers.get(provider) {
|
||||
api_key = provider_cfg
|
||||
.api_key_env
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
.or_else(|| provider_cfg.default_api_key.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
api_key
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! 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().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
|
||||
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 = self.inner.lock().unwrap();
|
||||
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;
|
||||
let _ = self.send_request("shutdown", &null);
|
||||
let _ = self.send_request("exit", &null);
|
||||
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() {
|
||||
let _ = inner.process.kill();
|
||||
let _ = inner.process.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! 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(),
|
||||
}
|
||||
}
|
||||
|
||||
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 (_lang, client) in &self.clients {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,16 @@
|
||||
//! 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>,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! 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
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Installs language servers (non-interactive, via package managers or
|
||||
//! direct download).
|
||||
|
||||
/// Install a language server for the given language.
|
||||
///
|
||||
/// Returns a success message or an error describing why installation failed.
|
||||
pub fn install_language_server(language: &str) -> anyhow::Result<String> {
|
||||
match language {
|
||||
"rust" => {
|
||||
// rust-analyzer is typically installed via rustup
|
||||
let output = std::process::Command::new("rustup")
|
||||
.args(["component", "add", "rust-analyzer"])
|
||||
.output()?;
|
||||
if output.status.success() {
|
||||
Ok("rust-analyzer installed via rustup".to_string())
|
||||
} else {
|
||||
anyhow::bail!("failed to install rust-analyzer: {}", String::from_utf8_lossy(&output.stderr))
|
||||
}
|
||||
}
|
||||
"python" => {
|
||||
let output = std::process::Command::new("npm")
|
||||
.args(["install", "-g", "pyright"])
|
||||
.output()?;
|
||||
if output.status.success() {
|
||||
Ok("pyright installed via npm".to_string())
|
||||
} else {
|
||||
anyhow::bail!("failed to install pyright: {}", String::from_utf8_lossy(&output.stderr))
|
||||
}
|
||||
}
|
||||
lang => anyhow::bail!("no install method known for language '{lang}'"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! High-level manager that discovers, installs (if needed), and starts
|
||||
//! LSP servers.
|
||||
|
||||
use crate::lsp::manager::LspManager;
|
||||
use super::discovery::discover_installed;
|
||||
use super::install::install_language_server;
|
||||
|
||||
/// Auto-provision language servers for the given list of languages.
|
||||
///
|
||||
/// Flow: discover already-installed servers → for each requested language
|
||||
/// not yet available, attempt auto-install → start each server.
|
||||
pub fn auto_provision(
|
||||
lsp_manager: &mut LspManager,
|
||||
languages: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut started = Vec::new();
|
||||
let installed = discover_installed();
|
||||
let mut installed_map: std::collections::HashMap<&str, &crate::lsp::provisioner::config::LspProvisionerConfig> = std::collections::HashMap::new();
|
||||
for cfg in &installed {
|
||||
installed_map.insert(cfg.language.as_str(), cfg);
|
||||
}
|
||||
|
||||
for lang in languages {
|
||||
if let Some(cfg) = installed_map.get(lang.as_str()) {
|
||||
if lsp_manager.start(lang, &cfg.command, &cfg.args).is_ok() {
|
||||
started.push(lang.clone());
|
||||
}
|
||||
} else {
|
||||
// Not installed — try auto-install
|
||||
if install_language_server(lang).is_ok() {
|
||||
// Re-discover after install
|
||||
let refreshed = discover_installed();
|
||||
for cfg in refreshed {
|
||||
if cfg.language == *lang {
|
||||
if lsp_manager.start(lang, &cfg.command, &cfg.args).is_ok() {
|
||||
started.push(lang.clone());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
started
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! LSP language server provisioner — discovers, installs, and manages
|
||||
//! language server executables.
|
||||
|
||||
pub mod config;
|
||||
pub mod discovery;
|
||||
pub mod install;
|
||||
pub mod manager;
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Manages MCP server connections — start, stop, list, and dispatch
|
||||
//! tool calls to remote MCP servers.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Metadata for a connected MCP server.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpServerHandle {
|
||||
pub name: String,
|
||||
pub transport: String,
|
||||
}
|
||||
|
||||
/// Manages MCP server connections.
|
||||
#[derive(Clone)]
|
||||
pub struct McpManager {
|
||||
servers: HashMap<String, McpServerHandle>,
|
||||
}
|
||||
|
||||
impl McpManager {
|
||||
pub fn new() -> Self {
|
||||
McpManager {
|
||||
servers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&mut self, name: &str, transport: &str) {
|
||||
self.servers.insert(
|
||||
name.to_string(),
|
||||
McpServerHandle {
|
||||
name: name.to_string(),
|
||||
transport: transport.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn unregister(&mut self, name: &str) {
|
||||
self.servers.remove(name);
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<McpServerHandle> {
|
||||
self.servers.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn get(&self, name: &str) -> Option<&McpServerHandle> {
|
||||
self.servers.get(name)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.servers.is_empty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Model Context Protocol (MCP) — bridge between agent tools and external MCP
|
||||
//! servers using the rmcp crate.
|
||||
|
||||
pub mod manager;
|
||||
pub mod transport;
|
||||
@@ -0,0 +1,40 @@
|
||||
//! MCP transport layer — manages child-process and HTTP-based transport
|
||||
//! for connecting to MCP servers.
|
||||
|
||||
use std::process::{Child, Command, Stdio};
|
||||
|
||||
/// A running MCP server process connected via stdio.
|
||||
pub struct McpTransport {
|
||||
process: Option<Child>,
|
||||
}
|
||||
|
||||
impl McpTransport {
|
||||
pub fn start_child_process(command: &str, args: &[String]) -> anyhow::Result<Self> {
|
||||
let child = Command::new(command)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()?;
|
||||
Ok(McpTransport {
|
||||
process: Some(child),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) -> anyhow::Result<()> {
|
||||
if let Some(mut child) = self.process.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpTransport {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut child) = self.process.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Authentication middleware — session-lock based auth for Axum.
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, Response, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower::{Layer, Service};
|
||||
|
||||
/// Identity extracted from a validated session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionIdentity {
|
||||
pub session_id: String,
|
||||
pub user_agent: String,
|
||||
pub connected_at: i64,
|
||||
}
|
||||
|
||||
impl SessionIdentity {
|
||||
pub fn new(session_id: String, user_agent: String) -> Self {
|
||||
let connected_at = chrono::Utc::now().timestamp();
|
||||
Self {
|
||||
session_id,
|
||||
user_agent,
|
||||
connected_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tower Layer that produces SessionAuthMiddleware services.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionAuthLayer;
|
||||
|
||||
impl SessionAuthLayer {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionAuthLayer {
|
||||
fn default() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for SessionAuthLayer {
|
||||
type Service = SessionAuthMiddleware<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
SessionAuthMiddleware { inner }
|
||||
}
|
||||
}
|
||||
|
||||
/// Tower Service that validates X-Session-Id before forwarding.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionAuthMiddleware<S> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
impl<S, ReqBody> Service<Request<ReqBody>> for SessionAuthMiddleware<S>
|
||||
where
|
||||
S: Service<Request<ReqBody>, Response = Response<Body>> + Send + 'static,
|
||||
S::Future: Send + 'static,
|
||||
ReqBody: Send + 'static,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
type Future =
|
||||
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
|
||||
let session_id = req
|
||||
.headers()
|
||||
.get("X-Session-Id")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if session_id.as_deref() != Some("valid-session") {
|
||||
// In production, this validates against the store
|
||||
return Box::pin(async move {
|
||||
Ok((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"missing or invalid X-Session-Id header",
|
||||
)
|
||||
.into_response())
|
||||
});
|
||||
}
|
||||
|
||||
let fut = self.inner.call(req);
|
||||
Box::pin(fut)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! CORS layer factory for the daemon HTTP server.
|
||||
|
||||
use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer};
|
||||
|
||||
/// Return a permissive CorsLayer for local daemon IPC.
|
||||
pub fn default_cors_layer() -> CorsLayer {
|
||||
CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::any())
|
||||
.allow_methods([
|
||||
"GET".parse().unwrap(),
|
||||
"POST".parse().unwrap(),
|
||||
"PUT".parse().unwrap(),
|
||||
"DELETE".parse().unwrap(),
|
||||
"PATCH".parse().unwrap(),
|
||||
"OPTIONS".parse().unwrap(),
|
||||
])
|
||||
.allow_headers(AllowHeaders::any())
|
||||
.expose_headers([
|
||||
"Content-Type".parse().unwrap(),
|
||||
"X-Session-Id".parse().unwrap(),
|
||||
"X-Request-Id".parse().unwrap(),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Axum middleware tower for the HTTP API layer.
|
||||
|
||||
pub mod auth;
|
||||
pub mod cors;
|
||||
pub mod rate_limit;
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Simple in-memory rate limiter for Axum.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// In-memory sliding-window rate limiter.
|
||||
#[derive(Debug)]
|
||||
pub struct RateLimiter {
|
||||
windows: Mutex<HashMap<String, Vec<i64>>>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new() -> Self {
|
||||
RateLimiter {
|
||||
windows: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_rate_limit(
|
||||
&self,
|
||||
client_id: &str,
|
||||
max_requests: u32,
|
||||
window_secs: u64,
|
||||
) -> anyhow::Result<bool> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
let cutoff = now.saturating_sub(window_secs as i64);
|
||||
let mut windows = self.windows.lock().map_err(|e| {
|
||||
anyhow::anyhow!("rate limiter lock poisoned: {e}")
|
||||
})?;
|
||||
|
||||
let timestamps = windows.entry(client_id.to_string()).or_insert_with(Vec::new);
|
||||
timestamps.retain(|&ts| ts >= cutoff);
|
||||
|
||||
if timestamps.len() >= max_requests as usize {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
timestamps.push(now);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn reset(&self) -> anyhow::Result<()> {
|
||||
let mut windows = self
|
||||
.windows
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("rate limiter lock poisoned: {e}"))?;
|
||||
windows.clear();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RateLimiter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! JSON file–backed `AppConfigRepository` with Claude credential auto-detection.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zesdex_domain::cms::{AppConfig, AppConfigRepository, ModelRole, ProviderConfig, RepositoryError};
|
||||
|
||||
use crate::utils::write_json_atomic;
|
||||
|
||||
/// File-based `AppConfigRepository` that reads/writes `app_config.json`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JsonAppConfigRepository;
|
||||
|
||||
impl JsonAppConfigRepository {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ClaudeEnv {
|
||||
#[serde(alias = "ANTHROPIC_BASE_URL")]
|
||||
anthropic_base_url: Option<String>,
|
||||
#[serde(alias = "ANTHROPIC_API_KEY")]
|
||||
anthropic_api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ClaudeSettings {
|
||||
env: Option<ClaudeEnv>,
|
||||
}
|
||||
|
||||
fn claude_credentials_from_file() -> Option<(String, String)> {
|
||||
let path = dirs::home_dir()?.join(".claude").join("settings.json");
|
||||
let content = std::fs::read_to_string(&path).ok()?;
|
||||
let settings: ClaudeSettings = serde_json::from_str(&content).ok()?;
|
||||
let env = settings.env?;
|
||||
let base_url = env.anthropic_base_url?;
|
||||
let key = env.anthropic_api_key?;
|
||||
Some((base_url, key))
|
||||
}
|
||||
|
||||
fn claude_credentials_from_env() -> Option<(String, String)> {
|
||||
let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?;
|
||||
let key = std::env::var("ANTHROPIC_API_KEY").ok()?;
|
||||
Some((base_url, key))
|
||||
}
|
||||
|
||||
fn detect_claude_settings_provider() -> Option<ProviderConfig> {
|
||||
let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?;
|
||||
Some(ProviderConfig {
|
||||
api_base: base_url,
|
||||
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
|
||||
default_model: None,
|
||||
default_api_key: Some(key),
|
||||
})
|
||||
}
|
||||
|
||||
impl AppConfigRepository for JsonAppConfigRepository {
|
||||
fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError> {
|
||||
let path = base_dir.join("app_config.json");
|
||||
let mut cfg: AppConfig = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => serde_json::from_str(&s)?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
AppConfig::default()
|
||||
}
|
||||
Err(e) => return Err(RepositoryError::Io(e)),
|
||||
};
|
||||
|
||||
let defaults = AppConfig::default();
|
||||
for (name, provider) in defaults.providers {
|
||||
cfg.providers.entry(name).or_insert(provider);
|
||||
}
|
||||
|
||||
if let Some(claude_provider) = detect_claude_settings_provider() {
|
||||
cfg.providers
|
||||
.entry("claude".to_string())
|
||||
.or_insert(claude_provider);
|
||||
|
||||
let claude_models: [(&str, &str); 3] = [
|
||||
("claude-opus-4-8", "claude-opus-4-8"),
|
||||
("claude-sonnet-5", "claude-sonnet-5"),
|
||||
("claude-haiku-4-5", "claude-haiku-4-5-20251001"),
|
||||
];
|
||||
for (role_name, model_name) in &claude_models {
|
||||
cfg.model_roles
|
||||
.entry(role_name.to_string())
|
||||
.or_insert(ModelRole {
|
||||
provider: "claude".to_string(),
|
||||
model: model_name.to_string(),
|
||||
max_tokens: Some(8192),
|
||||
context_window: Some(200_000),
|
||||
temperature: Some(0.7),
|
||||
});
|
||||
}
|
||||
|
||||
if cfg.default_provider == defaults.default_provider {
|
||||
cfg.default_provider = "claude".to_string();
|
||||
cfg.default_model = "claude-opus-4-8".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError> {
|
||||
std::fs::create_dir_all(base_dir)?;
|
||||
let path = base_dir.join("app_config.json");
|
||||
write_json_atomic(&path, config, None)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! JSON file–backed `ConversationRepository`.
|
||||
//! Stores `Conversation` at `<session_dir>/conversation.json`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use zesdex_domain::cms::{Conversation, ConversationRepository, RepositoryError};
|
||||
|
||||
use crate::utils::write_json_atomic;
|
||||
|
||||
/// File-based `ConversationRepository` that reads/writes `conversation.json`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JsonConversationRepository;
|
||||
|
||||
impl JsonConversationRepository {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl ConversationRepository for JsonConversationRepository {
|
||||
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError> {
|
||||
let path = session_dir.join("conversation.json");
|
||||
let data = std::fs::read_to_string(&path)?;
|
||||
let conv: Conversation = serde_json::from_str(&data)?;
|
||||
Ok(conv)
|
||||
}
|
||||
|
||||
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<(), RepositoryError> {
|
||||
std::fs::create_dir_all(session_dir)?;
|
||||
let path = session_dir.join("conversation.json");
|
||||
write_json_atomic(&path, conversation, None)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! JSONL file–backed `EditLogRepository`.
|
||||
//! Stores `EditLog` as an append-only newline-delimited JSON file.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use zesdex_domain::cms::{EditLog, EditLogEntry, EditLogRepository, RepositoryError};
|
||||
|
||||
/// Maximum number of edit entries held in memory at once.
|
||||
const MAX_MEMORY_ENTRIES: usize = 10_000;
|
||||
|
||||
/// File-based `EditLogRepository` that reads/writes `edits.jsonl`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JsonlEditLogRepository;
|
||||
|
||||
impl JsonlEditLogRepository {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn load_from_disk(path: &Path) -> Vec<EditLogEntry> {
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let reader = BufReader::new(file);
|
||||
let mut entries: Vec<EditLogEntry> = Vec::new();
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) {
|
||||
if entries.len() >= MAX_MEMORY_ENTRIES {
|
||||
entries.remove(0);
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
}
|
||||
|
||||
impl EditLogRepository for JsonlEditLogRepository {
|
||||
fn open(&self, session_dir: &Path) -> Result<EditLog, RepositoryError> {
|
||||
let path = session_dir.join("edits.jsonl");
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let entries = Self::load_from_disk(&path);
|
||||
if !path.exists() {
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?;
|
||||
}
|
||||
Ok(EditLog { entries })
|
||||
}
|
||||
|
||||
fn append(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
log: &mut EditLog,
|
||||
entry: EditLogEntry,
|
||||
) -> Result<(), RepositoryError> {
|
||||
let path = session_dir.join("edits.jsonl");
|
||||
let line = serde_json::to_string(&entry)? + "\n";
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
{
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?;
|
||||
file.write_all(line.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
}
|
||||
log.entries.push(entry);
|
||||
if log.entries.len() > MAX_MEMORY_ENTRIES {
|
||||
log.entries.remove(0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> {
|
||||
log.entries.clone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Markdown file–backed `MemoryRepository`.
|
||||
//! Each memory is stored as a `.md` file with YAML-ish frontmatter.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use zesdex_domain::cms::{Memory, MemoryRepository, RepositoryError};
|
||||
|
||||
/// File-based `MemoryRepository` that stores memories as `.md` files with
|
||||
/// YAML-ish frontmatter.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MarkdownMemoryRepository;
|
||||
|
||||
impl MarkdownMemoryRepository {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn build_frontmatter(memory: &Memory) -> String {
|
||||
let outcome_line = memory
|
||||
.outcome
|
||||
.as_ref()
|
||||
.map(|o| format!("outcome: {o}\n"))
|
||||
.unwrap_or_default();
|
||||
let scope_line = memory
|
||||
.scope
|
||||
.as_ref()
|
||||
.map(|s| format!("scope: {s}\n"))
|
||||
.unwrap_or_default();
|
||||
let before_line = memory
|
||||
.before_snippet
|
||||
.as_ref()
|
||||
.map(|s| format!("before: {s}\n"))
|
||||
.unwrap_or_default();
|
||||
let after_line = memory
|
||||
.after_snippet
|
||||
.as_ref()
|
||||
.map(|s| format!("after: {s}\n"))
|
||||
.unwrap_or_default();
|
||||
let prov_line = if memory.provenances.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("provenances: {}\n", memory.provenances.join(", "))
|
||||
};
|
||||
format!(
|
||||
"name: {name}\ndescription: {desc}\nkind: {kind}\n\
|
||||
created_at: {created}\nupdated_at: {updated}\nlifecycle: {lifecycle}\n\
|
||||
{outcome}{scope}{before}{after}{prov}",
|
||||
name = memory.name,
|
||||
desc = memory.description,
|
||||
kind = memory.kind,
|
||||
created = memory.created_at,
|
||||
updated = memory.updated_at,
|
||||
lifecycle = memory.lifecycle,
|
||||
outcome = outcome_line,
|
||||
scope = scope_line,
|
||||
before = before_line,
|
||||
after = after_line,
|
||||
prov = prov_line,
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_frontmatter(front: &str) -> HashMap<String, String> {
|
||||
front
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let mut it = l.splitn(2, ':');
|
||||
Some((
|
||||
it.next()?.trim().to_string(),
|
||||
it.next()?.trim().to_string(),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse(content: &str) -> std::io::Result<Memory> {
|
||||
let content = content.strip_prefix("---\n").unwrap_or(content);
|
||||
let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();
|
||||
if parts.len() < 2 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"missing frontmatter",
|
||||
));
|
||||
}
|
||||
let front = Self::parse_frontmatter(parts[0]);
|
||||
let body = parts.get(1).unwrap_or(&"").trim().to_string();
|
||||
Ok(Memory {
|
||||
name: front.get("name").cloned().unwrap_or_default(),
|
||||
description: front.get("description").cloned().unwrap_or_default(),
|
||||
content: body,
|
||||
kind: front
|
||||
.get("kind")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "reference".to_string()),
|
||||
created_at: front
|
||||
.get("created_at")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0),
|
||||
updated_at: front
|
||||
.get("updated_at")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0),
|
||||
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()),
|
||||
lifecycle: front
|
||||
.get("lifecycle")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "new".to_string()),
|
||||
scope: front.get("scope").cloned().filter(|s| !s.is_empty()),
|
||||
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()),
|
||||
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()),
|
||||
provenances: front
|
||||
.get("provenances")
|
||||
.cloned()
|
||||
.map(|s| s.split(", ").map(String::from).collect())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryRepository for MarkdownMemoryRepository {
|
||||
fn list(&self, memory_dir: &Path) -> Result<Vec<String>, RepositoryError> {
|
||||
let Ok(entries) = std::fs::read_dir(memory_dir) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let slugs: Vec<String> = entries
|
||||
.filter_map(std::result::Result::ok)
|
||||
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if name == "MEMORY.md" {
|
||||
return None;
|
||||
}
|
||||
name.strip_suffix(".md")
|
||||
.map(std::string::ToString::to_string)
|
||||
})
|
||||
.collect();
|
||||
Ok(slugs)
|
||||
}
|
||||
|
||||
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory, RepositoryError> {
|
||||
let path = Memory::path(memory_dir, name);
|
||||
let content = std::fs::read_to_string(&path)?;
|
||||
let memory = Self::parse(&content)
|
||||
.map_err(|e| RepositoryError::Other(format!("failed to parse memory '{name}': {e}")))?;
|
||||
Ok(memory)
|
||||
}
|
||||
|
||||
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<(), RepositoryError> {
|
||||
let path = Memory::path(memory_dir, &memory.name);
|
||||
let parent = path.parent().unwrap();
|
||||
std::fs::create_dir_all(parent)?;
|
||||
|
||||
let frontmatter = Self::build_frontmatter(memory);
|
||||
let content = format!("---\n{frontmatter}---\n\n{}", memory.content);
|
||||
|
||||
let tmp = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
|
||||
{
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&tmp)?;
|
||||
f.write_all(content.as_bytes())?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
if let Some(p) = path.parent() {
|
||||
if let Ok(d) = std::fs::File::open(p) {
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(&self, memory_dir: &Path, name: &str) -> Result<(), RepositoryError> {
|
||||
let path = Memory::path(memory_dir, name);
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! File-based repository implementations for CMS domain entities.
|
||||
//!
|
||||
//! ## Repositories
|
||||
//! - `JsonSettingsRepository` — reads/writes `settings.json`
|
||||
//! - `JsonAppConfigRepository` — reads/writes `app_config.json`
|
||||
//! - `JsonConversationRepository` — reads/writes `conversation.json`
|
||||
//! - `MarkdownMemoryRepository` — reads/writes `{slug}.md` files
|
||||
//! - `JsonlEditLogRepository` — appends to `edit_log.jsonl`
|
||||
//! - `FileRewindBlobRepository` — stores blobs as files
|
||||
|
||||
pub mod app_config_repo;
|
||||
pub mod conversation_repo;
|
||||
pub mod edit_log_repo;
|
||||
pub mod memory_repo;
|
||||
pub mod rewind_blob_repo;
|
||||
pub mod settings_repo;
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Filesystem-backed `RewindBlobRepository`.
|
||||
//! Blob bytes are stored at `<session_dir>/blobs/<hex(key)>.bin`.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zesdex_domain::cms::{RepositoryError, RewindBlobRepository};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct BlobIndexEntry {
|
||||
key: String,
|
||||
mime_type: Option<String>,
|
||||
created_at: i64,
|
||||
}
|
||||
|
||||
/// Concrete filesystem rewind-blob repository.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FileRewindBlobRepository;
|
||||
|
||||
impl FileRewindBlobRepository {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn blobs_dir(session_dir: &Path) -> std::path::PathBuf {
|
||||
session_dir.join("blobs")
|
||||
}
|
||||
|
||||
fn blob_file_path(session_dir: &Path, blob_key: &str) -> std::path::PathBuf {
|
||||
Self::blobs_dir(session_dir).join(format!("{}.bin", hex::encode(blob_key.as_bytes())))
|
||||
}
|
||||
|
||||
fn index_path(session_dir: &Path) -> std::path::PathBuf {
|
||||
Self::blobs_dir(session_dir).join("index.jsonl")
|
||||
}
|
||||
}
|
||||
|
||||
impl RewindBlobRepository for FileRewindBlobRepository {
|
||||
fn store_blob(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
blob_key: &str,
|
||||
data: &[u8],
|
||||
mime_type: Option<&str>,
|
||||
) -> Result<(), RepositoryError> {
|
||||
let blobs_dir = Self::blobs_dir(session_dir);
|
||||
std::fs::create_dir_all(&blobs_dir)?;
|
||||
|
||||
let path = Self::blob_file_path(session_dir, blob_key);
|
||||
let tmp = path.with_extension("bin.tmp");
|
||||
std::fs::write(&tmp, data)?;
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
|
||||
let entry = BlobIndexEntry {
|
||||
key: blob_key.to_string(),
|
||||
mime_type: mime_type.map(String::from),
|
||||
created_at: chrono::Utc::now().timestamp_millis(),
|
||||
};
|
||||
let index_path = Self::index_path(session_dir);
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&index_path)?;
|
||||
writeln!(f, "{}", serde_json::to_string(&entry)?)?;
|
||||
f.sync_all()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retrieve_blob(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
blob_key: &str,
|
||||
) -> Result<Option<Vec<u8>>, RepositoryError> {
|
||||
let path = Self::blob_file_path(session_dir, blob_key);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let data = std::fs::read(&path)?;
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>, RepositoryError> {
|
||||
let index_path = Self::index_path(session_dir);
|
||||
let Ok(content) = std::fs::read_to_string(&index_path) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let mut first_seen_order: Vec<String> = Vec::new();
|
||||
let mut latest_by_key: std::collections::HashMap<String, BlobIndexEntry> =
|
||||
std::collections::HashMap::new();
|
||||
for line in content.lines() {
|
||||
let Ok(entry) = serde_json::from_str::<BlobIndexEntry>(line) else {
|
||||
continue;
|
||||
};
|
||||
if !latest_by_key.contains_key(&entry.key) {
|
||||
first_seen_order.push(entry.key.clone());
|
||||
}
|
||||
latest_by_key.insert(entry.key.clone(), entry);
|
||||
}
|
||||
|
||||
let mut entries: Vec<BlobIndexEntry> = first_seen_order
|
||||
.into_iter()
|
||||
.filter_map(|k| latest_by_key.get(&k).cloned())
|
||||
.collect();
|
||||
entries.sort_by_key(|e| e.created_at);
|
||||
Ok(entries.into_iter().map(|e| e.key).collect())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! JSON file–backed `SettingsRepository`.
|
||||
//! Path: `<base_dir>/settings.json`
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use zesdex_domain::cms::{RepositoryError, Settings, SettingsRepository};
|
||||
|
||||
use crate::utils::write_json_atomic;
|
||||
|
||||
/// Persists `Settings` as pretty-printed JSON at `<base_dir>/settings.json`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JsonSettingsRepository;
|
||||
|
||||
impl JsonSettingsRepository {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsRepository for JsonSettingsRepository {
|
||||
fn load(&self, base_dir: &Path) -> Result<Settings, RepositoryError> {
|
||||
let path = base_dir.join("settings.json");
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(s) => match serde_json::from_str(&s) {
|
||||
Ok(settings) => Ok(settings),
|
||||
Err(e) => {
|
||||
tracing::warn!("settings.json at '{:?}' failed to parse ({e}); falling back to defaults", path);
|
||||
Ok(Settings::default())
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
Ok(Settings::default())
|
||||
}
|
||||
Err(e) => Err(RepositoryError::Io(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<(), RepositoryError> {
|
||||
std::fs::create_dir_all(base_dir)?;
|
||||
let path = base_dir.join("settings.json");
|
||||
write_json_atomic(&path, settings, None)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Filesystem-backed repository implementations for IAM entities.
|
||||
//!
|
||||
//! Implements domain repository traits using JSON file persistence for
|
||||
//! sessions, OAuth tokens, and PID-file session locks.
|
||||
|
||||
pub mod oauth_repo;
|
||||
pub mod session_lock_repo;
|
||||
pub mod session_repo;
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Filesystem-backed `OAuthRepository` implementation.
|
||||
//!
|
||||
//! Tokens are stored as a single JSON file with write-then-rename + fsync
|
||||
//! for crash safety, and restrictive owner-only mode `0o600` on Unix.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use zesdex_domain::auth::{OAuthRepository, OAuthToken, RepositoryError};
|
||||
|
||||
use crate::utils::write_json_atomic;
|
||||
|
||||
/// Concrete filesystem OAuth token repository.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FileSystemOAuthRepository;
|
||||
|
||||
impl FileSystemOAuthRepository {
|
||||
pub fn new() -> Self {
|
||||
FileSystemOAuthRepository
|
||||
}
|
||||
}
|
||||
|
||||
impl OAuthRepository for FileSystemOAuthRepository {
|
||||
fn save_token(&self, path: &Path, token: &OAuthToken) -> Result<(), RepositoryError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
write_json_atomic(path, token, Some(0o600))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_token(&self, path: &Path) -> Result<Option<OAuthToken>, RepositoryError> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
let token: OAuthToken = serde_json::from_str(&data)?;
|
||||
Ok(Some(token))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Filesystem-backed `SessionLockRepository` implementation using a PID file
|
||||
//! (`<session_dir>/.lock`) with atomic `O_CREAT|O_EXCL` acquisition.
|
||||
|
||||
use std::convert::TryInto;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use zesdex_domain::auth::{RepositoryError, SessionLockRepository};
|
||||
|
||||
/// Concrete filesystem session-lock repository.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FileSystemSessionLockRepository;
|
||||
|
||||
impl FileSystemSessionLockRepository {
|
||||
pub fn new() -> Self {
|
||||
FileSystemSessionLockRepository
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionLockRepository for FileSystemSessionLockRepository {
|
||||
fn try_lock(&self, session_dir: &Path) -> Result<bool, RepositoryError> {
|
||||
let path = session_dir.join(".lock");
|
||||
let pid = std::process::id();
|
||||
|
||||
match std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
{
|
||||
Ok(mut file) => {
|
||||
write!(file, "{pid}")?;
|
||||
file.sync_all()?;
|
||||
return Ok(true);
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(e) => return Err(RepositoryError::Io(e)),
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
if let Ok(existing_pid) = content.trim().parse::<u32>() {
|
||||
if self.is_alive(existing_pid) {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
let tmp = path.with_extension("lock.tmp");
|
||||
{
|
||||
let mut tmp_file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&tmp)?;
|
||||
write!(tmp_file, "{pid}")?;
|
||||
tmp_file.sync_all()?;
|
||||
}
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError> {
|
||||
let path = session_dir.join(".lock");
|
||||
let _ = std::fs::remove_file(path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_alive(&self, pid: u32) -> bool {
|
||||
let pid_signed: i32 = match pid.try_into() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if unsafe { libc::kill(pid_signed, 0) != 0 } {
|
||||
return false;
|
||||
}
|
||||
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
|
||||
if let Ok(target) = std::fs::read_link(&proc_exe) {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if target != exe {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Filesystem-backed `SessionRepository` implementation.
|
||||
//!
|
||||
//! Each session is stored as `<base_dir>/sessions/<id>/session.json`.
|
||||
//! Writes use a write-then-rename + fsync pattern for crash safety.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use zesdex_domain::auth::{RepositoryError, Session, SessionId, SessionRepository};
|
||||
|
||||
use crate::utils::write_json_atomic;
|
||||
|
||||
/// Concrete filesystem session repository.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FileSystemSessionRepository;
|
||||
|
||||
impl FileSystemSessionRepository {
|
||||
pub fn new() -> Self {
|
||||
FileSystemSessionRepository
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionRepository for FileSystemSessionRepository {
|
||||
fn list_sessions(&self, base_dir: &Path) -> Result<Vec<Session>, RepositoryError> {
|
||||
let sessions_dir = base_dir.join("sessions");
|
||||
let entries = match std::fs::read_dir(&sessions_dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Err(e) => return Err(RepositoryError::Io(e)),
|
||||
};
|
||||
let mut sessions = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
if !entry.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if let Ok(sid) = SessionId::new(&name) {
|
||||
if let Ok(session) = self.load_session(base_dir, &sid) {
|
||||
sessions.push(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
fn load_session(&self, base_dir: &Path, id: &SessionId) -> Result<Session, RepositoryError> {
|
||||
let path = base_dir
|
||||
.join("sessions")
|
||||
.join(id.as_str())
|
||||
.join("session.json");
|
||||
if !path.exists() {
|
||||
return Err(RepositoryError::NotFound(format!(
|
||||
"session not found: {}",
|
||||
id.as_str()
|
||||
)));
|
||||
}
|
||||
let data = std::fs::read_to_string(&path)?;
|
||||
let session: Session = serde_json::from_str(&data)?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn save_session(&self, base_dir: &Path, session: &Session) -> Result<(), RepositoryError> {
|
||||
let dir = session.session_dir(base_dir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = dir.join("session.json");
|
||||
write_json_atomic(&path, session, None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_session(&self, base_dir: &Path, id: &SessionId) -> Result<(), RepositoryError> {
|
||||
let dir = base_dir.join("sessions").join(id.as_str());
|
||||
if dir.exists() {
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Persistence adapters — concrete file-based repository implementations
|
||||
//! for both IAM and CMS domain repository traits.
|
||||
|
||||
pub mod cms;
|
||||
pub mod iam;
|
||||
pub mod sqlite;
|
||||
|
||||
pub use iam::{
|
||||
oauth_repo::FileSystemOAuthRepository,
|
||||
session_lock_repo::FileSystemSessionLockRepository,
|
||||
session_repo::FileSystemSessionRepository,
|
||||
};
|
||||
pub use cms::{
|
||||
app_config_repo::JsonAppConfigRepository,
|
||||
conversation_repo::JsonConversationRepository,
|
||||
edit_log_repo::JsonlEditLogRepository,
|
||||
memory_repo::MarkdownMemoryRepository,
|
||||
rewind_blob_repo::FileRewindBlobRepository,
|
||||
settings_repo::JsonSettingsRepository,
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
//! SQLite database connection initialisation and schema migrations.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// A shared SQLite connection wrapped for thread-safe access.
|
||||
#[derive(Clone)]
|
||||
pub struct DbConn {
|
||||
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl DbConn {
|
||||
/// Execute a closure with a reference to the underlying connection.
|
||||
pub fn with<F, T>(&self, f: F) -> anyhow::Result<T>
|
||||
where
|
||||
F: FnOnce(&rusqlite::Connection) -> anyhow::Result<T>,
|
||||
{
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("db lock poisoned: {e}"))?;
|
||||
f(&conn)
|
||||
}
|
||||
}
|
||||
|
||||
const SCHEMA_SQL: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
workspace_roots TEXT NOT NULL DEFAULT '[]',
|
||||
message_count INTEGER NOT NULL DEFAULT 0,
|
||||
token_count INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
summary TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
name TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS edit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
entry TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_edit_logs_session
|
||||
ON edit_logs (session_id);
|
||||
"#;
|
||||
|
||||
/// Initialise a shared SQLite connection at the given path.
|
||||
pub fn init_db(db_path: &str) -> anyhow::Result<DbConn> {
|
||||
let conn = rusqlite::Connection::open(db_path)
|
||||
.map_err(|e| anyhow::anyhow!("failed to open SQLite database at '{db_path}': {e}"))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
|
||||
conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
|
||||
|
||||
Ok(DbConn {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Run embedded SQL schema migrations.
|
||||
pub fn run_migrations(db: &DbConn) -> anyhow::Result<()> {
|
||||
db.with(|conn| {
|
||||
conn.execute_batch(SCHEMA_SQL)
|
||||
.map_err(|e| anyhow::anyhow!("failed to execute database schema migrations: {e}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//! SQLite database connection management and schema migrations.
|
||||
|
||||
pub mod database;
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Post-edit auto-review subagent — validates file edits and suggests
|
||||
//! improvements.
|
||||
|
||||
pub mod pending;
|
||||
pub mod probe;
|
||||
pub mod prompt;
|
||||
pub mod staleness;
|
||||
pub mod types;
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Pending review queue — tracks files modified by tools that have not
|
||||
//! yet been reviewed.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// A file mutation awaiting review.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingReview {
|
||||
pub path: String,
|
||||
pub tool: String,
|
||||
pub reason: String,
|
||||
pub content_sha256: String,
|
||||
}
|
||||
|
||||
/// Queue of files modified but not yet reviewed.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PendingReviewQueue {
|
||||
entries: VecDeque<PendingReview>,
|
||||
}
|
||||
|
||||
impl PendingReviewQueue {
|
||||
pub fn new() -> Self {
|
||||
PendingReviewQueue {
|
||||
entries: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, entry: PendingReview) {
|
||||
self.entries.push_back(entry);
|
||||
}
|
||||
|
||||
pub fn pop(&mut self) -> Option<PendingReview> {
|
||||
self.entries.pop_front()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Review probe — diff analysis and file inspection for review purposes.
|
||||
|
||||
use similar::{ChangeTag, TextDiff};
|
||||
|
||||
/// Compute a simple unified diff between old and new text.
|
||||
pub fn compute_diff(old: &str, new: &str) -> String {
|
||||
let diff = TextDiff::from_lines(old, new);
|
||||
let mut result = String::new();
|
||||
for change in diff.iter_all_changes() {
|
||||
let sign = match change.tag() {
|
||||
ChangeTag::Delete => "-",
|
||||
ChangeTag::Insert => "+",
|
||||
ChangeTag::Equal => " ",
|
||||
};
|
||||
result.push_str(&format!("{}{}", sign, change.value()));
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Review prompt construction — builds the system prompt for the
|
||||
//! auto-review subagent.
|
||||
|
||||
/// Build the review system prompt for the given diff and context.
|
||||
pub fn build_review_prompt(diff: &str, file_path: &str) -> String {
|
||||
format!(
|
||||
"You are a code reviewer. Review the following diff for file '{}':\n\
|
||||
\n\
|
||||
Focus on:\n\
|
||||
1. Correctness — does the change introduce bugs?\n\
|
||||
2. Security — does the change introduce vulnerabilities?\n\
|
||||
3. Style — does the change follow best practices?\n\
|
||||
4. Edge cases — are there unhandled edge cases?\n\
|
||||
\n\
|
||||
Diff:\n\
|
||||
```diff\n\
|
||||
{}\n\
|
||||
```\n\
|
||||
\n\
|
||||
Provide your review as a JSON array of findings with \
|
||||
'severity' (Info/Warning/Error), 'file', 'line' (optional), \
|
||||
'message', and 'suggestion' (optional).",
|
||||
file_path, diff
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Staleness detection for lesson cache entries.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// How long (in seconds) before a lesson is considered stale.
|
||||
const STALE_THRESHOLD_SECS: u64 = 86400 * 7; // 7 days
|
||||
|
||||
/// Check whether a lesson timestamp is stale.
|
||||
pub fn is_stale(updated_at: i64) -> bool {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
now.saturating_sub(updated_at) > STALE_THRESHOLD_SECS as i64
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Review types — findings, severity, and configuration.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Severity of a review finding.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReviewSeverity {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// A single review finding from the auto-review subagent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReviewFinding {
|
||||
pub severity: ReviewSeverity,
|
||||
pub file: String,
|
||||
pub line: Option<usize>,
|
||||
pub message: String,
|
||||
pub suggestion: Option<String>,
|
||||
}
|
||||
|
||||
/// Configuration for the auto-review subagent.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReviewConfig {
|
||||
pub max_lessons_per_run: usize,
|
||||
pub adaptive_max_skip: u32,
|
||||
pub enabled: bool,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Auto-subagents — automatically run review/test subagents at the end of
|
||||
//! each turn.
|
||||
|
||||
pub mod paths;
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Auto-subagent path resolution.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Resolve paths for auto-subagent scripts.
|
||||
pub fn auto_subagent_dir(base_dir: &PathBuf) -> PathBuf {
|
||||
base_dir.join("auto-agents")
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Subagent execution context — wraps the shared state needed by a subagent.
|
||||
//!
|
||||
//! Includes LLM connection parameters (base_url, api_key, model) so the
|
||||
//! engine can construct an `LlmClient` without loading settings itself.
|
||||
|
||||
use crate::tools::ToolCtx;
|
||||
|
||||
/// Context for a single subagent execution.
|
||||
///
|
||||
/// Flow: constructed by the caller (e.g. `execute_primitive`) with resolved
|
||||
/// LLM credentials → passed to `engine::run_agent` → used to create the
|
||||
/// `LlmClient` for LLM interaction.
|
||||
pub struct SubagentContext {
|
||||
/// The directive/instruction the subagent should execute.
|
||||
pub directive: String,
|
||||
/// Shared tool execution context (workspaces, session, memory paths).
|
||||
pub tool_ctx: ToolCtx,
|
||||
/// Access tier as a string (used for logging/serialization).
|
||||
pub access_tier: String,
|
||||
/// Base URL for the LLM provider API.
|
||||
pub base_url: String,
|
||||
/// API key for the LLM provider.
|
||||
pub api_key: String,
|
||||
/// Model identifier for the LLM provider.
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
impl SubagentContext {
|
||||
/// Create a new subagent context with all required fields.
|
||||
pub fn new(
|
||||
directive: String,
|
||||
tool_ctx: ToolCtx,
|
||||
access_tier: String,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
) -> Self {
|
||||
SubagentContext {
|
||||
directive,
|
||||
tool_ctx,
|
||||
access_tier,
|
||||
base_url,
|
||||
api_key,
|
||||
model,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Subagent division — access-tier tool filtering for subagent permissions.
|
||||
//!
|
||||
//! Flow: the calling code picks an `AccessTier` → `tools_for()` returns the
|
||||
//! subset of all built-in tools allowed at that tier → those tools are passed
|
||||
//! to `engine::run_agent` for the subagent's tool-execution loop.
|
||||
|
||||
use crate::tools::Tool;
|
||||
|
||||
/// Access tier for subagent tool permissions.
|
||||
///
|
||||
/// Tiers are cumulative: `Write` includes everything in `Read`, and `Full`
|
||||
/// includes everything in `Write`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccessTier {
|
||||
/// Read-only: search, read, glob, utility tools (no mutations).
|
||||
Read,
|
||||
/// Read + Write: above plus write, edit, delete, git, memory.
|
||||
Write,
|
||||
/// Full: above plus bash, shell, LSP, workflow, plan tools.
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Filter the available tools to match the given access tier.
|
||||
///
|
||||
/// Flow: `all_tools()` → filter by tier → return owned `Vec<Box<dyn Tool>>`.
|
||||
///
|
||||
/// Read tier: non-mutating introspection and utility tools only.
|
||||
/// Write tier: everything except dangerous system/network/process tools.
|
||||
/// Full tier: all 37 tools.
|
||||
pub fn tools_for(access: &AccessTier) -> Vec<Box<dyn Tool>> {
|
||||
let all = crate::tools::all_tools();
|
||||
|
||||
match access {
|
||||
AccessTier::Read => all
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
let name = t.name();
|
||||
matches!(
|
||||
name,
|
||||
"read"
|
||||
| "grep"
|
||||
| "glob"
|
||||
| "pong"
|
||||
| "todowrite"
|
||||
| "todofinish"
|
||||
| "dir_list"
|
||||
| "dir_cache_update"
|
||||
| "cd"
|
||||
| "remember"
|
||||
| "recall"
|
||||
| "forget"
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
|
||||
AccessTier::Write => all
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
let name = t.name();
|
||||
!matches!(
|
||||
name,
|
||||
"bash"
|
||||
| "bash_output"
|
||||
| "bash_kill"
|
||||
| "git_operator"
|
||||
| "git_worktree"
|
||||
| "git_cred"
|
||||
| "shell"
|
||||
| "workflow_run"
|
||||
| "note_finding"
|
||||
| "read_findings"
|
||||
| "hive_mind"
|
||||
| "spawn_agents"
|
||||
| "spawn_pipeline"
|
||||
| "plan_enter"
|
||||
| "plan_ready"
|
||||
| "sequential_think"
|
||||
| "lsp_connect"
|
||||
| "lsp_disconnect"
|
||||
| "lsp_hover"
|
||||
| "lsp_completion"
|
||||
| "lsp_definition"
|
||||
| "lsp_references"
|
||||
| "lsp_diagnostics"
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
|
||||
AccessTier::Full => all, // everything
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Subagent engine — runs an LLM-powered agent with tool execution loop.
|
||||
//!
|
||||
//! Flow: construct system message → call LLM → parse tool calls → execute
|
||||
//! tools → continue until the model returns a final text response (no more
|
||||
//! tool calls) or the iteration limit is reached.
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::llm::provider::LlmClient;
|
||||
use crate::subagent::context::SubagentContext;
|
||||
use crate::subagent::division::{tools_for, AccessTier};
|
||||
use crate::tools::{tool_defs, ToolCtx};
|
||||
use zesdex_domain::core::tool_call::sanitize_tool_arguments;
|
||||
use zesdex_domain::core::ChatMessage;
|
||||
|
||||
/// Maximum number of tool-call iterations before the engine gives up.
|
||||
const MAX_ITERATIONS: u32 = 25;
|
||||
|
||||
/// Run an agent with a directive, access tier, and tool context.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Resolve allowed tools for the given `access` tier.
|
||||
/// 2. Build a system prompt from the directive.
|
||||
/// 3. Loop (up to `MAX_ITERATIONS`):
|
||||
/// a. Call the LLM (non-streaming) with accumulated messages + tool defs.
|
||||
/// b. If the response has no tool calls → return the text content.
|
||||
/// c. Otherwise execute each tool call and append the result as a
|
||||
/// tool-role message.
|
||||
/// d. If the response also contained text, append an assistant message.
|
||||
/// 4. If the loop exits naturally, return the iteration-limit message.
|
||||
pub async fn run_agent(
|
||||
ctx: SubagentContext,
|
||||
directive: &str,
|
||||
access: AccessTier,
|
||||
tool_ctx: ToolCtx,
|
||||
) -> Result<String> {
|
||||
info!("Subagent starting with directive: {directive}");
|
||||
|
||||
let tools = tools_for(&access);
|
||||
let defs = tool_defs(&tools);
|
||||
|
||||
let mut messages = vec![ChatMessage::system(format!(
|
||||
"You are a focused subagent.\n\nYour directive:\n{directive}\n\n\
|
||||
Complete the directive autonomously using the tools available to you. \
|
||||
Return your final answer when done."
|
||||
))];
|
||||
|
||||
let client = LlmClient::new(
|
||||
ctx.api_key.clone(),
|
||||
ctx.model.clone(),
|
||||
Some(ctx.base_url.clone()),
|
||||
);
|
||||
|
||||
// Limited iteration loop so we don't run forever
|
||||
for iteration in 0..MAX_ITERATIONS {
|
||||
let (response_msg, _usage) = client.chat_with_tools_non_streaming(
|
||||
&messages,
|
||||
Some(defs.clone()),
|
||||
Some(4096),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
|
||||
let content = response_msg.content.clone().unwrap_or_default();
|
||||
let tool_calls = response_msg.tool_calls.unwrap_or_default();
|
||||
|
||||
// If no tool calls, we're done — return content
|
||||
if tool_calls.is_empty() {
|
||||
info!("Subagent completed after {iteration} iterations");
|
||||
return Ok(content);
|
||||
}
|
||||
|
||||
// Execute tool calls
|
||||
for tc in &tool_calls {
|
||||
let tool_name = &tc.function.name;
|
||||
let args = sanitize_tool_arguments(&tc.function.arguments);
|
||||
|
||||
debug!("Subagent executing tool: {tool_name}");
|
||||
|
||||
let result = if let Some(tool) = tools.iter().find(|t| t.name() == tool_name) {
|
||||
match tool.run(&tool_ctx, &args) {
|
||||
Ok(output) => output,
|
||||
Err(e) => format!("Error: {e}"),
|
||||
}
|
||||
} else {
|
||||
format!("Unknown tool: {tool_name}")
|
||||
};
|
||||
|
||||
messages.push(ChatMessage::tool(tc.id.clone(), result));
|
||||
}
|
||||
|
||||
// Add assistant response if there was text content
|
||||
if !content.is_empty() {
|
||||
messages.push(ChatMessage::assistant(Some(content)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok("Subagent reached iteration limit".to_string())
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Subagent event types — events emitted during subagent execution.
|
||||
|
||||
/// Events emitted by a running subagent.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SubagentEvent {
|
||||
Started {
|
||||
agent_id: String,
|
||||
directive: String,
|
||||
},
|
||||
ToolCall {
|
||||
agent_id: String,
|
||||
tool_name: String,
|
||||
},
|
||||
ToolResult {
|
||||
agent_id: String,
|
||||
tool_name: String,
|
||||
output: String,
|
||||
},
|
||||
Completed {
|
||||
agent_id: String,
|
||||
output: String,
|
||||
},
|
||||
Failed {
|
||||
agent_id: String,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Subagent gating — decide whether to run review/test/arch agents based
|
||||
//! on the current context.
|
||||
|
||||
/// Determine whether an auto-review should be triggered after an edit.
|
||||
pub fn should_review(edit_count: u32, consecutive_empty_reviews: u32, max_skip: u32) -> bool {
|
||||
if edit_count == 0 {
|
||||
return false;
|
||||
}
|
||||
// Skip review if we've had several consecutive empty reviews
|
||||
if consecutive_empty_reviews >= max_skip {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Subagent spawning and execution engine — spawn managed sub-processes
|
||||
//! for test generation, architecture review, security review, etc.
|
||||
|
||||
pub mod context;
|
||||
pub mod division;
|
||||
pub mod engine;
|
||||
pub mod event;
|
||||
pub mod gating;
|
||||
pub mod provider;
|
||||
pub mod spawn;
|
||||
pub mod tools;
|
||||
pub mod workspace;
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Subagent LLM provider — resolves provider/model from settings and wraps
|
||||
//! `LlmClient` in a higher-level API for subagent use.
|
||||
//!
|
||||
//! Flow: `resolve_subagent_provider` is called at startup to pick a provider
|
||||
//! + model → `SubagentProvider` wraps that pair around an `LlmClient` for use
|
||||
//! inside the subagent engine loop.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::llm::provider::LlmClient;
|
||||
use crate::tools::{tool_defs, Tool};
|
||||
use zesdex_domain::core::ChatMessage;
|
||||
|
||||
/// Provider wrapper for subagent LLM interactions.
|
||||
///
|
||||
/// Provides two convenience methods (`chat`, `chat_with_tools`) that
|
||||
/// abstract away the raw `LlmClient` parameter plumbing so the engine
|
||||
/// loop only deals with messages and tools.
|
||||
///
|
||||
/// The model identifier is already embedded in the `LlmClient` itself
|
||||
/// (its `model` field), so `SubagentProvider` does not duplicate it.
|
||||
pub struct SubagentProvider {
|
||||
client: LlmClient,
|
||||
}
|
||||
|
||||
impl SubagentProvider {
|
||||
/// Wrap an existing `LlmClient` for higher-level use.
|
||||
pub fn new(client: LlmClient) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Send messages to the LLM without any tool definitions.
|
||||
///
|
||||
/// Use this for a plain text-in/text-out conversation.
|
||||
pub fn chat(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||
self.client
|
||||
.chat_with_tools_non_streaming(messages, None, Some(4096), None, None)
|
||||
}
|
||||
|
||||
/// Send messages with available tool definitions.
|
||||
///
|
||||
/// Automatically converts the `&[Box<dyn Tool>]` slice to
|
||||
/// `Vec<ToolDef>` before passing to the underlying client.
|
||||
pub fn chat_with_tools(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
tools: &[Box<dyn Tool>],
|
||||
) -> Result<(ChatMessage, Option<(u64, u64)>)> {
|
||||
let defs = tool_defs(tools);
|
||||
self.client
|
||||
.chat_with_tools_non_streaming(messages, Some(defs), Some(4096), None, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve subagent provider and model from settings.
|
||||
///
|
||||
/// Flow: reads `settings.provider` and `settings.model` → if model is empty,
|
||||
/// falls back to the provider config's `default_model` → if that is also
|
||||
/// empty, uses `"deepseek-v4-flash-free"` as the ultimate default.
|
||||
pub fn resolve_subagent_provider(
|
||||
settings: &zesdex_domain::cms::Settings,
|
||||
app_config: &zesdex_domain::cms::AppConfig,
|
||||
) -> (String, String) {
|
||||
let provider = settings.provider.clone();
|
||||
let model = settings.model.clone();
|
||||
|
||||
// Use the default model from the provider config if available
|
||||
let model = if model.is_empty() {
|
||||
app_config
|
||||
.providers
|
||||
.get(&provider)
|
||||
.and_then(|p| p.default_model.clone())
|
||||
.unwrap_or_else(|| "deepseek-v4-flash-free".to_string())
|
||||
} else {
|
||||
model
|
||||
};
|
||||
|
||||
(provider, model)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Subagent spawning — launch a subagent on a background OS thread.
|
||||
//!
|
||||
//! Flow: creates a new tokio runtime on a dedicated OS thread, then
|
||||
//! `block_on` the engine's `run_agent` future. Returns a
|
||||
//! `JoinHandle<Result<String>>` the caller can `.join()`.
|
||||
|
||||
use std::thread;
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
use crate::subagent::context::SubagentContext;
|
||||
use crate::subagent::division::AccessTier;
|
||||
use crate::subagent::engine::run_agent;
|
||||
use crate::tools::ToolCtx;
|
||||
|
||||
/// Spawn a subagent on a background OS thread.
|
||||
///
|
||||
/// The subagent runs inside its own tokio runtime so it can make async calls
|
||||
/// without blocking the calling thread's runtime.
|
||||
///
|
||||
/// Flow: `thread::spawn` → create `tokio::runtime::Runtime` →
|
||||
/// `runtime.block_on(run_agent(...))` → return.
|
||||
///
|
||||
/// Returns a `JoinHandle` the caller can `join()` to await the result.
|
||||
pub fn spawn_subagent(
|
||||
ctx: SubagentContext,
|
||||
directive: String,
|
||||
access: AccessTier,
|
||||
tool_ctx: ToolCtx,
|
||||
) -> thread::JoinHandle<Result<String>> {
|
||||
info!("Spawning subagent: {directive}");
|
||||
thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(run_agent(ctx, &directive, access, tool_ctx))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Subagent tool helpers — wrap tool execution for subagent use.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Execute a single tool call within a subagent context.
|
||||
pub fn execute_tool_call(
|
||||
tool: &dyn Tool,
|
||||
ctx: &ToolCtx,
|
||||
args: &serde_json::Value,
|
||||
) -> Result<String> {
|
||||
tool.run(ctx, args)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Subagent workspace management — create isolated workspaces for subagents.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Create an isolated workspace directory for a subagent.
|
||||
pub fn create_subagent_workspace(base_dir: &PathBuf, agent_id: &str) -> anyhow::Result<PathBuf> {
|
||||
let ws = base_dir.join("subagent-workspaces").join(agent_id);
|
||||
std::fs::create_dir_all(&ws)?;
|
||||
Ok(ws)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Background bash process output and kill tools.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
use crate::tools::{arg_str, Tool, ToolCtx};
|
||||
|
||||
/// Get the output of a background bash job by ID.
|
||||
///
|
||||
/// Flow: look up `{session_dir}/bash-outputs/{job_id}` → read content back.
|
||||
pub struct BashOutput;
|
||||
|
||||
impl Tool for BashOutput {
|
||||
fn name(&self) -> &'static str {
|
||||
"bash_output"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Get the output of a background bash job by ID"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {
|
||||
"type": "string",
|
||||
"description": "Background job ID"
|
||||
}
|
||||
},
|
||||
"required": ["job_id"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let job_id = arg_str(args, "job_id")?;
|
||||
info!("Getting output for job: {job_id}");
|
||||
|
||||
// Read from the session's bash output directory
|
||||
let output_dir = ctx.session_dir.join("bash-outputs");
|
||||
let output_file = output_dir.join(&job_id);
|
||||
|
||||
if output_file.exists() {
|
||||
let content = std::fs::read_to_string(&output_file)
|
||||
.unwrap_or_else(|_| "Error reading output".to_string());
|
||||
Ok(format!("Output for job '{job_id}':\n{content}"))
|
||||
} else {
|
||||
Ok(format!(
|
||||
"No output found for job '{job_id}'. The job may still be running."
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BashKill;
|
||||
|
||||
impl Tool for BashKill {
|
||||
fn name(&self) -> &'static str {
|
||||
"bash_kill"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Kill a background bash job by ID"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {
|
||||
"type": "string",
|
||||
"description": "Background job ID to kill"
|
||||
}
|
||||
},
|
||||
"required": ["job_id"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let _job_id = crate::tools::arg_str(args, "job_id")?;
|
||||
// In production, look up and kill the job in BashControl
|
||||
Ok(format!("Killed background job '{}'", _job_id))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Delete a file or empty directory.
|
||||
|
||||
use crate::tools::{resolve_path, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
|
||||
pub struct Delete;
|
||||
|
||||
impl Tool for Delete {
|
||||
fn name(&self) -> &'static str {
|
||||
"delete"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Delete a file or empty directory"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to delete (relative to workspace root)"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Reason for deletion"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = crate::tools::arg_str(args, "path")?;
|
||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||
|
||||
if !path.exists() {
|
||||
anyhow::bail!("path '{rel}' does not exist");
|
||||
}
|
||||
|
||||
if path.is_file() {
|
||||
fs::remove_file(&path)?;
|
||||
Ok(format!("Deleted file '{rel}'"))
|
||||
} else if path.is_dir() {
|
||||
fs::remove_dir_all(&path)?;
|
||||
Ok(format!("Deleted directory '{rel}' and all contents"))
|
||||
} else {
|
||||
anyhow::bail!("'{rel}' is neither a file nor a directory")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Edit a file by replacing a text block.
|
||||
|
||||
use crate::tools::{resolve_path, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
|
||||
pub struct Edit;
|
||||
|
||||
impl Tool for Edit {
|
||||
fn name(&self) -> &'static str {
|
||||
"edit"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Edit a file by replacing 'old' text with 'new' text"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file to edit (relative to workspace root)"
|
||||
},
|
||||
"old": {
|
||||
"type": "string",
|
||||
"description": "Text to replace (must exist in the file)"
|
||||
},
|
||||
"new": {
|
||||
"type": "string",
|
||||
"description": "Replacement text"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Reason for this change"
|
||||
}
|
||||
},
|
||||
"required": ["path", "old", "new"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = crate::tools::arg_str(args, "path")?;
|
||||
let old = crate::tools::arg_str(args, "old")?;
|
||||
let new = crate::tools::arg_str(args, "new")?;
|
||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||
|
||||
if !path.exists() {
|
||||
anyhow::bail!("file '{rel}' does not exist");
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&path)?;
|
||||
if !content.contains(&old) {
|
||||
anyhow::bail!("old text not found in '{}'", rel);
|
||||
}
|
||||
|
||||
let new_content = content.replace(&old, &new);
|
||||
fs::write(&path, &new_content)?;
|
||||
|
||||
Ok(format!(
|
||||
"Edited '{}': replaced {} bytes with {} bytes",
|
||||
rel,
|
||||
old.len(),
|
||||
new.len()
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Helper utilities for filesystem tools — content hashing, path validation, etc.
|
||||
|
||||
use sha2::Digest;
|
||||
|
||||
/// Compute the SHA-256 hex digest of a string.
|
||||
pub fn sha256_hex(content: &str) -> String {
|
||||
hex::encode(sha2::Sha256::digest(content.as_bytes()))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Filesystem read/write/edit/delete tools with graduated-checks integration.
|
||||
|
||||
pub mod delete;
|
||||
pub mod edit;
|
||||
pub mod helpers;
|
||||
pub mod read;
|
||||
pub mod write;
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Read a file from the workspace.
|
||||
|
||||
use crate::tools::{resolve_path, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
|
||||
pub struct Read;
|
||||
|
||||
impl Tool for Read {
|
||||
fn name(&self) -> &'static str {
|
||||
"read"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Read the contents of a file"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file to read (relative to workspace root)"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = crate::tools::arg_str(args, "path")?;
|
||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||
if !path.exists() {
|
||||
anyhow::bail!("file '{rel}' does not exist");
|
||||
}
|
||||
if !path.is_file() {
|
||||
anyhow::bail!("'{rel}' is not a file");
|
||||
}
|
||||
let content = fs::read_to_string(&path)?;
|
||||
Ok(content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Write content to a file (create or overwrite).
|
||||
|
||||
use crate::tools::{check_graduated_checks, resolve_path, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
|
||||
pub struct Write;
|
||||
|
||||
impl Tool for Write {
|
||||
fn name(&self) -> &'static str {
|
||||
"write"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Write content to a file (creating or overwriting)"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to write to (relative to workspace root)"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Content to write"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Reason for this change"
|
||||
}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let rel = crate::tools::arg_str(args, "path")?;
|
||||
let content = crate::tools::arg_str(args, "content")?;
|
||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(&path, &content)?;
|
||||
|
||||
// Notify mention index
|
||||
ctx.mention_index.push(path.display().to_string());
|
||||
|
||||
// Check graduated checks
|
||||
let matched = check_graduated_checks(&rel, &content, &ctx.graduated_checks);
|
||||
if !matched.is_empty() {
|
||||
return Ok(format!(
|
||||
"Written {} bytes to '{}'. Note: graduated checks triggered: {}",
|
||||
content.len(),
|
||||
rel,
|
||||
matched.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
Ok(format!("Written {} bytes to '{}'", content.len(), rel))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Git credential management tool.
|
||||
|
||||
use crate::tools::{execute_cmd, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct GitCred;
|
||||
|
||||
impl Tool for GitCred {
|
||||
fn name(&self) -> &'static str {
|
||||
"git_cred"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Manage git credentials (store, retrieve, list)"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["store", "list", "erase"],
|
||||
"description": "Credential action to perform"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Git URL for the credential"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "Username for authentication"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "Password or token for authentication"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let action = crate::tools::arg_str(args, "action")?;
|
||||
|
||||
match action.as_str() {
|
||||
"store" => {
|
||||
let url = crate::tools::arg_str(args, "url")?;
|
||||
let username = crate::tools::arg_str(args, "username")?;
|
||||
let password = crate::tools::arg_str(args, "password")?;
|
||||
let _input = format!("url={url}\nusername={username}\npassword={password}\n");
|
||||
let _output = execute_cmd(
|
||||
std::process::Command::new("git").args(["credential", "approve"]),
|
||||
)?;
|
||||
Ok(format!("Credential stored for {url}"))
|
||||
}
|
||||
"list" => {
|
||||
let output = execute_cmd(
|
||||
std::process::Command::new("git").args(["config", "--global", "--list"]),
|
||||
)?;
|
||||
Ok(output)
|
||||
}
|
||||
"erase" => {
|
||||
let url = crate::tools::arg_str(args, "url")?;
|
||||
let _input = format!("url={url}\n");
|
||||
Ok(format!("Credential erased for {url}"))
|
||||
}
|
||||
_ => anyhow::bail!("unknown action: {}", action),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Git operator tool — commit, push, pull, branch operations.
|
||||
|
||||
use crate::tools::{execute_cmd, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct GitOperator;
|
||||
|
||||
impl Tool for GitOperator {
|
||||
fn name(&self) -> &'static str {
|
||||
"git_operator"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Execute git operations (commit, push, pull, branch, status, log, etc.)"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["status", "log", "diff", "commit", "branch", "checkout", "pull", "push", "add", "stash"],
|
||||
"description": "Git operation to perform"
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Additional arguments for the git operation"
|
||||
}
|
||||
},
|
||||
"required": ["operation"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let operation = crate::tools::arg_str(args, "operation")?;
|
||||
let extra_args: Vec<String> = args
|
||||
.get("args")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.arg(&operation);
|
||||
for arg in &extra_args {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
|
||||
let output = execute_cmd(&mut cmd)?;
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Git worktree management tool.
|
||||
|
||||
use crate::tools::{execute_cmd, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct GitWorktree;
|
||||
|
||||
impl Tool for GitWorktree {
|
||||
fn name(&self) -> &'static str {
|
||||
"git_worktree"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Manage git worktrees (add, list, remove, prune)"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["add", "list", "remove", "prune"],
|
||||
"description": "Worktree action to perform"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path for the new worktree (for 'add')"
|
||||
},
|
||||
"branch": {
|
||||
"type": "string",
|
||||
"description": "Branch name for the new worktree (for 'add')"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let action = crate::tools::arg_str(args, "action")?;
|
||||
|
||||
match action.as_str() {
|
||||
"add" => {
|
||||
let path = crate::tools::arg_str(args, "path")?;
|
||||
let branch = crate::tools::arg_str(args, "branch")?;
|
||||
let output = execute_cmd(
|
||||
std::process::Command::new("git")
|
||||
.args(["worktree", "add", &path, &branch]),
|
||||
)?;
|
||||
Ok(output)
|
||||
}
|
||||
"list" => {
|
||||
let output = execute_cmd(
|
||||
std::process::Command::new("git").args(["worktree", "list"]),
|
||||
)?;
|
||||
Ok(output)
|
||||
}
|
||||
"remove" => {
|
||||
let path = crate::tools::arg_str(args, "path")?;
|
||||
let output = execute_cmd(
|
||||
std::process::Command::new("git").args(["worktree", "remove", &path]),
|
||||
)?;
|
||||
Ok(output)
|
||||
}
|
||||
"prune" => {
|
||||
let output = execute_cmd(
|
||||
std::process::Command::new("git").args(["worktree", "prune"]),
|
||||
)?;
|
||||
Ok(output)
|
||||
}
|
||||
_ => anyhow::bail!("unknown action: {}", action),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Git integration tools.
|
||||
|
||||
pub mod git_cred;
|
||||
pub mod git_operator;
|
||||
pub mod git_worktree;
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Get completion suggestions from LSP.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct LspCompletion;
|
||||
|
||||
impl Tool for LspCompletion {
|
||||
fn name(&self) -> &'static str {
|
||||
"lsp_completion"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Get completion suggestions at a position"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language identifier"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path"
|
||||
},
|
||||
"line": {
|
||||
"type": "integer",
|
||||
"description": "Line number (0-based)"
|
||||
},
|
||||
"character": {
|
||||
"type": "integer",
|
||||
"description": "Character offset (0-based)"
|
||||
}
|
||||
},
|
||||
"required": ["language", "path", "line", "character"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let language = crate::tools::arg_str(args, "language")?;
|
||||
let path = crate::tools::arg_str(args, "path")?;
|
||||
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
let manager = ctx.lsp_manager.lock().unwrap();
|
||||
if let Some(client) = manager.get_client(&language) {
|
||||
let result = client.send_request("textDocument/completion", &json!({
|
||||
"textDocument": { "uri": format!("file://{}", path) },
|
||||
"position": { "line": line, "character": character }
|
||||
}))?;
|
||||
Ok(serde_json::to_string_pretty(&result)?)
|
||||
} else {
|
||||
anyhow::bail!("no LSP client connected for '{language}'")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Connect to an LSP language server.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct LspConnect;
|
||||
|
||||
impl Tool for LspConnect {
|
||||
fn name(&self) -> &'static str {
|
||||
"lsp_connect"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Connect to an LSP language server for a given language"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language identifier (e.g. 'rust', 'python')"
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Command to start the language server"
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Arguments for the language server command"
|
||||
}
|
||||
},
|
||||
"required": ["language", "command"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let language = crate::tools::arg_str(args, "language")?;
|
||||
let command = crate::tools::arg_str(args, "command")?;
|
||||
let extra_args: Vec<String> = args
|
||||
.get("args")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut manager = ctx.lsp_manager.lock().unwrap();
|
||||
manager.start(&language, &command, &extra_args)?;
|
||||
|
||||
Ok(format!("Connected LSP for '{language}'"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Go-to-definition via LSP.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct LspDefinition;
|
||||
|
||||
impl Tool for LspDefinition {
|
||||
fn name(&self) -> &'static str {
|
||||
"lsp_definition"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Go to definition for a symbol at a position"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language identifier"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path"
|
||||
},
|
||||
"line": {
|
||||
"type": "integer",
|
||||
"description": "Line number (0-based)"
|
||||
},
|
||||
"character": {
|
||||
"type": "integer",
|
||||
"description": "Character offset (0-based)"
|
||||
}
|
||||
},
|
||||
"required": ["language", "path", "line", "character"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let language = crate::tools::arg_str(args, "language")?;
|
||||
let path = crate::tools::arg_str(args, "path")?;
|
||||
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
let manager = ctx.lsp_manager.lock().unwrap();
|
||||
if let Some(client) = manager.get_client(&language) {
|
||||
let result = client.send_request("textDocument/definition", &json!({
|
||||
"textDocument": { "uri": format!("file://{}", path) },
|
||||
"position": { "line": line, "character": character }
|
||||
}))?;
|
||||
Ok(serde_json::to_string_pretty(&result)?)
|
||||
} else {
|
||||
anyhow::bail!("no LSP client connected for '{language}'")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Get diagnostics from LSP.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct LspDiagnostics;
|
||||
|
||||
impl Tool for LspDiagnostics {
|
||||
fn name(&self) -> &'static str {
|
||||
"lsp_diagnostics"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Get diagnostics (errors, warnings) from the LSP for a file"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language identifier"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path to get diagnostics for"
|
||||
}
|
||||
},
|
||||
"required": ["language", "path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let language = crate::tools::arg_str(args, "language")?;
|
||||
let path = crate::tools::arg_str(args, "path")?;
|
||||
|
||||
let manager = ctx.lsp_manager.lock().unwrap();
|
||||
if let Some(client) = manager.get_client(&language) {
|
||||
let result = client.send_request("textDocument/diagnostic", &json!({
|
||||
"textDocument": { "uri": format!("file://{}", path) }
|
||||
}))?;
|
||||
Ok(serde_json::to_string_pretty(&result)?)
|
||||
} else {
|
||||
anyhow::bail!("no LSP client connected for '{language}'")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Disconnect from an LSP language server.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct LspDisconnect;
|
||||
|
||||
impl Tool for LspDisconnect {
|
||||
fn name(&self) -> &'static str {
|
||||
"lsp_disconnect"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Disconnect from an LSP language server"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language identifier to disconnect"
|
||||
}
|
||||
},
|
||||
"required": ["language"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let language = crate::tools::arg_str(args, "language")?;
|
||||
let _manager = ctx.lsp_manager.lock().unwrap();
|
||||
Ok(format!("Disconnected LSP for '{language}'"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Get hover information from LSP.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct LspHover;
|
||||
|
||||
impl Tool for LspHover {
|
||||
fn name(&self) -> &'static str {
|
||||
"lsp_hover"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Get hover information for a symbol at a position"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language identifier"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path"
|
||||
},
|
||||
"line": {
|
||||
"type": "integer",
|
||||
"description": "Line number (0-based)"
|
||||
},
|
||||
"character": {
|
||||
"type": "integer",
|
||||
"description": "Character offset (0-based)"
|
||||
}
|
||||
},
|
||||
"required": ["language", "path", "line", "character"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let language = crate::tools::arg_str(args, "language")?;
|
||||
let path = crate::tools::arg_str(args, "path")?;
|
||||
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
let manager = ctx.lsp_manager.lock().unwrap();
|
||||
if let Some(client) = manager.get_client(&language) {
|
||||
let result = client.send_request("textDocument/hover", &json!({
|
||||
"textDocument": { "uri": format!("file://{}", path) },
|
||||
"position": { "line": line, "character": character }
|
||||
}))?;
|
||||
Ok(serde_json::to_string_pretty(&result)?)
|
||||
} else {
|
||||
anyhow::bail!("no LSP client connected for '{language}'")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! LSP tool implementations — connect, diagnostics, hover, completion,
|
||||
//! definition, references, disconnect.
|
||||
|
||||
pub mod completion;
|
||||
pub mod connect;
|
||||
pub mod definition;
|
||||
pub mod diagnostics;
|
||||
pub mod disconnect;
|
||||
pub mod hover;
|
||||
pub mod references;
|
||||
|
||||
pub use connect::LspConnect;
|
||||
pub use diagnostics::LspDiagnostics;
|
||||
pub use hover::LspHover;
|
||||
pub use completion::LspCompletion;
|
||||
pub use definition::LspDefinition;
|
||||
pub use references::LspReferences;
|
||||
pub use disconnect::LspDisconnect;
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Find references via LSP.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct LspReferences;
|
||||
|
||||
impl Tool for LspReferences {
|
||||
fn name(&self) -> &'static str {
|
||||
"lsp_references"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Find all references to a symbol at a position"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language identifier"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path"
|
||||
},
|
||||
"line": {
|
||||
"type": "integer",
|
||||
"description": "Line number (0-based)"
|
||||
},
|
||||
"character": {
|
||||
"type": "integer",
|
||||
"description": "Character offset (0-based)"
|
||||
}
|
||||
},
|
||||
"required": ["language", "path", "line", "character"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let language = crate::tools::arg_str(args, "language")?;
|
||||
let path = crate::tools::arg_str(args, "path")?;
|
||||
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
let manager = ctx.lsp_manager.lock().unwrap();
|
||||
if let Some(client) = manager.get_client(&language) {
|
||||
let result = client.send_request("textDocument/references", &json!({
|
||||
"textDocument": { "uri": format!("file://{}", path) },
|
||||
"position": { "line": line, "character": character }
|
||||
}))?;
|
||||
Ok(serde_json::to_string_pretty(&result)?)
|
||||
} else {
|
||||
anyhow::bail!("no LSP client connected for '{language}'")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Delete a memory by name.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use zesdex_domain::cms::MemoryRepository;
|
||||
|
||||
pub struct Forget;
|
||||
|
||||
impl Tool for Forget {
|
||||
fn name(&self) -> &'static str {
|
||||
"forget"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Delete a saved memory by name"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the memory to delete"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let name = crate::tools::arg_str(args, "name")?;
|
||||
let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new();
|
||||
repo.delete(&ctx.memory_dir, &name)?;
|
||||
Ok(format!("Memory '{}' deleted", name))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Memory management tools — remember, recall, forget.
|
||||
|
||||
pub mod forget;
|
||||
pub mod recall;
|
||||
pub mod remember;
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Recall previously saved memories.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use zesdex_domain::cms::MemoryRepository;
|
||||
|
||||
pub struct Recall;
|
||||
|
||||
impl Tool for Recall {
|
||||
fn name(&self) -> &'static str {
|
||||
"recall"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"List or search saved memories"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional: specific memory name to recall"
|
||||
},
|
||||
"search": {
|
||||
"type": "string",
|
||||
"description": "Optional: keyword to search in memory descriptions"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new();
|
||||
|
||||
let specific_name = args.get("name").and_then(|v| v.as_str());
|
||||
|
||||
if let Some(name) = specific_name {
|
||||
let memory = repo.load(&ctx.memory_dir, name)?;
|
||||
Ok(serde_json::to_string_pretty(&memory)?)
|
||||
} else {
|
||||
let names = repo.list(&ctx.memory_dir)?;
|
||||
if names.is_empty() {
|
||||
return Ok("No memories saved yet".to_string());
|
||||
}
|
||||
Ok(format!("Available memories:\n{}", names.join("\n")))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Remember a lesson or fact as persistent memory.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use zesdex_domain::cms::{Memory, MemoryRepository};
|
||||
|
||||
pub struct Remember;
|
||||
|
||||
impl Tool for Remember {
|
||||
fn name(&self) -> &'static str {
|
||||
"remember"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Save a lesson or fact to persistent memory"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Unique name for this memory"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Short summary of the memory"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Full content of the memory"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["lesson", "reference", "fact"],
|
||||
"description": "Category of memory"
|
||||
}
|
||||
},
|
||||
"required": ["name", "description", "content"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let name = crate::tools::arg_str(args, "name")?;
|
||||
let description = crate::tools::arg_str(args, "description")?;
|
||||
let content = crate::tools::arg_str(args, "content")?;
|
||||
let kind = args
|
||||
.get("kind")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("reference")
|
||||
.to_string();
|
||||
|
||||
let memory = Memory {
|
||||
name: name.clone(),
|
||||
description,
|
||||
content,
|
||||
kind,
|
||||
created_at: chrono::Utc::now().timestamp(),
|
||||
updated_at: chrono::Utc::now().timestamp(),
|
||||
outcome: None,
|
||||
lifecycle: "active".to_string(),
|
||||
scope: None,
|
||||
before_snippet: None,
|
||||
after_snippet: None,
|
||||
provenances: Vec::new(),
|
||||
};
|
||||
|
||||
let repo = crate::persistence::cms::memory_repo::MarkdownMemoryRepository::new();
|
||||
repo.save(&ctx.memory_dir, &memory)?;
|
||||
|
||||
Ok(format!("Memory '{}' saved", name))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! Tool trait, execution context, and the registry of all built-in tools.
|
||||
//!
|
||||
//! This module defines the core `Tool` trait that every agent-invocable tool
|
||||
//! must implement, the shared `ToolCtx` execution context passed to every tool
|
||||
//! invocation, and utility functions for path resolution, command execution,
|
||||
//! argument extraction, and edit-log persistence.
|
||||
|
||||
use crate::utils::CastOr;
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
use sha2::Digest;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub mod bash_tools;
|
||||
pub mod fs;
|
||||
pub mod git;
|
||||
pub mod lsp;
|
||||
pub mod memory;
|
||||
pub mod plan;
|
||||
pub mod search;
|
||||
pub mod sequential_think;
|
||||
pub mod shell;
|
||||
pub mod shell_filter;
|
||||
pub mod spawn;
|
||||
pub mod utility;
|
||||
pub mod workflow;
|
||||
|
||||
pub use git::git_cred;
|
||||
pub use git::git_operator;
|
||||
pub use git::git_worktree;
|
||||
|
||||
/// Common interface every agent-invocable tool implements.
|
||||
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>;
|
||||
}
|
||||
|
||||
/// A project-defined rule that flags a matching file path or content pattern
|
||||
/// for review.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GraduatedCheck {
|
||||
pub name: String,
|
||||
pub pattern: String,
|
||||
pub rule: String,
|
||||
}
|
||||
|
||||
/// Shared execution context passed to every `Tool::run` call: workspace roots,
|
||||
/// session paths, cached directory state, and workflow-level findings sharing.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolCtx {
|
||||
pub workspaces: Vec<PathBuf>,
|
||||
pub session_dir: PathBuf,
|
||||
pub memory_dir: PathBuf,
|
||||
pub worktrees_dir: PathBuf,
|
||||
pub dir_cache: Arc<tokio::sync::RwLock<crate::DirCache>>,
|
||||
pub mention_index: crate::MentionIndex,
|
||||
pub origin: crate::Origin,
|
||||
pub graduated_checks: Vec<GraduatedCheck>,
|
||||
pub lsp_manager: Arc<Mutex<crate::lsp::manager::LspManager>>,
|
||||
pub turn_events:
|
||||
Option<Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>>,
|
||||
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
|
||||
pub abort_flag: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl ToolCtx {
|
||||
pub fn builder() -> ToolCtxBuilder {
|
||||
ToolCtxBuilder::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for `ToolCtx`; fields default to empty paths and a fresh `DirCache`.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolCtxBuilder {
|
||||
pub workspaces: Vec<PathBuf>,
|
||||
pub session_dir: PathBuf,
|
||||
pub memory_dir: PathBuf,
|
||||
pub worktrees_dir: PathBuf,
|
||||
pub dir_cache: Arc<tokio::sync::RwLock<crate::DirCache>>,
|
||||
pub mention_index: crate::MentionIndex,
|
||||
pub origin: crate::Origin,
|
||||
pub graduated_checks: Vec<GraduatedCheck>,
|
||||
pub lsp_manager: Arc<Mutex<crate::lsp::manager::LspManager>>,
|
||||
pub turn_events:
|
||||
Option<Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>>,
|
||||
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
|
||||
pub abort_flag: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl Default for ToolCtxBuilder {
|
||||
fn default() -> Self {
|
||||
ToolCtxBuilder {
|
||||
workspaces: Vec::new(),
|
||||
session_dir: PathBuf::new(),
|
||||
memory_dir: PathBuf::new(),
|
||||
worktrees_dir: PathBuf::new(),
|
||||
dir_cache: Arc::new(tokio::sync::RwLock::new(crate::DirCache::new())),
|
||||
mention_index: crate::MentionIndex::new(),
|
||||
origin: crate::Origin::Main,
|
||||
graduated_checks: Vec::new(),
|
||||
lsp_manager: Arc::new(Mutex::new(crate::lsp::manager::LspManager::new())),
|
||||
turn_events: None,
|
||||
workflow_findings: None,
|
||||
abort_flag: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolCtxBuilder {
|
||||
pub fn session_dir(mut self, v: PathBuf) -> Self {
|
||||
self.session_dir = v;
|
||||
self
|
||||
}
|
||||
pub fn workspaces(mut self, v: Vec<PathBuf>) -> Self {
|
||||
self.workspaces = v;
|
||||
self
|
||||
}
|
||||
pub fn origin(mut self, v: crate::Origin) -> Self {
|
||||
self.origin = v;
|
||||
self
|
||||
}
|
||||
pub fn workflow_findings(mut self, v: Option<Arc<Mutex<Vec<String>>>>) -> Self {
|
||||
self.workflow_findings = v;
|
||||
self
|
||||
}
|
||||
pub fn build(self) -> ToolCtx {
|
||||
ToolCtx {
|
||||
workspaces: self.workspaces,
|
||||
session_dir: self.session_dir,
|
||||
memory_dir: self.memory_dir,
|
||||
worktrees_dir: self.worktrees_dir,
|
||||
dir_cache: self.dir_cache,
|
||||
mention_index: self.mention_index,
|
||||
origin: self.origin,
|
||||
graduated_checks: self.graduated_checks,
|
||||
lsp_manager: self.lsp_manager,
|
||||
turn_events: self.turn_events,
|
||||
workflow_findings: self.workflow_findings,
|
||||
abort_flag: self.abort_flag,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check which graduated checks apply to a given file path/content pair.
|
||||
pub fn check_graduated_checks(path: &str, content: &str, checks: &[GraduatedCheck]) -> Vec<String> {
|
||||
let mut matches = Vec::new();
|
||||
for check in checks {
|
||||
if path.contains(&check.pattern) || content.contains(&check.rule) {
|
||||
matches.push(check.name.clone());
|
||||
}
|
||||
}
|
||||
matches
|
||||
}
|
||||
|
||||
/// Construct one instance of every built-in tool.
|
||||
pub fn all_tools() -> Vec<Box<dyn Tool>> {
|
||||
vec![
|
||||
Box::new(fs::read::Read),
|
||||
Box::new(fs::write::Write),
|
||||
Box::new(fs::edit::Edit),
|
||||
Box::new(fs::delete::Delete),
|
||||
Box::new(search::Grep),
|
||||
Box::new(search::Glob),
|
||||
Box::new(bash_tools::BashOutput),
|
||||
Box::new(bash_tools::BashKill),
|
||||
Box::new(shell::Bash),
|
||||
Box::new(git_operator::GitOperator),
|
||||
Box::new(git_worktree::GitWorktree),
|
||||
Box::new(git_cred::GitCred),
|
||||
Box::new(sequential_think::SeqThink),
|
||||
Box::new(plan::PlanEnter),
|
||||
Box::new(plan::PlanReady),
|
||||
Box::new(workflow::WorkflowRun),
|
||||
Box::new(workflow::NoteFinding),
|
||||
Box::new(workflow::ReadFindings),
|
||||
Box::new(workflow::HiveMind),
|
||||
Box::new(spawn::SpawnAgents),
|
||||
Box::new(spawn::SpawnPipeline),
|
||||
Box::new(memory::remember::Remember),
|
||||
Box::new(memory::forget::Forget),
|
||||
Box::new(memory::recall::Recall),
|
||||
Box::new(utility::cd::Cd),
|
||||
Box::new(utility::dir_list::DirList),
|
||||
Box::new(utility::dir_cache_update::DirCacheUpdate),
|
||||
Box::new(utility::pong::Pong),
|
||||
Box::new(utility::todowrite::Todowrite),
|
||||
Box::new(utility::todofinish::Todofinish),
|
||||
Box::new(lsp::LspConnect),
|
||||
Box::new(lsp::LspDiagnostics),
|
||||
Box::new(lsp::LspHover),
|
||||
Box::new(lsp::LspCompletion),
|
||||
Box::new(lsp::LspDefinition),
|
||||
Box::new(lsp::LspReferences),
|
||||
Box::new(lsp::LspDisconnect),
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether a tool by name can mutate the filesystem or run arbitrary shell commands.
|
||||
pub fn tool_is_risky(name: &str) -> bool {
|
||||
matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator")
|
||||
}
|
||||
|
||||
/// Extract a required string argument from a JSON args map.
|
||||
pub fn arg_str(args: &Value, name: &str) -> Result<String> {
|
||||
args.get(name)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(std::string::ToString::to_string)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing required argument: {name}"))
|
||||
}
|
||||
|
||||
/// Execute a `std::process::Command` and return its combined stdout/stderr.
|
||||
pub fn execute_cmd(cmd: &mut std::process::Command) -> Result<String> {
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| anyhow::anyhow!("command execution failed: {e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let combined = if stderr.is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
format!("{}\n{}", stdout, stderr)
|
||||
.trim()
|
||||
.to_string()
|
||||
};
|
||||
if output.status.success() {
|
||||
Ok(combined)
|
||||
} else {
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
anyhow::bail!("command failed with exit code {code}:\n{combined}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a tool-supplied relative path to an absolute path within a workspace
|
||||
/// root, rejecting escapes.
|
||||
pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
|
||||
let (ws_idx, path) = if rel.starts_with('[') {
|
||||
let close = rel
|
||||
.find(']')
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid workspace prefix"))?;
|
||||
let idx: usize = rel[1..close]
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("invalid workspace index"))?;
|
||||
(idx, &rel[close + 1..])
|
||||
} else {
|
||||
(0, rel)
|
||||
};
|
||||
let base = workspaces
|
||||
.get(ws_idx)
|
||||
.ok_or_else(|| anyhow::anyhow!("workspace index {ws_idx} out of range"))?;
|
||||
let abs = if path.is_empty() {
|
||||
base.clone()
|
||||
} else {
|
||||
base.join(path)
|
||||
};
|
||||
let canon = if let Ok(c) = abs.canonicalize() {
|
||||
c
|
||||
} else {
|
||||
let base_canon = workspaces
|
||||
.iter()
|
||||
.find_map(|w| w.canonicalize().ok())
|
||||
.unwrap_or_else(|| base.clone());
|
||||
let mut resolved = base_canon.clone();
|
||||
if let Ok(rel_components) = abs.strip_prefix(&base_canon) {
|
||||
for comp in rel_components.components() {
|
||||
match comp {
|
||||
std::path::Component::ParentDir => {
|
||||
resolved.pop();
|
||||
}
|
||||
std::path::Component::CurDir => {}
|
||||
c => resolved.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
resolved
|
||||
};
|
||||
if workspaces.iter().any(|w| canon.starts_with(w)) {
|
||||
Ok(canon)
|
||||
} else {
|
||||
anyhow::bail!("path '{rel}' is outside all workspace roots")
|
||||
}
|
||||
}
|
||||
|
||||
/// After a successful write/edit tool run, compute content hash and byte
|
||||
/// delta, then persist an `EditLogEntry` to the session's edit log.
|
||||
pub fn log_write_edit_tool(
|
||||
args: &serde_json::Value,
|
||||
tool_name: &str,
|
||||
origin_tag: &str,
|
||||
session_dir: &std::path::Path,
|
||||
session_id: &str,
|
||||
) {
|
||||
let reason = args
|
||||
.get("reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unnamed");
|
||||
let path = args
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let content = args.get("content").or_else(|| args.get("new"));
|
||||
let content_str = content.and_then(|v| v.as_str()).unwrap_or("");
|
||||
let content_sha256 = hex::encode(sha2::Sha256::digest(content_str.as_bytes()));
|
||||
let bytes_delta = if tool_name == "write" {
|
||||
content_str.len().cast_or(0i64)
|
||||
} else {
|
||||
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let new_len: i64 = new.len().cast_or(0i64);
|
||||
let old_len: i64 = old.len().cast_or(0i64);
|
||||
(new_len - old_len).abs()
|
||||
};
|
||||
let entry = zesdex_domain::cms::EditLogEntry {
|
||||
ts: chrono::Utc::now().timestamp_millis(),
|
||||
tool: tool_name.to_string(),
|
||||
path: path.to_string(),
|
||||
reason: reason.to_string(),
|
||||
content_sha256,
|
||||
bytes_delta,
|
||||
origin: origin_tag.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
};
|
||||
use zesdex_domain::cms::repository::EditLogRepository;
|
||||
let repo = crate::persistence::cms::edit_log_repo::JsonlEditLogRepository::new();
|
||||
if let Ok(mut el) = repo.open(session_dir) {
|
||||
let _ = repo.append(session_dir, &mut el, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a list of tools into provider-facing `ToolDef` request schema.
|
||||
pub fn tool_defs(tools: &[Box<dyn Tool>]) -> Vec<zesdex_domain::core::ToolDef> {
|
||||
tools
|
||||
.iter()
|
||||
.map(|t| zesdex_domain::core::ToolDef {
|
||||
type_: "function".to_string(),
|
||||
function: zesdex_domain::core::ToolFunctionDef {
|
||||
name: t.name().to_string(),
|
||||
description: t.description().to_string(),
|
||||
parameters: t.parameters(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Plan management tools — enter and mark ready.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct PlanEnter;
|
||||
|
||||
impl Tool for PlanEnter {
|
||||
fn name(&self) -> &'static str {
|
||||
"plan_enter"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Enter a planning phase — present a structured plan for approval"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"plan": {
|
||||
"type": "string",
|
||||
"description": "The structured plan text"
|
||||
}
|
||||
},
|
||||
"required": ["plan"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let plan_text = crate::tools::arg_str(args, "plan")?;
|
||||
Ok(format!(
|
||||
"Plan entered (length: {} chars). Waiting for approval...",
|
||||
plan_text.len()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PlanReady;
|
||||
|
||||
impl Tool for PlanReady {
|
||||
fn name(&self) -> &'static str {
|
||||
"plan_ready"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Signal that the plan is ready and execution can begin"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, _args: &Value) -> Result<String> {
|
||||
Ok("Plan is ready. Starting execution.".to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Text search tools: Grep (line matching) and Glob (filename pattern matching).
|
||||
|
||||
use crate::tools::{resolve_path, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use globset::{GlobBuilder, GlobSetBuilder};
|
||||
use ignore::Walk;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
|
||||
pub struct Grep;
|
||||
|
||||
impl Tool for Grep {
|
||||
fn name(&self) -> &'static str {
|
||||
"grep"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Search for a pattern in files using recursive text search"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Text pattern to search for"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to search in (relative to workspace root)"
|
||||
}
|
||||
},
|
||||
"required": ["pattern", "path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let pattern = crate::tools::arg_str(args, "pattern")?;
|
||||
let rel = crate::tools::arg_str(args, "path")?;
|
||||
let path = resolve_path(&ctx.workspaces, &rel)?;
|
||||
|
||||
if !path.exists() {
|
||||
anyhow::bail!("path '{rel}' does not exist");
|
||||
}
|
||||
if !path.is_dir() {
|
||||
anyhow::bail!("path '{rel}' is not a directory");
|
||||
}
|
||||
|
||||
let mut results: Vec<(String, usize, String)> = Vec::new();
|
||||
for entry in Walk::new(&path).flatten() {
|
||||
let file_path = entry.path();
|
||||
if !file_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(content) = fs::read_to_string(file_path) {
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
if line.contains(&pattern) {
|
||||
let rel_path = file_path
|
||||
.strip_prefix(&path)
|
||||
.unwrap_or(file_path)
|
||||
.display()
|
||||
.to_string();
|
||||
results.push((rel_path, i + 1, line.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if results.is_empty() {
|
||||
return Ok(format!("no matches found for '{pattern}' in {rel}"));
|
||||
}
|
||||
let output = results
|
||||
.iter()
|
||||
.map(|(f, line, text)| format!("{f}:{line}:{text}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Ok(format!("found {} matches:\n{}", results.len(), output))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Glob;
|
||||
|
||||
impl Tool for Glob {
|
||||
fn name(&self) -> &'static str {
|
||||
"glob"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"List files matching a glob pattern"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern to match files (e.g. '**/*.rs')"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Root path to search from (relative to workspace root)"
|
||||
}
|
||||
},
|
||||
"required": ["pattern", "path"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let pat_str = crate::tools::arg_str(args, "pattern")?;
|
||||
let rel = crate::tools::arg_str(args, "path")?;
|
||||
let root = resolve_path(&ctx.workspaces, &rel)?;
|
||||
|
||||
if !root.exists() || !root.is_dir() {
|
||||
anyhow::bail!("path '{rel}' is not a valid directory");
|
||||
}
|
||||
|
||||
let mut builder = GlobSetBuilder::new();
|
||||
let full_pattern = root.join(&pat_str).display().to_string();
|
||||
builder.add(
|
||||
GlobBuilder::new(&full_pattern)
|
||||
.build()
|
||||
.map_err(|e| anyhow::anyhow!("invalid glob pattern '{pat_str}': {e}"))?,
|
||||
);
|
||||
let glob_set = builder.build()?;
|
||||
|
||||
let mut matches: Vec<String> = Vec::new();
|
||||
for entry in Walk::new(&root).flatten() {
|
||||
let p = entry.path();
|
||||
if glob_set.is_match(p) {
|
||||
let rel_path = p.strip_prefix(&root).unwrap_or(p).display().to_string();
|
||||
matches.push(format!(
|
||||
"{}{}",
|
||||
rel_path,
|
||||
if p.is_dir() { "/" } else { "" }
|
||||
));
|
||||
}
|
||||
}
|
||||
matches.sort();
|
||||
if matches.is_empty() {
|
||||
return Ok(format!("no files match '{pat_str}' in {rel}"));
|
||||
}
|
||||
Ok(matches.join("\n"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Sequential thinking tool — step-by-step reasoning.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct SeqThink;
|
||||
|
||||
impl Tool for SeqThink {
|
||||
fn name(&self) -> &'static str {
|
||||
"sequential_think"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Perform sequential / step-by-step reasoning (chain-of-thought)"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"thought": {
|
||||
"type": "string",
|
||||
"description": "The current step of reasoning"
|
||||
},
|
||||
"step_number": {
|
||||
"type": "integer",
|
||||
"description": "Current step number"
|
||||
},
|
||||
"total_steps": {
|
||||
"type": "integer",
|
||||
"description": "Total number of steps planned"
|
||||
},
|
||||
"next_thought_needed": {
|
||||
"type": "boolean",
|
||||
"description": "Whether another thinking step is needed"
|
||||
}
|
||||
},
|
||||
"required": ["thought"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let thought = crate::tools::arg_str(args, "thought")?;
|
||||
let step = args
|
||||
.get("step_number")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
let total = args
|
||||
.get("total_steps")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(1);
|
||||
let next_needed = args
|
||||
.get("next_thought_needed")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(format!(
|
||||
"Step {}/{}: {}\n{}",
|
||||
step,
|
||||
total,
|
||||
thought,
|
||||
if next_needed {
|
||||
"Continuing reasoning..."
|
||||
} else {
|
||||
"Reasoning complete."
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Bash-shell execution tool with safety filters and optional timeout.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct Bash;
|
||||
|
||||
impl Tool for Bash {
|
||||
fn name(&self) -> &'static str {
|
||||
"bash"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Execute a shell command via bash -c"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "Shell command to execute"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Human-readable description of what the command does"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Timeout in milliseconds (default 120000, max 600000)"
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Run the command in the background"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let cmd = crate::tools::arg_str(args, "command")?;
|
||||
let timeout_ms = args
|
||||
.get("timeout")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(120_000)
|
||||
.min(600_000);
|
||||
|
||||
// Safety filter: block destructive git operations
|
||||
crate::tools::shell_filter::git::check_git_destructive(&cmd)
|
||||
.map_err(|e| anyhow::anyhow!("blocked: {e}"))?;
|
||||
|
||||
let run_in_background = args
|
||||
.get("run_in_background")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
|
||||
if run_in_background {
|
||||
let job = crate::bgbash::job::spawn_bash_job(cmd);
|
||||
return Ok(format!("Background job: {}", job.id));
|
||||
}
|
||||
|
||||
let mut child = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn bash: {e}"))?;
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| anyhow::anyhow!("failed to collect output: {e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let combined = if stderr.is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
format!("{stdout}\n{stderr}")
|
||||
};
|
||||
let trimmed = combined.trim().to_string();
|
||||
if status.success() {
|
||||
return Ok(if trimmed.is_empty() {
|
||||
format!("Command completed in {elapsed:.2}s (exit code 0)")
|
||||
} else {
|
||||
format!("{trimmed}\n\nExit code: 0 ({elapsed:.2}s)")
|
||||
});
|
||||
}
|
||||
return Ok(format!(
|
||||
"{}\n\nExit code: {} ({:.2}s)",
|
||||
trimmed,
|
||||
status.code().unwrap_or(-1),
|
||||
elapsed
|
||||
));
|
||||
}
|
||||
Ok(None) => {
|
||||
if start.elapsed() > timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
anyhow::bail!("command timed out after {timeout_ms}ms");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(e) => {
|
||||
anyhow::bail!("failed to wait for command: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Credential read detection — detects commands that might exfiltrate secrets.
|
||||
//!
|
||||
//! NOTE: This filter is intentionally NOT wired into the bash tool by default.
|
||||
//! See the module doc for rationale.
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
/// Paths that are likely to contain credentials.
|
||||
pub fn is_credential_path(path: &str) -> bool {
|
||||
let patterns = [
|
||||
r"~/.ssh/",
|
||||
r"\.netrc",
|
||||
r"\.aws/credentials",
|
||||
r"\.aws/config",
|
||||
r"\.azure/",
|
||||
r"\.gcp/",
|
||||
r"\.docker/config\.json",
|
||||
r"id_rsa",
|
||||
r"id_ed25519",
|
||||
r"known_hosts",
|
||||
];
|
||||
patterns.iter().any(|p| path.contains(p))
|
||||
}
|
||||
|
||||
/// Check whether a command reads credential files.
|
||||
pub fn check_credential_read(cmd: &str) -> Vec<String> {
|
||||
let re = Regex::new(r#"(?i)(?:cat|head|tail|less|more|vim?|nano|xdg-open|open|type|echo)\s+(~?/[\w/.@-]+)"#).unwrap();
|
||||
let mut findings = Vec::new();
|
||||
for cap in re.captures_iter(cmd) {
|
||||
let path = cap.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||
if is_credential_path(path) {
|
||||
findings.push(format!("potential credential read: '{}'", path));
|
||||
}
|
||||
}
|
||||
findings
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Git operation safety filter — blocks destructive git commands.
|
||||
|
||||
/// Check whether a shell command contains a destructive git operation.
|
||||
///
|
||||
/// Blocks: `git push --force`, `git reset --hard`, `git rebase`, etc.
|
||||
pub fn check_git_destructive(cmd: &str) -> Result<(), String> {
|
||||
let cmd_lower = cmd.to_lowercase();
|
||||
|
||||
let destructive_patterns = [
|
||||
"git push --force",
|
||||
"git push -f",
|
||||
"git reset --hard",
|
||||
"git rebase",
|
||||
"git branch -d",
|
||||
"git branch -D",
|
||||
"git tag -d",
|
||||
"git tag --delete",
|
||||
];
|
||||
|
||||
for pattern in &destructive_patterns {
|
||||
if cmd_lower.contains(pattern) {
|
||||
return Err(format!(
|
||||
"destructive git operation blocked: '{}'",
|
||||
pattern
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Safety filters for bash command execution.
|
||||
|
||||
pub mod credentials;
|
||||
pub mod git;
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Agent spawning tools — launch subagents and pipelines.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
use zesdex_domain::cms::{AppConfigRepository, SettingsRepository};
|
||||
use zesdex_domain::core::Store;
|
||||
|
||||
use crate::persistence::{JsonAppConfigRepository, JsonSettingsRepository};
|
||||
use crate::subagent::context::SubagentContext;
|
||||
use crate::subagent::division::AccessTier;
|
||||
use crate::subagent::engine::run_agent;
|
||||
use crate::subagent::spawn::spawn_subagent;
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
|
||||
/// Spawn multiple agent instances to work in parallel on subtasks.
|
||||
///
|
||||
/// Flow: parse agents array → load settings → for each agent, build a
|
||||
/// SubagentContext and call spawn_subagent → join all threads → collect results.
|
||||
pub struct SpawnAgents;
|
||||
|
||||
impl Tool for SpawnAgents {
|
||||
fn name(&self) -> &'static str {
|
||||
"spawn_agents"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Spawn multiple agent instances to work in parallel on subtasks"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directive": {"type": "string", "description": "Directive for the agent"},
|
||||
"access": {"type": "string", "enum": ["read", "write", "full"], "description": "Access tier"}
|
||||
},
|
||||
"required": ["directive"]
|
||||
},
|
||||
"description": "List of agents to spawn"
|
||||
}
|
||||
},
|
||||
"required": ["agents"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let agents = args
|
||||
.get("agents")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing 'agents' array"))?;
|
||||
|
||||
info!("Spawning {} agents", agents.len());
|
||||
|
||||
// Load LLM credentials once for all agents
|
||||
let store = Store::new();
|
||||
let settings = JsonSettingsRepository::new()
|
||||
.load(&store.base_dir)
|
||||
.unwrap_or_default();
|
||||
let app_config = JsonAppConfigRepository::new()
|
||||
.load(&store.base_dir)
|
||||
.unwrap_or_default();
|
||||
|
||||
let (provider, model) =
|
||||
crate::subagent::provider::resolve_subagent_provider(&settings, &app_config);
|
||||
|
||||
let base_url = app_config
|
||||
.providers
|
||||
.get(&provider)
|
||||
.map(|p| p.api_base.clone())
|
||||
.unwrap_or_else(|| "https://opencode.ai/zen/v1".to_string());
|
||||
|
||||
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for (i, agent) in agents.iter().enumerate() {
|
||||
let directive = agent
|
||||
.get("directive")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let access_str = agent
|
||||
.get("access")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("full");
|
||||
|
||||
let access = match access_str {
|
||||
"read" => AccessTier::Read,
|
||||
"write" => AccessTier::Write,
|
||||
_ => AccessTier::Full,
|
||||
};
|
||||
|
||||
let subagent_ctx = SubagentContext::new(
|
||||
directive.clone(),
|
||||
ctx.clone(),
|
||||
access_str.to_string(),
|
||||
base_url.clone(),
|
||||
api_key.clone(),
|
||||
model.clone(),
|
||||
);
|
||||
|
||||
let handle = spawn_subagent(subagent_ctx, directive.clone(), access, ctx.clone());
|
||||
handles.push((i, handle));
|
||||
}
|
||||
|
||||
// Join all handles and collect results
|
||||
let mut results = Vec::new();
|
||||
for (i, handle) in handles {
|
||||
let result = handle
|
||||
.join()
|
||||
.map_err(|e| anyhow::anyhow!("subagent {i} panicked: {e:?}"))??;
|
||||
results.push(format!("Agent {i}: {result}"));
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Spawned {} agents.\n\nResults:\n{}",
|
||||
agents.len(),
|
||||
results.join("\n")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a sequential pipeline of agent stages.
|
||||
///
|
||||
/// Flow: parse stages → load settings → for each stage, build a
|
||||
/// SubagentContext and call run_agent sequentially → collect results.
|
||||
pub struct SpawnPipeline;
|
||||
|
||||
impl Tool for SpawnPipeline {
|
||||
fn name(&self) -> &'static str {
|
||||
"spawn_pipeline"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Spawn a sequential pipeline of agent stages"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directive": {"type": "string", "description": "Directive for this pipeline stage"}
|
||||
},
|
||||
"required": ["directive"]
|
||||
},
|
||||
"description": "Pipeline stages in order"
|
||||
}
|
||||
},
|
||||
"required": ["stages"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let stages = args
|
||||
.get("stages")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing 'stages' array"))?;
|
||||
|
||||
info!("Spawning pipeline with {} stages", stages.len());
|
||||
|
||||
// Load LLM credentials once for all stages
|
||||
let store = Store::new();
|
||||
let settings = JsonSettingsRepository::new()
|
||||
.load(&store.base_dir)
|
||||
.unwrap_or_default();
|
||||
let app_config = JsonAppConfigRepository::new()
|
||||
.load(&store.base_dir)
|
||||
.unwrap_or_default();
|
||||
|
||||
let (provider, model) =
|
||||
crate::subagent::provider::resolve_subagent_provider(&settings, &app_config);
|
||||
|
||||
let base_url = app_config
|
||||
.providers
|
||||
.get(&provider)
|
||||
.map(|p| p.api_base.clone())
|
||||
.unwrap_or_else(|| "https://opencode.ai/zen/v1".to_string());
|
||||
|
||||
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
let mut pipeline_result = String::new();
|
||||
|
||||
for (i, stage) in stages.iter().enumerate() {
|
||||
let directive = stage
|
||||
.get("directive")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let subagent_ctx = SubagentContext::new(
|
||||
directive.clone(),
|
||||
ctx.clone(),
|
||||
"full".to_string(),
|
||||
base_url.clone(),
|
||||
api_key.clone(),
|
||||
model.clone(),
|
||||
);
|
||||
|
||||
let result = rt.block_on(async {
|
||||
run_agent(subagent_ctx, &directive, AccessTier::Full, ctx.clone()).await
|
||||
})?;
|
||||
|
||||
pipeline_result.push_str(&format!("Stage {}: {}\n", i, result));
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Pipeline with {} stages completed.\n\n{}",
|
||||
stages.len(),
|
||||
pipeline_result
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Change the working directory for subsequent commands.
|
||||
|
||||
use crate::tools::{Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct Cd;
|
||||
|
||||
impl Tool for Cd {
|
||||
fn name(&self) -> &'static str {
|
||||
"cd"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Set the working directory for subsequent tool calls"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string",
|
||||
"description": "Directory path to change to (relative to workspace root)"
|
||||
}
|
||||
},
|
||||
"required": ["directory"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let dir = crate::tools::arg_str(args, "directory")?;
|
||||
std::env::set_current_dir(&dir)?;
|
||||
Ok(format!("Changed directory to '{dir}'"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Update the shared directory cache.
|
||||
|
||||
use crate::tools::ToolCtx;
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub struct DirCacheUpdate;
|
||||
|
||||
impl crate::tools::Tool for DirCacheUpdate {
|
||||
fn name(&self) -> &'static str {
|
||||
"dir_cache_update"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Update the cached directory listing"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"paths": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "New list of paths for the cache"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let paths: Vec<String> = args
|
||||
.get("paths")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let count = paths.len();
|
||||
Ok(format!("Directory cache updated with {} entries", count))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user