ci: add GitHub Actions workflows with semantic-release auto-versioning
chore: fix all 702 clippy warnings across codebase - auto-fix 475 via cargo clippy --fix - fix remaining 227 manually: uninlined_format_args, redundant_closure, match_same_arms, underscore_binding, format_push_string, items_after_statements, needless_pass_by_value, clone_on_copy, case_sensitive_extension, single_match/let-else, write_with_newline, and other clippy lints
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Global registry of running background bash jobs, and control operations
|
||||
//! (output polling, kill) exposed to the rest of the app.
|
||||
//!
|
||||
@@ -57,7 +58,7 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
|
||||
/// Return: `Ok(())` on success, `Err` if the lock is poisoned or no job
|
||||
/// with that id exists.
|
||||
pub fn bash_kill(id: &str) -> anyhow::Result<()> {
|
||||
let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {}", e))?;
|
||||
let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {e}"))?;
|
||||
let job = map.remove(id);
|
||||
match job {
|
||||
Some(job) => {
|
||||
@@ -70,6 +71,6 @@ pub fn bash_kill(id: &str) -> anyhow::Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => anyhow::bail!("bash job '{}' not found", id),
|
||||
None => anyhow::bail!("bash job '{id}' not found"),
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -17,7 +17,7 @@ use std::io::BufRead;
|
||||
|
||||
/// Maximum number of output lines buffered in memory per background job.
|
||||
/// Beyond this limit, old output is dropped to prevent OOM (CWE-770).
|
||||
/// 10_000 lines at ~100 bytes each ≈ 1 MiB per job, sufficient for most
|
||||
/// `10_000` lines at ~100 bytes each ≈ 1 MiB per job, sufficient for most
|
||||
/// command output. The stderr drain thread also uses the same limit.
|
||||
const MAX_OUTPUT_LINES: usize = 10_000;
|
||||
|
||||
@@ -52,7 +52,7 @@ pub fn spawn_bash_job(command: String) -> BashJob {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let (output_tx, output_rx) = mpsc::sync_channel::<String>(MAX_OUTPUT_LINES);
|
||||
let (pid_tx, pid_rx) = mpsc::channel::<u32>();
|
||||
let cmd = command.clone();
|
||||
let cmd = command;
|
||||
let id_for_log = id.clone();
|
||||
let thread_id = id.clone();
|
||||
|
||||
@@ -66,12 +66,12 @@ pub fn spawn_bash_job(command: String) -> BashJob {
|
||||
let output_tx = output_tx.clone();
|
||||
let pid_tx = pid_tx.clone();
|
||||
let id_for_log = id_for_log.clone();
|
||||
move || spawn_bash_thread_body(cmd, output_tx, pid_tx, id_for_log)
|
||||
move || spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log)
|
||||
}).is_err()
|
||||
{
|
||||
tracing::warn!("[bgbash:{}] failed to spawn named thread, using unnamed fallback", id_for_log);
|
||||
thread::spawn(move || {
|
||||
spawn_bash_thread_body(cmd, output_tx, pid_tx, id_for_log)
|
||||
spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,21 +89,21 @@ pub fn spawn_bash_job(command: String) -> BashJob {
|
||||
/// spawned from both the named Builder and the unnamed fallback without
|
||||
/// double-moving the closure.
|
||||
fn spawn_bash_thread_body(
|
||||
cmd: String,
|
||||
output_tx: std::sync::mpsc::SyncSender<String>,
|
||||
pid_tx: std::sync::mpsc::Sender<u32>,
|
||||
id_for_log: String,
|
||||
cmd: &str,
|
||||
output_tx: &std::sync::mpsc::SyncSender<String>,
|
||||
pid_tx: &std::sync::mpsc::Sender<u32>,
|
||||
id_for_log: &str,
|
||||
) {
|
||||
let mut child = match Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.arg(cmd)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let _ = output_tx.try_send(format!("__error:{}", e));
|
||||
let _ = output_tx.try_send(format!("__error:{e}"));
|
||||
let _ = output_tx.try_send("__exit:-1".to_string());
|
||||
return;
|
||||
}
|
||||
@@ -124,7 +124,7 @@ fn spawn_bash_thread_body(
|
||||
std::thread::spawn(move || {
|
||||
let reader = std::io::BufReader::new(stderr);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
if stderr_tx.try_send(format!("[stderr] {}", line)).is_err() {
|
||||
if stderr_tx.try_send(format!("[stderr] {line}")).is_err() {
|
||||
tracing::debug!("[bgbash] stderr buffer full, discarding remaining stderr");
|
||||
break;
|
||||
}
|
||||
@@ -153,7 +153,7 @@ fn spawn_bash_thread_body(
|
||||
impl BashJob {
|
||||
/// Non-blocking poll for the next output line from the job's channel.
|
||||
///
|
||||
/// Flow: try_recv the channel → if it's an `__exit:<code>` sentinel,
|
||||
/// Flow: `try_recv` the channel → if it's an `__exit:<code>` sentinel,
|
||||
/// record `exit_code` and return `None` instead of surfacing it as
|
||||
/// output → otherwise return the line.
|
||||
///
|
||||
|
||||
+3
-3
@@ -122,6 +122,7 @@ impl Harness {
|
||||
/// as risky because their behaviour is unknown.
|
||||
///
|
||||
/// Return: `Verdict::Allow` or `Verdict::Block(reason)`.
|
||||
#[allow(clippy::too_many_lines, clippy::unnecessary_debug_formatting)]
|
||||
pub fn gate_tool_call(
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
@@ -163,8 +164,7 @@ impl Harness {
|
||||
let allowed = workspace_roots.iter().any(|r| out_path.starts_with(r));
|
||||
if !allowed {
|
||||
return Verdict::Block(format!(
|
||||
"output path '{:?}' is outside all workspace roots",
|
||||
out_path
|
||||
"output path '{out_path:?}' is outside all workspace roots"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -286,7 +286,7 @@ impl Harness {
|
||||
(>= {MIN_REASON_LEN} chars) explaining why it is needed"
|
||||
));
|
||||
}
|
||||
} else if args.as_object().map(|m| !m.is_empty()).unwrap_or(false) {
|
||||
} else if args.as_object().is_some_and(|m| !m.is_empty()) {
|
||||
// Only require reason when there are meaningful arguments
|
||||
return Verdict::Block(format!(
|
||||
"MCP tool '{tool_name}' requires a 'reason' argument \
|
||||
|
||||
+37
-39
@@ -30,12 +30,12 @@ fn file_path_to_uri(path: &str) -> String {
|
||||
if cfg!(windows) {
|
||||
let path_str = path_str.replace('\\', "/");
|
||||
if path_str.starts_with('/') {
|
||||
format!("file://{}", path_str)
|
||||
format!("file://{path_str}")
|
||||
} else {
|
||||
format!("file:///{}", path_str)
|
||||
format!("file:///{path_str}")
|
||||
}
|
||||
} else {
|
||||
format!("file://{}", path_str)
|
||||
format!("file://{path_str}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ impl LspClient {
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn LSP server '{}': {}", command, e))?;
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn LSP server '{command}': {e}"))?;
|
||||
|
||||
let stdin = child.stdin.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to capture stdin for LSP server"))?;
|
||||
@@ -106,10 +106,10 @@ impl LspClient {
|
||||
}
|
||||
});
|
||||
|
||||
let result = client.call_with_timeout("initialize", init_params, Duration::from_millis(LSP_INIT_TIMEOUT_MS))?;
|
||||
let result = client.call_with_timeout("initialize", &init_params, Duration::from_millis(LSP_INIT_TIMEOUT_MS))?;
|
||||
client.server_capabilities = result.get("capabilities").cloned().unwrap_or_default();
|
||||
|
||||
client.notify("initialized", json!({}))?;
|
||||
client.notify("initialized", &json!({}))?;
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
@@ -118,11 +118,11 @@ impl LspClient {
|
||||
&self.server_capabilities
|
||||
}
|
||||
|
||||
pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result<Value> {
|
||||
pub fn call(&mut self, method: &str, params: &Value) -> anyhow::Result<Value> {
|
||||
self.call_with_timeout(method, params, Duration::from_millis(LSP_CALL_TIMEOUT_MS))
|
||||
}
|
||||
|
||||
fn call_with_timeout(&mut self, method: &str, params: Value, timeout: Duration) -> anyhow::Result<Value> {
|
||||
fn call_with_timeout(&mut self, method: &str, params: &Value, timeout: Duration) -> anyhow::Result<Value> {
|
||||
self.next_id += 1;
|
||||
let id = self.next_id;
|
||||
let req = json!({
|
||||
@@ -135,7 +135,7 @@ impl LspClient {
|
||||
self.read_response(id, timeout)
|
||||
}
|
||||
|
||||
pub fn notify(&mut self, method: &str, params: Value) -> anyhow::Result<()> {
|
||||
pub fn notify(&mut self, method: &str, params: &Value) -> anyhow::Result<()> {
|
||||
let req = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
@@ -146,14 +146,14 @@ impl LspClient {
|
||||
|
||||
fn send_frame(&mut self, msg: &Value) -> anyhow::Result<()> {
|
||||
let body = serde_json::to_string(msg)
|
||||
.map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {e}"))?;
|
||||
let header = format!("Content-Length: {}\r\n\r\n", body.len());
|
||||
self.stdin.write_all(header.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("failed to write LSP frame header: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("failed to write LSP frame header: {e}"))?;
|
||||
self.stdin.write_all(body.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("failed to write LSP frame body: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("failed to write LSP frame body: {e}"))?;
|
||||
self.stdin.flush()
|
||||
.map_err(|e| anyhow::anyhow!("failed to flush LSP stdin: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("failed to flush LSP stdin: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -166,9 +166,9 @@ impl LspClient {
|
||||
let frame = self.read_frame()?;
|
||||
if frame.get("id") == Some(&json!(expected_id)) {
|
||||
if let Some(err) = frame.get("error") {
|
||||
let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(0);
|
||||
let code = err.get("code").and_then(serde_json::Value::as_i64).unwrap_or(0);
|
||||
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error");
|
||||
anyhow::bail!("LSP error {}: {}", code, msg);
|
||||
anyhow::bail!("LSP error {code}: {msg}");
|
||||
}
|
||||
return Ok(frame.get("result").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
@@ -179,7 +179,7 @@ impl LspClient {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if Instant::now() > deadline {
|
||||
anyhow::bail!("timed out waiting for LSP notification '{}'", method);
|
||||
anyhow::bail!("timed out waiting for LSP notification '{method}'");
|
||||
}
|
||||
let frame = self.read_frame()?;
|
||||
if frame.get("method") == Some(&json!(method)) {
|
||||
@@ -195,22 +195,21 @@ impl LspClient {
|
||||
match self.stdout.read_line(&mut line) {
|
||||
Ok(0) => anyhow::bail!("LSP server closed the connection"),
|
||||
Ok(_) => {}
|
||||
Err(e) => anyhow::bail!("LSP read error: {}", e),
|
||||
Err(e) => anyhow::bail!("LSP read error: {e}"),
|
||||
}
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
|
||||
let length: usize = len_str.trim().parse::<usize>()
|
||||
.map_err(|e| anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e))?;
|
||||
// Cap Content-Length at 64 MiB to prevent OOM from a
|
||||
// malicious or misconfigured LSP server (CWE-400).
|
||||
const MAX_CONTENT_LENGTH: usize = 64 * 1024 * 1024;
|
||||
let length: usize = len_str.trim().parse::<usize>()
|
||||
.map_err(|e| anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e))?;
|
||||
if length > MAX_CONTENT_LENGTH {
|
||||
anyhow::bail!(
|
||||
"Content-Length {} exceeds maximum allowed size of {} bytes",
|
||||
length, MAX_CONTENT_LENGTH,
|
||||
"Content-Length {length} exceeds maximum allowed size of {MAX_CONTENT_LENGTH} bytes",
|
||||
);
|
||||
}
|
||||
content_length = Some(length);
|
||||
@@ -222,17 +221,17 @@ impl LspClient {
|
||||
|
||||
let mut body = vec![0u8; length];
|
||||
self.stdout.read_exact(&mut body)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read LSP body ({} bytes): {}", length, e))?;
|
||||
.map_err(|e| anyhow::anyhow!("failed to read LSP body ({length} bytes): {e}"))?;
|
||||
|
||||
let json_str = String::from_utf8(body)
|
||||
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in LSP response: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in LSP response: {e}"))?;
|
||||
|
||||
serde_json::from_str(&json_str)
|
||||
.map_err(|e| anyhow::anyhow!("invalid JSON in LSP response: {}", e))
|
||||
.map_err(|e| anyhow::anyhow!("invalid JSON in LSP response: {e}"))
|
||||
}
|
||||
|
||||
pub fn did_open(&mut self, uri: &str, language_id: &str, version: i32, text: &str) -> anyhow::Result<()> {
|
||||
self.notify("textDocument/didOpen", json!({
|
||||
self.notify("textDocument/didOpen", &json!({
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"languageId": language_id,
|
||||
@@ -244,7 +243,7 @@ impl LspClient {
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> {
|
||||
self.notify("textDocument/didChange", json!({
|
||||
self.notify("textDocument/didChange", &json!({
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"version": version
|
||||
@@ -256,7 +255,7 @@ impl LspClient {
|
||||
}
|
||||
|
||||
pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> {
|
||||
self.notify("textDocument/didClose", json!({
|
||||
self.notify("textDocument/didClose", &json!({
|
||||
"textDocument": {
|
||||
"uri": uri
|
||||
}
|
||||
@@ -264,28 +263,28 @@ impl LspClient {
|
||||
}
|
||||
|
||||
pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/hover", json!({
|
||||
self.call("textDocument/hover", &json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character }
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/completion", json!({
|
||||
self.call("textDocument/completion", &json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character }
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn goto_definition(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/definition", json!({
|
||||
self.call("textDocument/definition", &json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character }
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/references", json!({
|
||||
self.call("textDocument/references", &json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character },
|
||||
"context": {
|
||||
@@ -296,7 +295,7 @@ impl LspClient {
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn document_symbols(&mut self, uri: &str) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/documentSymbol", json!({
|
||||
self.call("textDocument/documentSymbol", &json!({
|
||||
"textDocument": { "uri": uri }
|
||||
}))
|
||||
}
|
||||
@@ -327,7 +326,7 @@ impl LspClient {
|
||||
/// still proves the process is up and the JSON-RPC channel is live.
|
||||
/// Returns `false` on timeout, EOF, or any read/write error.
|
||||
///
|
||||
/// Flow: build request → send_frame → poll frames until id matches
|
||||
/// Flow: build request → `send_frame` → poll frames until id matches
|
||||
/// (alive) or deadline/read error fires (dead).
|
||||
#[allow(dead_code)]
|
||||
pub fn is_alive(&mut self) -> bool {
|
||||
@@ -369,19 +368,18 @@ impl LspClient {
|
||||
/// not block on any reply.
|
||||
#[allow(dead_code)]
|
||||
pub fn exit(&mut self) -> anyhow::Result<()> {
|
||||
self.notify("exit", json!({}))
|
||||
self.notify("exit", &json!({}))
|
||||
}
|
||||
|
||||
pub fn shutdown(&mut self) -> anyhow::Result<()> {
|
||||
let _ = self.call_with_timeout("shutdown", json!({}), Duration::from_secs(5));
|
||||
let _ = self.notify("exit", json!({}));
|
||||
Ok(())
|
||||
pub fn shutdown(&mut self) {
|
||||
let _ = self.call_with_timeout("shutdown", &json!({}), Duration::from_secs(5));
|
||||
let _ = self.notify("exit", &json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LspClient {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.notify("exit", json!({}));
|
||||
let _ = self.notify("exit", &json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-28
@@ -70,7 +70,7 @@ impl LspManager {
|
||||
language_id: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
if self.servers.iter().any(|s| s.name == name) {
|
||||
anyhow::bail!("LSP server '{}' is already connected", name);
|
||||
anyhow::bail!("LSP server '{name}' is already connected");
|
||||
}
|
||||
let client = LspClient::spawn(command, args)?;
|
||||
self.servers.push(LspServer {
|
||||
@@ -101,7 +101,7 @@ impl LspManager {
|
||||
pub fn disconnect(&mut self, name: &str) -> bool {
|
||||
if let Some(server) = self.servers.iter().find(|s| s.name == name) {
|
||||
if let Ok(mut client) = server.client.lock() {
|
||||
let _ = client.shutdown();
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
let len = self.servers.len();
|
||||
@@ -134,7 +134,7 @@ impl LspManager {
|
||||
pub fn find_server_for_path(&self, path: &Path) -> Option<Arc<Mutex<LspClient>>> {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| format!(".{}", s))
|
||||
.map(|s| format!(".{s}"))
|
||||
.and_then(|ext| self.find_server_for_extension(&ext))
|
||||
}
|
||||
|
||||
@@ -171,21 +171,15 @@ impl LspManager {
|
||||
/// Non-critical failures (file missing, server unreachable, send
|
||||
/// error) are logged with `tracing::warn!` rather than propagated,
|
||||
/// so a stale notification cannot abort the calling flow.
|
||||
pub fn did_change_file(&mut self, path: &Path) -> anyhow::Result<()> {
|
||||
let ext = match path.extension().and_then(|e| e.to_str()).map(|s| format!(".{}", s)) {
|
||||
Some(ext) => ext,
|
||||
None => {
|
||||
tracing::warn!("did_change_file: path has no extension: {:?}", path);
|
||||
return Ok(());
|
||||
}
|
||||
pub fn did_change_file(&mut self, path: &Path) {
|
||||
let Some(ext) = path.extension().and_then(|e| e.to_str()).map(|s| format!(".{s}")) else {
|
||||
tracing::warn!("did_change_file: path has no extension: {:?}", path);
|
||||
return;
|
||||
};
|
||||
|
||||
let server_name = match self.extension_registry.get(&ext) {
|
||||
Some(name) => name.clone(),
|
||||
None => {
|
||||
tracing::warn!("did_change_file: no LSP server registered for extension '{}'", ext);
|
||||
return Ok(());
|
||||
}
|
||||
let server_name = if let Some(name) = self.extension_registry.get(&ext) { name.clone() } else {
|
||||
tracing::warn!("did_change_file: no LSP server registered for extension '{}'", ext);
|
||||
return;
|
||||
};
|
||||
|
||||
let uri = path_to_lsp_uri(&path.to_string_lossy());
|
||||
@@ -194,7 +188,7 @@ impl LspManager {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::warn!("did_change_file: failed to read {:?}: {}", path, e);
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -202,12 +196,9 @@ impl LspManager {
|
||||
.get_language_id(&server_name)
|
||||
.unwrap_or_else(|| "plaintext".to_string());
|
||||
|
||||
let client = match self.get_client(&server_name) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::warn!("did_change_file: server '{}' has no client", server_name);
|
||||
return Ok(());
|
||||
}
|
||||
let Some(client) = self.get_client(&server_name) else {
|
||||
tracing::warn!("did_change_file: server '{}' has no client", server_name);
|
||||
return;
|
||||
};
|
||||
|
||||
let next_version = match self.open_files.get(&uri) {
|
||||
@@ -220,7 +211,7 @@ impl LspManager {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("did_change_file: client mutex poisoned for '{}': {}", server_name, e);
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
};
|
||||
if self.open_files.contains_key(&uri) {
|
||||
@@ -237,7 +228,7 @@ impl LspManager {
|
||||
uri,
|
||||
e
|
||||
);
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
|
||||
self.open_files.insert(
|
||||
@@ -248,7 +239,6 @@ impl LspManager {
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record that `server_name` has an open document at `uri`.
|
||||
@@ -274,9 +264,9 @@ impl LspManager {
|
||||
/// drop the vec. Failures from individual shutdowns are swallowed
|
||||
/// because the goal is best-effort termination during teardown.
|
||||
pub fn shutdown_all(&mut self) {
|
||||
for server in self.servers.iter() {
|
||||
for server in &self.servers {
|
||||
if let Ok(mut client) = server.client.lock() {
|
||||
let _ = client.shutdown();
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
self.servers.clear();
|
||||
|
||||
+46
-51
@@ -1,10 +1,10 @@
|
||||
//! Auto-provisioning engine for LSP language servers.
|
||||
//!
|
||||
//! Flow: detect_env() → for each supported server in supported_servers()
|
||||
//! → provision_single() tries install tiers in order → returns
|
||||
//! ProvisionResult (AlreadyAvailable / Installed / Failed).
|
||||
//! Caller can then call auto_connect() to attach available servers
|
||||
//! to an existing LspManager.
|
||||
//! Flow: `detect_env()` → for each supported server in `supported_servers()`
|
||||
//! → `provision_single()` tries install tiers in order → returns
|
||||
//! `ProvisionResult` (`AlreadyAvailable` / Installed / Failed).
|
||||
//! Caller can then call `auto_connect()` to attach available servers
|
||||
//! to an existing `LspManager`.
|
||||
//!
|
||||
//! Why: opening a project on a fresh machine should not require the user
|
||||
//! to manually hunt down and install 4 different language servers.
|
||||
@@ -28,7 +28,7 @@ pub type ProgressFn<'a> = Option<&'a dyn Fn(&str)>;
|
||||
|
||||
/// Result of attempting to make a single language server available.
|
||||
///
|
||||
/// The caller should switch on this variant: AlreadyAvailable and
|
||||
/// The caller should switch on this variant: `AlreadyAvailable` and
|
||||
/// Installed both mean the binary can be launched; Failed means we
|
||||
/// gave up and the user needs to install manually (see `manual_instructions`).
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -99,12 +99,13 @@ pub struct InstallTier {
|
||||
|
||||
/// Snapshot of the host environment used to decide which install tiers are viable.
|
||||
///
|
||||
/// Populated by `detect_env()` once per provision_all() call so we
|
||||
/// Populated by `detect_env()` once per `provision_all()` call so we
|
||||
/// don't re-shell out for every server. `is_linux` / `is_macos` are
|
||||
/// computed at startup (compile time would also work, but keeping the
|
||||
/// shape uniform with the rest of the struct makes the call sites tidy).
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct EnvInfo {
|
||||
pub has_rustup: bool,
|
||||
pub has_npm: bool,
|
||||
@@ -126,7 +127,7 @@ pub struct EnvInfo {
|
||||
///
|
||||
/// Flow: `Command::new("which").arg(binary).output()` → on Unix
|
||||
/// `which` returns exit 0 + stdout path when found, non-zero
|
||||
/// otherwise. We return the first stdout line as the PathBuf.
|
||||
/// otherwise. We return the first stdout line as the `PathBuf`.
|
||||
///
|
||||
/// Returns None if `which` itself is missing, fails to spawn, or the
|
||||
/// binary is not on PATH. We deliberately don't cache this — it's only
|
||||
@@ -151,7 +152,7 @@ pub fn which(binary: &str) -> Option<PathBuf> {
|
||||
///
|
||||
/// Flow: shell out to `which` for each tool in parallel (sequentially,
|
||||
/// actually — the calls are fast and the ordering doesn't matter)
|
||||
/// → set EnvInfo flags. Linux/macOS are detected via cfg at
|
||||
/// → set `EnvInfo` flags. Linux/macOS are detected via cfg at
|
||||
/// compile time since `which` won't tell us.
|
||||
///
|
||||
/// Edge case: `which` may not exist on Windows; we guard with cfg so
|
||||
@@ -185,6 +186,7 @@ pub fn detect_env() -> EnvInfo {
|
||||
/// Why hard-coded rather than loaded from settings: the set is small,
|
||||
/// changes rarely, and bundling it lets the provisioner run before any
|
||||
/// user config has been read (e.g. on first launch).
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn supported_servers() -> Vec<LanguageServerDef> {
|
||||
vec![
|
||||
LanguageServerDef {
|
||||
@@ -307,7 +309,7 @@ pub fn supported_servers() -> Vec<LanguageServerDef> {
|
||||
/// commands tend to emit errors to stderr, and we want to surface
|
||||
/// those.
|
||||
///
|
||||
/// Why a custom timeout: std::process::Command has no built-in timeout,
|
||||
/// Why a custom timeout: `std::process::Command` has no built-in timeout,
|
||||
/// and we'd rather kill a hung `apt` than block the TUI indefinitely.
|
||||
pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)> {
|
||||
let mut command = Command::new(cmd);
|
||||
@@ -334,23 +336,19 @@ pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)>
|
||||
})
|
||||
});
|
||||
|
||||
let timeout = Duration::from_secs(180);
|
||||
let timeout = Duration::from_mins(3);
|
||||
let start = Instant::now();
|
||||
let status = loop {
|
||||
match child.try_wait()? {
|
||||
Some(status) => break Ok(status),
|
||||
None => {
|
||||
if start.elapsed() > timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("command '{}' timed out after {}s", cmd, timeout.as_secs()),
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
if let Some(status) = child.try_wait()? { break Ok(status) }
|
||||
if start.elapsed() > timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
format!("command '{}' timed out after {}s", cmd, timeout.as_secs()),
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
};
|
||||
|
||||
let stdout = stdout_thread
|
||||
@@ -362,7 +360,7 @@ pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)>
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => Ok((true, stdout)),
|
||||
Ok(_) => Ok((false, format!("{}{}", stdout, stderr))),
|
||||
Ok(_) => Ok((false, format!("{stdout}{stderr}"))),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
@@ -412,7 +410,7 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
|
||||
"-o", &path_str,
|
||||
url,
|
||||
];
|
||||
let (ok, out) = run_command("curl", &args).map_err(|e| format!("curl spawn: {}", e))?;
|
||||
let (ok, out) = run_command("curl", &args).map_err(|e| format!("curl spawn: {e}"))?;
|
||||
if !ok {
|
||||
return Err(format!("download failed: {}", out.trim()));
|
||||
}
|
||||
@@ -423,7 +421,7 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
|
||||
/// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`.
|
||||
fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Result<PathBuf, String> {
|
||||
let base = lsp_install_dir("rust-analyzer")?;
|
||||
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {}", e))?;
|
||||
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
|
||||
|
||||
let url = if env.is_linux {
|
||||
"https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-x86_64-unknown-linux-gnu.gz"
|
||||
@@ -440,7 +438,7 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu
|
||||
download_url(url, &gz, 120)?;
|
||||
if let Some(cb) = progress { cb("Rust: decompressing..."); }
|
||||
let (ok, out) = run_command("gunzip", &["-f", &gz.to_string_lossy()])
|
||||
.map_err(|e| format!("gunzip spawn: {}", e))?;
|
||||
.map_err(|e| format!("gunzip spawn: {e}"))?;
|
||||
if !ok {
|
||||
return Err(format!("gunzip: {}", out.trim()));
|
||||
}
|
||||
@@ -452,7 +450,7 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| format!("chmod: {}", e))?;
|
||||
.map_err(|e| format!("chmod: {e}"))?;
|
||||
}
|
||||
if let Some(cb) = progress { cb("Rust: installed ✓"); }
|
||||
Ok(target)
|
||||
@@ -462,7 +460,7 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu
|
||||
/// and create a launcher script at `bin/jdtls`.
|
||||
fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
|
||||
let base = lsp_install_dir("jdtls")?;
|
||||
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {}", e))?;
|
||||
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
|
||||
|
||||
let url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz";
|
||||
let tarball = base.join("jdtls.tar.gz");
|
||||
@@ -473,7 +471,7 @@ fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
|
||||
let (ok, out) = run_command("tar", &[
|
||||
"-xzf", tarball.to_str().unwrap_or(""),
|
||||
"-C", base.to_str().unwrap_or("."),
|
||||
]).map_err(|e| format!("tar spawn: {}", e))?;
|
||||
]).map_err(|e| format!("tar spawn: {e}"))?;
|
||||
if !ok {
|
||||
return Err(format!("tar: {}", out.trim()));
|
||||
}
|
||||
@@ -484,7 +482,7 @@ fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
let bin_dir = base.join("bin");
|
||||
std::fs::create_dir_all(&bin_dir).map_err(|e| format!("mkdir bin: {}", e))?;
|
||||
std::fs::create_dir_all(&bin_dir).map_err(|e| format!("mkdir bin: {e}"))?;
|
||||
let launcher = bin_dir.join("jdtls");
|
||||
|
||||
let script = r#"#!/usr/bin/env bash
|
||||
@@ -505,12 +503,12 @@ exec java \
|
||||
--add-opens java.base/java.lang=ALL-UNNAMED \
|
||||
"$@"
|
||||
"#;
|
||||
std::fs::write(&launcher, script).map_err(|e| format!("write launcher: {}", e))?;
|
||||
std::fs::write(&launcher, script).map_err(|e| format!("write launcher: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| format!("chmod launcher: {}", e))?;
|
||||
.map_err(|e| format!("chmod launcher: {e}"))?;
|
||||
}
|
||||
if let Some(cb) = progress { cb("Java: JDT-LS installed ✓"); }
|
||||
Ok(launcher)
|
||||
@@ -521,7 +519,7 @@ fn run_download_tier(name: &str, env: &EnvInfo, progress: ProgressFn<'_>) -> Res
|
||||
match name {
|
||||
DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress),
|
||||
DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress),
|
||||
other => Err(format!("unknown download tier '{}'", other)),
|
||||
other => Err(format!("unknown download tier '{other}'")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,7 +554,7 @@ fn manual_instructions(def: &LanguageServerDef) -> String {
|
||||
/// Try to provision a single language server.
|
||||
///
|
||||
/// Flow: check whether any `binary_names` candidate is already on PATH
|
||||
/// → if yes, return AlreadyAvailable → otherwise walk
|
||||
/// → if yes, return `AlreadyAvailable` → otherwise walk
|
||||
/// `install_tiers` in order, skipping tiers whose `requires`
|
||||
/// binaries are missing → for each viable tier, run the install
|
||||
/// command (120s timeout) → if it succeeds AND the binary now
|
||||
@@ -641,7 +639,7 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre
|
||||
}
|
||||
|
||||
// Normal shell-out tier.
|
||||
let arg_refs: Vec<&str> = tier.args.iter().map(|s| s.as_str()).collect();
|
||||
let arg_refs: Vec<&str> = tier.args.iter().map(std::string::String::as_str).collect();
|
||||
match run_command(&tier.command, &arg_refs) {
|
||||
Ok((true, _)) => {
|
||||
let located = def
|
||||
@@ -683,9 +681,9 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre
|
||||
/// Provision every supported server in order, returning one
|
||||
/// `ProvisionResult` per server.
|
||||
///
|
||||
/// Flow: detect_env() once → for each server in supported_servers()
|
||||
/// call provision_single() → collect results. Order matches
|
||||
/// supported_servers() (rust, typescript, go, java).
|
||||
/// Flow: `detect_env()` once → for each server in `supported_servers()`
|
||||
/// call `provision_single()` → collect results. Order matches
|
||||
/// `supported_servers()` (rust, typescript, go, java).
|
||||
#[allow(dead_code)]
|
||||
pub fn provision_all() -> Vec<ProvisionResult> {
|
||||
let env = detect_env();
|
||||
@@ -725,7 +723,7 @@ pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult>
|
||||
let avail: String = flags.iter()
|
||||
.filter(|(_, v)| *v).map(|(k, _)| *k)
|
||||
.collect::<Vec<_>>().join(", ");
|
||||
cb(&format!("LSP: environment ready — {}", avail));
|
||||
cb(&format!("LSP: environment ready — {avail}"));
|
||||
}
|
||||
supported_servers()
|
||||
.iter()
|
||||
@@ -736,8 +734,8 @@ pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult>
|
||||
/// For every successful provision result, attach the corresponding
|
||||
/// server to the given `LspManager`.
|
||||
///
|
||||
/// Flow: for each result, if it's AlreadyAvailable or Installed, look
|
||||
/// up the LanguageServerDef, then call manager.connect() with
|
||||
/// Flow: for each result, if it's `AlreadyAvailable` or Installed, look
|
||||
/// up the `LanguageServerDef`, then call `manager.connect()` with
|
||||
/// the binary path and empty args. On connect success, log and
|
||||
/// record the name; on failure, log a warning and skip.
|
||||
/// Returns the names that successfully connected.
|
||||
@@ -745,7 +743,7 @@ pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult>
|
||||
/// Why empty args: most LSP servers don't need CLI flags to start;
|
||||
/// the spec for each server lives in the protocol handshake, not the
|
||||
/// argv. If we ever need flags (e.g. --stdio), they'll be a per-server
|
||||
/// constant in supported_servers().
|
||||
/// constant in `supported_servers()`.
|
||||
pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult]) -> Vec<String> {
|
||||
let defs = supported_servers();
|
||||
let mut connected: Vec<String> = Vec::new();
|
||||
@@ -767,12 +765,9 @@ pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult
|
||||
|
||||
// Sanity: only connect to servers we know about. Protects against
|
||||
// future ProvisionResult variants sneaking in unknown names.
|
||||
let def = match defs.iter().find(|d| d.name == name) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
warn!(name = %name, "skipping connect: unknown server");
|
||||
continue;
|
||||
}
|
||||
let Some(def) = defs.iter().find(|d| d.name == name) else {
|
||||
warn!(name = %name, "skipping connect: unknown server");
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut guard = match manager.lock() {
|
||||
@@ -784,7 +779,7 @@ pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult
|
||||
};
|
||||
|
||||
// Build extension slice for connect_with_extensions.
|
||||
let ext_refs: Vec<&str> = def.extensions.iter().map(|s| s.as_str()).collect();
|
||||
let ext_refs: Vec<&str> = def.extensions.iter().map(std::string::String::as_str).collect();
|
||||
|
||||
match guard.connect_with_extensions(&name, &binary, &[], &language, &ext_refs) {
|
||||
Ok(()) => {
|
||||
|
||||
+31
-32
@@ -88,7 +88,8 @@ impl StdioChild {
|
||||
///
|
||||
/// Return: the `result` value of the matching response, or `Err` on
|
||||
/// timeout, EOF, JSON-RPC error, or I/O failure.
|
||||
pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result<Value> {
|
||||
pub fn call(&mut self, method: &str, params: &Value) -> anyhow::Result<Value> {
|
||||
const MAX_LINE_LENGTH: usize = 1_048_576; // 1 MiB
|
||||
self.next_id += 1;
|
||||
let id = self.next_id;
|
||||
let req = json!({
|
||||
@@ -107,18 +108,17 @@ impl StdioChild {
|
||||
+ std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS);
|
||||
loop {
|
||||
if std::time::Instant::now() > deadline {
|
||||
anyhow::bail!("MCP call timed out after {}ms", MCP_CALL_TIMEOUT_MS);
|
||||
anyhow::bail!("MCP call timed out after {MCP_CALL_TIMEOUT_MS}ms");
|
||||
}
|
||||
response_line.clear();
|
||||
// Read one byte at a time up to MAX_LINE_LENGTH to prevent
|
||||
// OOM from a malicious server (CWE-400). BufReader already
|
||||
// buffers reads, so byte-by-byte over a buffered reader is
|
||||
// cheap (hits the in-memory buffer).
|
||||
const MAX_LINE_LENGTH: usize = 1_048_576; // 1 MiB
|
||||
response_line.clear();
|
||||
let mut line_truncated = false;
|
||||
loop {
|
||||
let byte = match self.stdout.fill_buf() {
|
||||
Ok(buf) if buf.is_empty() => {
|
||||
Ok([]) => {
|
||||
// EOF without newline
|
||||
anyhow::bail!("MCP stdio child process closed unexpectedly");
|
||||
}
|
||||
@@ -127,7 +127,7 @@ impl StdioChild {
|
||||
self.stdout.consume(1);
|
||||
b
|
||||
}
|
||||
Err(e) => anyhow::bail!("MCP stdio read error: {}", e),
|
||||
Err(e) => anyhow::bail!("MCP stdio read error: {e}"),
|
||||
};
|
||||
if byte == b'\n' {
|
||||
break;
|
||||
@@ -137,7 +137,7 @@ impl StdioChild {
|
||||
// Consume rest of line to keep stream in sync
|
||||
loop {
|
||||
let buf = self.stdout.fill_buf()
|
||||
.map_err(|e| anyhow::anyhow!("MCP stdio read error: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("MCP stdio read error: {e}"))?;
|
||||
if buf.is_empty() {
|
||||
anyhow::bail!("MCP stdio child closed mid-line");
|
||||
}
|
||||
@@ -153,8 +153,7 @@ impl StdioChild {
|
||||
}
|
||||
if line_truncated {
|
||||
anyhow::bail!(
|
||||
"MCP response line exceeded {} byte limit",
|
||||
MAX_LINE_LENGTH,
|
||||
"MCP response line exceeded {MAX_LINE_LENGTH} byte limit",
|
||||
);
|
||||
}
|
||||
let trimmed = response_line.trim();
|
||||
@@ -162,10 +161,10 @@ impl StdioChild {
|
||||
continue;
|
||||
}
|
||||
let resp: Value = serde_json::from_str(trimmed)
|
||||
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP server: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP server: {e}"))?;
|
||||
if resp.get("id") == Some(&json!(id)) {
|
||||
if let Some(err) = resp.get("error") {
|
||||
anyhow::bail!("MCP error: {}", err);
|
||||
anyhow::bail!("MCP error: {err}");
|
||||
}
|
||||
return Ok(resp.get("result").cloned().unwrap_or_else(|| {
|
||||
tracing::warn!("[mcp] stdio response missing 'result' field: {}", trimmed);
|
||||
@@ -191,7 +190,7 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn MCP stdio server '{}': {}", command, e))?;
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn MCP stdio server '{command}': {e}"))?;
|
||||
|
||||
let stdin = child.stdin.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to get stdin for MCP server"))?;
|
||||
@@ -207,7 +206,7 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
|
||||
let deadline = std::time::Instant::now()
|
||||
+ std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS);
|
||||
|
||||
let init_result = mcp.call("initialize", json!({
|
||||
let init_result = mcp.call("initialize", &json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
@@ -220,9 +219,9 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
|
||||
anyhow::bail!("MCP initialize timed out");
|
||||
}
|
||||
|
||||
init_result.map_err(|e| anyhow::anyhow!("MCP initialize failed: {}", e))?;
|
||||
init_result.map_err(|e| anyhow::anyhow!("MCP initialize failed: {e}"))?;
|
||||
|
||||
let _ = mcp.call("notifications/initialized", json!({}));
|
||||
let _ = mcp.call("notifications/initialized", &json!({}));
|
||||
|
||||
Ok(mcp)
|
||||
}
|
||||
@@ -237,23 +236,23 @@ fn call_via_stdio(
|
||||
// Reuse the persistent child handle if available; otherwise spawn a new one.
|
||||
let mut guard;
|
||||
let child: &mut StdioChild = if let Some(mtx) = existing_handle {
|
||||
guard = mtx.lock().map_err(|e| anyhow::anyhow!("MCP handle lock: {}", e))?;
|
||||
guard = mtx.lock().map_err(|e| anyhow::anyhow!("MCP handle lock: {e}"))?;
|
||||
&mut guard
|
||||
} else {
|
||||
let mut fresh = spawn_stdio_child(command, extra_args)?;
|
||||
let result = fresh.call("tools/call", json!({
|
||||
let result = fresh.call("tools/call", &json!({
|
||||
"name": tool_name,
|
||||
"arguments": tool_args
|
||||
}))?;
|
||||
return extract_text_content(&result);
|
||||
return Ok(extract_text_content(&result));
|
||||
};
|
||||
|
||||
let result = child.call("tools/call", json!({
|
||||
let result = child.call("tools/call", &json!({
|
||||
"name": tool_name,
|
||||
"arguments": tool_args
|
||||
}))?;
|
||||
|
||||
extract_text_content(&result)
|
||||
Ok(extract_text_content(&result))
|
||||
}
|
||||
|
||||
fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Result<String> {
|
||||
@@ -294,7 +293,7 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.map_err(|e| anyhow::anyhow!("MCP HTTP request failed: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("MCP HTTP request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
@@ -302,42 +301,42 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
|
||||
tracing::warn!("[mcp] failed to read HTTP response body: {}", e);
|
||||
String::new()
|
||||
});
|
||||
anyhow::bail!("MCP HTTP server returned {}: {}", status, text);
|
||||
anyhow::bail!("MCP HTTP server returned {status}: {text}");
|
||||
}
|
||||
|
||||
let response: Value = resp.json()
|
||||
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP HTTP server: {}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP HTTP server: {e}"))?;
|
||||
|
||||
if let Some(err) = response.get("error") {
|
||||
anyhow::bail!("MCP HTTP error: {}", err);
|
||||
anyhow::bail!("MCP HTTP error: {err}");
|
||||
}
|
||||
|
||||
let result = response.get("result").cloned().unwrap_or_else(|| {
|
||||
tracing::warn!("[mcp] HTTP response missing 'result' field");
|
||||
Value::Null
|
||||
});
|
||||
extract_text_content(&result)
|
||||
Ok(extract_text_content(&result))
|
||||
}
|
||||
|
||||
fn extract_text_content(result: &Value) -> anyhow::Result<String> {
|
||||
fn extract_text_content(result: &Value) -> String {
|
||||
if let Some(content) = result.get("content") {
|
||||
if let Some(arr) = content.as_array() {
|
||||
let text: Vec<String> = arr.iter().filter_map(|item| {
|
||||
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
|
||||
item.get("text").and_then(|t| t.as_str()).map(|s| s.to_string())
|
||||
item.get("text").and_then(|t| t.as_str()).map(std::string::ToString::to_string)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}).collect();
|
||||
if !text.is_empty() {
|
||||
return Ok(text.join("\n"));
|
||||
return text.join("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(serde_json::to_string_pretty(result).unwrap_or_else(|e| {
|
||||
serde_json::to_string_pretty(result).unwrap_or_else(|e| {
|
||||
tracing::warn!("[mcp] failed to pretty-print result: {}", e);
|
||||
result.to_string()
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Registry of connected MCP servers and their tools for the current session.
|
||||
@@ -374,7 +373,7 @@ impl crate::tool::Tool for McpToolAdapter {
|
||||
fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result<String> {
|
||||
match &self.transport {
|
||||
McpTransport::Stdio { command, args: extra_args } => {
|
||||
call_via_stdio(self.child_handle.as_ref().map(|h| h.as_ref()), command, extra_args, &self.tool_name, args)
|
||||
call_via_stdio(self.child_handle.as_ref().map(std::convert::AsRef::as_ref), command, extra_args, &self.tool_name, args)
|
||||
}
|
||||
McpTransport::StreamableHttp { url } => {
|
||||
call_via_http(url, &self.tool_name, args)
|
||||
@@ -428,7 +427,7 @@ impl McpManager {
|
||||
};
|
||||
|
||||
let mut child = spawn_stdio_child(command, extra_args)?;
|
||||
let result = child.call("tools/list", json!({}))?;
|
||||
let result = child.call("tools/list", &json!({}))?;
|
||||
|
||||
let tools = if let Some(tool_list) = result.get("tools").and_then(|v| v.as_array()) {
|
||||
tool_list.iter().filter_map(|t| {
|
||||
|
||||
@@ -66,7 +66,7 @@ impl EditorState {
|
||||
self.cursor_line += 1;
|
||||
}
|
||||
self.cursor_col = self.cursor_col.min(
|
||||
self.content.get(self.cursor_line).map(|l| l.len()).unwrap_or(0),
|
||||
self.content.get(self.cursor_line).map_or(0, std::string::String::len),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ impl EditorState {
|
||||
/// Flow: no-op if no editor is open → for each char: `\n`/`\r` inserts a
|
||||
/// line and moves down, `\t` inserts two spaces, everything else inserts
|
||||
/// the char directly → mark state dirty.
|
||||
pub fn handle_editor_input(state: &mut AppStateRest, text: String) {
|
||||
pub fn handle_editor_input(state: &mut AppStateRest, text: &str) {
|
||||
let editor = &mut state.misc.editor;
|
||||
if editor.is_none() {
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Effort mode: cycles the agent's reasoning effort level, which scales the
|
||||
//! LLM's temperature and max_tokens for subsequent turns.
|
||||
//! LLM's temperature and `max_tokens` for subsequent turns.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
@@ -44,7 +45,7 @@ pub fn cycle_effort(state: &mut AppStateRest) {
|
||||
let label = current_effort_str(state);
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Info,
|
||||
format!("Effort: {}", label),
|
||||
format!("Effort: {label}"),
|
||||
));
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
+11
-14
@@ -1,23 +1,20 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Rewind mode: restores a file to a pre-edit snapshot stored in the
|
||||
//! session's SQLite blob store.
|
||||
//! session's `SQLite` blob store.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use sha2::Digest;
|
||||
|
||||
/// Returns the number of stored pre-edit blobs (snapshots) for this session.
|
||||
pub fn rewind_count(state: &AppStateRest) -> usize {
|
||||
let conn = match open_session_db(&state.session_dir) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
let Ok(conn) = open_session_db(&state.session_dir) else { return 0 };
|
||||
crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id)
|
||||
.ok()
|
||||
.map(|keys| keys.len())
|
||||
.unwrap_or(0)
|
||||
.map_or(0, |keys| keys.len())
|
||||
}
|
||||
|
||||
/// Restores a file to its pre-edit state by retrieving the blob stored under index
|
||||
/// `index` (0 = oldest). Opens a fresh SQLite connection so this works outside
|
||||
/// `index` (0 = oldest). Opens a fresh `SQLite` connection so this works outside
|
||||
/// of a running turn (e.g. from the Rewind overlay).
|
||||
pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
let conn = match open_session_db(&state.session_dir) {
|
||||
@@ -25,7 +22,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
Err(e) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
format!("Failed to open session DB: {}", e),
|
||||
format!("Failed to open session DB: {e}"),
|
||||
));
|
||||
state.dirty = true;
|
||||
return;
|
||||
@@ -37,7 +34,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
Err(e) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
format!("Failed to list snapshots: {}", e),
|
||||
format!("Failed to list snapshots: {e}"),
|
||||
));
|
||||
state.dirty = true;
|
||||
return;
|
||||
@@ -67,7 +64,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
Err(e) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
format!("Failed to retrieve snapshot: {}", e),
|
||||
format!("Failed to retrieve snapshot: {e}"),
|
||||
));
|
||||
state.dirty = true;
|
||||
return;
|
||||
@@ -81,7 +78,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
.unwrap_or_else(|| state.session_dir.join("snapshot.dat"));
|
||||
|
||||
match std::fs::write(&restore_path, &bytes) {
|
||||
Ok(_) => {
|
||||
Ok(()) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Success,
|
||||
format!("Restored {} from snapshot", restore_path.display()),
|
||||
@@ -90,7 +87,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
Err(e) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
format!("Failed to write restored file: {}", e),
|
||||
format!("Failed to write restored file: {e}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -101,7 +98,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
ts: chrono::Utc::now().timestamp_millis(),
|
||||
tool: "rewind".to_string(),
|
||||
path: restore_path.to_string_lossy().to_string(),
|
||||
reason: format!("rewind_to({})", index),
|
||||
reason: format!("rewind_to({index})"),
|
||||
content_sha256: format!("{:x}", sha2::Sha256::digest(&bytes)),
|
||||
bytes_delta: bytes.len() as i64,
|
||||
origin: crate::app::state::types::Origin::Main.tag(),
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::model::settings::{Settings, InternetMode};
|
||||
|
||||
/// Advance the internet access mode to the next value in the cycle.
|
||||
///
|
||||
/// Flow: Off -> ReadOnly -> Full -> Off, wrapping around.
|
||||
/// Flow: Off -> `ReadOnly` -> Full -> Off, wrapping around.
|
||||
///
|
||||
/// Why: used by a settings-toggle keybinding to step through modes
|
||||
/// without needing a dropdown/menu.
|
||||
|
||||
+25
-30
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Adaptive quality-review triggering, build/test probing, staleness
|
||||
//! sweeps for stored lessons, and the pending-lesson approval workflow.
|
||||
use std::process::Command;
|
||||
@@ -74,10 +75,7 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
||||
if origin != Origin::Main {
|
||||
return false;
|
||||
}
|
||||
let runtime = match &state.session_runtime {
|
||||
Some(r) => r,
|
||||
None => return false,
|
||||
};
|
||||
let Some(runtime) = &state.session_runtime else { return false };
|
||||
if !state.settings.review_enabled {
|
||||
return false;
|
||||
}
|
||||
@@ -122,8 +120,7 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
|
||||
let probe_dir = workspaces.first()?;
|
||||
let cmd = resolve_verify_command(probe_dir, verify_command)?;
|
||||
|
||||
let (cmd_prog, cmd_args) = cmd.split_once(' ').map(|(p, a)| (p.to_string(), a.to_string()))
|
||||
.unwrap_or_else(|| (cmd.clone(), String::new()));
|
||||
let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else(|| (cmd.clone(), String::new()), |(p, a)| (p.to_string(), a.to_string()));
|
||||
|
||||
let Ok(mut child) = Command::new(&cmd_prog)
|
||||
.args(cmd_args.split_whitespace())
|
||||
@@ -143,7 +140,7 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
|
||||
let output = child.wait_with_output().ok();
|
||||
let stdout = output.as_ref().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
|
||||
let stderr = output.as_ref().map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()).unwrap_or_default();
|
||||
let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) };
|
||||
let combined = if stderr.is_empty() { stdout } else { format!("{stdout}\n{stderr}") };
|
||||
return Some(ProbeResult {
|
||||
command: cmd.clone(),
|
||||
passed: status.success(),
|
||||
@@ -201,10 +198,10 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
|
||||
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
|
||||
let scripts = v.get("scripts")?;
|
||||
if scripts.get("test").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() {
|
||||
if scripts.get("test").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) {
|
||||
return Some("npm test 2>&1".to_string());
|
||||
}
|
||||
if scripts.get("build").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() {
|
||||
if scripts.get("build").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) {
|
||||
return Some("npm run build 2>&1".to_string());
|
||||
}
|
||||
}
|
||||
@@ -306,14 +303,15 @@ fn truncate_output(s: &str, max: usize) -> String {
|
||||
/// Return: `Ok(())` once the review has been kicked off; errors only
|
||||
/// propagate from constructing the subagent context, not from the review
|
||||
/// itself (that failure is reported via a `SystemNote` instead).
|
||||
pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
#[allow(clippy::unnecessary_debug_formatting)]
|
||||
pub fn trigger_review(state: &mut AppStateRest) {
|
||||
let def = AgentDefinition::new(
|
||||
"quality-reviewer".to_string(),
|
||||
"reviewer".to_string(),
|
||||
);
|
||||
let mut ctx = build_subagent_context(def);
|
||||
ctx.session_dir = state.session_dir.clone();
|
||||
ctx.workspaces = state.workspace_roots.clone();
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir.clone_from(&state.session_dir);
|
||||
ctx.workspaces.clone_from(&state.workspace_roots);
|
||||
let probe_result = probe_build_test(
|
||||
&state.workspace_roots,
|
||||
state.settings.verify_command.as_deref(),
|
||||
@@ -333,21 +331,20 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
None => "No build/test probe matched. Confidence: opinion (reasoning-based).".to_string(),
|
||||
};
|
||||
|
||||
let session_dir = &state.session_dir;
|
||||
ctx.system_prompt = format!(
|
||||
"You are a code quality reviewer. Review the recent code changes \
|
||||
for correctness, and adherence to best practices. \
|
||||
Use read-only tools (read, grep, glob, recall, remember) to \
|
||||
inspect the session files and provide a concise review verdict. \
|
||||
Session directory: {:?}\n\n\
|
||||
Build/Test Probe:\n{}\n\n\
|
||||
Session directory: {session_dir:?}\n\n\
|
||||
Build/Test Probe:\n{probe_note}\n\n\
|
||||
When writing a lesson via remember(), set tags appropriately:\n\
|
||||
- If build/test verification printed any FAILED/ERROR lines, tag\n\
|
||||
the lesson as \"confidence: verified\" (backed by a real failure).\n\
|
||||
- If the probe passed or was skipped, tag as \"confidence: opinion\"\n\
|
||||
(reviewer judgment only).\n\
|
||||
Check for duplicate lessons via recall before writing a new one.",
|
||||
state.session_dir,
|
||||
probe_note,
|
||||
);
|
||||
|
||||
// Use a drain thread for subagent events (so blocking_send never
|
||||
@@ -359,17 +356,17 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
let mut rx = rx;
|
||||
while let Some(event) = rx.blocking_recv() {
|
||||
match &event {
|
||||
SubagentEvent::ToolCall { _tool, .. } => {
|
||||
tracing::debug!("[review] tool call: {}", _tool);
|
||||
SubagentEvent::ToolCall { tool, .. } => {
|
||||
tracing::debug!("[review] tool call: {}", tool);
|
||||
}
|
||||
SubagentEvent::ToolResult { _tool, .. } => {
|
||||
tracing::debug!("[review] tool result: {}", _tool);
|
||||
SubagentEvent::ToolResult { tool, .. } => {
|
||||
tracing::debug!("[review] tool result: {}", tool);
|
||||
}
|
||||
SubagentEvent::StepCompleted { _step, .. } => {
|
||||
tracing::trace!("[review] step {} completed", _step);
|
||||
SubagentEvent::StepCompleted { .. } => {
|
||||
tracing::trace!("[review] step completed");
|
||||
}
|
||||
SubagentEvent::StepFailed { _step, _error } => {
|
||||
tracing::warn!("[review] step {} failed: {}", _step, _error);
|
||||
SubagentEvent::StepFailed { step, error } => {
|
||||
tracing::warn!("[review] step {} failed: {}", step, error);
|
||||
}
|
||||
SubagentEvent::Completed { .. } => {
|
||||
tracing::debug!("[review] completed");
|
||||
@@ -380,13 +377,13 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
let turn_events = state.turn_events.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let result = run_subagent(ctx, tx);
|
||||
let result = run_subagent(&ctx, &tx);
|
||||
let message = match result {
|
||||
Ok(verdict) => {
|
||||
let first_line = verdict.lines().next().unwrap_or(&verdict);
|
||||
format!("Quality review: {}", first_line)
|
||||
format!("Quality review: {first_line}")
|
||||
}
|
||||
Err(e) => format!("Quality review failed: {}", e),
|
||||
Err(e) => format!("Quality review failed: {e}"),
|
||||
};
|
||||
if let Ok(mut q) = turn_events.lock() {
|
||||
q.push_back(TurnEvent::SystemNote {
|
||||
@@ -400,8 +397,6 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
ToastKind::Info,
|
||||
"Quality review triggered".to_string(),
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const STALE_AFTER_DAYS: i64 = 60;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! loop calls `apply_action(&mut state, action)` → for turn-producing
|
||||
//! actions (`SubmitInput`), `spawn_turn` is kicked off on a background OS
|
||||
//! thread which drives `run_agent_turn` (stream to the LLM, gate and
|
||||
//! execute tool calls via `Harness`, archive messages to SQLite, log edits)
|
||||
//! execute tool calls via `Harness`, archive messages to `SQLite`, log edits)
|
||||
//! and pushes `TurnEvent`s onto a shared queue → on the next `Tick`, queued
|
||||
//! `TurnEvent`s are drained back into `AppStateRest` (transcript, toasts,
|
||||
//! usage counters).
|
||||
@@ -16,7 +16,10 @@
|
||||
//! running turns on plain OS threads (rather than blocking the main loop)
|
||||
//! keeps the TUI responsive while the LLM streams.
|
||||
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::app::harness::Verdict;
|
||||
use sha2::Digest;
|
||||
@@ -30,7 +33,7 @@ use crate::dto::chat::message::{ChatMessage, Role};
|
||||
/// streaming pipeline, or subagent threads — that mutates `AppStateRest`
|
||||
/// when applied via `apply_action`.
|
||||
///
|
||||
/// Step bounds intentionally left unbounded (usize::MAX) so the agent can
|
||||
/// Step bounds intentionally left unbounded (`usize::MAX`) so the agent can
|
||||
/// continue across as many turns as needed. Each iteration still honours
|
||||
/// `tc.abort_flag` and the per-call LLM timeout, so a runaway loop is
|
||||
/// observable and cancellable from the UI.
|
||||
@@ -99,6 +102,7 @@ pub enum Action {
|
||||
/// need to know how to *produce* actions.
|
||||
///
|
||||
/// Return: nothing; `state` is mutated in place.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
match action {
|
||||
Action::ForceQuit => {
|
||||
@@ -168,37 +172,36 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
Ok(abs_path) => {
|
||||
let content = std::fs::read_to_string(&abs_path)
|
||||
.unwrap_or_default();
|
||||
let lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
|
||||
let lines: Vec<String> = content.lines().map(std::string::ToString::to_string).collect();
|
||||
let ed = crate::app::mode::editor::EditorState::open(
|
||||
abs_path.to_string_lossy().to_string(),
|
||||
Some(lines),
|
||||
);
|
||||
state.misc.editor = Some(ed);
|
||||
state.misc.overlay = Overlay::Editor;
|
||||
state.push_toast(Toast::new(ToastKind::Info, format!("Editing {}", path)));
|
||||
state.push_toast(Toast::new(ToastKind::Info, format!("Editing {path}")));
|
||||
}
|
||||
Err(e) => {
|
||||
state.push_toast(Toast::new(ToastKind::Error, format!("Failed to open {}: {}", path, e)));
|
||||
state.push_toast(Toast::new(ToastKind::Error, format!("Failed to open {path}: {e}")));
|
||||
}
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::McpAdd { name, command } => {
|
||||
let extra_args: Vec<String> = command.split_whitespace().map(|s| s.to_string()).collect();
|
||||
let extra_args: Vec<String> = command.split_whitespace().map(std::string::ToString::to_string).collect();
|
||||
let cmd = extra_args.first().cloned().unwrap_or_default();
|
||||
let args: Vec<String> = extra_args.into_iter().skip(1).collect();
|
||||
match state.mcp_manager.connect_stdio(&name, &cmd, &args) {
|
||||
Ok(_) => {
|
||||
Ok(()) => {
|
||||
let tool_count = state.mcp_manager.servers.last()
|
||||
.map(|s| s.tools.len())
|
||||
.unwrap_or(0);
|
||||
.map_or(0, |s| s.tools.len());
|
||||
state.push_toast(Toast::new(ToastKind::Success,
|
||||
format!("Connected MCP server '{}' ({} tools)", name, tool_count)));
|
||||
format!("Connected MCP server '{name}' ({tool_count} tools)")));
|
||||
state.dirty = true;
|
||||
}
|
||||
Err(e) => {
|
||||
state.push_toast(Toast::new(ToastKind::Error,
|
||||
format!("MCP connect failed: {}", e)));
|
||||
format!("MCP connect failed: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,7 +241,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
let result = run_oauth_flow(&provider_clone);
|
||||
let message = match result {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => format!("OAuth login failed: {}", e),
|
||||
Err(e) => format!("OAuth login failed: {e}"),
|
||||
};
|
||||
if let Ok(mut q) = turn_events.lock() {
|
||||
q.push_back(TurnEvent::SystemNote {
|
||||
@@ -247,7 +250,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
});
|
||||
}
|
||||
});
|
||||
let toast = Toast::new(ToastKind::Info, format!("Opening browser for {} login...", provider));
|
||||
let toast = Toast::new(ToastKind::Info, format!("Opening browser for {provider} login..."));
|
||||
state.push_toast(toast);
|
||||
state.dirty = true;
|
||||
}
|
||||
@@ -325,13 +328,13 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
let display_path = path.unwrap_or_default();
|
||||
let display = if tool_name == "read" {
|
||||
let line_count = output.lines().count();
|
||||
if !display_path.is_empty() {
|
||||
format!("read: {} ({} lines)", display_path, line_count)
|
||||
if display_path.is_empty() {
|
||||
format!("read: {line_count} line(s)")
|
||||
} else {
|
||||
format!("read: {} line(s)", line_count)
|
||||
format!("read: {display_path} ({line_count} lines)")
|
||||
}
|
||||
} else {
|
||||
format!("{}: {}", tool_name, output)
|
||||
format!("{tool_name}: {output}")
|
||||
};
|
||||
state.push_transcript(ChatMessageDisplay::new(
|
||||
Role::Tool,
|
||||
@@ -356,7 +359,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
}
|
||||
}
|
||||
if should_trigger_review(state, Origin::Main) {
|
||||
let _ = trigger_review(state);
|
||||
trigger_review(state);
|
||||
}
|
||||
} else if kind == "review" {
|
||||
let counted = if let Some(ref mut rt) = state.session_runtime {
|
||||
@@ -422,7 +425,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
});
|
||||
state.push_transcript(ChatMessageDisplay::new(
|
||||
crate::dto::chat::message::Role::System,
|
||||
format!("✓ {}", message),
|
||||
format!("✓ {message}"),
|
||||
));
|
||||
if state.misc.overlay == Overlay::Workflow {
|
||||
state.misc.overlay = Overlay::None;
|
||||
@@ -437,7 +440,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
});
|
||||
state.push_transcript(ChatMessageDisplay::new(
|
||||
crate::dto::chat::message::Role::System,
|
||||
format!("✗ {}", message),
|
||||
format!("✗ {message}"),
|
||||
));
|
||||
if state.misc.overlay == Overlay::Workflow {
|
||||
state.misc.overlay = Overlay::None;
|
||||
@@ -486,7 +489,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
state.push_toast(long_toast);
|
||||
state.push_transcript(ChatMessageDisplay::new(
|
||||
crate::dto::chat::message::Role::System,
|
||||
format!("Error: {}", msg),
|
||||
format!("Error: {msg}"),
|
||||
));
|
||||
turn_finished = true;
|
||||
}
|
||||
@@ -548,7 +551,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
let total_chars: usize = rt.messages.iter()
|
||||
.filter_map(|m| m.content.as_deref())
|
||||
.map(|c| c.len())
|
||||
.map(str::len)
|
||||
.sum();
|
||||
let token_estimate = total_chars / 3;
|
||||
rt.messages = crate::app::runtime::shortsend::shape_messages(&rt.messages, token_estimate, max_wire_tokens, true, None);
|
||||
@@ -566,7 +569,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
refresh_lesson_counters(&state.memory_dir, rt);
|
||||
}
|
||||
state.push_toast(Toast::new(ToastKind::Success,
|
||||
format!("accepted lesson: {}", name)));
|
||||
format!("accepted lesson: {name}")));
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::LessonReject { name } => {
|
||||
@@ -579,7 +582,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
refresh_lesson_counters(&state.memory_dir, rt);
|
||||
}
|
||||
state.push_toast(Toast::new(ToastKind::Info,
|
||||
format!("rejected lesson: {}", name)));
|
||||
format!("rejected lesson: {name}")));
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::LessonDelete { name } => {
|
||||
@@ -587,7 +590,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
refresh_lesson_counters(&state.memory_dir, rt);
|
||||
}
|
||||
state.push_toast(Toast::new(ToastKind::Info, format!("deleted lesson: {}", name)));
|
||||
state.push_toast(Toast::new(ToastKind::Info, format!("deleted lesson: {name}")));
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::RunPipeline { mode } => {
|
||||
@@ -606,10 +609,10 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
}
|
||||
"status" => {
|
||||
let current = state.misc.pipeline_override.as_deref().unwrap_or("auto");
|
||||
state.push_toast(Toast::new(ToastKind::Info, format!("Pipeline mode: {} (use /pipeline full|quick|skip to change)", current)));
|
||||
state.push_toast(Toast::new(ToastKind::Info, format!("Pipeline mode: {current} (use /pipeline full|quick|skip to change)")));
|
||||
}
|
||||
_ => {
|
||||
state.push_toast(Toast::new(ToastKind::Error, format!("Unknown pipeline mode: {} (use: full, quick, skip)", mode)));
|
||||
state.push_toast(Toast::new(ToastKind::Error, format!("Unknown pipeline mode: {mode} (use: full, quick, skip)")));
|
||||
}
|
||||
}
|
||||
state.dirty = true;
|
||||
@@ -644,8 +647,8 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
// "prompt1 | prompt2 | prompt3" → Parallel of 3 agents
|
||||
// "prompt1 -> prompt2" → Pipeline of 2 stages
|
||||
// "prompt" → single Agent
|
||||
let parts_pipe: Vec<&str> = script.split('|').map(|s| s.trim()).collect();
|
||||
let parts_arrow: Vec<&str> = script.split("->").map(|s| s.trim()).collect();
|
||||
let parts_pipe: Vec<&str> = script.split('|').map(str::trim).collect();
|
||||
let parts_arrow: Vec<&str> = script.split("->").map(str::trim).collect();
|
||||
|
||||
let primitive = if parts_pipe.len() > 1 {
|
||||
ScriptPrimitive::Parallel(
|
||||
@@ -680,12 +683,12 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
|
||||
let args: HashMap<String, String> = HashMap::new();
|
||||
let result = crate::app::workflow::engine::run_workflow_tracked(
|
||||
&wf, &args, Some(live), &session_dir, &workspace_roots,
|
||||
&wf, &args, Some(&live), &session_dir, &workspace_roots,
|
||||
);
|
||||
|
||||
let (kind, message) = match result {
|
||||
Ok(summary) => ("workflow_done".to_string(), summary),
|
||||
Err(e) => ("workflow_error".to_string(), format!("Workflow failed: {}", e)),
|
||||
Err(e) => ("workflow_error".to_string(), format!("Workflow failed: {e}")),
|
||||
};
|
||||
|
||||
if let Ok(mut q) = turn_events.lock() {
|
||||
@@ -793,7 +796,7 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
abort_flag,
|
||||
pipeline_mode,
|
||||
};
|
||||
let result = run_agent_turn(tc, &messages, &events_q);
|
||||
let result = run_agent_turn(&tc, &messages, &events_q);
|
||||
if let Err(e) = result {
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::Error(e.to_string()));
|
||||
@@ -837,7 +840,7 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("Current Workspace Directory Structure:\n");
|
||||
for root in roots {
|
||||
out.push_str(&format!("Root: {}\n", root.display()));
|
||||
writeln!(out, "Root: {}", root.display()).unwrap();
|
||||
let walker = ignore::WalkBuilder::new(root)
|
||||
.hidden(true)
|
||||
.git_ignore(true)
|
||||
@@ -847,9 +850,9 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
|
||||
let path = entry.path();
|
||||
if let Ok(rel) = path.strip_prefix(root) {
|
||||
if rel.as_os_str().is_empty() { continue; }
|
||||
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
|
||||
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
|
||||
let prefix = if is_dir { "[DIR] " } else { " " };
|
||||
out.push_str(&format!(" {}{}\n", prefix, rel.display()));
|
||||
writeln!(out, " {}{}", prefix, rel.display()).unwrap();
|
||||
count += 1;
|
||||
if count > 1000 {
|
||||
out.push_str(" ... (truncated)\n");
|
||||
@@ -881,7 +884,7 @@ fn build_memory_section(memory_dir: &std::path::Path) -> String {
|
||||
}
|
||||
|
||||
let mut section = String::from("\n\n--- Persistent Memory ---\n");
|
||||
section.push_str(&format!("Total entries: {}\n\n", names.len()));
|
||||
write!(section, "Total entries: {}\n\n", names.len()).unwrap();
|
||||
|
||||
for name in &names {
|
||||
if section.len() > 3000 {
|
||||
@@ -892,7 +895,7 @@ fn build_memory_section(memory_dir: &std::path::Path) -> String {
|
||||
if mem.lifecycle == "stale" {
|
||||
continue;
|
||||
}
|
||||
section.push_str(&format!("## [{}] {}\n{}\n\n", mem.kind, mem.name, mem.content));
|
||||
write!(section, "## [{}] {}\n{}\n\n", mem.kind, mem.name, mem.content).unwrap();
|
||||
}
|
||||
}
|
||||
section.push_str("---");
|
||||
@@ -940,13 +943,13 @@ fn refresh_lesson_counters(memory_dir: &std::path::Path, rt: &mut crate::app::st
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist a `ChatMessage` to the SQLite message log, if a database
|
||||
/// Persist a `ChatMessage` to the `SQLite` message log, if a database
|
||||
/// connection is available.
|
||||
///
|
||||
/// Flow: if `db` is `Some`, lock the mutex and call `insert_message`.
|
||||
/// Errors are silently ignored.
|
||||
fn archive_message(db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) {
|
||||
if let Some(ref arc) = db {
|
||||
fn archive_message(db: Option<&std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) {
|
||||
if let Some(arc) = db {
|
||||
if let Ok(conn) = arc.lock() {
|
||||
let _ = crate::model::msglog::insert_message(&conn, session_id, msg);
|
||||
}
|
||||
@@ -979,7 +982,7 @@ const MAX_AUTO_REVIEWS_PER_TURN: usize = 2;
|
||||
/// through `Harness::gate_tool_call`) or unwrap the final assistant
|
||||
/// message → check for unfinished todo.md tasks (auto-retry with a
|
||||
/// system message if any remain) → finalise with `Done` and an `edits`
|
||||
/// SystemNote.
|
||||
/// `SystemNote`.
|
||||
///
|
||||
/// On streaming failure: retry once with a non-streaming call → if that
|
||||
/// also fails and there are unfinished tasks, sleep 5s and loop back;
|
||||
@@ -990,11 +993,13 @@ const MAX_AUTO_REVIEWS_PER_TURN: usize = 2;
|
||||
///
|
||||
/// Return: `Ok(())` on successful completion, or an error from the LLM
|
||||
/// API after retries are exhausted.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn run_agent_turn(
|
||||
tc: TurnCtx,
|
||||
tc: &TurnCtx,
|
||||
messages: &[ChatMessage],
|
||||
events_q: &std::sync::Arc<std::sync::Mutex<VecDeque<TurnEvent>>>,
|
||||
) -> anyhow::Result<()> {
|
||||
const MAX_TODO_RETRIES: usize = 5;
|
||||
let mut msgs = messages.to_vec();
|
||||
let mut edits_this_turn = 0u32;
|
||||
let mut edited_paths: Vec<String> = Vec::new();
|
||||
@@ -1016,7 +1021,7 @@ fn run_agent_turn(
|
||||
);
|
||||
if !msgs.iter().any(|m| matches!(m.role, crate::dto::chat::message::Role::System)) {
|
||||
let sys = ChatMessage::system(system_text);
|
||||
archive_message(&tc.db, &tc.session_id, &sys);
|
||||
archive_message(tc.db.as_ref(), &tc.session_id, &sys);
|
||||
msgs.insert(0, sys);
|
||||
}
|
||||
|
||||
@@ -1032,24 +1037,21 @@ fn run_agent_turn(
|
||||
.count();
|
||||
let should_pipeline = if user_msg_count <= 2 {
|
||||
let user_request = msgs.iter()
|
||||
.rev()
|
||||
.filter(|m| matches!(m.role, crate::dto::chat::message::Role::User))
|
||||
.next()
|
||||
.rev().find(|m| matches!(m.role, crate::dto::chat::message::Role::User))
|
||||
.and_then(|m| m.content.as_deref())
|
||||
.unwrap_or("");
|
||||
|
||||
if !user_request.is_empty() {
|
||||
if user_request.is_empty() {
|
||||
false
|
||||
} else {
|
||||
match tc.pipeline_mode.as_deref() {
|
||||
Some("skip") => {
|
||||
tracing::debug!("[ceo] pipeline skipped via /pipeline skip");
|
||||
false
|
||||
}
|
||||
Some("full") => true,
|
||||
Some("quick") => true,
|
||||
Some("full" | "quick") => true,
|
||||
_ => crate::app::workflow::company::is_complex_request(user_request),
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
@@ -1057,9 +1059,7 @@ fn run_agent_turn(
|
||||
|
||||
if should_pipeline {
|
||||
let user_request = msgs.iter()
|
||||
.rev()
|
||||
.filter(|m| matches!(m.role, crate::dto::chat::message::Role::User))
|
||||
.next()
|
||||
.rev().find(|m| matches!(m.role, crate::dto::chat::message::Role::User))
|
||||
.and_then(|m| m.content.as_deref())
|
||||
.unwrap_or("");
|
||||
|
||||
@@ -1102,26 +1102,23 @@ fn run_agent_turn(
|
||||
Ok(summary) => {
|
||||
tracing::info!("[ceo] company pipeline completed successfully");
|
||||
let pipeline_msg = ChatMessage::system(format!(
|
||||
"[Company Pipeline: {}]\n{}",
|
||||
mode_label,
|
||||
summary,
|
||||
"[Company Pipeline: {mode_label}]\n{summary}",
|
||||
));
|
||||
archive_message(&tc.db, &tc.session_id, &pipeline_msg);
|
||||
archive_message(tc.db.as_ref(), &tc.session_id, &pipeline_msg);
|
||||
msgs.push(pipeline_msg);
|
||||
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::SystemNote {
|
||||
kind: "pipeline".to_string(),
|
||||
message: format!("Company pipeline ({}) complete. CEO reviewing results...", mode_label),
|
||||
message: format!("Company pipeline ({mode_label}) complete. CEO reviewing results..."),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[ceo] company pipeline failed: {}", e);
|
||||
let fail_msg = ChatMessage::system(format!(
|
||||
"[Pipeline Note] The company pipeline encountered issues: {}.\n\
|
||||
"[Pipeline Note] The company pipeline encountered issues: {e}.\n\
|
||||
Proceeding with direct execution as fallback.",
|
||||
e,
|
||||
));
|
||||
msgs.push(fail_msg);
|
||||
}
|
||||
@@ -1132,15 +1129,13 @@ fn run_agent_turn(
|
||||
|
||||
let mut turn_step = 0usize;
|
||||
let mut todo_retry_count = 0usize;
|
||||
const MAX_TODO_RETRIES: usize = 5;
|
||||
|
||||
loop {
|
||||
turn_step += 1;
|
||||
if turn_step > MAX_TURN_STEPS {
|
||||
anyhow::bail!(
|
||||
"turn exceeded maximum steps ({}) — possible runaway loop. \
|
||||
"turn exceeded maximum steps ({MAX_TURN_STEPS}) — possible runaway loop. \
|
||||
aborting to prevent excessive token usage",
|
||||
MAX_TURN_STEPS,
|
||||
);
|
||||
}
|
||||
if turn_start_ms.elapsed().as_millis() as u64 > MAX_TURN_TIMEOUT_MS {
|
||||
@@ -1152,7 +1147,7 @@ fn run_agent_turn(
|
||||
}
|
||||
let total_chars: usize = msgs.iter()
|
||||
.filter_map(|m| m.content.as_deref())
|
||||
.map(|c| c.len())
|
||||
.map(str::len)
|
||||
.sum();
|
||||
let token_estimate = total_chars / 4;
|
||||
let max_wire_tokens = tc.context_window;
|
||||
@@ -1168,7 +1163,7 @@ fn run_agent_turn(
|
||||
}
|
||||
|
||||
// Also update our local `msgs` variable so the rest of the loop operates on the compacted version
|
||||
msgs = compacted.clone();
|
||||
msgs.clone_from(&compacted);
|
||||
compacted
|
||||
} else {
|
||||
prev_shaped = false;
|
||||
@@ -1251,15 +1246,14 @@ fn run_agent_turn(
|
||||
todo_retry_count += 1;
|
||||
if todo_retry_count > MAX_TODO_RETRIES {
|
||||
anyhow::bail!(
|
||||
"exhausted {} todo-retries — giving up on unfinished tasks. \
|
||||
"exhausted {MAX_TODO_RETRIES} todo-retries — giving up on unfinished tasks. \
|
||||
Edit todo.md manually or ask me to focus on specific items.",
|
||||
MAX_TODO_RETRIES,
|
||||
);
|
||||
}
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::SystemNote {
|
||||
kind: "task_retry".to_string(),
|
||||
message: format!("Network/API error: {}. Auto-retrying to finish tasks... (retry {}/{})", api_err, todo_retry_count, MAX_TODO_RETRIES),
|
||||
message: format!("Network/API error: {api_err}. Auto-retrying to finish tasks... (retry {todo_retry_count}/{MAX_TODO_RETRIES})"),
|
||||
});
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
@@ -1283,7 +1277,7 @@ fn run_agent_turn(
|
||||
let content = response.content.clone().unwrap_or_default();
|
||||
if has_tool_calls {
|
||||
let tool_calls = response.tool_calls.clone().unwrap_or_default();
|
||||
archive_message(&tc.db, &tc.session_id, &response);
|
||||
archive_message(tc.db.as_ref(), &tc.session_id, &response);
|
||||
msgs.push(response);
|
||||
for tool_call in tool_calls {
|
||||
if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
@@ -1298,7 +1292,7 @@ fn run_agent_turn(
|
||||
);
|
||||
|
||||
let ws_roots: Vec<&std::path::Path> =
|
||||
tc.workspace_roots.iter().map(|p| p.as_path()).collect();
|
||||
tc.workspace_roots.iter().map(std::path::PathBuf::as_path).collect();
|
||||
let verdict = crate::app::harness::Harness::gate_tool_call(
|
||||
&tool_name,
|
||||
&args,
|
||||
@@ -1316,12 +1310,12 @@ fn run_agent_turn(
|
||||
&args,
|
||||
&tc.edit_log_session_dir,
|
||||
&tc.session_id,
|
||||
&tc.db,
|
||||
tc.db.as_ref(),
|
||||
) {
|
||||
Ok(result) => (result, false, is_edit_tool),
|
||||
Err(e) => (e.to_string(), true, false),
|
||||
},
|
||||
Verdict::Block(reason) => (format!("Blocked: {}", reason), true, false),
|
||||
Verdict::Block(reason) => (format!("Blocked: {reason}"), true, false),
|
||||
};
|
||||
|
||||
if is_edit {
|
||||
@@ -1332,7 +1326,7 @@ fn run_agent_turn(
|
||||
// background subagent tracking.
|
||||
let edit_path = args.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
.map(std::string::ToString::to_string);
|
||||
if let Some(ref p) = edit_path {
|
||||
edited_paths.push(p.clone());
|
||||
|
||||
@@ -1353,7 +1347,7 @@ fn run_agent_turn(
|
||||
Ok(verdict) => {
|
||||
let elapsed = review_start.elapsed().as_millis();
|
||||
let review_msg = ChatMessage::tool_result(
|
||||
format!("auto-review-{}", inline_reviews_count),
|
||||
format!("auto-review-{inline_reviews_count}"),
|
||||
format!(
|
||||
"[Auto inline review: {} ({}ms)]\n{}",
|
||||
p,
|
||||
@@ -1361,7 +1355,7 @@ fn run_agent_turn(
|
||||
verdict.trim(),
|
||||
),
|
||||
);
|
||||
archive_message(&tc.db, &tc.session_id, &review_msg);
|
||||
archive_message(tc.db.as_ref(), &tc.session_id, &review_msg);
|
||||
msgs.push(review_msg);
|
||||
tracing::info!(
|
||||
"[auto-review] inline review for '{}' completed in {}ms: {}",
|
||||
@@ -1381,7 +1375,7 @@ fn run_agent_turn(
|
||||
}
|
||||
|
||||
|
||||
let tool_path = args.get("path").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let tool_path = args.get("path").and_then(|v| v.as_str()).map(std::string::ToString::to_string);
|
||||
|
||||
{
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
@@ -1396,12 +1390,12 @@ fn run_agent_turn(
|
||||
}
|
||||
|
||||
let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output);
|
||||
archive_message(&tc.db, &tc.session_id, &tool_msg);
|
||||
archive_message(tc.db.as_ref(), &tc.session_id, &tool_msg);
|
||||
msgs.push(tool_msg);
|
||||
}
|
||||
} else {
|
||||
if !content.is_empty() {
|
||||
archive_message(&tc.db, &tc.session_id, &response);
|
||||
archive_message(tc.db.as_ref(), &tc.session_id, &response);
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
if stream_started {
|
||||
q.push_back(TurnEvent::StreamDone(response.clone()));
|
||||
@@ -1425,15 +1419,15 @@ fn run_agent_turn(
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::SystemNote {
|
||||
kind: "task_retry".to_string(),
|
||||
message: format!("Giving up after {} retries — some todo items remain unfinished. Edit todo.md manually or ask again.", MAX_TODO_RETRIES),
|
||||
message: format!("Giving up after {MAX_TODO_RETRIES} retries — some todo items remain unfinished. Edit todo.md manually or ask again."),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
let sys_text = format!("You stopped, but you still have unfinished tasks in todo.md (marked with '- [ ]'). You MUST continue working and use tools to finish them, or edit todo.md to mark them as done if they are finished. (Retry {}/{})", todo_retry_count, MAX_TODO_RETRIES);
|
||||
let sys_text = format!("You stopped, but you still have unfinished tasks in todo.md (marked with '- [ ]'). You MUST continue working and use tools to finish them, or edit todo.md to mark them as done if they are finished. (Retry {todo_retry_count}/{MAX_TODO_RETRIES})");
|
||||
let sys_text_clone = sys_text.clone();
|
||||
let msg = ChatMessage::system(sys_text);
|
||||
archive_message(&tc.db, &tc.session_id, &msg);
|
||||
archive_message(tc.db.as_ref(), &tc.session_id, &msg);
|
||||
msgs.push(msg);
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::SystemNote {
|
||||
@@ -1510,13 +1504,13 @@ fn execute_one_tool(
|
||||
args: &serde_json::Value,
|
||||
session_dir: &std::path::Path,
|
||||
session_id: &str,
|
||||
db: &Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
|
||||
db: Option<&std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
|
||||
) -> anyhow::Result<String> {
|
||||
for tool in tools {
|
||||
if tool.name() == name {
|
||||
// Snapshot current file content before write/edit for rewind
|
||||
if (name == "write" || name == "edit") && !tool_call_id.is_empty() {
|
||||
if let Some(ref arc) = db {
|
||||
if let Some(arc) = db {
|
||||
if let Ok(conn) = arc.lock() {
|
||||
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if let Ok(abs_path) = crate::tool::resolve_path(&ctx.workspaces, path) {
|
||||
@@ -1544,13 +1538,12 @@ fn execute_one_tool(
|
||||
let hash = sha2::Sha256::digest(
|
||||
content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(),
|
||||
);
|
||||
format!("{:x}", hash)
|
||||
format!("{hash:x}")
|
||||
};
|
||||
let bytes_delta = if name == "write" {
|
||||
args.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.len() as i64)
|
||||
.unwrap_or(0)
|
||||
.map_or(0, |s| s.len() as i64)
|
||||
} 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("");
|
||||
@@ -1572,7 +1565,7 @@ fn execute_one_tool(
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("tool not found: {}", name)
|
||||
anyhow::bail!("tool not found: {name}")
|
||||
}
|
||||
|
||||
/// Optionally push a review-available toast at the end of a turn that
|
||||
@@ -1591,14 +1584,13 @@ fn maybe_trigger_review(state: &mut AppStateRest) {
|
||||
let edit_count = state
|
||||
.session_runtime
|
||||
.as_ref()
|
||||
.map(|rt| rt.edit_count)
|
||||
.unwrap_or(0);
|
||||
.map_or(0, |rt| rt.edit_count);
|
||||
if edit_count == 0 {
|
||||
return;
|
||||
}
|
||||
state.push_toast(Toast::new(
|
||||
ToastKind::Info,
|
||||
format!("{} file(s) modified this session. Review available.", edit_count),
|
||||
format!("{edit_count} file(s) modified this session. Review available."),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1698,13 +1690,13 @@ fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
|
||||
let code = server.wait_for_code(120_000, &state_token)?;
|
||||
|
||||
manager.exchange_code(&code, &redirect_uri, verifier.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
if let Some(ref token) = manager.token {
|
||||
let token_path = dirs::config_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("zesdex")
|
||||
.join(format!("oauth_{}.json", provider));
|
||||
.join(format!("oauth_{provider}.json"));
|
||||
if let Some(parent) = token_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
@@ -1713,7 +1705,7 @@ fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(format!("Successfully authenticated with {}.", provider))
|
||||
Ok(format!("Successfully authenticated with {provider}."))
|
||||
}
|
||||
|
||||
/// Spawn a background thread that checks API reachability via a lightweight HEAD
|
||||
@@ -1722,16 +1714,14 @@ fn run_oauth_flow(provider: &str) -> anyhow::Result<String> {
|
||||
///
|
||||
/// Flow: resolve the provider's base URL → build a short-lived reqwest client
|
||||
/// with 3s connect / 5s total timeout → HEAD the `/models` endpoint → push
|
||||
/// a `connectivity` SystemNote with the result.
|
||||
/// a `connectivity` `SystemNote` with the result.
|
||||
///
|
||||
/// Why: runs off the event loop so a slow/TIMEOUT network does not block the TUI.
|
||||
fn spawn_api_connectivity_check(state: &AppStateRest) {
|
||||
let base_url = state
|
||||
.app_config
|
||||
.providers
|
||||
.get(&state.settings.provider)
|
||||
.map(|p| p.api_base.clone())
|
||||
.unwrap_or_else(|| crate::service::provider::DEFAULT_BASE_URL.to_string());
|
||||
.get(&state.settings.provider).map_or_else(|| crate::service::provider::DEFAULT_BASE_URL.to_string(), |p| p.api_base.clone());
|
||||
let turn_events = state.turn_events.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
|
||||
@@ -75,7 +75,7 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
||||
Command::Unknown(cmd) => {
|
||||
vec![Action::SystemNote {
|
||||
kind: "error".to_string(),
|
||||
message: format!("unknown command: {}", cmd),
|
||||
message: format!("unknown command: {cmd}"),
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Short-send / message shaping: compacts long conversation histories so
|
||||
//! they fit within the provider's context window before being sent to the
|
||||
//! LLM API.
|
||||
@@ -59,10 +60,10 @@ pub fn shape_messages(
|
||||
// Always keep the very first message (System Prompt) which we don't count here
|
||||
// as we just blindly preserve it later.
|
||||
let mut msgs_to_eval = messages.to_vec();
|
||||
let first = if !msgs_to_eval.is_empty() {
|
||||
Some(msgs_to_eval.remove(0))
|
||||
} else {
|
||||
let first = if msgs_to_eval.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(msgs_to_eval.remove(0))
|
||||
};
|
||||
|
||||
// Iterate backwards from the most recent to oldest
|
||||
@@ -105,7 +106,7 @@ pub fn shape_messages(
|
||||
match llm.chat_with_tools_non_streaming(&req_msgs, None) {
|
||||
Ok(resp) => {
|
||||
if let Some(content) = resp.0.content {
|
||||
summary_text = format!("[Summary of compacted prior conversation:\n{}\n]", content);
|
||||
summary_text = format!("[Summary of compacted prior conversation:\n{content}\n]");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! SSE stream parser: converts SSE- or JSON-chunked LLM responses into
|
||||
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
|
||||
pub mod turn;
|
||||
@@ -88,6 +89,7 @@ impl SseParser {
|
||||
/// provider-specific parsing layer.
|
||||
///
|
||||
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn flush_event(&mut self) -> Vec<StreamEvent> {
|
||||
let data = self.data_lines.join("\n");
|
||||
self.data_lines.clear();
|
||||
@@ -107,15 +109,15 @@ impl SseParser {
|
||||
};
|
||||
if let Some(usage) = value.get("usage") {
|
||||
if !usage.is_null() {
|
||||
let prompt_tokens = usage.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or_else(|| {
|
||||
let prompt_tokens = usage.get("prompt_tokens").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] prompt_tokens missing in usage chunk");
|
||||
0
|
||||
});
|
||||
let completion_tokens = usage.get("completion_tokens").and_then(|v| v.as_u64()).unwrap_or_else(|| {
|
||||
let completion_tokens = usage.get("completion_tokens").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] completion_tokens missing in usage chunk");
|
||||
0
|
||||
});
|
||||
let total_tokens = usage.get("total_tokens").and_then(|v| v.as_u64())
|
||||
let total_tokens = usage.get("total_tokens").and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] total_tokens missing in usage chunk");
|
||||
prompt_tokens + completion_tokens
|
||||
@@ -126,12 +128,11 @@ impl SseParser {
|
||||
// in the same chunk; emitting both prevents content loss.
|
||||
let has_other_content = value.get("choices")
|
||||
.and_then(|c| c.as_array())
|
||||
.map(|arr| arr.iter().any(|ch| {
|
||||
.is_some_and(|arr| arr.iter().any(|ch| {
|
||||
ch.get("delta").and_then(|d| d.get("content")).is_some()
|
||||
|| ch.get("delta").and_then(|d| d.get("reasoning_content")).is_some()
|
||||
|| ch.get("delta").and_then(|d| d.get("tool_calls")).is_some()
|
||||
}))
|
||||
.unwrap_or(false);
|
||||
}));
|
||||
if !has_other_content {
|
||||
return vec![StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens }];
|
||||
}
|
||||
@@ -139,21 +140,11 @@ impl SseParser {
|
||||
}
|
||||
match event_type.as_str() {
|
||||
"message.stop" => vec![StreamEvent::Done],
|
||||
"message.start" => vec![],
|
||||
"message.delta" | "" => {
|
||||
let delta = match value.get("delta").or_else(|| value.get("choices")) {
|
||||
Some(d) => d,
|
||||
None => return vec![],
|
||||
};
|
||||
let Some(delta) = value.get("delta").or_else(|| value.get("choices")) else { return vec![] };
|
||||
if let Some(choices) = delta.as_array() {
|
||||
let choice = match choices.first() {
|
||||
Some(c) => c,
|
||||
None => return vec![],
|
||||
};
|
||||
let d = match choice.get("delta") {
|
||||
Some(v) => v,
|
||||
None => return vec![],
|
||||
};
|
||||
let Some(choice) = choices.first() else { return vec![] };
|
||||
let Some(d) = choice.get("delta") else { return vec![] };
|
||||
|
||||
// Content token
|
||||
if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
|
||||
@@ -169,15 +160,15 @@ impl SseParser {
|
||||
if let Some(tool_calls) = d.get("tool_calls").and_then(|tc| tc.as_array()) {
|
||||
let mut events = Vec::with_capacity(tool_calls.len());
|
||||
for tc in tool_calls {
|
||||
let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or_else(|| {
|
||||
let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] tool call delta missing index, defaulting to 0");
|
||||
0
|
||||
}) as usize;
|
||||
let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string());
|
||||
let id = tc.get("id").and_then(|i| i.as_str()).map(std::string::ToString::to_string);
|
||||
let name = tc.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|s| s.to_string());
|
||||
.map(std::string::ToString::to_string);
|
||||
let args_delta = tc.get("function")
|
||||
.and_then(|f| f.get("arguments"))
|
||||
.and_then(|a| a.as_str())
|
||||
@@ -253,15 +244,15 @@ pub fn parse_stream_chunk(data: &str) -> Option<StreamEvent> {
|
||||
}
|
||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) {
|
||||
if let Some(tc) = tool_calls.first() {
|
||||
let index = tc.get("index").and_then(|i| i.as_u64()).unwrap_or_else(|| {
|
||||
let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] fallback parser: tool call missing index, defaulting to 0");
|
||||
0
|
||||
}) as usize;
|
||||
let id = tc.get("id").and_then(|i| i.as_str()).map(|s| s.to_string());
|
||||
let id = tc.get("id").and_then(|i| i.as_str()).map(std::string::ToString::to_string);
|
||||
let name = tc.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|s| s.to_string());
|
||||
.map(std::string::ToString::to_string);
|
||||
let args = tc.get("function")
|
||||
.and_then(|f| f.get("arguments"))
|
||||
.and_then(|a| a.as_str())
|
||||
|
||||
@@ -84,12 +84,12 @@ impl StreamedTurn {
|
||||
let tc = &mut self.tool_calls[*index];
|
||||
if let Some(new_id) = id {
|
||||
if !new_id.is_empty() {
|
||||
tc.id = new_id.clone();
|
||||
tc.id.clone_from(new_id);
|
||||
}
|
||||
}
|
||||
if let Some(new_name) = name {
|
||||
if !new_name.is_empty() {
|
||||
tc.name = new_name.clone();
|
||||
tc.name.clone_from(new_name);
|
||||
}
|
||||
}
|
||||
tc.arguments.push_str(arguments_delta);
|
||||
|
||||
+10
-9
@@ -139,7 +139,7 @@ impl InputState {
|
||||
self.autocomplete_candidates = COMMANDS
|
||||
.iter()
|
||||
.filter(|c| c.starts_with(&prefix))
|
||||
.map(|c| c.to_string())
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
self.autocomplete_prefix = prefix;
|
||||
self.autocomplete_idx = 0;
|
||||
@@ -178,10 +178,10 @@ impl InputState {
|
||||
pub fn tab_complete(&mut self) {
|
||||
// Legacy inline tab-complete — used as a fallback when the dropdown
|
||||
// isn't visible yet. Opens the dropdown on the first Tab press.
|
||||
if !self.autocomplete_visible {
|
||||
self.open_autocomplete();
|
||||
} else {
|
||||
if self.autocomplete_visible {
|
||||
self.cycle_autocomplete(true);
|
||||
} else {
|
||||
self.open_autocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ impl InputState {
|
||||
.open(path)
|
||||
{
|
||||
use std::io::Write;
|
||||
let _ = writeln!(file, "{}", result);
|
||||
let _ = writeln!(file, "{result}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,10 +294,11 @@ pub struct MiscState {
|
||||
pub tick_count: u64,
|
||||
pub todo_content: String,
|
||||
/// Pipeline mode override set by `/pipeline` command.
|
||||
/// - `None`: auto-detect (default)
|
||||
/// - `Some("full")`: force full pipeline
|
||||
/// - `Some("quick")`: force quick pipeline
|
||||
/// - `Some("skip")`: skip pipeline, handle directly
|
||||
/// - `None`: auto-detect (default)
|
||||
/// - `Some("full")`: force full pipeline
|
||||
/// - `Some("quick")`: force quick pipeline
|
||||
/// - `Some("skip")`: skip pipeline, handle directly
|
||||
///
|
||||
/// Consumed on the next agent turn.
|
||||
pub pipeline_override: Option<String>,
|
||||
}
|
||||
|
||||
+16
-24
@@ -84,7 +84,7 @@ impl AppStateRest {
|
||||
/// Why: falls back to `memory_dir` itself (with a warning) when it has
|
||||
/// no parent, and to an empty session id when the dir name can't be
|
||||
/// read, so construction never fails.
|
||||
pub fn new(workspace_roots: Vec<PathBuf>, session_dir: PathBuf, memory_dir: PathBuf) -> Self {
|
||||
pub fn new(workspace_roots: Vec<PathBuf>, session_dir: &std::path::Path, memory_dir: PathBuf) -> Self {
|
||||
let settings = Settings::load();
|
||||
let app_config = AppConfig::load();
|
||||
let worktrees_dir = memory_dir.parent().unwrap_or_else(|| {
|
||||
@@ -93,27 +93,25 @@ impl AppStateRest {
|
||||
}).join("worktrees");
|
||||
let dir_cache = DirCache::new();
|
||||
let session_id = session_dir
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| {
|
||||
.file_name().map_or_else(|| {
|
||||
tracing::warn!("[state] session_dir has no file_name component, using empty session_id");
|
||||
String::new()
|
||||
});
|
||||
}, |n| n.to_string_lossy().to_string());
|
||||
let mut state = AppStateRest {
|
||||
|
||||
settings,
|
||||
app_config,
|
||||
workspace_roots,
|
||||
session_id,
|
||||
session_dir: session_dir.clone(),
|
||||
session_dir: session_dir.to_path_buf(),
|
||||
memory_dir,
|
||||
worktrees_dir,
|
||||
turn_events: Arc::new(Mutex::new(VecDeque::new())),
|
||||
turn_in_flight: Arc::new(Mutex::new(false)),
|
||||
abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
dir_cache: Arc::new(RwLock::new(dir_cache)),
|
||||
edit_log: EditLog::new(&session_dir),
|
||||
session_runtime: Some(SessionRuntime::new(session_dir.clone())),
|
||||
edit_log: EditLog::new(session_dir),
|
||||
session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
|
||||
workflow_engine: WorkflowEngine::new(),
|
||||
mcp_manager: McpManager::new(),
|
||||
lsp_provision_msgs: Arc::new(Mutex::new(VecDeque::new())),
|
||||
@@ -135,9 +133,7 @@ impl AppStateRest {
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(abs_root.to_string_lossy().as_bytes());
|
||||
let hash_hex = format!("{:x}", hasher.finalize());
|
||||
let folder_name = abs_root.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "root".to_string());
|
||||
let folder_name = abs_root.file_name().map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string());
|
||||
let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]);
|
||||
let history_dir = base_dir.join("history");
|
||||
let _ = std::fs::create_dir_all(&history_dir);
|
||||
@@ -146,7 +142,7 @@ impl AppStateRest {
|
||||
if let Ok(content) = std::fs::read_to_string(&history_file) {
|
||||
let history: Vec<String> = content
|
||||
.lines()
|
||||
.map(|s| s.to_string())
|
||||
.map(std::string::ToString::to_string)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
state.input.history = history;
|
||||
@@ -192,12 +188,12 @@ impl AppStateRest {
|
||||
let connected = provisioner::auto_connect(&lsp_mgr, &results);
|
||||
for name in &connected {
|
||||
tracing::info!("LSP: {} connected", name);
|
||||
let m = format!("LSP: {} connected ✓", name); push_msg(&msg_queue, &m);
|
||||
let m = format!("LSP: {name} connected ✓"); push_msg(&msg_queue, &m);
|
||||
}
|
||||
for r in &results {
|
||||
if let ProvisionResult::Failed { language, server_name, reason, .. } = r {
|
||||
tracing::warn!("LSP {} ({}): {}", server_name, language, reason);
|
||||
let m = format!("LSP: {} ({}) ✗ - {}", server_name, language, reason); push_msg(&msg_queue, &m);
|
||||
let m = format!("LSP: {server_name} ({language}) ✗ - {reason}"); push_msg(&msg_queue, &m);
|
||||
}
|
||||
}
|
||||
if connected.is_empty() {
|
||||
@@ -216,10 +212,10 @@ impl AppStateRest {
|
||||
/// Return: `false` (and logs a warning) if the mutex is poisoned, rather
|
||||
/// than propagating a panic.
|
||||
pub fn turn_in_flight(&self) -> bool {
|
||||
self.turn_in_flight.lock().map(|g| *g).unwrap_or_else(|_| {
|
||||
self.turn_in_flight.lock().map_or_else(|_| {
|
||||
tracing::warn!("[state] turn_in_flight mutex poisoned");
|
||||
false
|
||||
})
|
||||
}, |g| *g)
|
||||
}
|
||||
|
||||
/// Shut down every running LSP server process.
|
||||
@@ -259,17 +255,13 @@ impl AppStateRest {
|
||||
/// never fails even on a shallow path.
|
||||
pub fn store_base_dir(&self) -> std::path::PathBuf {
|
||||
self.session_dir.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| {
|
||||
.and_then(|p| p.parent()).map_or_else(|| {
|
||||
tracing::warn!("[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display());
|
||||
self.session_dir.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| {
|
||||
self.session_dir.parent().map_or_else(|| {
|
||||
tracing::warn!("[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display());
|
||||
self.session_dir.clone()
|
||||
})
|
||||
})
|
||||
}, std::path::Path::to_path_buf)
|
||||
}, std::path::Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// Build a `ToolCtx` for tool calls originating from the main agent.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! Shared small state types: toasts, overlays, the transcript cache,
|
||||
//! tool execution model, and call origin tags.
|
||||
|
||||
@@ -109,7 +110,7 @@ pub enum Origin {
|
||||
|
||||
impl Origin {
|
||||
/// Short string tag for this origin, used in filenames and logs.
|
||||
pub fn tag(&self) -> String {
|
||||
pub fn tag(self) -> String {
|
||||
match self {
|
||||
Origin::Main => "main".to_string(),
|
||||
Origin::SubAgent => "subagent".to_string(),
|
||||
|
||||
+47
-41
@@ -72,12 +72,18 @@ fn is_production_code(path: &str) -> bool {
|
||||
if lower.contains("test") || lower.contains("spec") || lower.contains("_test.") {
|
||||
return false;
|
||||
}
|
||||
// Only source files
|
||||
lower.ends_with(".rs") || lower.ends_with(".ts") || lower.ends_with(".tsx")
|
||||
|| lower.ends_with(".js") || lower.ends_with(".jsx") || lower.ends_with(".go")
|
||||
|| lower.ends_with(".py") || lower.ends_with(".java") || lower.ends_with(".kt")
|
||||
|| lower.ends_with(".swift") || lower.ends_with(".c") || lower.ends_with(".cpp")
|
||||
|| lower.ends_with(".h") || lower.ends_with(".hpp")
|
||||
// Only source files — use Path::extension() to avoid clippy
|
||||
// case_sensitive_file_extension_comparisons lint
|
||||
std::path::Path::new(&lower)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| {
|
||||
matches!(
|
||||
ext,
|
||||
"rs" | "ts" | "tsx" | "js" | "jsx" | "go" | "py" | "java" | "kt" | "swift"
|
||||
| "c" | "cpp" | "h" | "hpp"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// ─── Inline Quick Review (synchronous, feeds back to LLM) ───
|
||||
@@ -111,7 +117,7 @@ pub fn spawn_quick_review(
|
||||
.with_system_prompt(prompt)
|
||||
.with_max_steps(QUICK_REVIEW_MAX_STEPS);
|
||||
|
||||
let mut ctx = build_subagent_context(def);
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir = session_dir.to_path_buf();
|
||||
ctx.workspaces = workspaces.to_vec();
|
||||
|
||||
@@ -119,11 +125,11 @@ pub fn spawn_quick_review(
|
||||
let _drain = std::thread::spawn(move || {
|
||||
while let Some(event) = rx.blocking_recv() {
|
||||
match &event {
|
||||
SubagentEvent::ToolCall { _tool, .. } => {
|
||||
tracing::debug!("[auto-review] tool call: {}", _tool);
|
||||
SubagentEvent::ToolCall { tool, .. } => {
|
||||
tracing::debug!("[auto-review] tool call: {}", tool);
|
||||
}
|
||||
SubagentEvent::ToolResult { _tool, .. } => {
|
||||
tracing::debug!("[auto-review] tool result: {}", _tool);
|
||||
SubagentEvent::ToolResult { tool, .. } => {
|
||||
tracing::debug!("[auto-review] tool result: {}", tool);
|
||||
}
|
||||
SubagentEvent::Completed { .. } => {
|
||||
tracing::debug!("[auto-review] completed");
|
||||
@@ -133,7 +139,7 @@ pub fn spawn_quick_review(
|
||||
}
|
||||
});
|
||||
|
||||
let verdict = run_subagent(ctx, tx)?;
|
||||
let verdict = run_subagent(&ctx, &tx)?;
|
||||
tracing::info!(
|
||||
"[auto-review] quick review for '{}': {}",
|
||||
file_path,
|
||||
@@ -142,7 +148,7 @@ pub fn spawn_quick_review(
|
||||
Ok(verdict)
|
||||
}
|
||||
|
||||
/// ─── Background Subagent Spawners (async, report via SystemNote) ───
|
||||
/// ─── Background Subagent Spawners (async, report via `SystemNote`) ───
|
||||
///
|
||||
/// Spawn a background subagent that generates tests for modified files.
|
||||
///
|
||||
@@ -185,7 +191,7 @@ pub fn spawn_background_test_gen(
|
||||
.with_system_prompt(prompt)
|
||||
.with_max_steps(BG_SUBAGENT_MAX_STEPS);
|
||||
|
||||
let mut ctx = build_subagent_context(def);
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir = sd;
|
||||
ctx.workspaces = ws;
|
||||
|
||||
@@ -193,17 +199,17 @@ pub fn spawn_background_test_gen(
|
||||
let _drain = std::thread::spawn(move || {
|
||||
while let Some(event) = rx.blocking_recv() {
|
||||
match &event {
|
||||
SubagentEvent::ToolCall { _tool, .. } => {
|
||||
tracing::debug!("[bg-test-gen] tool: {}", _tool);
|
||||
SubagentEvent::ToolCall { tool, .. } => {
|
||||
tracing::debug!("[bg-test-gen] tool: {}", tool);
|
||||
}
|
||||
SubagentEvent::ToolResult { _tool, .. } => {
|
||||
tracing::debug!("[bg-test-gen] result: {}", _tool);
|
||||
SubagentEvent::ToolResult { tool, .. } => {
|
||||
tracing::debug!("[bg-test-gen] result: {}", tool);
|
||||
}
|
||||
SubagentEvent::StepCompleted { _step, .. } => {
|
||||
tracing::trace!("[bg-test-gen] step {} done", _step);
|
||||
SubagentEvent::StepCompleted { .. } => {
|
||||
tracing::trace!("[bg-test-gen] step done");
|
||||
}
|
||||
SubagentEvent::StepFailed { _step, _error } => {
|
||||
tracing::warn!("[bg-test-gen] step {} failed: {}", _step, _error);
|
||||
SubagentEvent::StepFailed { step, error } => {
|
||||
tracing::warn!("[bg-test-gen] step {} failed: {}", step, error);
|
||||
}
|
||||
SubagentEvent::Completed { .. } => {
|
||||
tracing::debug!("[bg-test-gen] completed");
|
||||
@@ -212,13 +218,13 @@ pub fn spawn_background_test_gen(
|
||||
}
|
||||
});
|
||||
|
||||
let result = run_subagent(ctx, tx);
|
||||
let result = run_subagent(&ctx, &tx);
|
||||
let message = match &result {
|
||||
Ok(output) => {
|
||||
let first = output.lines().next().unwrap_or(output);
|
||||
format!("Auto test-gen: {}", first)
|
||||
format!("Auto test-gen: {first}")
|
||||
}
|
||||
Err(e) => format!("Auto test-gen failed: {}", e),
|
||||
Err(e) => format!("Auto test-gen failed: {e}"),
|
||||
};
|
||||
|
||||
if let Ok(mut q) = events.lock() {
|
||||
@@ -265,7 +271,7 @@ pub fn spawn_background_arch_review(
|
||||
.with_system_prompt(prompt)
|
||||
.with_max_steps(BG_SUBAGENT_MAX_STEPS);
|
||||
|
||||
let mut ctx = build_subagent_context(def);
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir = sd;
|
||||
ctx.workspaces = ws;
|
||||
|
||||
@@ -273,11 +279,11 @@ pub fn spawn_background_arch_review(
|
||||
let _drain = std::thread::spawn(move || {
|
||||
while let Some(event) = rx.blocking_recv() {
|
||||
match &event {
|
||||
SubagentEvent::ToolCall { _tool, .. } => {
|
||||
tracing::debug!("[bg-arch] tool: {}", _tool);
|
||||
SubagentEvent::ToolCall { tool, .. } => {
|
||||
tracing::debug!("[bg-arch] tool: {}", tool);
|
||||
}
|
||||
SubagentEvent::ToolResult { _tool, .. } => {
|
||||
tracing::debug!("[bg-arch] result: {}", _tool);
|
||||
SubagentEvent::ToolResult { tool, .. } => {
|
||||
tracing::debug!("[bg-arch] result: {}", tool);
|
||||
}
|
||||
SubagentEvent::Completed { .. } => {
|
||||
tracing::debug!("[bg-arch] completed");
|
||||
@@ -287,13 +293,13 @@ pub fn spawn_background_arch_review(
|
||||
}
|
||||
});
|
||||
|
||||
let result = run_subagent(ctx, tx);
|
||||
let result = run_subagent(&ctx, &tx);
|
||||
let message = match &result {
|
||||
Ok(output) => {
|
||||
let first = output.lines().next().unwrap_or(output);
|
||||
format!("Architecture review: {}", first)
|
||||
format!("Architecture review: {first}")
|
||||
}
|
||||
Err(e) => format!("Architecture review failed: {}", e),
|
||||
Err(e) => format!("Architecture review failed: {e}"),
|
||||
};
|
||||
|
||||
if let Ok(mut q) = events.lock() {
|
||||
@@ -351,7 +357,7 @@ pub fn spawn_background_security_review(
|
||||
.with_system_prompt(prompt)
|
||||
.with_max_steps(BG_SUBAGENT_MAX_STEPS);
|
||||
|
||||
let mut ctx = build_subagent_context(def);
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir = sd;
|
||||
ctx.workspaces = ws;
|
||||
|
||||
@@ -359,11 +365,11 @@ pub fn spawn_background_security_review(
|
||||
let _drain = std::thread::spawn(move || {
|
||||
while let Some(event) = rx.blocking_recv() {
|
||||
match &event {
|
||||
SubagentEvent::ToolCall { _tool, .. } => {
|
||||
tracing::debug!("[bg-security] tool: {}", _tool);
|
||||
SubagentEvent::ToolCall { tool, .. } => {
|
||||
tracing::debug!("[bg-security] tool: {}", tool);
|
||||
}
|
||||
SubagentEvent::ToolResult { _tool, .. } => {
|
||||
tracing::debug!("[bg-security] result: {}", _tool);
|
||||
SubagentEvent::ToolResult { tool, .. } => {
|
||||
tracing::debug!("[bg-security] result: {}", tool);
|
||||
}
|
||||
SubagentEvent::Completed { .. } => {
|
||||
tracing::debug!("[bg-security] completed");
|
||||
@@ -373,13 +379,13 @@ pub fn spawn_background_security_review(
|
||||
}
|
||||
});
|
||||
|
||||
let result = run_subagent(ctx, tx);
|
||||
let result = run_subagent(&ctx, &tx);
|
||||
let message = match &result {
|
||||
Ok(output) => {
|
||||
let first = output.lines().next().unwrap_or(output);
|
||||
format!("Security review: {}", first)
|
||||
format!("Security review: {first}")
|
||||
}
|
||||
Err(e) => format!("Security review failed: {}", e),
|
||||
Err(e) => format!("Security review failed: {e}"),
|
||||
};
|
||||
|
||||
if let Ok(mut q) = events.lock() {
|
||||
|
||||
@@ -37,10 +37,10 @@ pub struct SubagentContext {
|
||||
///
|
||||
/// Return: a context with empty `system_prompt`, empty `workspaces`,
|
||||
/// empty `session_dir`, resolved `max_steps`, and the resolved allowed-tool list.
|
||||
pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext {
|
||||
pub fn build_subagent_context(def: &AgentDefinition) -> SubagentContext {
|
||||
let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| {
|
||||
if def.role == "reviewer" {
|
||||
REVIEWER_ALLOWED.iter().map(|s| s.to_string()).collect()
|
||||
REVIEWER_ALLOWED.iter().map(std::string::ToString::to_string).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
|
||||
/// Division roles — used as both the `role` field in AgentDefinition
|
||||
/// Division roles — used as both the `role` field in `AgentDefinition`
|
||||
/// and as the key for pipeline routing.
|
||||
pub mod roles {
|
||||
/// Strategy Division: plans architecture, creates diagrams, breaks down work.
|
||||
|
||||
+40
-41
@@ -7,6 +7,7 @@
|
||||
//! bash exfiltration and destructive-pattern detection) so that subagents
|
||||
//! are not a weaker link than the main agent.
|
||||
|
||||
use std::fmt::Write;
|
||||
use tokio::sync::mpsc;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
use crate::dto::provider::request::ToolDef;
|
||||
@@ -143,8 +144,7 @@ fn gate_subagent_tool_call(
|
||||
let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if reason.trim().len() < MIN_REASON_LEN {
|
||||
return Some(format!(
|
||||
"{} requires a non-trivial 'reason' (>= {} chars) explaining why",
|
||||
tool_name, MIN_REASON_LEN,
|
||||
"{tool_name} requires a non-trivial 'reason' (>= {MIN_REASON_LEN} chars) explaining why",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -157,9 +157,7 @@ fn gate_subagent_tool_call(
|
||||
let old = args.get("old").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let new = args.get("new").and_then(|v| v.as_str()).unwrap_or("");
|
||||
// For edits, scanning old+new together catches stubs in both
|
||||
return if contains_any(old, STUB_PATTERNS) {
|
||||
Some("content contains stub/placeholder pattern; production code must be fully implemented".to_string())
|
||||
} else if contains_any(new, STUB_PATTERNS) {
|
||||
return if contains_any(old, STUB_PATTERNS) || contains_any(new, STUB_PATTERNS) {
|
||||
Some("content contains stub/placeholder pattern; production code must be fully implemented".to_string())
|
||||
} else if contains_any(new, DENIAL_PATTERNS) {
|
||||
Some("content contains denial/punt pattern; implement properly instead of skipping".to_string())
|
||||
@@ -202,13 +200,13 @@ fn gate_subagent_tool_call(
|
||||
if !is_standard {
|
||||
for pat in EXFIL_PATTERNS {
|
||||
if cmd.contains(pat) {
|
||||
return Some(format!("potential data-exfiltration command blocked (matched '{}')", pat));
|
||||
return Some(format!("potential data-exfiltration command blocked (matched '{pat}')"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for pat in SENSITIVE_PATH_PATTERNS {
|
||||
if cmd.contains(pat) {
|
||||
return Some(format!("refused to read/write sensitive path '{}'", pat));
|
||||
return Some(format!("refused to read/write sensitive path '{pat}'"));
|
||||
}
|
||||
}
|
||||
let dangerous = ["rm -rf /", "rm -rf --no-preserve-root", "rm -rf ~",
|
||||
@@ -216,7 +214,7 @@ fn gate_subagent_tool_call(
|
||||
"chmod -R 000 /", "shutdown ", "poweroff ", "reboot ", "halt "];
|
||||
for pat in &dangerous {
|
||||
if cmd.contains(pat) {
|
||||
return Some(format!("destructive command pattern blocked: {}", pat));
|
||||
return Some(format!("destructive command pattern blocked: {pat}"));
|
||||
}
|
||||
}
|
||||
if contains_any(cmd, STUB_PATTERNS) {
|
||||
@@ -251,7 +249,7 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("Current Workspace Directory Structure:\n");
|
||||
for root in roots {
|
||||
out.push_str(&format!("Root: {}\n", root.display()));
|
||||
writeln!(out, "Root: {}", root.display()).unwrap();
|
||||
let walker = ignore::WalkBuilder::new(root)
|
||||
.hidden(true)
|
||||
.git_ignore(true)
|
||||
@@ -261,9 +259,9 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
|
||||
let path = entry.path();
|
||||
if let Ok(rel) = path.strip_prefix(root) {
|
||||
if rel.as_os_str().is_empty() { continue; }
|
||||
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
|
||||
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
|
||||
let prefix = if is_dir { "[DIR] " } else { " " };
|
||||
out.push_str(&format!(" {}{}\n", prefix, rel.display()));
|
||||
writeln!(out, " {}{}", prefix, rel.display()).unwrap();
|
||||
count += 1;
|
||||
if count > 1000 {
|
||||
out.push_str(" ... (truncated)\n");
|
||||
@@ -291,7 +289,8 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
|
||||
///
|
||||
/// Return: the concatenated text output, or an `anyhow::Error` if the LLM
|
||||
/// call fails at any step.
|
||||
pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
|
||||
let mut output = String::new();
|
||||
let mut messages: Vec<ChatMessage> = Vec::new();
|
||||
|
||||
@@ -328,10 +327,10 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
||||
// be cancelled from the parent (mirrors main agent behaviour).
|
||||
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
|
||||
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||
_step: step,
|
||||
_error: "subagent aborted by parent".to_string(),
|
||||
step,
|
||||
error: "subagent aborted by parent".to_string(),
|
||||
});
|
||||
anyhow::bail!("subagent aborted by parent at step {}", step);
|
||||
anyhow::bail!("subagent aborted by parent at step {step}");
|
||||
}
|
||||
|
||||
// Use the structured tool-calling API so the LLM can request tools with
|
||||
@@ -340,10 +339,10 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||
_step: step,
|
||||
_error: e.to_string(),
|
||||
step,
|
||||
error: e.to_string(),
|
||||
});
|
||||
anyhow::bail!("subagent call failed at step {}: {}", step, e);
|
||||
anyhow::bail!("subagent call failed at step {step}: {e}");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -361,10 +360,10 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
||||
// Check abort flag before each tool execution
|
||||
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
|
||||
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||
_step: step,
|
||||
_error: "subagent aborted by parent during tool execution".to_string(),
|
||||
step,
|
||||
error: "subagent aborted by parent during tool execution".to_string(),
|
||||
});
|
||||
anyhow::bail!("subagent aborted by parent during tool call at step {}", step);
|
||||
anyhow::bail!("subagent aborted by parent during tool call at step {step}");
|
||||
}
|
||||
|
||||
let tool_name = &tool_call.function.name;
|
||||
@@ -373,28 +372,28 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
||||
let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed;
|
||||
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolCall {
|
||||
_tool: tool_name.clone(),
|
||||
_args: args.clone(),
|
||||
tool: tool_name.clone(),
|
||||
args: args.clone(),
|
||||
});
|
||||
|
||||
// Level 1: allowlist check — is this tool even permitted?
|
||||
if !generally_allowed {
|
||||
let msg = format!("tool '{}' not allowed for this subagent", tool_name);
|
||||
let msg = format!("tool '{tool_name}' not allowed for this subagent");
|
||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
_tool: tool_name.clone(),
|
||||
_output: msg,
|
||||
tool: tool_name.clone(),
|
||||
output: msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Level 2: risky tool check — risky tools require explicit permission
|
||||
if tool_is_risky(tool_name) && !explicitly_allowed {
|
||||
let msg = format!("risky tool '{}' requires explicit permission; not allowed for this subagent", tool_name);
|
||||
let msg = format!("risky tool '{tool_name}' requires explicit permission; not allowed for this subagent");
|
||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
_tool: tool_name.clone(),
|
||||
_output: msg,
|
||||
tool: tool_name.clone(),
|
||||
output: msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -404,34 +403,34 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
||||
// stub/denial/assumption scanning, bash exfiltration, destructive
|
||||
// commands, sensitive path reads).
|
||||
if let Some(block_reason) = gate_subagent_tool_call(tool_name, &args) {
|
||||
let msg = format!("Blocked by subagent gate: {}", block_reason);
|
||||
let msg = format!("Blocked by subagent gate: {block_reason}");
|
||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
_tool: tool_name.clone(),
|
||||
_output: msg,
|
||||
tool: tool_name.clone(),
|
||||
output: msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = match tools.iter().find(|t| t.name() == tool_name.as_str()) {
|
||||
Some(tool) => tool.run(&tool_ctx, &args),
|
||||
None => Err(anyhow::anyhow!("tool '{}' not found", tool_name)),
|
||||
None => Err(anyhow::anyhow!("tool '{tool_name}' not found")),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(output_text) => {
|
||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone()));
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
_tool: tool_name.clone(),
|
||||
_output: output_text,
|
||||
tool: tool_name.clone(),
|
||||
output: output_text,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("tool '{}' failed: {}", tool_name, e);
|
||||
let msg = format!("tool '{tool_name}' failed: {e}");
|
||||
messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone()));
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
_tool: tool_name.clone(),
|
||||
_output: msg,
|
||||
tool: tool_name.clone(),
|
||||
output: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -443,8 +442,8 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
||||
output.push('\n');
|
||||
}
|
||||
let _ = tx.blocking_send(SubagentEvent::StepCompleted {
|
||||
_step: step,
|
||||
_output: content.clone(),
|
||||
step,
|
||||
output: content.clone(),
|
||||
});
|
||||
// Break only when we got real content; empty means something went wrong
|
||||
if !content.is_empty() {
|
||||
@@ -453,6 +452,6 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tx.blocking_send(SubagentEvent::Completed { _output: output.clone() });
|
||||
let _ = tx.blocking_send(SubagentEvent::Completed { output: output.clone() });
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
@@ -8,22 +8,27 @@ use serde_json::Value;
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SubagentEvent {
|
||||
StepCompleted {
|
||||
_step: usize,
|
||||
_output: String,
|
||||
#[allow(dead_code)]
|
||||
step: usize,
|
||||
#[allow(dead_code)]
|
||||
output: String,
|
||||
},
|
||||
StepFailed {
|
||||
_step: usize,
|
||||
_error: String,
|
||||
step: usize,
|
||||
error: String,
|
||||
},
|
||||
Completed {
|
||||
_output: String,
|
||||
#[allow(dead_code)]
|
||||
output: String,
|
||||
},
|
||||
ToolCall {
|
||||
_tool: String,
|
||||
_args: Value,
|
||||
tool: String,
|
||||
#[allow(dead_code)]
|
||||
args: Value,
|
||||
},
|
||||
ToolResult {
|
||||
_tool: String,
|
||||
_output: String,
|
||||
tool: String,
|
||||
#[allow(dead_code)]
|
||||
output: String,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! AgentDefinition -- declarative specification for instantiating a
|
||||
//! `AgentDefinition` -- declarative specification for instantiating a
|
||||
//! subagent from workflow scripts or programmatic calls.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use crate::app::workflow::engine::{execute_primitive, LiveStateFn, AgentStatus};
|
||||
use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript};
|
||||
@@ -64,9 +65,7 @@ pub fn run_company_pipeline(
|
||||
|
||||
let wf = WorkflowScript {
|
||||
name: "company-pipeline".to_string(),
|
||||
description: format!(
|
||||
"Company Pipeline (full): Strategy → Engineering → Quality → Security → Documentation",
|
||||
),
|
||||
description: "Company Pipeline (full): Strategy → Engineering → Quality → Security → Documentation".to_string(),
|
||||
script: ScriptPrimitive::Pipeline(pipeline_scripts),
|
||||
options: ScriptOptions {
|
||||
max_concurrency: 1, // sequential by design
|
||||
@@ -197,21 +196,19 @@ fn build_executive_summary(
|
||||
divisions: &[division::Division],
|
||||
) -> String {
|
||||
let mut summary = String::new();
|
||||
summary.push_str(&format!("Pipeline for: {}\n", request));
|
||||
writeln!(summary, "Pipeline for: {request}").unwrap();
|
||||
|
||||
for (i, div) in divisions.iter().enumerate() {
|
||||
let verdict = results.get(i)
|
||||
.map(|r| {
|
||||
let verdict = results.get(i).map_or_else(|| "—".to_string(), |r| {
|
||||
r.lines().next().unwrap_or(r)
|
||||
.chars().take(100).collect::<String>()
|
||||
})
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
});
|
||||
|
||||
summary.push_str(&format!(" {}: {}\n", div.name, verdict));
|
||||
writeln!(summary, " {}: {}", div.name, verdict).unwrap();
|
||||
}
|
||||
|
||||
if !findings.is_empty() {
|
||||
summary.push_str(&format!(" Notes: {} cross-division finding(s)\n", findings.len()));
|
||||
writeln!(summary, " Notes: {} cross-division finding(s)", findings.len()).unwrap();
|
||||
}
|
||||
|
||||
summary
|
||||
@@ -223,7 +220,7 @@ fn build_executive_summary(
|
||||
/// Simple = single file, minor fix, quick lookup, config change.
|
||||
/// Complex = new feature, multi-file refactor, architecture change.
|
||||
///
|
||||
/// Used by the auto-CEO pipeline trigger in run_agent_turn to decide
|
||||
/// Used by the auto-CEO pipeline trigger in `run_agent_turn` to decide
|
||||
/// whether to delegate to the full company pipeline or handle directly.
|
||||
///
|
||||
/// Heuristics:
|
||||
@@ -249,7 +246,7 @@ pub fn is_complex_request(request: &str) -> bool {
|
||||
return false;
|
||||
}
|
||||
// Multi-line/multi-sentence → likely complex
|
||||
let sentences = trimmed.split(|c| c == '.' || c == '!' || c == '?')
|
||||
let sentences = trimmed.split(['.', '!', '?'])
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.count();
|
||||
if sentences >= 3 {
|
||||
|
||||
+27
-27
@@ -83,7 +83,7 @@ pub type LiveStateFn = Arc<dyn Fn(String, String, AgentStatus) + Send + Sync>;
|
||||
/// any findings from earlier sibling agents. Updates live state before and
|
||||
/// after to reflect Running → Completed/Failed transitions.
|
||||
///
|
||||
/// Flow: push agent as `Running` → build SubagentContext with prompt +
|
||||
/// Flow: push agent as `Running` → build `SubagentContext` with prompt +
|
||||
/// findings preamble, linking the `workflow_findings` Arc so the subagent's
|
||||
/// `note_finding` tool pushes into the same vec → call `run_subagent`
|
||||
/// (draining the event channel into a consumer so events are not blocked)
|
||||
@@ -98,11 +98,12 @@ pub type LiveStateFn = Arc<dyn Fn(String, String, AgentStatus) + Send + Sync>;
|
||||
/// a stuck stage from blocking the entire pipeline forever.
|
||||
///
|
||||
/// Return: the agent's text output, or an error on failure.
|
||||
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
|
||||
fn spawn_single_agent(
|
||||
agent_id: &str,
|
||||
agent_name: &str,
|
||||
prompt: &str,
|
||||
findings_snapshot: Vec<String>,
|
||||
findings_snapshot: &[String],
|
||||
findings: &Arc<Mutex<Vec<String>>>,
|
||||
live: Option<&LiveStateFn>,
|
||||
session_dir: &std::path::Path,
|
||||
@@ -134,7 +135,7 @@ fn spawn_single_agent(
|
||||
|
||||
let def = AgentDefinition::new(agent_name.to_string(), "coder".to_string())
|
||||
.with_max_steps(50);
|
||||
let mut ctx = build_subagent_context(def);
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir = session_dir.to_path_buf();
|
||||
ctx.workspaces = workspaces.to_vec();
|
||||
|
||||
@@ -152,7 +153,7 @@ fn spawn_single_agent(
|
||||
)
|
||||
};
|
||||
|
||||
ctx.system_prompt = format!("{}{}", prompt, findings_section);
|
||||
ctx.system_prompt = format!("{prompt}{findings_section}");
|
||||
// Link the shared findings Arc so note_finding calls within this
|
||||
// subagent write into the same vec visible to sibling agents.
|
||||
ctx.workflow_findings = Some(findings.clone());
|
||||
@@ -174,8 +175,8 @@ fn spawn_single_agent(
|
||||
let mut rx = rx;
|
||||
while let Some(event) = rx.blocking_recv() {
|
||||
match &event {
|
||||
SubagentEvent::ToolCall { _tool, _args } => {
|
||||
tracing::debug!("[subagent] tool call: {}", _tool);
|
||||
SubagentEvent::ToolCall { tool, .. } => {
|
||||
tracing::debug!("[subagent] tool call: {}", tool);
|
||||
// Push intra-division progress: which tool is running
|
||||
if let Some(ref f) = drain_live {
|
||||
f(
|
||||
@@ -186,13 +187,13 @@ fn spawn_single_agent(
|
||||
started_at: Some(drain_started_at),
|
||||
completed_at: None,
|
||||
error: None,
|
||||
progress: Some(format!("tool: {}", _tool)),
|
||||
progress: Some(format!("tool: {tool}")),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
SubagentEvent::ToolResult { _tool, .. } => {
|
||||
tracing::debug!("[subagent] tool result: {}", _tool);
|
||||
SubagentEvent::ToolResult { tool, .. } => {
|
||||
tracing::debug!("[subagent] tool result: {}", tool);
|
||||
if let Some(ref f) = drain_live {
|
||||
f(
|
||||
drain_agent_id.clone(),
|
||||
@@ -202,16 +203,16 @@ fn spawn_single_agent(
|
||||
started_at: Some(drain_started_at),
|
||||
completed_at: None,
|
||||
error: None,
|
||||
progress: Some(format!("done: {}", _tool)),
|
||||
progress: Some(format!("done: {tool}")),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
SubagentEvent::StepCompleted { _step, .. } => {
|
||||
tracing::trace!("[subagent] step {} completed", _step);
|
||||
SubagentEvent::StepCompleted { .. } => {
|
||||
tracing::trace!("[subagent] step completed");
|
||||
}
|
||||
SubagentEvent::StepFailed { _step, _error } => {
|
||||
tracing::warn!("[subagent] step {} failed: {}", _step, _error);
|
||||
SubagentEvent::StepFailed { step, error } => {
|
||||
tracing::warn!("[subagent] step {} failed: {}", step, error);
|
||||
}
|
||||
SubagentEvent::Completed { .. } => {
|
||||
tracing::debug!("[subagent] completed");
|
||||
@@ -230,17 +231,16 @@ fn spawn_single_agent(
|
||||
let timeout_ctx = ctx;
|
||||
let timeout_tx = tx;
|
||||
std::thread::spawn(move || {
|
||||
let _ = done_tx.send(run_subagent(timeout_ctx, timeout_tx));
|
||||
let _ = done_tx.send(run_subagent(&timeout_ctx, &timeout_tx));
|
||||
});
|
||||
match done_rx.recv_timeout(Duration::from_millis(timeout)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(anyhow::anyhow!(
|
||||
"subagent '{}' timed out after {}ms",
|
||||
agent_name, timeout,
|
||||
"subagent '{agent_name}' timed out after {timeout}ms",
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
run_subagent(ctx, tx)
|
||||
run_subagent(&ctx, &tx)
|
||||
};
|
||||
|
||||
let completed_at = chrono::Utc::now().timestamp_millis();
|
||||
@@ -299,6 +299,7 @@ type ParallelResult = (usize, anyhow::Result<Vec<String>>);
|
||||
///
|
||||
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
|
||||
/// the order they were submitted.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn execute_primitive(
|
||||
primitive: &ScriptPrimitive,
|
||||
args: &HashMap<String, String>,
|
||||
@@ -316,7 +317,7 @@ pub fn execute_primitive(
|
||||
let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default();
|
||||
let agent_id = uuid::Uuid::new_v4().to_string();
|
||||
let agent_name = resolved.chars().take(40).collect::<String>();
|
||||
match spawn_single_agent(&agent_id, &agent_name, &resolved, findings_snapshot, findings, live, session_dir, workspaces, timeout_ms) {
|
||||
match spawn_single_agent(&agent_id, &agent_name, &resolved, &findings_snapshot, findings, live, session_dir, workspaces, timeout_ms) {
|
||||
Ok(text) => Ok(vec![text]),
|
||||
Err(e) => {
|
||||
if continue_on_error {
|
||||
@@ -380,7 +381,7 @@ pub fn execute_primitive(
|
||||
for (_, res) in locked.drain(..) {
|
||||
match res {
|
||||
Ok(outputs) => all.extend(outputs),
|
||||
Err(e) => all.push(format!("agent error: {}", e)),
|
||||
Err(e) => all.push(format!("agent error: {e}")),
|
||||
}
|
||||
}
|
||||
Ok(all)
|
||||
@@ -399,7 +400,7 @@ pub fn execute_primitive(
|
||||
Ok(outputs) => all.extend(outputs),
|
||||
Err(e) => {
|
||||
if continue_on_error {
|
||||
all.push(format!("pipeline stage {} error: {}", idx, e));
|
||||
all.push(format!("pipeline stage {idx} error: {e}"));
|
||||
} else {
|
||||
return Err(e);
|
||||
}
|
||||
@@ -437,13 +438,13 @@ pub fn run_workflow(
|
||||
///
|
||||
/// Why: findings are scoped to an `Arc<Mutex<Vec<String>>>` rather than a
|
||||
/// global static, so concurrent `run_workflow_tracked` calls from different
|
||||
/// spawn_agents invocations remain fully isolated.
|
||||
/// `spawn_agents` invocations remain fully isolated.
|
||||
///
|
||||
/// Return: a human-readable summary string.
|
||||
pub fn run_workflow_tracked(
|
||||
script: &WorkflowScript,
|
||||
args: &HashMap<String, String>,
|
||||
live: Option<LiveStateFn>,
|
||||
live: Option<&LiveStateFn>,
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
) -> anyhow::Result<String> {
|
||||
@@ -453,11 +454,10 @@ pub fn run_workflow_tracked(
|
||||
10
|
||||
};
|
||||
|
||||
let live_ref = live.as_ref();
|
||||
let findings = Arc::new(Mutex::new(Vec::new()));
|
||||
let results = execute_primitive(
|
||||
&script.script, args, concurrency_cap,
|
||||
script.options.continue_on_error, live_ref,
|
||||
script.options.continue_on_error, live,
|
||||
session_dir, workspaces, &findings,
|
||||
script.options.timeout_ms,
|
||||
)?;
|
||||
@@ -489,7 +489,7 @@ pub fn run_workflow_tracked(
|
||||
fn resolve_template(template: &str, args: &HashMap<String, String>) -> String {
|
||||
let mut result = template.to_string();
|
||||
for (key, value) in args {
|
||||
result = result.replace(&format!("{{{{{}}}}}", key), value);
|
||||
result = result.replace(&format!("{{{{{key}}}}}"), value);
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -535,7 +535,7 @@ struct SemaphoreGuard<'a> {
|
||||
sem: &'a Semaphore,
|
||||
}
|
||||
|
||||
impl<'a> Drop for SemaphoreGuard<'a> {
|
||||
impl Drop for SemaphoreGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
let mut count = self.sem.count.lock().unwrap_or_else(|e| {
|
||||
tracing::warn!("[semaphore] mutex poisoned in drop, recovering");
|
||||
|
||||
Reference in New Issue
Block a user