Refactor view modules for improved readability and consistency
- Updated markdown rendering logic to use more concise methods for obtaining vector lengths. - Changed review status display to use the correct flag from settings. - Cleaned up sidebar rendering code for better formatting and readability. - Enhanced status bar rendering with improved string formatting and consistent style application. - Refined workflow panel rendering, ensuring consistent style usage and improved readability. - Added architecture overview and detailed documentation for backend, data, dependencies, and frontend structures.
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Global registry of running background bash jobs, and control operations
|
||||
//! (output polling, kill) exposed to the rest of the app.
|
||||
//!
|
||||
@@ -10,7 +15,6 @@
|
||||
//! Why: a single static map (rather than storing jobs in `AppStateRest`)
|
||||
//! lets background jobs outlive the borrow of any particular state mutation
|
||||
//! and be looked up by id from tool calls issued at arbitrary points.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::OnceLock;
|
||||
@@ -44,7 +48,11 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
|
||||
while let Some(line) = job.try_read_line() {
|
||||
lines.push(line);
|
||||
}
|
||||
if lines.is_empty() { None } else { Some(lines) }
|
||||
if lines.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(lines)
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminate a running background bash job and remove it from the registry.
|
||||
@@ -58,7 +66,9 @@ pub fn bash_output(id: &str) -> Option<Vec<String>> {
|
||||
/// Return: `Ok(())` on success, `Err` if the lock is poisoned or no job
|
||||
/// 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) => {
|
||||
|
||||
+19
-13
@@ -9,11 +9,10 @@
|
||||
//! Why: running bash commands on a detached thread with a channel (rather
|
||||
//! than synchronously) lets the TUI stay responsive while long-running
|
||||
//! shell commands execute in the background.
|
||||
|
||||
use std::io::BufRead;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
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).
|
||||
@@ -59,17 +58,23 @@ pub fn spawn_bash_job(command: String) -> BashJob {
|
||||
// Spawn a named thread for easier debugging. If Builder::spawn fails
|
||||
// (e.g. OS resource limit), fall back to unnameable thread::spawn.
|
||||
let thread_name = format!("bgbash-{}", &thread_id[..8.min(thread_id.len())]);
|
||||
if thread::Builder::new().name(thread_name).spawn({
|
||||
// Clone everything the closure captures so we can also pass it
|
||||
// to the fallback thread without moving.
|
||||
let cmd = cmd.clone();
|
||||
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)
|
||||
}).is_err()
|
||||
if thread::Builder::new()
|
||||
.name(thread_name)
|
||||
.spawn({
|
||||
// Clone everything the closure captures so we can also pass it
|
||||
// to the fallback thread without moving.
|
||||
let cmd = cmd.clone();
|
||||
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)
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!("[bgbash:{}] failed to spawn named thread, using unnamed fallback", id_for_log);
|
||||
tracing::warn!(
|
||||
"[bgbash:{}] failed to spawn named thread, using unnamed fallback",
|
||||
id_for_log
|
||||
);
|
||||
thread::spawn(move || {
|
||||
spawn_bash_thread_body(&cmd, &output_tx, &pid_tx, &id_for_log);
|
||||
});
|
||||
@@ -139,7 +144,8 @@ fn spawn_bash_thread_body(
|
||||
if output_tx.try_send(line).is_err() {
|
||||
tracing::debug!(
|
||||
"[bgbash:{}] output buffer full ({} lines), discarding remaining output",
|
||||
id_for_log, MAX_OUTPUT_LINES,
|
||||
id_for_log,
|
||||
MAX_OUTPUT_LINES,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Background bash: run shell commands off the main thread, poll their
|
||||
//! output non-blockingly, and terminate them on demand.
|
||||
|
||||
pub mod control;
|
||||
pub mod job;
|
||||
|
||||
+231
-158
@@ -84,10 +84,17 @@ const ASSUMPTION_PATTERNS: &[&str] = &[
|
||||
|
||||
/// Network-exfiltration and credential-disclosure patterns for bash.
|
||||
const EXFIL_PATTERNS: &[&str] = &[
|
||||
"curl ", "wget ", "nc -e ", "ncat ", "/dev/tcp/",
|
||||
"base64 -d |", "base64 --decode |",
|
||||
"openssl s_client", "ssh -R ",
|
||||
"scp /", "rsync /",
|
||||
"curl ",
|
||||
"wget ",
|
||||
"nc -e ",
|
||||
"ncat ",
|
||||
"/dev/tcp/",
|
||||
"base64 -d |",
|
||||
"base64 --decode |",
|
||||
"openssl s_client",
|
||||
"ssh -R ",
|
||||
"scp /",
|
||||
"rsync /",
|
||||
];
|
||||
|
||||
/// Substrings of well-known credential / secret files that bash must not read.
|
||||
@@ -115,169 +122,63 @@ impl Harness {
|
||||
/// Decide whether a tool call is allowed to execute.
|
||||
///
|
||||
/// Flow: ALL tools are gated (not just risky ones), closing the bypass
|
||||
/// for MCP tools (which are never in the risky list). Basic path
|
||||
/// traversal and reason validation applies to any tool with a `path`
|
||||
/// argument. Heavy content scanning (stub/denial/assumption/exfiltration)
|
||||
/// only applies to risky tools. MCP tools (mcp__ prefix) are treated
|
||||
/// as risky because their behaviour is unknown.
|
||||
/// for MCP tools (which are never in the risky list). Delegates to
|
||||
/// smaller helper methods for each concern: path traversal, output
|
||||
/// path validation, content scanning, bash safety, and reason checks.
|
||||
///
|
||||
/// 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,
|
||||
workspace_roots: &[&std::path::Path],
|
||||
) -> Verdict {
|
||||
|
||||
let is_risky = crate::tool::tool_is_risky(tool_name);
|
||||
let is_mcp = tool_name.starts_with("mcp__");
|
||||
|
||||
// ── Universal checks applied to EVERY tool ──
|
||||
|
||||
// Path traversal: check ANY tool that accepts a path argument,
|
||||
// not just write/edit/delete, so tools like read, MCP tools,
|
||||
// and future tools are also protected.
|
||||
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
|
||||
if path.contains("..") {
|
||||
return Verdict::Block(
|
||||
"path traversal detected in 'path' argument".to_string(),
|
||||
);
|
||||
}
|
||||
if !workspace_roots.is_empty() {
|
||||
let abs_check = std::path::PathBuf::from(path);
|
||||
if abs_check.is_absolute()
|
||||
&& !workspace_roots.iter().any(|r| abs_check.starts_with(r))
|
||||
{
|
||||
return Verdict::Block(format!(
|
||||
"absolute path '{path}' is outside all workspace roots"
|
||||
));
|
||||
}
|
||||
}
|
||||
// Universal checks applied to EVERY tool.
|
||||
if let Some(v) = Self::check_path_traversal(args, workspace_roots) {
|
||||
return v;
|
||||
}
|
||||
if let Some(v) = Self::check_output_path(tool_name, args, workspace_roots) {
|
||||
return v;
|
||||
}
|
||||
|
||||
// Workspace-root validation for output path.
|
||||
if let Some(out_path) = Self::find_output_path(tool_name, args) {
|
||||
if !workspace_roots.is_empty()
|
||||
&& !out_path.starts_with("/tmp")
|
||||
&& !out_path.is_absolute()
|
||||
{
|
||||
let allowed = workspace_roots.iter().any(|r| out_path.starts_with(r));
|
||||
if !allowed {
|
||||
return Verdict::Block(format!(
|
||||
"output path '{out_path:?}' is outside all workspace roots"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Risky / MCP tool checks ──
|
||||
// Non-risky, non-MCP tools (read, grep, glob, recall, etc.) are
|
||||
// allowed after universal checks above.
|
||||
// Non-risky, non-MCP tools pass after universal checks.
|
||||
if !is_risky && !is_mcp {
|
||||
return Verdict::Allow;
|
||||
}
|
||||
|
||||
// File-mutating tools: write / edit / delete
|
||||
// File-mutating tools: require a meaningful reason.
|
||||
if matches!(tool_name, "write" | "edit" | "delete") {
|
||||
match Self::validate_reason(tool_name, args) {
|
||||
Ok(()) => {}
|
||||
Err(msg) => return Verdict::Block(msg),
|
||||
if let Err(msg) = Self::validate_reason(tool_name, args) {
|
||||
return Verdict::Block(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// write / edit content must not contain stubs, denial language, or
|
||||
// assumption language.
|
||||
if matches!(tool_name, "write" | "edit") {
|
||||
if let Some(content) = Self::extract_content(tool_name, args) {
|
||||
if let Some(pat) = Self::first_match(&content, STUB_PATTERNS) {
|
||||
return Verdict::Block(format!(
|
||||
"content contains stub/placeholder pattern '{pat}'; \
|
||||
production code must be fully implemented — \
|
||||
replace the stub with a real implementation"
|
||||
));
|
||||
}
|
||||
if let Some(pat) = Self::first_match(&content, DENIAL_PATTERNS) {
|
||||
return Verdict::Block(format!(
|
||||
"content contains denial/punt pattern '{pat}'; \
|
||||
implement the change properly instead of skipping"
|
||||
));
|
||||
}
|
||||
if let Some(pat) = Self::first_match(&content, ASSUMPTION_PATTERNS) {
|
||||
return Verdict::Block(format!(
|
||||
"content contains assumption pattern '{pat}'; \
|
||||
verify against data/tests instead of guessing"
|
||||
));
|
||||
}
|
||||
}
|
||||
// write / edit content scanning for stub/denial/assumption patterns.
|
||||
if let Some(v) = Self::check_content_safety(tool_name, args) {
|
||||
return v;
|
||||
}
|
||||
|
||||
// Bash: destructive patterns, exfiltration (ALL commands checked,
|
||||
// no safe-command whitelist), sensitive-path reads.
|
||||
if tool_name == "bash" {
|
||||
let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if cmd.contains("..") {
|
||||
return Verdict::Block(
|
||||
"path traversal detected in bash command".to_string(),
|
||||
);
|
||||
}
|
||||
// Exfiltration patterns are checked on EVERY bash command,
|
||||
// regardless of prefix. The safe-command whitelist was removed
|
||||
// because it could be bypassed with command chaining.
|
||||
for pat in EXFIL_PATTERNS {
|
||||
if cmd.contains(pat) {
|
||||
return Verdict::Block(format!(
|
||||
"potential data-exfiltration command blocked (matched '{pat}')"
|
||||
));
|
||||
}
|
||||
}
|
||||
for pat in SENSITIVE_PATH_PATTERNS {
|
||||
if cmd.contains(pat) {
|
||||
return Verdict::Block(format!(
|
||||
"refused to read/write sensitive path '{pat}'"
|
||||
));
|
||||
}
|
||||
}
|
||||
let dangerous_patterns = [
|
||||
"rm -rf /", "rm -rf --no-preserve-root",
|
||||
"rm -rf ~", "rm -fr /", "mkfs.", "dd if=",
|
||||
":(){", "> /dev/sda", "chmod -R 000 /",
|
||||
"shutdown ", "poweroff ", "reboot ", "halt ",
|
||||
];
|
||||
for pat in &dangerous_patterns {
|
||||
if cmd.contains(pat) {
|
||||
return Verdict::Block(format!(
|
||||
"destructive command pattern blocked: {pat}"
|
||||
));
|
||||
}
|
||||
}
|
||||
// Also scan heredocs / -c / inline content for stub/denial
|
||||
// language (e.g. `bash -c 'echo todo!()'`)
|
||||
if let Some(pat) = Self::first_match(cmd, STUB_PATTERNS) {
|
||||
// Bash-specific destructive / exfiltration checks.
|
||||
if let Some(v) = Self::check_bash_safety(args) {
|
||||
return v;
|
||||
}
|
||||
|
||||
// git_operator: require a non-trivial reason.
|
||||
if tool_name == "git_operator" && !Self::has_valid_reason(args, MIN_REASON_LEN) {
|
||||
if args.get("reason").and_then(|v| v.as_str()).is_some() {
|
||||
return Verdict::Block(format!(
|
||||
"bash command contains stub pattern '{pat}'"
|
||||
"git_operator requires a non-trivial 'reason' \
|
||||
(>= {MIN_REASON_LEN} chars) explaining the operation"
|
||||
));
|
||||
}
|
||||
return Verdict::Block(
|
||||
"git_operator requires a 'reason' argument explaining the operation".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// git_operator: require a non-trivial reason as well.
|
||||
if tool_name == "git_operator" {
|
||||
if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
|
||||
if reason.trim().len() < MIN_REASON_LEN {
|
||||
return Verdict::Block(format!(
|
||||
"git_operator requires a non-trivial 'reason' \
|
||||
(>= {MIN_REASON_LEN} chars) explaining the operation"
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Verdict::Block(
|
||||
"git_operator requires a 'reason' argument explaining the operation"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// MCP tools: unknown behaviour — require a reason if they take
|
||||
// arguments, to discourage lazy invocations.
|
||||
// MCP tools: require a reason when they take meaningful arguments.
|
||||
if is_mcp {
|
||||
if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) {
|
||||
if reason.trim().len() < MIN_REASON_LEN {
|
||||
@@ -287,7 +188,6 @@ impl Harness {
|
||||
));
|
||||
}
|
||||
} else if args.as_object().is_some_and(|m| !m.is_empty()) {
|
||||
// Only require reason when there are meaningful arguments
|
||||
return Verdict::Block(format!(
|
||||
"MCP tool '{tool_name}' requires a 'reason' argument \
|
||||
explaining the operation"
|
||||
@@ -298,6 +198,163 @@ impl Harness {
|
||||
Verdict::Allow
|
||||
}
|
||||
|
||||
/// Check for path traversal in the `path` argument and verify it stays
|
||||
/// within workspace roots.
|
||||
///
|
||||
/// Flow: reject any path containing `..` → if workspace roots are set,
|
||||
/// reject absolute paths outside every root.
|
||||
///
|
||||
/// Return: `Some(Verdict::Block)` on violation, `None` if the check
|
||||
/// passes or the tool has no `path` argument.
|
||||
fn check_path_traversal(
|
||||
args: &serde_json::Value,
|
||||
workspace_roots: &[&std::path::Path],
|
||||
) -> Option<Verdict> {
|
||||
let path = args.get("path")?.as_str()?;
|
||||
if path.contains("..") {
|
||||
return Some(Verdict::Block(
|
||||
"path traversal detected in 'path' argument".to_string(),
|
||||
));
|
||||
}
|
||||
if !workspace_roots.is_empty() {
|
||||
let abs_check = std::path::PathBuf::from(path);
|
||||
if abs_check.is_absolute() && !workspace_roots.iter().any(|r| abs_check.starts_with(r))
|
||||
{
|
||||
return Some(Verdict::Block(format!(
|
||||
"absolute path '{path}' is outside all workspace roots"
|
||||
)));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Verify that a tool's output path (if any) stays within workspace roots.
|
||||
///
|
||||
/// Flow: if `find_output_path` yields a path, reject it unless it's
|
||||
/// under `/tmp`, already absolute, or within a workspace root.
|
||||
///
|
||||
/// Return: `Some(Verdict::Block)` on violation, `None` otherwise.
|
||||
fn check_output_path(
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
workspace_roots: &[&std::path::Path],
|
||||
) -> Option<Verdict> {
|
||||
let out_path = Self::find_output_path(tool_name, args)?;
|
||||
if !workspace_roots.is_empty() && !out_path.starts_with("/tmp") && !out_path.is_absolute() {
|
||||
let allowed = workspace_roots.iter().any(|r| out_path.starts_with(r));
|
||||
if !allowed {
|
||||
return Some(Verdict::Block(format!(
|
||||
"output path '{}' is outside all workspace roots",
|
||||
out_path.display(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check write/edit content for stub, denial, and assumption patterns.
|
||||
///
|
||||
/// Return: `Some(Verdict::Block)` with a description of the first
|
||||
/// matched pattern, `None` if the content is clean or not applicable.
|
||||
fn check_content_safety(tool_name: &str, args: &serde_json::Value) -> Option<Verdict> {
|
||||
if !matches!(tool_name, "write" | "edit") {
|
||||
return None;
|
||||
}
|
||||
let content = Self::extract_content(tool_name, args)?;
|
||||
for (patterns, msg_prefix) in [
|
||||
(&STUB_PATTERNS, "stub/placeholder"),
|
||||
(&DENIAL_PATTERNS, "denial/punt"),
|
||||
(&ASSUMPTION_PATTERNS, "assumption"),
|
||||
] {
|
||||
if let Some(pat) = Self::first_match(&content, patterns) {
|
||||
let msg = match msg_prefix {
|
||||
"stub/placeholder" => format!(
|
||||
"content contains stub/placeholder pattern '{pat}'; \
|
||||
production code must be fully implemented — \
|
||||
replace the stub with a real implementation"
|
||||
),
|
||||
"denial/punt" => format!(
|
||||
"content contains denial/punt pattern '{pat}'; \
|
||||
implement the change properly instead of skipping"
|
||||
),
|
||||
_ => format!(
|
||||
"content contains assumption pattern '{pat}'; \
|
||||
verify against data/tests instead of guessing"
|
||||
),
|
||||
};
|
||||
return Some(Verdict::Block(msg));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check bash commands for path traversal, exfiltration, sensitive
|
||||
/// path reads, destructive patterns, and stub language.
|
||||
///
|
||||
/// Flow: extract the `command` argument → check each category in
|
||||
/// sequence, returning the first violation found.
|
||||
///
|
||||
/// Return: `Some(Verdict::Block)` on any violation, `None` if the
|
||||
/// tool is not bash or the command is safe.
|
||||
fn check_bash_safety(args: &serde_json::Value) -> Option<Verdict> {
|
||||
let cmd = args.get("command")?.as_str()?;
|
||||
if cmd.contains("..") {
|
||||
return Some(Verdict::Block(
|
||||
"path traversal detected in bash command".to_string(),
|
||||
));
|
||||
}
|
||||
for pat in EXFIL_PATTERNS {
|
||||
if cmd.contains(pat) {
|
||||
return Some(Verdict::Block(format!(
|
||||
"potential data-exfiltration command blocked (matched '{pat}')"
|
||||
)));
|
||||
}
|
||||
}
|
||||
for pat in SENSITIVE_PATH_PATTERNS {
|
||||
if cmd.contains(pat) {
|
||||
return Some(Verdict::Block(format!(
|
||||
"refused to read/write sensitive path '{pat}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let dangerous_patterns = [
|
||||
"rm -rf /",
|
||||
"rm -rf --no-preserve-root",
|
||||
"rm -rf ~",
|
||||
"rm -fr /",
|
||||
"mkfs.",
|
||||
"dd if=",
|
||||
":(){",
|
||||
"> /dev/sda",
|
||||
"chmod -R 000 /",
|
||||
"shutdown ",
|
||||
"poweroff ",
|
||||
"reboot ",
|
||||
"halt ",
|
||||
];
|
||||
for pat in &dangerous_patterns {
|
||||
if cmd.contains(pat) {
|
||||
return Some(Verdict::Block(format!(
|
||||
"destructive command pattern blocked: {pat}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(pat) = Self::first_match(cmd, STUB_PATTERNS) {
|
||||
return Some(Verdict::Block(format!(
|
||||
"bash command contains stub pattern '{pat}'"
|
||||
)));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check whether the given `args` contain a non-trivial `reason`
|
||||
/// argument meeting the minimum length requirement.
|
||||
fn has_valid_reason(args: &serde_json::Value, min_len: usize) -> bool {
|
||||
args.get("reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|r| r.trim().len() >= min_len)
|
||||
}
|
||||
|
||||
/// Validate the `reason` argument for a mutating tool.
|
||||
///
|
||||
/// Flow: require the field to exist and be a non-empty string ≥
|
||||
@@ -317,17 +374,13 @@ impl Harness {
|
||||
Some(v) => match v.as_str() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(format!(
|
||||
"{tool_name} 'reason' must be a string"
|
||||
));
|
||||
return Err(format!("{tool_name} 'reason' must be a string"));
|
||||
}
|
||||
},
|
||||
};
|
||||
let trimmed = reason.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!(
|
||||
"{tool_name} 'reason' must not be empty"
|
||||
));
|
||||
return Err(format!("{tool_name} 'reason' must not be empty"));
|
||||
}
|
||||
if trimmed.len() < MIN_REASON_LEN {
|
||||
return Err(format!(
|
||||
@@ -339,9 +392,20 @@ impl Harness {
|
||||
// Reject generic non-answers
|
||||
let lower = trimmed.to_lowercase();
|
||||
let non_answers = [
|
||||
"fix", "update", "change", "edit", "modify",
|
||||
"implement", "add", "remove", "delete",
|
||||
"make it work", "make work", "test", "wip", "tbd",
|
||||
"fix",
|
||||
"update",
|
||||
"change",
|
||||
"edit",
|
||||
"modify",
|
||||
"implement",
|
||||
"add",
|
||||
"remove",
|
||||
"delete",
|
||||
"make it work",
|
||||
"make work",
|
||||
"test",
|
||||
"wip",
|
||||
"tbd",
|
||||
];
|
||||
if non_answers.iter().any(|n| lower == *n) {
|
||||
return Err(format!(
|
||||
@@ -356,7 +420,10 @@ impl Harness {
|
||||
/// Extract the textual content of a write/edit call, if any.
|
||||
fn extract_content(tool_name: &str, args: &serde_json::Value) -> Option<String> {
|
||||
match tool_name {
|
||||
"write" => args.get("content").and_then(|v| v.as_str()).map(String::from),
|
||||
"write" => args
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
"edit" => {
|
||||
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("");
|
||||
@@ -377,9 +444,10 @@ impl Harness {
|
||||
/// Extract a candidate output path from a tool call, if one exists.
|
||||
fn find_output_path(tool_name: &str, args: &serde_json::Value) -> Option<std::path::PathBuf> {
|
||||
match tool_name {
|
||||
"write" | "edit" | "delete" | "read" => {
|
||||
args.get("path").and_then(|v| v.as_str()).map(std::path::PathBuf::from)
|
||||
}
|
||||
"write" | "edit" | "delete" | "read" => args
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(std::path::PathBuf::from),
|
||||
"bash" => {
|
||||
let cmd = args.get("command").and_then(|v| v.as_str())?;
|
||||
let lower = cmd.to_lowercase();
|
||||
@@ -397,7 +465,6 @@ impl Harness {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl Default for Harness {
|
||||
@@ -418,7 +485,10 @@ mod tests {
|
||||
return match verdict.to_lowercase().as_str() {
|
||||
"allow" => Some(Verdict::Allow),
|
||||
"block" => Some(Verdict::Block(
|
||||
v.get("reason").and_then(|r| r.as_str()).unwrap_or("blocked").to_string()
|
||||
v.get("reason")
|
||||
.and_then(|r| r.as_str())
|
||||
.unwrap_or("blocked")
|
||||
.to_string(),
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
@@ -430,7 +500,11 @@ mod tests {
|
||||
return Some(Verdict::Allow);
|
||||
}
|
||||
if l.starts_with("verdict: block") {
|
||||
let reason = line.split_once(':').map_or("blocked", |x| x.1).trim().to_string();
|
||||
let reason = line
|
||||
.split_once(':')
|
||||
.map_or("blocked", |x| x.1)
|
||||
.trim()
|
||||
.to_string();
|
||||
return Some(Verdict::Block(reason));
|
||||
}
|
||||
}
|
||||
@@ -450,7 +524,6 @@ mod tests {
|
||||
assert_eq!(result, Verdict::Allow);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn test_parse_verdict_json_allow() {
|
||||
let v = parse_verdict(r#"{"verdict": "allow"}"#);
|
||||
|
||||
+120
-119
@@ -47,13 +47,20 @@ impl LspClient {
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn LSP server '{command}': {e}"))?;
|
||||
|
||||
let stdin = child.stdin.take()
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to capture stdin for LSP server"))?;
|
||||
let stdout = BufReader::new(child.stdout.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to capture stdout for LSP server"))?);
|
||||
let stdout = BufReader::new(
|
||||
child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to capture stdout for LSP server"))?,
|
||||
);
|
||||
|
||||
let mut client = LspClient {
|
||||
stdin,
|
||||
@@ -106,7 +113,11 @@ impl LspClient {
|
||||
}
|
||||
});
|
||||
|
||||
let result = client.call_with_timeout("initialize", &init_params, Duration::from_millis(LSP_INIT_TIMEOUT_MS))?;
|
||||
let result = client.call_with_timeout(
|
||||
"initialize",
|
||||
&init_params,
|
||||
Duration::from_millis(LSP_INIT_TIMEOUT_MS),
|
||||
)?;
|
||||
client.server_capabilities = result.get("capabilities").cloned().unwrap_or_default();
|
||||
|
||||
client.notify("initialized", &json!({}))?;
|
||||
@@ -122,7 +133,12 @@ impl LspClient {
|
||||
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!({
|
||||
@@ -148,11 +164,14 @@ impl LspClient {
|
||||
let body = serde_json::to_string(msg)
|
||||
.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())
|
||||
self.stdin
|
||||
.write_all(header.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("failed to write LSP frame header: {e}"))?;
|
||||
self.stdin.write_all(body.as_bytes())
|
||||
self.stdin
|
||||
.write_all(body.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("failed to write LSP frame body: {e}"))?;
|
||||
self.stdin.flush()
|
||||
self.stdin
|
||||
.flush()
|
||||
.map_err(|e| anyhow::anyhow!("failed to flush LSP stdin: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -166,8 +185,14 @@ 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(serde_json::Value::as_i64).unwrap_or(0);
|
||||
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown error");
|
||||
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}");
|
||||
}
|
||||
return Ok(frame.get("result").cloned().unwrap_or(Value::Null));
|
||||
@@ -205,8 +230,9 @@ impl LspClient {
|
||||
// Cap Content-Length at 64 MiB to prevent OOM from a
|
||||
// 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))?;
|
||||
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 {length} exceeds maximum allowed size of {MAX_CONTENT_LENGTH} bytes",
|
||||
@@ -220,7 +246,8 @@ impl LspClient {
|
||||
.ok_or_else(|| anyhow::anyhow!("missing Content-Length header in LSP response"))?;
|
||||
|
||||
let mut body = vec![0u8; length];
|
||||
self.stdout.read_exact(&mut body)
|
||||
self.stdout
|
||||
.read_exact(&mut body)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read LSP body ({length} bytes): {e}"))?;
|
||||
|
||||
let json_str = String::from_utf8(body)
|
||||
@@ -230,74 +257,98 @@ impl LspClient {
|
||||
.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!({
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"languageId": language_id,
|
||||
"version": version,
|
||||
"text": text
|
||||
}
|
||||
}))
|
||||
pub fn did_open(
|
||||
&mut self,
|
||||
uri: &str,
|
||||
language_id: &str,
|
||||
version: i32,
|
||||
text: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
self.notify(
|
||||
"textDocument/didOpen",
|
||||
&json!({
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"languageId": language_id,
|
||||
"version": version,
|
||||
"text": text
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> {
|
||||
self.notify("textDocument/didChange", &json!({
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"version": version
|
||||
},
|
||||
"contentChanges": [{
|
||||
"text": text
|
||||
}]
|
||||
}))
|
||||
self.notify(
|
||||
"textDocument/didChange",
|
||||
&json!({
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"version": version
|
||||
},
|
||||
"contentChanges": [{
|
||||
"text": text
|
||||
}]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> {
|
||||
self.notify("textDocument/didClose", &json!({
|
||||
"textDocument": {
|
||||
"uri": uri
|
||||
}
|
||||
}))
|
||||
self.notify(
|
||||
"textDocument/didClose",
|
||||
&json!({
|
||||
"textDocument": {
|
||||
"uri": uri
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/hover", &json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character }
|
||||
}))
|
||||
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!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character }
|
||||
}))
|
||||
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!({
|
||||
"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!({
|
||||
"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!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character },
|
||||
"context": {
|
||||
"includeDeclaration": true
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn document_symbols(&mut self, uri: &str) -> anyhow::Result<Value> {
|
||||
self.call("textDocument/documentSymbol", &json!({
|
||||
"textDocument": { "uri": uri }
|
||||
}))
|
||||
self.call(
|
||||
"textDocument/references",
|
||||
&json!({
|
||||
"textDocument": { "uri": uri },
|
||||
"position": { "line": line, "character": character },
|
||||
"context": {
|
||||
"includeDeclaration": true
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn collect_diagnostics(
|
||||
@@ -313,64 +364,14 @@ impl LspClient {
|
||||
);
|
||||
self.did_close(uri)?;
|
||||
match result {
|
||||
Ok(params) => Ok(params.get("diagnostics").cloned().unwrap_or_else(|| json!([]))),
|
||||
Ok(params) => Ok(params
|
||||
.get("diagnostics")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([]))),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Health-check the LSP server.
|
||||
///
|
||||
/// Sends a `textDocument/documentSymbol` request on a dummy URI with a
|
||||
/// 2-second timeout. Returns `true` if the server responds at all —
|
||||
/// including with an error response such as "file not found", which
|
||||
/// still proves the process is up and the JSON-RPC channel is live.
|
||||
/// Returns `false` on timeout, EOF, or any read/write error.
|
||||
///
|
||||
/// Flow: build request → `send_frame` → poll frames until id matches
|
||||
/// (alive) or deadline/read error fires (dead).
|
||||
#[allow(dead_code)]
|
||||
pub fn is_alive(&mut self) -> bool {
|
||||
self.next_id += 1;
|
||||
let id = self.next_id;
|
||||
let req = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"method": "textDocument/documentSymbol",
|
||||
"params": {
|
||||
"textDocument": { "uri": "file:///__zesdex_lsp_health_check__.txt" }
|
||||
}
|
||||
});
|
||||
if self.send_frame(&req).is_err() {
|
||||
return false;
|
||||
}
|
||||
let timeout = Duration::from_secs(2);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if Instant::now() > deadline {
|
||||
return false;
|
||||
}
|
||||
match self.read_frame() {
|
||||
Ok(frame) => {
|
||||
if frame.get("id") == Some(&json!(id)) {
|
||||
return true;
|
||||
}
|
||||
// Skip unrelated notifications/responses on the same channel.
|
||||
}
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the LSP `exit` notification to request graceful shutdown.
|
||||
///
|
||||
/// Per the LSP spec, `exit` is a notification — the server is expected
|
||||
/// to terminate after receiving it without sending a response. We do
|
||||
/// not block on any reply.
|
||||
#[allow(dead_code)]
|
||||
pub fn exit(&mut self) -> anyhow::Result<()> {
|
||||
self.notify("exit", &json!({}))
|
||||
}
|
||||
|
||||
pub fn shutdown(&mut self) {
|
||||
let _ = self.call_with_timeout("shutdown", &json!({}), Duration::from_secs(5));
|
||||
let _ = self.notify("exit", &json!({}));
|
||||
|
||||
+54
-113
@@ -13,12 +13,6 @@ pub use client::{path_to_lsp_uri, LspClient};
|
||||
/// to issue LSP requests from threads or async tasks.
|
||||
#[derive(Clone)]
|
||||
pub struct LspServer {
|
||||
#[allow(dead_code)]
|
||||
pub name: String,
|
||||
#[allow(dead_code)]
|
||||
pub command: String,
|
||||
#[allow(dead_code)]
|
||||
pub args: Vec<String>,
|
||||
pub language_id: String,
|
||||
pub client: Arc<Mutex<LspClient>>,
|
||||
}
|
||||
@@ -38,12 +32,12 @@ pub struct OpenDoc {
|
||||
///
|
||||
/// Flow: caller calls `connect*` -> client spawned -> entry pushed to
|
||||
/// `servers` -> `extension_registry` is populated by `register_extensions`.
|
||||
/// File edits route through `find_server_for_path` / `find_server_for_extension`
|
||||
/// and are dispatched as `didOpen` / `didChange` notifications.
|
||||
/// File edits route through `extension_registry` and are dispatched as
|
||||
/// `didOpen` / `didChange` notifications.
|
||||
#[derive(Clone)]
|
||||
pub struct LspManager {
|
||||
pub servers: Vec<LspServer>,
|
||||
/// Maps file extension (".rs", ".ts", ...) -> server name.
|
||||
/// Maps file extension (".rs", ".ts", ...) -> language id.
|
||||
pub extension_registry: HashMap<String, String>,
|
||||
/// Maps document URI -> tracked open document state.
|
||||
pub open_files: HashMap<String, OpenDoc>,
|
||||
@@ -59,112 +53,73 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn an LSP server and register it under `name`.
|
||||
/// Spawn an LSP server and register it under `language_id`.
|
||||
///
|
||||
/// Fails if a server with the same name is already connected.
|
||||
/// Fails if a server with the same `language_id` is already connected.
|
||||
pub fn connect(
|
||||
&mut self,
|
||||
name: &str,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
language_id: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
if self.servers.iter().any(|s| s.name == name) {
|
||||
anyhow::bail!("LSP server '{name}' is already connected");
|
||||
if self.servers.iter().any(|s| s.language_id == language_id) {
|
||||
anyhow::bail!("LSP server for language '{language_id}' is already connected");
|
||||
}
|
||||
let client = LspClient::spawn(command, args)?;
|
||||
self.servers.push(LspServer {
|
||||
name: name.to_string(),
|
||||
command: command.to_string(),
|
||||
args: args.to_vec(),
|
||||
language_id: language_id.to_string(),
|
||||
client: Arc::new(Mutex::new(client)),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up a connected server by name and return a reference to its entry.
|
||||
#[allow(dead_code)]
|
||||
pub fn find_server(&self, name: &str) -> Option<&LspServer> {
|
||||
self.servers.iter().find(|s| s.name == name)
|
||||
}
|
||||
|
||||
/// Return a clone of the `Arc<Mutex<LspClient>>` for a connected server.
|
||||
///
|
||||
/// Cloning the `Arc` lets callers issue requests without holding a
|
||||
/// borrow on the manager.
|
||||
pub fn get_client(&self, name: &str) -> Option<Arc<Mutex<LspClient>>> {
|
||||
self.servers.iter().find(|s| s.name == name).map(|s| s.client.clone())
|
||||
pub fn get_client(&self, language_id: &str) -> Option<Arc<Mutex<LspClient>>> {
|
||||
self.servers
|
||||
.iter()
|
||||
.find(|s| s.language_id == language_id)
|
||||
.map(|s| s.client.clone())
|
||||
}
|
||||
|
||||
/// Shut down and remove a server by name. Returns true if it existed.
|
||||
pub fn disconnect(&mut self, name: &str) -> bool {
|
||||
if let Some(server) = self.servers.iter().find(|s| s.name == name) {
|
||||
/// Shut down and remove a server by language. Returns true if it existed.
|
||||
pub fn disconnect(&mut self, language_id: &str) -> bool {
|
||||
if let Some(server) = self.servers.iter().find(|s| s.language_id == language_id) {
|
||||
if let Ok(mut client) = server.client.lock() {
|
||||
client.shutdown();
|
||||
}
|
||||
}
|
||||
let len = self.servers.len();
|
||||
self.servers.retain(|s| s.name != name);
|
||||
self.servers.retain(|s| s.language_id != language_id);
|
||||
self.servers.len() < len
|
||||
}
|
||||
|
||||
/// Return the language id (e.g. "rust") registered for `name`.
|
||||
pub fn get_language_id(&self, name: &str) -> Option<String> {
|
||||
self.servers.iter().find(|s| s.name == name).map(|s| s.language_id.clone())
|
||||
}
|
||||
|
||||
/// Resolve an extension (".rs", ".ts", ...) to its server's client.
|
||||
///
|
||||
/// Flow: lookup `extension_registry` -> resolve server name -> clone client.
|
||||
/// Returns `None` if no server has been registered for `ext`.
|
||||
#[allow(dead_code)]
|
||||
pub fn find_server_for_extension(&self, ext: &str) -> Option<Arc<Mutex<LspClient>>> {
|
||||
self.extension_registry
|
||||
.get(ext)
|
||||
.and_then(|name| self.get_client(name))
|
||||
}
|
||||
|
||||
/// Resolve a file path to its server's client by extension.
|
||||
///
|
||||
/// Flow: extract the extension from `path` -> delegate to
|
||||
/// `find_server_for_extension`. Files without an extension or with
|
||||
/// an unmapped extension return `None`.
|
||||
#[allow(dead_code)]
|
||||
pub fn find_server_for_path(&self, path: &Path) -> Option<Arc<Mutex<LspClient>>> {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| format!(".{s}"))
|
||||
.and_then(|ext| self.find_server_for_extension(&ext))
|
||||
/// Return the language id (e.g. "rust") registered for `language_id`.
|
||||
pub fn get_language_id(&self, language_id: &str) -> Option<String> {
|
||||
self.servers
|
||||
.iter()
|
||||
.find(|s| s.language_id == language_id)
|
||||
.map(|s| s.language_id.clone())
|
||||
}
|
||||
|
||||
/// Register a set of file extensions for an already-connected server.
|
||||
///
|
||||
/// Flow: for each `ext`, write `server_name` into `extension_registry`.
|
||||
/// Re-registration overwrites the previous target. Unknown server
|
||||
/// names are accepted at this layer — caller must ensure `server_name`
|
||||
/// is connected or will be connected later.
|
||||
pub fn register_extensions(&mut self, server_name: &str, extensions: &[&str]) {
|
||||
/// Flow: for each `ext`, write `language_id` into `extension_registry`.
|
||||
/// Re-registration overwrites the previous target. Unknown language IDs
|
||||
/// are accepted at this layer — caller must ensure a server for
|
||||
/// `language_id` is connected or will be connected later.
|
||||
pub fn register_extensions(&mut self, language_id: &str, extensions: &[&str]) {
|
||||
for ext in extensions {
|
||||
self.extension_registry.insert(ext.to_string(), server_name.to_string());
|
||||
self.extension_registry
|
||||
.insert(ext.to_string(), language_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the registered server name for a given language id.
|
||||
///
|
||||
/// Flow: scan `servers` for the first entry whose `language_id` matches.
|
||||
/// Used when callers have a language hint rather than a file path.
|
||||
#[allow(dead_code)]
|
||||
pub fn get_server_name(&self, language: &str) -> Option<String> {
|
||||
self.servers
|
||||
.iter()
|
||||
.find(|s| s.language_id == language)
|
||||
.map(|s| s.name.clone())
|
||||
}
|
||||
|
||||
/// Notify the relevant LSP server that a file's contents have changed.
|
||||
///
|
||||
/// Flow: resolve server by extension -> read file contents ->
|
||||
/// Flow: resolve language by extension from the registry -> read file contents ->
|
||||
/// either send `didOpen` (first time) or `didChange` (already tracked)
|
||||
/// -> update `open_files` with the new version.
|
||||
///
|
||||
@@ -172,13 +127,20 @@ impl LspManager {
|
||||
/// 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) {
|
||||
let Some(ext) = path.extension().and_then(|e| e.to_str()).map(|s| format!(".{s}")) else {
|
||||
let Some(ext) = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|s| format!(".{s}"))
|
||||
else {
|
||||
tracing::warn!("did_change_file: path has no extension: {:?}", path);
|
||||
return;
|
||||
};
|
||||
|
||||
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);
|
||||
let Some(language_id) = self.extension_registry.get(&ext).cloned() else {
|
||||
tracing::warn!(
|
||||
"did_change_file: no LSP server registered for extension '{}'",
|
||||
ext
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -192,12 +154,8 @@ impl LspManager {
|
||||
}
|
||||
};
|
||||
|
||||
let language_id = self
|
||||
.get_language_id(&server_name)
|
||||
.unwrap_or_else(|| "plaintext".to_string());
|
||||
|
||||
let Some(client) = self.get_client(&server_name) else {
|
||||
tracing::warn!("did_change_file: server '{}' has no client", server_name);
|
||||
let Some(client) = self.get_client(&language_id) else {
|
||||
tracing::warn!("did_change_file: no client for language '{}'", language_id);
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -210,7 +168,11 @@ impl LspManager {
|
||||
let mut client = match client.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("did_change_file: client mutex poisoned for '{}': {}", server_name, e);
|
||||
tracing::warn!(
|
||||
"did_change_file: client mutex poisoned for '{}': {}",
|
||||
language_id,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -224,7 +186,7 @@ impl LspManager {
|
||||
if let Err(e) = send_result {
|
||||
tracing::warn!(
|
||||
"did_change_file: failed to notify '{}' for {}: {}",
|
||||
server_name,
|
||||
language_id,
|
||||
uri,
|
||||
e
|
||||
);
|
||||
@@ -238,24 +200,6 @@ impl LspManager {
|
||||
version: next_version,
|
||||
},
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/// Record that `server_name` has an open document at `uri`.
|
||||
///
|
||||
/// Flow: insert/overwrite the `OpenDoc` entry in `open_files`.
|
||||
/// Does not contact the LSP server — pure local bookkeeping.
|
||||
#[allow(dead_code)]
|
||||
pub fn track_open_doc(&mut self, server_name: &str, uri: &str, language: &str, version: i32) {
|
||||
// server_name retained for future routing extensions; not stored today.
|
||||
let _ = server_name;
|
||||
self.open_files.insert(
|
||||
uri.to_string(),
|
||||
OpenDoc {
|
||||
language: language.to_string(),
|
||||
version,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Shut down every connected server and clear the server list.
|
||||
@@ -272,21 +216,20 @@ impl LspManager {
|
||||
self.servers.clear();
|
||||
}
|
||||
|
||||
/// Snapshot the connected servers as `(name, language_id, has_open_docs)` triples.
|
||||
/// Snapshot the connected servers as `(language_id, has_open_docs)` pairs.
|
||||
///
|
||||
/// `has_open_docs` is true if any tracked `OpenDoc` was registered
|
||||
/// against this server's clients. Useful for status displays.
|
||||
pub fn list_servers(&self) -> Vec<(String, String, bool)> {
|
||||
pub fn list_servers(&self) -> Vec<(String, bool)> {
|
||||
self.servers
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let name = s.name.clone();
|
||||
let lang = s.language_id.clone();
|
||||
let has_open = self
|
||||
.open_files
|
||||
.values()
|
||||
.any(|d| d.language == s.language_id);
|
||||
(name, lang, has_open)
|
||||
(lang, has_open)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -294,18 +237,17 @@ impl LspManager {
|
||||
/// Connect an LSP server and register its default extensions in one call.
|
||||
///
|
||||
/// Flow: invoke `connect` -> on success, register `extensions` against
|
||||
/// `name` in `extension_registry`. If `connect` fails, the registries
|
||||
/// `language_id` in `extension_registry`. If `connect` fails, the registries
|
||||
/// are left untouched and the error is propagated.
|
||||
pub fn connect_with_extensions(
|
||||
&mut self,
|
||||
name: &str,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
language_id: &str,
|
||||
extensions: &[&str],
|
||||
) -> anyhow::Result<()> {
|
||||
self.connect(name, command, args, language_id)?;
|
||||
self.register_extensions(name, extensions);
|
||||
self.connect(command, args, language_id)?;
|
||||
self.register_extensions(language_id, extensions);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -315,4 +257,3 @@ impl Default for LspManager {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+205
-161
@@ -11,7 +11,6 @@
|
||||
//! Each tier is a fallback for the previous, so we try the most
|
||||
//! user-friendly path first (rustup component, npm global, etc.) and
|
||||
//! only fall back to package managers or manual download if those fail.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -45,14 +44,11 @@ pub enum ProvisionResult {
|
||||
language: String,
|
||||
binary_path: String,
|
||||
},
|
||||
/// Every install tier failed — `manual_instructions` tells the user how
|
||||
/// to install by hand.
|
||||
/// Every install tier failed. Tells the user how to install by hand.
|
||||
Failed {
|
||||
language: String,
|
||||
server_name: String,
|
||||
reason: String,
|
||||
#[allow(dead_code)]
|
||||
manual_instructions: String,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -97,28 +93,55 @@ pub struct InstallTier {
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
/// Rust toolchain availability on the host PATH.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RustToolchain {
|
||||
pub has_rustup: bool,
|
||||
pub has_cargo: bool,
|
||||
}
|
||||
|
||||
/// Web / scripting language toolchain availability.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebToolchain {
|
||||
pub has_npm: bool,
|
||||
pub has_go: bool,
|
||||
pub has_java: bool,
|
||||
}
|
||||
|
||||
/// General-purpose platform utilities.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlatformUtils {
|
||||
pub has_curl: bool,
|
||||
pub has_tar: bool,
|
||||
}
|
||||
|
||||
/// Pacman and Brew package managers (Arch / macOS).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PacmanBrew {
|
||||
pub has_pacman: bool,
|
||||
pub has_brew: bool,
|
||||
}
|
||||
|
||||
/// Apt and DNF package managers (Debian / Fedora).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AptDnf {
|
||||
pub has_apt: bool,
|
||||
pub has_dnf: bool,
|
||||
}
|
||||
|
||||
/// Snapshot of the host environment used to decide which install tiers are viable.
|
||||
///
|
||||
/// Populated by `detect_env()` once per `provision_all()` call so we
|
||||
/// Populated by `detect_env()` once per `provision_all_with_progress()` call so we
|
||||
/// don't re-shell out for every server. `is_linux` / `is_macos` are
|
||||
/// 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,
|
||||
pub has_go: bool,
|
||||
pub has_java: bool,
|
||||
pub has_cargo: bool,
|
||||
pub has_curl: bool,
|
||||
pub has_wget: bool,
|
||||
pub has_tar: bool,
|
||||
pub has_pacman: bool,
|
||||
pub has_apt: bool,
|
||||
pub has_brew: bool,
|
||||
pub has_dnf: bool,
|
||||
pub rust: RustToolchain,
|
||||
pub web: WebToolchain,
|
||||
pub platform: PlatformUtils,
|
||||
pub pacman_brew: PacmanBrew,
|
||||
pub apt_dnf: AptDnf,
|
||||
pub is_linux: bool,
|
||||
pub is_macos: bool,
|
||||
}
|
||||
@@ -159,18 +182,27 @@ pub fn which(binary: &str) -> Option<PathBuf> {
|
||||
/// this only ever runs on Unix-like targets.
|
||||
pub fn detect_env() -> EnvInfo {
|
||||
EnvInfo {
|
||||
has_rustup: which("rustup").is_some(),
|
||||
has_npm: which("npm").is_some(),
|
||||
has_go: which("go").is_some(),
|
||||
has_java: which("java").is_some(),
|
||||
has_cargo: which("cargo").is_some(),
|
||||
has_curl: which("curl").is_some(),
|
||||
has_wget: which("wget").is_some(),
|
||||
has_tar: which("tar").is_some(),
|
||||
has_pacman: which("pacman").is_some(),
|
||||
has_apt: which("apt").is_some() || which("apt-get").is_some(),
|
||||
has_brew: which("brew").is_some(),
|
||||
has_dnf: which("dnf").is_some(),
|
||||
rust: RustToolchain {
|
||||
has_rustup: which("rustup").is_some(),
|
||||
has_cargo: which("cargo").is_some(),
|
||||
},
|
||||
web: WebToolchain {
|
||||
has_npm: which("npm").is_some(),
|
||||
has_go: which("go").is_some(),
|
||||
has_java: which("java").is_some(),
|
||||
},
|
||||
platform: PlatformUtils {
|
||||
has_curl: which("curl").is_some(),
|
||||
has_tar: which("tar").is_some(),
|
||||
},
|
||||
pacman_brew: PacmanBrew {
|
||||
has_pacman: which("pacman").is_some(),
|
||||
has_brew: which("brew").is_some(),
|
||||
},
|
||||
apt_dnf: AptDnf {
|
||||
has_apt: which("apt").is_some() || which("apt-get").is_some(),
|
||||
has_dnf: which("dnf").is_some(),
|
||||
},
|
||||
is_linux: cfg!(target_os = "linux"),
|
||||
is_macos: cfg!(target_os = "macos"),
|
||||
}
|
||||
@@ -179,14 +211,13 @@ pub fn detect_env() -> EnvInfo {
|
||||
/// Return the static set of supported language servers.
|
||||
///
|
||||
/// The order is significant: it determines provisioning order and
|
||||
/// the order results appear in `provision_all()`. Tier 1 paths are
|
||||
/// the order results appear in `provision_all_with_progress()`. Tier 1 paths are
|
||||
/// the canonical/idiomatic install for each ecosystem; later tiers
|
||||
/// are fallbacks for hosts that lack the primary tooling.
|
||||
///
|
||||
/// 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 {
|
||||
@@ -199,13 +230,22 @@ pub fn supported_servers() -> Vec<LanguageServerDef> {
|
||||
label: "rustup component".to_string(),
|
||||
requires: vec!["rustup".to_string()],
|
||||
command: "rustup".to_string(),
|
||||
args: vec!["component".to_string(), "add".to_string(), "rust-analyzer".to_string()],
|
||||
args: vec![
|
||||
"component".to_string(),
|
||||
"add".to_string(),
|
||||
"rust-analyzer".to_string(),
|
||||
],
|
||||
},
|
||||
InstallTier {
|
||||
label: "pacman".to_string(),
|
||||
requires: vec!["pacman".to_string()],
|
||||
command: "pacman".to_string(),
|
||||
args: vec!["-S".to_string(), "--noconfirm".to_string(), "--needed".to_string(), "rust-analyzer".to_string()],
|
||||
args: vec![
|
||||
"-S".to_string(),
|
||||
"--noconfirm".to_string(),
|
||||
"--needed".to_string(),
|
||||
"rust-analyzer".to_string(),
|
||||
],
|
||||
},
|
||||
InstallTier {
|
||||
label: "brew".to_string(),
|
||||
@@ -217,7 +257,11 @@ pub fn supported_servers() -> Vec<LanguageServerDef> {
|
||||
label: "cargo install".to_string(),
|
||||
requires: vec!["cargo".to_string()],
|
||||
command: "cargo".to_string(),
|
||||
args: vec!["install".to_string(), "--locked".to_string(), "rust-analyzer".to_string()],
|
||||
args: vec![
|
||||
"install".to_string(),
|
||||
"--locked".to_string(),
|
||||
"rust-analyzer".to_string(),
|
||||
],
|
||||
},
|
||||
InstallTier {
|
||||
label: "download prebuilt".to_string(),
|
||||
@@ -268,19 +312,33 @@ pub fn supported_servers() -> Vec<LanguageServerDef> {
|
||||
name: "jdtls".to_string(),
|
||||
language: "java".to_string(),
|
||||
extensions: vec![".java".to_string()],
|
||||
binary_names: vec!["jdtls".to_string(), "eclipse-jdt-ls".to_string(), "jdtls-launcher".to_string()],
|
||||
binary_names: vec![
|
||||
"jdtls".to_string(),
|
||||
"eclipse-jdt-ls".to_string(),
|
||||
"jdtls-launcher".to_string(),
|
||||
],
|
||||
install_tiers: vec![
|
||||
InstallTier {
|
||||
label: "pacman".to_string(),
|
||||
requires: vec!["java".to_string(), "pacman".to_string()],
|
||||
command: "pacman".to_string(),
|
||||
args: vec!["-S".to_string(), "--noconfirm".to_string(), "--needed".to_string(), "eclipse-jdt-ls".to_string()],
|
||||
args: vec![
|
||||
"-S".to_string(),
|
||||
"--noconfirm".to_string(),
|
||||
"--needed".to_string(),
|
||||
"eclipse-jdt-ls".to_string(),
|
||||
],
|
||||
},
|
||||
InstallTier {
|
||||
label: "apt".to_string(),
|
||||
requires: vec!["java".to_string(), "apt".to_string()],
|
||||
command: "sudo".to_string(),
|
||||
args: vec!["apt".to_string(), "install".to_string(), "-y".to_string(), "eclipse-jdt-ls".to_string()],
|
||||
args: vec![
|
||||
"apt".to_string(),
|
||||
"install".to_string(),
|
||||
"-y".to_string(),
|
||||
"eclipse-jdt-ls".to_string(),
|
||||
],
|
||||
},
|
||||
InstallTier {
|
||||
label: "brew".to_string(),
|
||||
@@ -339,7 +397,9 @@ pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)>
|
||||
let timeout = Duration::from_mins(3);
|
||||
let start = Instant::now();
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait()? { break Ok(status) }
|
||||
if let Some(status) = child.try_wait()? {
|
||||
break Ok(status);
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
@@ -405,9 +465,12 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
|
||||
info!(url = url, dest = %path_str, "downloading");
|
||||
let args = [
|
||||
"-fsSL",
|
||||
"--connect-timeout", "15",
|
||||
"--max-time", &max_secs.to_string(),
|
||||
"-o", &path_str,
|
||||
"--connect-timeout",
|
||||
"15",
|
||||
"--max-time",
|
||||
&max_secs.to_string(),
|
||||
"-o",
|
||||
&path_str,
|
||||
url,
|
||||
];
|
||||
let (ok, out) = run_command("curl", &args).map_err(|e| format!("curl spawn: {e}"))?;
|
||||
@@ -419,7 +482,10 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
|
||||
|
||||
/// Download rust-analyzer from GitHub releases and install into
|
||||
/// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`.
|
||||
fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Result<PathBuf, String> {
|
||||
fn install_rust_analyzer_binary(
|
||||
env: &EnvInfo,
|
||||
progress: ProgressFn<'_>,
|
||||
) -> Result<PathBuf, String> {
|
||||
let base = lsp_install_dir("rust-analyzer")?;
|
||||
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
|
||||
|
||||
@@ -434,9 +500,13 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu
|
||||
let gz = base.join("rust-analyzer.gz");
|
||||
let target = base.join("rust-analyzer");
|
||||
|
||||
if let Some(cb) = progress { cb("Rust: downloading prebuilt binary..."); }
|
||||
if let Some(cb) = progress {
|
||||
cb("Rust: downloading prebuilt binary...");
|
||||
}
|
||||
download_url(url, &gz, 120)?;
|
||||
if let Some(cb) = progress { cb("Rust: decompressing..."); }
|
||||
if let Some(cb) = progress {
|
||||
cb("Rust: decompressing...");
|
||||
}
|
||||
let (ok, out) = run_command("gunzip", &["-f", &gz.to_string_lossy()])
|
||||
.map_err(|e| format!("gunzip spawn: {e}"))?;
|
||||
if !ok {
|
||||
@@ -452,7 +522,9 @@ fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Resu
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| format!("chmod: {e}"))?;
|
||||
}
|
||||
if let Some(cb) = progress { cb("Rust: installed ✓"); }
|
||||
if let Some(cb) = progress {
|
||||
cb("Rust: installed ✓");
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
@@ -464,14 +536,24 @@ fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
|
||||
|
||||
let url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz";
|
||||
let tarball = base.join("jdtls.tar.gz");
|
||||
if let Some(cb) = progress { cb("Java: downloading JDT-LS (~150MB)..."); }
|
||||
if let Some(cb) = progress {
|
||||
cb("Java: downloading JDT-LS (~150MB)...");
|
||||
}
|
||||
download_url(url, &tarball, 300)?;
|
||||
if let Some(cb) = progress { cb("Java: extracting..."); }
|
||||
if let Some(cb) = progress {
|
||||
cb("Java: extracting...");
|
||||
}
|
||||
|
||||
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}"))?;
|
||||
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}"))?;
|
||||
if !ok {
|
||||
return Err(format!("tar: {}", out.trim()));
|
||||
}
|
||||
@@ -510,12 +592,18 @@ exec java \
|
||||
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| format!("chmod launcher: {e}"))?;
|
||||
}
|
||||
if let Some(cb) = progress { cb("Java: JDT-LS installed ✓"); }
|
||||
if let Some(cb) = progress {
|
||||
cb("Java: JDT-LS installed ✓");
|
||||
}
|
||||
Ok(launcher)
|
||||
}
|
||||
|
||||
/// Dispatch a sentinel download tier to the correct helper.
|
||||
fn run_download_tier(name: &str, env: &EnvInfo, progress: ProgressFn<'_>) -> Result<PathBuf, String> {
|
||||
fn run_download_tier(
|
||||
name: &str,
|
||||
env: &EnvInfo,
|
||||
progress: ProgressFn<'_>,
|
||||
) -> Result<PathBuf, String> {
|
||||
match name {
|
||||
DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress),
|
||||
DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress),
|
||||
@@ -523,59 +611,17 @@ fn run_download_tier(name: &str, env: &EnvInfo, progress: ProgressFn<'_>) -> Res
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the "install by hand" message shown to the user when every
|
||||
/// automated tier fails.
|
||||
fn manual_instructions(def: &LanguageServerDef) -> String {
|
||||
match def.language.as_str() {
|
||||
"rust" => "Install rust-analyzer:\n \
|
||||
Arch: sudo pacman -S rust-analyzer\n \
|
||||
macOS: brew install rust-analyzer\n \
|
||||
Any: cargo install --locked rust-analyzer\n \
|
||||
Rustup: rustup component add rust-analyzer"
|
||||
.to_string(),
|
||||
"typescript" => "Install typescript-language-server:\n \
|
||||
npm install -g typescript typescript-language-server\n \
|
||||
Arch: sudo pacman -S typescript-language-server"
|
||||
.to_string(),
|
||||
"go" => "Install gopls:\n \
|
||||
go install golang.org/x/tools/gopls@latest\n \
|
||||
Arch: sudo pacman -S gopls"
|
||||
.to_string(),
|
||||
"java" => "Install Eclipse JDT-LS:\n \
|
||||
Arch: sudo pacman -S eclipse-jdt-ls\n \
|
||||
Debian: sudo apt install eclipse-jdt-ls\n \
|
||||
macOS: brew install jdtls\n \
|
||||
Other: see https://.eclipse.org/jdtls/#download"
|
||||
.to_string(),
|
||||
_ => format!("No automated install available for '{}'.", def.language),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to provision a single language server.
|
||||
///
|
||||
/// Flow: check whether any `binary_names` candidate is already on PATH
|
||||
/// → if yes, return `AlreadyAvailable` → otherwise walk
|
||||
/// `install_tiers` in order, skipping tiers whose `requires`
|
||||
/// binaries are missing → for each viable tier, run the install
|
||||
/// command (120s timeout) → if it succeeds AND the binary now
|
||||
/// appears on PATH (or the tier is jdtls-manual returning a
|
||||
/// launcher path), return Installed → if every tier fails, return
|
||||
/// Failed with the last error and manual install instructions.
|
||||
///
|
||||
/// Why we re-check `which` after the install: `rustup component add`
|
||||
/// can exit 0 even if the binary wasn't actually placed on PATH (rare,
|
||||
/// but happens with broken rustup installs). Re-checking gives us a
|
||||
/// real signal rather than trusting the exit code alone.
|
||||
#[allow(dead_code)]
|
||||
pub fn provision_single(def: &LanguageServerDef, env: &EnvInfo) -> ProvisionResult {
|
||||
provision_single_with_progress(def, env, None)
|
||||
}
|
||||
|
||||
fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progress: ProgressFn<'_>) -> ProvisionResult {
|
||||
fn provision_single_with_progress(
|
||||
def: &LanguageServerDef,
|
||||
env: &EnvInfo,
|
||||
progress: ProgressFn<'_>,
|
||||
) -> ProvisionResult {
|
||||
// 1. Check PATH.
|
||||
for bin in &def.binary_names {
|
||||
if let Some(path) = which(bin) {
|
||||
if let Some(cb) = progress { cb(&format!("{}: already installed (PATH)", def.language)); }
|
||||
if let Some(cb) = progress {
|
||||
cb(&format!("{}: already installed (PATH)", def.language));
|
||||
}
|
||||
return ProvisionResult::AlreadyAvailable {
|
||||
server_name: def.name.clone(),
|
||||
language: def.language.clone(),
|
||||
@@ -586,7 +632,9 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre
|
||||
|
||||
// 2. Check download-install directory (~/.local/share/zesdex/lsp/<name>/...).
|
||||
if let Some(path) = previous_download_install(def) {
|
||||
if let Some(cb) = progress { cb(&format!("{}: found previous install", def.language)); }
|
||||
if let Some(cb) = progress {
|
||||
cb(&format!("{}: found previous install", def.language));
|
||||
}
|
||||
return ProvisionResult::AlreadyAvailable {
|
||||
server_name: def.name.clone(),
|
||||
language: def.language.clone(),
|
||||
@@ -594,30 +642,42 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(cb) = progress { cb(&format!("{}: checking install options...", def.language)); }
|
||||
if let Some(cb) = progress {
|
||||
cb(&format!("{}: checking install options...", def.language));
|
||||
}
|
||||
|
||||
let mut last_reason = String::from("no install tiers succeeded");
|
||||
|
||||
for tier in &def.install_tiers {
|
||||
// Prerequisite gating
|
||||
let prereqs_met = tier.requires.iter().all(|req| match req.as_str() {
|
||||
"rustup" => env.has_rustup, "npm" => env.has_npm,
|
||||
"go" => env.has_go, "java" => env.has_java,
|
||||
"cargo" => env.has_cargo, "curl" => env.has_curl,
|
||||
"tar" => env.has_tar, "pacman" => env.has_pacman,
|
||||
"apt" => env.has_apt, "brew" => env.has_brew,
|
||||
"dnf" => env.has_dnf, _ => which(req).is_some(),
|
||||
"rustup" => env.rust.has_rustup,
|
||||
"npm" => env.web.has_npm,
|
||||
"go" => env.web.has_go,
|
||||
"java" => env.web.has_java,
|
||||
"cargo" => env.rust.has_cargo,
|
||||
"curl" => env.platform.has_curl,
|
||||
"tar" => env.platform.has_tar,
|
||||
"pacman" => env.pacman_brew.has_pacman,
|
||||
"apt" => env.apt_dnf.has_apt,
|
||||
"brew" => env.pacman_brew.has_brew,
|
||||
"dnf" => env.apt_dnf.has_dnf,
|
||||
_ => which(req).is_some(),
|
||||
});
|
||||
if !prereqs_met {
|
||||
let skip = format!("{}: {} — missing prerequisite", def.language, tier.label);
|
||||
if let Some(cb) = progress { cb(&skip); }
|
||||
if let Some(cb) = progress {
|
||||
cb(&skip);
|
||||
}
|
||||
last_reason = format!("tier '{}' skipped: missing prerequisite", tier.label);
|
||||
warn!(server = %def.name, tier = %tier.label, "skipped — missing prerequisites");
|
||||
continue;
|
||||
}
|
||||
|
||||
let trying = format!("{}: {}...", def.language, tier.label);
|
||||
if let Some(cb) = progress { cb(&trying); }
|
||||
if let Some(cb) = progress {
|
||||
cb(&trying);
|
||||
}
|
||||
|
||||
// Download sentinel → helper.
|
||||
if tier.command.starts_with("__download_") && tier.command.ends_with("__") {
|
||||
@@ -647,7 +707,9 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre
|
||||
.iter()
|
||||
.find_map(|b| which(b).map(|p| p.to_string_lossy().to_string()));
|
||||
if let Some(path) = located {
|
||||
if let Some(cb) = progress { cb(&format!("{}: installed ✓", def.language)); }
|
||||
if let Some(cb) = progress {
|
||||
cb(&format!("{}: installed ✓", def.language));
|
||||
}
|
||||
info!(server = %def.name, tier = %tier.label, binary = %path, "installed");
|
||||
return ProvisionResult::Installed {
|
||||
server_name: def.name.clone(),
|
||||
@@ -671,58 +733,36 @@ fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progre
|
||||
}
|
||||
}
|
||||
|
||||
let manual = manual_instructions(def);
|
||||
ProvisionResult::Failed {
|
||||
language: def.language.clone(), server_name: def.name.clone(),
|
||||
reason: last_reason, manual_instructions: manual,
|
||||
language: def.language.clone(),
|
||||
server_name: def.name.clone(),
|
||||
reason: last_reason,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
#[allow(dead_code)]
|
||||
pub fn provision_all() -> Vec<ProvisionResult> {
|
||||
let env = detect_env();
|
||||
info!(
|
||||
linux = env.is_linux,
|
||||
macos = env.is_macos,
|
||||
rustup = env.has_rustup,
|
||||
cargo = env.has_cargo,
|
||||
npm = env.has_npm,
|
||||
go = env.has_go,
|
||||
java = env.has_java,
|
||||
curl = env.has_curl,
|
||||
tar = env.has_tar,
|
||||
pacman = env.has_pacman,
|
||||
apt = env.has_apt,
|
||||
brew = env.has_brew,
|
||||
dnf = env.has_dnf,
|
||||
"starting LSP provisioning"
|
||||
);
|
||||
supported_servers()
|
||||
.iter()
|
||||
.map(|def| provision_single(def, &env))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Like `provision_all` but calls `progress` with a human-readable status
|
||||
/// Provision every supported server with progress callbacks with a human-readable status
|
||||
/// string at each stage of each server's install attempt.
|
||||
pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult> {
|
||||
let env = detect_env();
|
||||
if let Some(cb) = progress {
|
||||
let flags = [
|
||||
("rustup", env.has_rustup), ("cargo", env.has_cargo),
|
||||
("npm", env.has_npm), ("go", env.has_go), ("java", env.has_java),
|
||||
("curl", env.has_curl), ("tar", env.has_tar),
|
||||
("pacman", env.has_pacman), ("apt", env.has_apt), ("brew", env.has_brew),
|
||||
("rustup", env.rust.has_rustup),
|
||||
("cargo", env.rust.has_cargo),
|
||||
("npm", env.web.has_npm),
|
||||
("go", env.web.has_go),
|
||||
("java", env.web.has_java),
|
||||
("curl", env.platform.has_curl),
|
||||
("tar", env.platform.has_tar),
|
||||
("pacman", env.pacman_brew.has_pacman),
|
||||
("apt", env.apt_dnf.has_apt),
|
||||
("brew", env.pacman_brew.has_brew),
|
||||
];
|
||||
let avail: String = flags.iter()
|
||||
.filter(|(_, v)| *v).map(|(k, _)| *k)
|
||||
.collect::<Vec<_>>().join(", ");
|
||||
let avail: String = flags
|
||||
.iter()
|
||||
.filter(|(_, v)| *v)
|
||||
.map(|(k, _)| *k)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
cb(&format!("LSP: environment ready — {avail}"));
|
||||
}
|
||||
supported_servers()
|
||||
@@ -779,9 +819,13 @@ 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(std::string::String::as_str).collect();
|
||||
let ext_refs: Vec<&str> = def
|
||||
.extensions
|
||||
.iter()
|
||||
.map(std::string::String::as_str)
|
||||
.collect();
|
||||
|
||||
match guard.connect_with_extensions(&name, &binary, &[], &language, &ext_refs) {
|
||||
match guard.connect_with_extensions(&binary, &[], &language, &ext_refs) {
|
||||
Ok(()) => {
|
||||
info!(
|
||||
name = %name,
|
||||
|
||||
+129
-91
@@ -1,13 +1,11 @@
|
||||
//! MCP server connection management: spawning/talking to stdio child
|
||||
//! processes and HTTP endpoints, and adapting their advertised tools to
|
||||
//! the crate's `Tool` trait.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
|
||||
const MCP_CONNECT_TIMEOUT_MS: u64 = 20_000;
|
||||
const MCP_CALL_TIMEOUT_MS: u64 = 60_000;
|
||||
|
||||
@@ -35,13 +33,8 @@ fn mcp_static_str(s: &str) -> &'static str {
|
||||
/// newline-delimited JSON-RPC over stdio, or a remote HTTP endpoint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum McpTransport {
|
||||
Stdio {
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
},
|
||||
StreamableHttp {
|
||||
url: String,
|
||||
},
|
||||
Stdio { command: String, args: Vec<String> },
|
||||
StreamableHttp { url: String },
|
||||
}
|
||||
|
||||
/// A single tool advertised by an MCP server, as returned by `tools/list`.
|
||||
@@ -104,8 +97,8 @@ impl StdioChild {
|
||||
self.stdin.flush()?;
|
||||
|
||||
let mut response_line = String::new();
|
||||
let deadline = std::time::Instant::now()
|
||||
+ std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS);
|
||||
let deadline =
|
||||
std::time::Instant::now() + std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS);
|
||||
loop {
|
||||
if std::time::Instant::now() > deadline {
|
||||
anyhow::bail!("MCP call timed out after {MCP_CALL_TIMEOUT_MS}ms");
|
||||
@@ -136,7 +129,9 @@ impl StdioChild {
|
||||
line_truncated = true;
|
||||
// Consume rest of line to keep stream in sync
|
||||
loop {
|
||||
let buf = self.stdout.fill_buf()
|
||||
let buf = self
|
||||
.stdout
|
||||
.fill_buf()
|
||||
.map_err(|e| anyhow::anyhow!("MCP stdio read error: {e}"))?;
|
||||
if buf.is_empty() {
|
||||
anyhow::bail!("MCP stdio child closed mid-line");
|
||||
@@ -152,9 +147,7 @@ impl StdioChild {
|
||||
response_line.push(byte as char);
|
||||
}
|
||||
if line_truncated {
|
||||
anyhow::bail!(
|
||||
"MCP response line exceeded {MAX_LINE_LENGTH} byte limit",
|
||||
);
|
||||
anyhow::bail!("MCP response line exceeded {MAX_LINE_LENGTH} byte limit");
|
||||
}
|
||||
let trimmed = response_line.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -172,12 +165,16 @@ impl StdioChild {
|
||||
}));
|
||||
}
|
||||
}
|
||||
} // close fn call
|
||||
} // close impl StdioChild
|
||||
} // close fn call
|
||||
} // close impl StdioChild
|
||||
|
||||
pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow::Result<StdioChild> {
|
||||
pub(crate) fn spawn_stdio_child(
|
||||
command: &str,
|
||||
extra_args: &[String],
|
||||
) -> anyhow::Result<StdioChild> {
|
||||
let parts: Vec<&str> = command.split_whitespace().collect();
|
||||
let (prog, prog_args) = parts.split_first()
|
||||
let (prog, prog_args) = parts
|
||||
.split_first()
|
||||
.ok_or_else(|| anyhow::anyhow!("MCP stdio command is empty"))?;
|
||||
|
||||
let mut cmd = std::process::Command::new(prog);
|
||||
@@ -189,12 +186,17 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
|
||||
// rather than discarded silently, making connectivity issues debugable.
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn MCP stdio server '{command}': {e}"))?;
|
||||
|
||||
let stdin = child.stdin.take()
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to get stdin for MCP server"))?;
|
||||
let stdout = child.stdout.take()
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to get stdout for MCP server"))?;
|
||||
|
||||
let mut mcp = StdioChild {
|
||||
@@ -203,17 +205,20 @@ pub(crate) fn spawn_stdio_child(command: &str, extra_args: &[String]) -> anyhow:
|
||||
next_id: 0,
|
||||
};
|
||||
|
||||
let deadline = std::time::Instant::now()
|
||||
+ std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS);
|
||||
let deadline =
|
||||
std::time::Instant::now() + std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS);
|
||||
|
||||
let init_result = mcp.call("initialize", &json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "zesdex",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}));
|
||||
let init_result = mcp.call(
|
||||
"initialize",
|
||||
&json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "zesdex",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if std::time::Instant::now() > deadline {
|
||||
anyhow::bail!("MCP initialize timed out");
|
||||
@@ -236,21 +241,29 @@ fn call_via_stdio(
|
||||
// Reuse the persistent child handle if available; otherwise spawn a new one.
|
||||
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!({
|
||||
"name": tool_name,
|
||||
"arguments": tool_args
|
||||
}))?;
|
||||
let result = fresh.call(
|
||||
"tools/call",
|
||||
&json!({
|
||||
"name": tool_name,
|
||||
"arguments": tool_args
|
||||
}),
|
||||
)?;
|
||||
return Ok(extract_text_content(&result));
|
||||
};
|
||||
|
||||
let result = child.call("tools/call", &json!({
|
||||
"name": tool_name,
|
||||
"arguments": tool_args
|
||||
}))?;
|
||||
let result = child.call(
|
||||
"tools/call",
|
||||
&json!({
|
||||
"name": tool_name,
|
||||
"arguments": tool_args
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(extract_text_content(&result))
|
||||
}
|
||||
@@ -289,7 +302,8 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
|
||||
}
|
||||
});
|
||||
|
||||
let resp = client.post(url)
|
||||
let resp = client
|
||||
.post(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
@@ -304,7 +318,8 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
|
||||
anyhow::bail!("MCP HTTP server returned {status}: {text}");
|
||||
}
|
||||
|
||||
let response: Value = resp.json()
|
||||
let response: Value = resp
|
||||
.json()
|
||||
.map_err(|e| anyhow::anyhow!("invalid JSON from MCP HTTP server: {e}"))?;
|
||||
|
||||
if let Some(err) = response.get("error") {
|
||||
@@ -321,13 +336,18 @@ fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Resul
|
||||
fn extract_text_content(result: &Value) -> String {
|
||||
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(std::string::ToString::to_string)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}).collect();
|
||||
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(std::string::ToString::to_string)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !text.is_empty() {
|
||||
return text.join("\n");
|
||||
}
|
||||
@@ -372,12 +392,17 @@ impl crate::tool::Tool for McpToolAdapter {
|
||||
|
||||
fn run(&self, _ctx: &crate::tool::ToolCtx, args: &Value) -> anyhow::Result<String> {
|
||||
match &self.transport {
|
||||
McpTransport::Stdio { command, args: extra_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)
|
||||
}
|
||||
McpTransport::Stdio {
|
||||
command,
|
||||
args: extra_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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,27 +425,35 @@ impl McpManager {
|
||||
///
|
||||
/// Return: boxed `Tool` trait objects ready to merge into the harness's tool list.
|
||||
pub fn as_tools(&self) -> Vec<Box<dyn crate::tool::Tool>> {
|
||||
self.servers.iter().flat_map(|server| {
|
||||
let handle = server.child_handle.clone();
|
||||
server.tools.iter().map(move |info| {
|
||||
let adapter: Box<dyn crate::tool::Tool> = Box::new(McpToolAdapter {
|
||||
tool_name: info.name.clone(),
|
||||
server_name: server.name.clone(),
|
||||
transport: server.transport.clone(),
|
||||
description: info.description.clone(),
|
||||
parameters: info.input_schema.clone(),
|
||||
child_handle: handle.clone(),
|
||||
});
|
||||
adapter
|
||||
self.servers
|
||||
.iter()
|
||||
.flat_map(|server| {
|
||||
let handle = server.child_handle.clone();
|
||||
server.tools.iter().map(move |info| {
|
||||
let adapter: Box<dyn crate::tool::Tool> = Box::new(McpToolAdapter {
|
||||
tool_name: info.name.clone(),
|
||||
server_name: server.name.clone(),
|
||||
transport: server.transport.clone(),
|
||||
description: info.description.clone(),
|
||||
parameters: info.input_schema.clone(),
|
||||
child_handle: handle.clone(),
|
||||
});
|
||||
adapter
|
||||
})
|
||||
})
|
||||
}).collect()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Connects to an MCP server via stdio by spawning the child process, running
|
||||
/// the `initialize` handshake, calling `tools/list`, and registering the server
|
||||
/// with its advertised tools in `self.servers`. The child process stays alive
|
||||
/// for subsequent `tools/call` invocations via the stored `McpServer.tools`.
|
||||
pub fn connect_stdio(&mut self, name: &str, command: &str, extra_args: &[String]) -> anyhow::Result<()> {
|
||||
pub fn connect_stdio(
|
||||
&mut self,
|
||||
name: &str,
|
||||
command: &str,
|
||||
extra_args: &[String],
|
||||
) -> anyhow::Result<()> {
|
||||
let transport = McpTransport::Stdio {
|
||||
command: command.to_string(),
|
||||
args: extra_args.to_vec(),
|
||||
@@ -430,19 +463,32 @@ impl McpManager {
|
||||
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| {
|
||||
Some(McpToolInfo {
|
||||
name: t.get("name")?.as_str()?.to_string(),
|
||||
description: t.get("description").and_then(|v| v.as_str()).unwrap_or_else(|| {
|
||||
tracing::warn!("[mcp] tool {} missing description", t.get("name").and_then(|n| n.as_str()).unwrap_or("?"));
|
||||
""
|
||||
}).to_string(),
|
||||
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| {
|
||||
tracing::warn!("[mcp] tool {} missing inputSchema", t.get("name").and_then(|n| n.as_str()).unwrap_or("?"));
|
||||
serde_json::Value::Null
|
||||
}),
|
||||
tool_list
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
Some(McpToolInfo {
|
||||
name: t.get("name")?.as_str()?.to_string(),
|
||||
description: t
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!(
|
||||
"[mcp] tool {} missing description",
|
||||
t.get("name").and_then(|n| n.as_str()).unwrap_or("?")
|
||||
);
|
||||
""
|
||||
})
|
||||
.to_string(),
|
||||
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| {
|
||||
tracing::warn!(
|
||||
"[mcp] tool {} missing inputSchema",
|
||||
t.get("name").and_then(|n| n.as_str()).unwrap_or("?")
|
||||
);
|
||||
serde_json::Value::Null
|
||||
}),
|
||||
})
|
||||
})
|
||||
}).collect()
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -458,12 +504,4 @@ impl McpManager {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a server by name. Returns `true` if a server was found and removed.
|
||||
#[allow(dead_code)]
|
||||
pub fn disconnect(&mut self, name: &str) -> bool {
|
||||
let len = self.servers.len();
|
||||
self.servers.retain(|s| s.name != name);
|
||||
self.servers.len() < len
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//! Model Context Protocol (MCP) client: connects to external MCP servers
|
||||
//! (stdio or HTTP) and exposes their tools through the crate's `Tool` trait.
|
||||
|
||||
pub mod manager;
|
||||
|
||||
+5
-5
@@ -1,13 +1,13 @@
|
||||
//! Top-level application module: harness, modes, runtime loop, state,
|
||||
//! workflows, subagents, review, background bash, MCP integration, and
|
||||
//! native LSP client.
|
||||
pub mod bgbash;
|
||||
pub mod harness;
|
||||
pub mod lsp;
|
||||
pub mod mcp;
|
||||
pub mod mode;
|
||||
pub mod review;
|
||||
pub mod runtime;
|
||||
pub mod state;
|
||||
pub mod workflow;
|
||||
pub mod subagent;
|
||||
pub mod review;
|
||||
pub mod bgbash;
|
||||
pub mod mcp;
|
||||
pub mod lsp;
|
||||
pub mod workflow;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Bash mode: handles submitting a shell command from the bash input panel.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
/// Launch a background bash job for the submitted command.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Editor mode: a minimal in-TUI line editor for viewing/modifying a file,
|
||||
//! with bounded undo history.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
@@ -66,7 +65,9 @@ impl EditorState {
|
||||
self.cursor_line += 1;
|
||||
}
|
||||
self.cursor_col = self.cursor_col.min(
|
||||
self.content.get(self.cursor_line).map_or(0, std::string::String::len),
|
||||
self.content
|
||||
.get(self.cursor_line)
|
||||
.map_or(0, std::string::String::len),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,10 +115,9 @@ impl EditorState {
|
||||
/// the char directly → mark state dirty.
|
||||
pub fn handle_editor_input(state: &mut AppStateRest, text: &str) {
|
||||
let editor = &mut state.misc.editor;
|
||||
if editor.is_none() {
|
||||
let Some(ed) = editor.as_mut() else {
|
||||
return;
|
||||
}
|
||||
let ed = editor.as_mut().unwrap();
|
||||
};
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'\n' | '\r' => {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Effort mode: cycles the agent's reasoning effort level, which scales the
|
||||
//! LLM's temperature and `max_tokens` for subsequent turns.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Help mode: static help text and the action that opens/closes the help overlay.
|
||||
|
||||
use crate::app::runtime::actions::Action;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Key input mode: raw text capture overlay used for one-off key/text prompts.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
/// Replace the input buffer with the given text and mark state dirty.
|
||||
|
||||
@@ -33,14 +33,16 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
|
||||
let scope_str = match p.lesson.scope {
|
||||
crate::app::review::LessonScope::Project => "project",
|
||||
crate::app::review::LessonScope::Global => "global",
|
||||
}.to_string();
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let conf_str = match p.lesson.confidence {
|
||||
crate::app::review::Confidence::Human => "human",
|
||||
crate::app::review::Confidence::Verified => "verified",
|
||||
crate::app::review::Confidence::Unverified => "unverified",
|
||||
crate::app::review::Confidence::Auto => "auto",
|
||||
}.to_string();
|
||||
}
|
||||
.to_string();
|
||||
|
||||
items.push(LearningItem::Pending {
|
||||
name: p.lesson.name,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Loading mode: transient overlay shown while waiting on an async operation.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
pub const LOADING_MESSAGES: &[&str] = &[
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! MCP mode: overlay for connecting to a configured MCP server.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
/// Placeholder entry point for connecting to an MCP server by name.
|
||||
|
||||
+1
-2
@@ -1,14 +1,13 @@
|
||||
//! TUI mode definitions and per-mode input/action handlers, one submodule
|
||||
//! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.).
|
||||
|
||||
pub mod bash;
|
||||
pub mod editor;
|
||||
pub mod effort;
|
||||
pub mod key_input;
|
||||
pub mod mcp;
|
||||
|
||||
pub mod learning;
|
||||
pub mod quit_confirm;
|
||||
pub mod rewind;
|
||||
pub mod settings;
|
||||
pub mod todo;
|
||||
pub mod learning;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Quit-confirm mode: the "are you sure?" overlay shown before exiting.
|
||||
|
||||
use crate::app::runtime::actions::Action;
|
||||
|
||||
/// Translate the user's yes/no answer on the quit-confirm overlay into an action.
|
||||
|
||||
+18
-7
@@ -1,13 +1,19 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Rewind mode: restores a file to a pre-edit snapshot stored in the
|
||||
//! 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 Ok(conn) = open_session_db(&state.session_dir) else { return 0 };
|
||||
let Ok(conn) = open_session_db(&state.session_dir) else {
|
||||
return 0;
|
||||
};
|
||||
crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id)
|
||||
.ok()
|
||||
.map_or(0, |keys| keys.len())
|
||||
@@ -51,7 +57,8 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
}
|
||||
|
||||
let blob_key = &keys[index];
|
||||
let bytes = match crate::model::msglog::blobs::retrieve_blob(&conn, &state.session_id, blob_key) {
|
||||
let bytes = match crate::model::msglog::blobs::retrieve_blob(&conn, &state.session_id, blob_key)
|
||||
{
|
||||
Ok(Some(b)) => b,
|
||||
Ok(None) => {
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
@@ -74,8 +81,8 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
// Look up the path from the edit log — the blob key is the tool_call_id.
|
||||
// The edit log doesn't store the tool_call_id directly, so fall back to the
|
||||
// path from the most recent write/edit entry.
|
||||
let restore_path = find_edit_path(state, blob_key)
|
||||
.unwrap_or_else(|| state.session_dir.join("snapshot.dat"));
|
||||
let restore_path =
|
||||
find_edit_path(state, blob_key).unwrap_or_else(|| state.session_dir.join("snapshot.dat"));
|
||||
|
||||
match std::fs::write(&restore_path, &bytes) {
|
||||
Ok(()) => {
|
||||
@@ -119,6 +126,10 @@ fn open_session_db(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Co
|
||||
|
||||
fn find_edit_path(state: &AppStateRest, _blob_key: &str) -> Option<std::path::PathBuf> {
|
||||
let el = crate::model::editlog::EditLog::new(&state.session_dir);
|
||||
let entry = el.entries.iter().rev().find(|e| e.tool == "write" || e.tool == "edit")?;
|
||||
let entry = el
|
||||
.entries
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|e| e.tool == "write" || e.tool == "edit")?;
|
||||
Some(std::path::PathBuf::from(&entry.path))
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
//! Flow: exposes small mutation functions (currently just cycling the
|
||||
//! internet access mode) invoked by keybindings while the settings overlay
|
||||
//! is active.
|
||||
|
||||
use crate::model::settings::{Settings, InternetMode};
|
||||
use crate::model::settings::{InternetMode, Settings};
|
||||
|
||||
/// Advance the internet access mode to the next value in the cycle.
|
||||
///
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//!
|
||||
//! Flow: exposes the toggle handler invoked by a keybinding to show/hide
|
||||
//! the todo overlay.
|
||||
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
||||
return false;
|
||||
}
|
||||
let Some(runtime) = &state.session_runtime else { return false };
|
||||
if !state.settings.review_enabled {
|
||||
if !state.settings.flags.review_enabled {
|
||||
return false;
|
||||
}
|
||||
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
|
||||
|
||||
+68
-133
@@ -545,44 +545,21 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
state.push_toast(Toast::new(ToastKind::Warning, "Aborting generation...".to_string()));
|
||||
}
|
||||
Action::Compact => {
|
||||
let Some(messages) = state.session_runtime.as_ref().map(|rt| rt.messages.clone()) else {
|
||||
return;
|
||||
};
|
||||
if messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (api_key, model, base_url) = match resolve_llm_client_config(state) {
|
||||
Ok(v) => v,
|
||||
Err(msg) => {
|
||||
state.push_toast(Toast::new(ToastKind::Error, msg));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let max_wire_tokens = crate::app::runtime::context::window::resolve(&state.app_config, &state.settings);
|
||||
let turn_events = state.turn_events.clone();
|
||||
|
||||
state.push_toast(Toast::new(ToastKind::Info, "Compacting conversation history...".to_string()));
|
||||
|
||||
// Manual /compact previously ran synchronously and always
|
||||
// passed `client: None` to shape_messages, so it never got
|
||||
// LLM summarization — only automatic mid-turn compaction did.
|
||||
// Running this on a background thread (same pattern as
|
||||
// spawn_turn) fixes that asymmetry: both paths now summarize
|
||||
// dropped history with the LLM instead of one silently
|
||||
// falling back to a bare placeholder.
|
||||
std::thread::spawn(move || {
|
||||
let client = crate::service::provider::LlmClient::new(api_key, model, base_url);
|
||||
let (deduped, _) = crate::app::runtime::context::dedup::collapse(&messages);
|
||||
let token_count: usize = deduped.iter()
|
||||
.map(crate::app::runtime::context::tokens::count_message_tokens)
|
||||
let max_wire_tokens = state.app_config.model_roles.values()
|
||||
.find(|role| role.provider == state.settings.provider && role.model == state.settings.model)
|
||||
.and_then(|role| role.context_window)
|
||||
.unwrap_or(state.app_config.default_context_window) as usize;
|
||||
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
let total_chars: usize = rt.messages.iter()
|
||||
.filter_map(|m| m.content.as_deref())
|
||||
.map(str::len)
|
||||
.sum();
|
||||
let compacted = crate::app::runtime::context::shaping::shape_messages(
|
||||
&deduped, token_count, max_wire_tokens, true, Some(&client),
|
||||
);
|
||||
if let Ok(mut q) = turn_events.lock() {
|
||||
q.push_back(TurnEvent::Compacted(compacted));
|
||||
}
|
||||
});
|
||||
let token_estimate = total_chars / 3;
|
||||
rt.messages = crate::app::runtime::shortsend::shape_messages(&rt.messages, token_estimate, max_wire_tokens, true, None);
|
||||
state.push_toast(Toast::new(ToastKind::Success, "Conversation history compacted.".to_string()));
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
Action::LessonAccept { name } => {
|
||||
if let Some(ref rt) = state.session_runtime {
|
||||
@@ -623,45 +600,6 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the API key, model name, and base URL for the currently
|
||||
/// configured provider.
|
||||
///
|
||||
/// Flow: look up the provider's `ProviderConfig` for its `api_base` ->
|
||||
/// resolve the API key from `Settings.api_keys`, falling back to the
|
||||
/// provider's `api_key_env` environment variable, then its
|
||||
/// `default_api_key`, then the crate-wide empty-string default.
|
||||
///
|
||||
/// Why: this exact resolution was duplicated between `spawn_turn` and
|
||||
/// needed again for `Action::Compact`'s background-thread LLM call —
|
||||
/// factored out so both stay in sync.
|
||||
///
|
||||
/// Return: `Ok((api_key, model, base_url))`, or `Err(message)` — a
|
||||
/// user-facing string — if the configured provider has no entry in
|
||||
/// `AppConfig.providers` at all.
|
||||
fn resolve_llm_client_config(state: &AppStateRest) -> Result<(String, String, Option<String>), String> {
|
||||
let base_url = state.app_config.providers.get(&state.settings.provider).map(|p| p.api_base.clone());
|
||||
let Some(base_url) = base_url else {
|
||||
return Err(format!(
|
||||
"Provider '{}' is not configured — no matching entry found. \
|
||||
Pick a different provider in Settings, or configure it.",
|
||||
state.settings.provider
|
||||
));
|
||||
};
|
||||
let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default();
|
||||
if api_key.is_empty() {
|
||||
if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) {
|
||||
api_key = provider_cfg.api_key_env.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
.or_else(|| provider_cfg.default_api_key.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
if api_key.is_empty() {
|
||||
api_key = crate::service::provider::DEFAULT_API_KEY.to_string();
|
||||
}
|
||||
Ok((api_key, state.settings.model.clone(), Some(base_url)))
|
||||
}
|
||||
|
||||
/// Spawn a background thread that runs one full LLM turn.
|
||||
///
|
||||
/// Flow: check that no turn is currently in-flight → bail if so →
|
||||
@@ -692,17 +630,42 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
if messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (api_key, model, base_url) = match resolve_llm_client_config(state) {
|
||||
Ok(v) => v,
|
||||
Err(msg) => {
|
||||
if let Ok(mut q) = state.turn_events.lock() {
|
||||
q.push_back(TurnEvent::Error(msg));
|
||||
}
|
||||
return;
|
||||
let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default();
|
||||
let model = state.settings.model.clone();
|
||||
let base_url = state.app_config.providers.get(&state.settings.provider)
|
||||
.map(|p| p.api_base.clone());
|
||||
let context_window = state.app_config.model_roles.values()
|
||||
.find(|role| role.provider == state.settings.provider && role.model == state.settings.model)
|
||||
.and_then(|role| role.context_window)
|
||||
.unwrap_or(state.app_config.default_context_window) as usize;
|
||||
// The selected provider has no entry in app_config at all (e.g. the
|
||||
// Claude-settings auto-detection that registers "claude" found nothing
|
||||
// this run). Without this check, LlmClient::new silently falls back to
|
||||
// the zen default base URL while keeping this provider's model name —
|
||||
// a mismatched request that reaches a real server and comes back as a
|
||||
// confusing "Missing API key" 401 from an unrelated provider, instead
|
||||
// of the actual problem: the configured provider doesn't exist.
|
||||
if base_url.is_none() {
|
||||
if let Ok(mut q) = state.turn_events.lock() {
|
||||
q.push_back(TurnEvent::Error(format!(
|
||||
"Provider '{}' is not configured — no matching entry found. \
|
||||
Pick a different provider in Settings, or configure it.",
|
||||
state.settings.provider
|
||||
)));
|
||||
}
|
||||
};
|
||||
let context_window = crate::app::runtime::context::window::resolve(&state.app_config, &state.settings);
|
||||
let concise_output = state.settings.concise_output;
|
||||
return;
|
||||
}
|
||||
if api_key.is_empty() {
|
||||
if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) {
|
||||
api_key = provider_cfg.api_key_env.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok())
|
||||
.or_else(|| provider_cfg.default_api_key.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
if api_key.is_empty() {
|
||||
api_key = crate::service::provider::DEFAULT_API_KEY.to_string();
|
||||
}
|
||||
let (temperature, max_tokens) = crate::app::mode::effort::generation_params(
|
||||
state.misc.effort_level,
|
||||
state.settings.max_tokens,
|
||||
@@ -747,7 +710,6 @@ fn spawn_turn(state: &AppStateRest) {
|
||||
max_tokens,
|
||||
abort_flag,
|
||||
hive_mind_converged,
|
||||
concise_output,
|
||||
};
|
||||
let result = run_agent_turn(&tc, &messages, &events_q);
|
||||
if let Err(e) = result {
|
||||
@@ -780,10 +742,6 @@ struct TurnCtx {
|
||||
/// of this turn — whether a hive-mind convergence already completed
|
||||
/// earlier in this session.
|
||||
hive_mind_converged: bool,
|
||||
/// Snapshot of `Settings.concise_output` taken at the start of this
|
||||
/// turn, so the system-prompt assembly above can read it without
|
||||
/// `TurnCtx` needing a `Settings` reference.
|
||||
concise_output: bool,
|
||||
}
|
||||
|
||||
/// Build an ASCII tree of the workspace directory structure for the
|
||||
@@ -933,9 +891,8 @@ const HIVE_MIND_KICKOFF_NOTE: &str = "The Hive is stirring — Core Intelligence
|
||||
/// handle tool calls, and loop until the LLM produces a non-tool response
|
||||
/// or runs out of unfinished todo items.
|
||||
///
|
||||
/// Flow: build system prompt with workspace tree → deduplicate messages
|
||||
/// via `context::dedup::collapse` → optionally shape (compact) messages via
|
||||
/// `context::shaping::{should_shape, shape_messages}` → call `chat_with_tools_streaming`
|
||||
/// Flow: build system prompt with workspace tree → optionally shape
|
||||
/// (compact) messages via `shortsend` → call `chat_with_tools_streaming`
|
||||
/// with a callback that pushes `StreamStart`, `StreamToken`, `Reasoning`,
|
||||
/// and `Usage` events → on streaming success, handle tool calls (gated
|
||||
/// through `Harness::gate_tool_call`) or unwrap the final assistant
|
||||
@@ -970,23 +927,12 @@ fn run_agent_turn(
|
||||
// workspace tree and reads all memory files each time).
|
||||
let tree_info = generate_workspace_tree(&tc.workspace_roots);
|
||||
let memory_section = build_memory_section(&tc.ctx.memory_dir);
|
||||
let concise_section = if tc.concise_output {
|
||||
"\n\nWrite tersely: drop articles (a/an/the), filler words (just/really/basically/\
|
||||
actually/simply), pleasantries (sure/certainly/of course/happy to), and hedging. \
|
||||
Fragments are fine. Code, commands, file paths, and error text must stay byte-exact \
|
||||
— never abbreviate or paraphrase those. Exception: for destructive-operation \
|
||||
confirmations and security-relevant warnings, always give full detail regardless of \
|
||||
this instruction — clarity matters more than brevity when something risky is at stake."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let system_text = format!(
|
||||
"{}\n\n{}\n\n{}{}{}",
|
||||
"{}\n\n{}\n\n{}{}",
|
||||
crate::resources::SYSTEM_PROMPT,
|
||||
crate::resources::SYSTEM_TOOLS,
|
||||
tree_info,
|
||||
memory_section,
|
||||
concise_section,
|
||||
);
|
||||
if !msgs.iter().any(|m| matches!(m.role, crate::dto::chat::message::Role::System)) {
|
||||
let sys = ChatMessage::system(system_text);
|
||||
@@ -1198,43 +1144,33 @@ fn run_agent_turn(
|
||||
let mut todo_retry_count = 0usize;
|
||||
|
||||
loop {
|
||||
// Dedup runs every iteration, unconditionally — repeated
|
||||
// read-only tool calls (same tool + same arguments) are
|
||||
// collapsed to their latest result before anything else, so
|
||||
// context stays minimal from turn 1 instead of only shrinking
|
||||
// once shaping's budget threshold trips.
|
||||
let (deduped, dedup_changed) = crate::app::runtime::context::dedup::collapse(&msgs);
|
||||
let token_count: usize = deduped.iter()
|
||||
.map(crate::app::runtime::context::tokens::count_message_tokens)
|
||||
let total_chars: usize = msgs.iter()
|
||||
.filter_map(|m| m.content.as_deref())
|
||||
.map(str::len)
|
||||
.sum();
|
||||
let token_estimate = total_chars / 4;
|
||||
let max_wire_tokens = tc.context_window;
|
||||
|
||||
// Skip shaping if abort was requested — the non-streaming LLM
|
||||
// call for summarization would block without checking abort_flag.
|
||||
// Skip message compaction if abort was requested — the non-streaming
|
||||
// LLM call for summarization would block without checking abort_flag.
|
||||
let wire_msgs = if !tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst)
|
||||
&& crate::app::runtime::context::shaping::should_shape(token_count, max_wire_tokens, prev_shaped)
|
||||
&& crate::app::runtime::shortsend::should_shape(token_estimate, max_wire_tokens, prev_shaped)
|
||||
{
|
||||
prev_shaped = true;
|
||||
let compacted = crate::app::runtime::context::shaping::shape_messages(&deduped, token_count, max_wire_tokens, false, Some(&tc.client));
|
||||
|
||||
// Dispatch to the main thread so the local session history is
|
||||
// permanently updated and doesn't re-trigger shaping immediately
|
||||
// on the next turn.
|
||||
let compacted = crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate, max_wire_tokens, false, Some(&tc.client));
|
||||
|
||||
// Dispatch the compacted messages to the main thread so the local session history
|
||||
// is permanently compacted and doesn't trigger shaping again immediately on next turn.
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::Compacted(compacted.clone()));
|
||||
}
|
||||
|
||||
|
||||
// Also update our local `msgs` variable so the rest of the loop operates on the compacted version
|
||||
msgs.clone_from(&compacted);
|
||||
compacted
|
||||
} else {
|
||||
prev_shaped = false;
|
||||
if dedup_changed {
|
||||
if let Ok(mut q) = events_q.lock() {
|
||||
q.push_back(TurnEvent::Compacted(deduped.clone()));
|
||||
}
|
||||
msgs.clone_from(&deduped);
|
||||
}
|
||||
deduped
|
||||
msgs.clone()
|
||||
};
|
||||
|
||||
let mut stream_started = false;
|
||||
@@ -1481,8 +1417,7 @@ fn run_agent_turn(
|
||||
}
|
||||
}
|
||||
|
||||
let squashed_output = crate::app::runtime::context::squash::apply(&tool_name, &output);
|
||||
let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), squashed_output);
|
||||
let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output);
|
||||
archive_message(tc.db.as_ref(), &tc.session_id, &tool_msg);
|
||||
msgs.push(tool_msg);
|
||||
}
|
||||
@@ -1677,7 +1612,7 @@ fn execute_one_tool(
|
||||
/// `should_trigger_review` on `Tick`), only informs the user that
|
||||
/// a review has material to examine.
|
||||
fn maybe_trigger_review(state: &mut AppStateRest) {
|
||||
if !state.settings.review_enabled {
|
||||
if !state.settings.flags.review_enabled {
|
||||
return;
|
||||
}
|
||||
let edit_count = state
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Maps parsed `/` slash commands into one or more `Action` variants
|
||||
//! that `apply_action` can process.
|
||||
use crate::controller::command::Command;
|
||||
use crate::app::runtime::actions::Action;
|
||||
use crate::app::state::types::Overlay;
|
||||
use crate::controller::command::Command;
|
||||
|
||||
/// Convert a parsed `Command` into the corresponding sequence of `Action`s.
|
||||
///
|
||||
|
||||
@@ -15,11 +15,10 @@
|
||||
//! `git_operator`, ...) are never touched, even with identical
|
||||
//! arguments, because call order and repetition can be semantically
|
||||
//! meaningful (e.g. retrying a flaky `bash` command until it passes).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use sha2::Digest;
|
||||
use crate::app::subagent::division::tool_scope::READ_TOOLS;
|
||||
use crate::dto::chat::message::{ChatMessage, Role};
|
||||
use sha2::Digest;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const DUPLICATE_PLACEHOLDER: &str =
|
||||
"[duplicate result — superseded by a later identical call, see below]";
|
||||
@@ -50,7 +49,9 @@ pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = &m.tool_call_id else { continue };
|
||||
let Some((name, args)) = call_info.get(id) else { continue };
|
||||
let Some((name, args)) = call_info.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if !READ_TOOLS.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
@@ -58,22 +59,30 @@ pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, bool) {
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
let result = messages.iter().enumerate().map(|(idx, m)| {
|
||||
if m.role != Role::Tool {
|
||||
return m.clone();
|
||||
}
|
||||
let Some(id) = &m.tool_call_id else { return m.clone() };
|
||||
let Some((name, args)) = call_info.get(id) else { return m.clone() };
|
||||
if !READ_TOOLS.contains(&name.as_str()) {
|
||||
return m.clone();
|
||||
}
|
||||
let key = dedup_key(name, args);
|
||||
if last_index_for_key.get(&key) == Some(&idx) {
|
||||
return m.clone();
|
||||
}
|
||||
changed = true;
|
||||
ChatMessage::tool_result(id.clone(), DUPLICATE_PLACEHOLDER.to_string())
|
||||
}).collect();
|
||||
let result = messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, m)| {
|
||||
if m.role != Role::Tool {
|
||||
return m.clone();
|
||||
}
|
||||
let Some(id) = &m.tool_call_id else {
|
||||
return m.clone();
|
||||
};
|
||||
let Some((name, args)) = call_info.get(id) else {
|
||||
return m.clone();
|
||||
};
|
||||
if !READ_TOOLS.contains(&name.as_str()) {
|
||||
return m.clone();
|
||||
}
|
||||
let key = dedup_key(name, args);
|
||||
if last_index_for_key.get(&key) == Some(&idx) {
|
||||
return m.clone();
|
||||
}
|
||||
changed = true;
|
||||
ChatMessage::tool_result(id.clone(), DUPLICATE_PLACEHOLDER.to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
(result, changed)
|
||||
}
|
||||
@@ -102,7 +111,10 @@ mod tests {
|
||||
m.tool_calls = Some(vec![ToolCall {
|
||||
id: id.to_string(),
|
||||
type_: "function".to_string(),
|
||||
function: ToolFunction { name: name.to_string(), arguments: args },
|
||||
function: ToolFunction {
|
||||
name: name.to_string(),
|
||||
arguments: args,
|
||||
},
|
||||
}]);
|
||||
m
|
||||
}
|
||||
@@ -172,9 +184,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_result_with_no_matching_call_is_left_untouched() {
|
||||
let messages = vec![
|
||||
ChatMessage::tool_result("orphan-id".to_string(), "some result".to_string()),
|
||||
];
|
||||
let messages = vec![ChatMessage::tool_result(
|
||||
"orphan-id".to_string(),
|
||||
"some result".to_string(),
|
||||
)];
|
||||
|
||||
let (result, changed) = collapse(&messages);
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
//! layer would only serve one of the two callers generically — the
|
||||
//! auto-loop already needs per-stage control to decide when to emit
|
||||
//! `TurnEvent::Compacted`.
|
||||
|
||||
pub mod dedup;
|
||||
pub mod shaping;
|
||||
pub mod squash;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! the LLM API. Ported from the former `runtime::shortsend` — behavior
|
||||
//! is unchanged, only its token-counting now goes through
|
||||
//! `context::tokens` instead of an inline heuristic.
|
||||
|
||||
use super::tokens::count_tokens;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
|
||||
@@ -101,7 +100,8 @@ 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{content}\n]");
|
||||
summary_text =
|
||||
format!("[Summary of compacted prior conversation:\n{content}\n]");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -136,7 +136,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn should_shape_uses_95_percent_threshold_once_already_shaped() {
|
||||
assert!(!should_shape(900, 1000, true), "below 95% and already shaped: no re-trigger yet");
|
||||
assert!(
|
||||
!should_shape(900, 1000, true),
|
||||
"below 95% and already shaped: no re-trigger yet"
|
||||
);
|
||||
assert!(should_shape(950, 1000, true));
|
||||
}
|
||||
|
||||
@@ -186,9 +189,9 @@ mod tests {
|
||||
messages.push(ChatMessage::user(padded_message(i)));
|
||||
}
|
||||
let result = shape_messages(&messages, 100_000, 1000, true, None);
|
||||
let has_placeholder = result.iter().any(|m| {
|
||||
m.content.as_deref() == Some("[prior conversation compacted]")
|
||||
});
|
||||
let has_placeholder = result
|
||||
.iter()
|
||||
.any(|m| m.content.as_deref() == Some("[prior conversation compacted]"));
|
||||
assert!(has_placeholder);
|
||||
}
|
||||
|
||||
@@ -200,6 +203,9 @@ mod tests {
|
||||
}
|
||||
let result = shape_messages(&messages, 100_000, 1000, true, None);
|
||||
let last_content = messages.last().unwrap().content.clone();
|
||||
assert!(result.iter().any(|m| m.content == last_content), "most recent message must survive shaping");
|
||||
assert!(
|
||||
result.iter().any(|m| m.content == last_content),
|
||||
"most recent message must survive shaping"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
//! conversation's token budget even on its first occurrence, long
|
||||
//! before `dedup`/`shaping` ever get a chance to act on repeats or
|
||||
//! overall budget.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Write;
|
||||
|
||||
@@ -220,12 +219,24 @@ fn squash_log(text: &str) -> String {
|
||||
level_score + stack_boost
|
||||
};
|
||||
|
||||
let mut error_idxs: Vec<usize> = (0..lines.len()).filter(|&i| levels[i] == LogLevel::Error).collect();
|
||||
error_idxs.sort_by(|&a, &b| score(b).partial_cmp(&score(a)).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let mut error_idxs: Vec<usize> = (0..lines.len())
|
||||
.filter(|&i| levels[i] == LogLevel::Error)
|
||||
.collect();
|
||||
error_idxs.sort_by(|&a, &b| {
|
||||
score(b)
|
||||
.partial_cmp(&score(a))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
error_idxs.truncate(20);
|
||||
|
||||
let mut warn_idxs: Vec<usize> = (0..lines.len()).filter(|&i| levels[i] == LogLevel::Warn).collect();
|
||||
warn_idxs.sort_by(|&a, &b| score(b).partial_cmp(&score(a)).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let mut warn_idxs: Vec<usize> = (0..lines.len())
|
||||
.filter(|&i| levels[i] == LogLevel::Warn)
|
||||
.collect();
|
||||
warn_idxs.sort_by(|&a, &b| {
|
||||
score(b)
|
||||
.partial_cmp(&score(a))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
warn_idxs.truncate(10);
|
||||
|
||||
let mut keep: HashSet<usize> = HashSet::new();
|
||||
@@ -257,7 +268,10 @@ fn squash_generic(text: &str, budget: usize) -> String {
|
||||
let mut keep: HashSet<usize> = (0..head_end).chain(tail_start..lines.len()).collect();
|
||||
|
||||
let mut used: usize = lines[..head_end].iter().map(|l| l.len() + 1).sum::<usize>()
|
||||
+ lines[tail_start..].iter().map(|l| l.len() + 1).sum::<usize>();
|
||||
+ lines[tail_start..]
|
||||
.iter()
|
||||
.map(|l| l.len() + 1)
|
||||
.sum::<usize>();
|
||||
let mut prev = "";
|
||||
for (i, &line) in lines.iter().enumerate().take(tail_start).skip(head_end) {
|
||||
let non_trivial = !line.trim().is_empty() && line != prev;
|
||||
@@ -324,8 +338,8 @@ mod tests {
|
||||
assert!(text.len() > SQUASH_FLOOR_BYTES);
|
||||
|
||||
let result = apply("some_mcp_tool", &text);
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result)
|
||||
.expect("squashed JSON must still be valid JSON");
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&result).expect("squashed JSON must still be valid JSON");
|
||||
|
||||
assert_eq!(parsed["id"], "abc123", "short values must survive");
|
||||
assert_eq!(parsed["note"], "hi", "short values must survive");
|
||||
@@ -357,7 +371,11 @@ mod tests {
|
||||
let items = parsed["items"].as_array().unwrap();
|
||||
|
||||
assert_eq!(items[0].as_str().unwrap(), identifier, "index 0 is under the array cutoff and identifier-shaped, so it's kept under the normal rule");
|
||||
assert_eq!(items[2].as_str().unwrap(), identifier, "index 2 is still under the cutoff (past-third means index >= 3)");
|
||||
assert_eq!(
|
||||
items[2].as_str().unwrap(),
|
||||
identifier,
|
||||
"index 2 is still under the cutoff (past-third means index >= 3)"
|
||||
);
|
||||
assert_ne!(items[3].as_str().unwrap(), identifier, "index 3 must be force-elided even though it's identifier-shaped and would survive at any earlier index");
|
||||
}
|
||||
|
||||
@@ -412,20 +430,34 @@ mod tests {
|
||||
|
||||
let result = apply("grep", &text);
|
||||
|
||||
assert!(result.contains("src/file0.rs:0: error handling for case 0"), "generic keeps head");
|
||||
assert!(result.contains("src/file49.rs:49: error handling for case 49"), "generic keeps tail — squash_log would have dropped this");
|
||||
assert!(
|
||||
result.contains("src/file0.rs:0: error handling for case 0"),
|
||||
"generic keeps head"
|
||||
);
|
||||
assert!(
|
||||
result.contains("src/file49.rs:49: error handling for case 49"),
|
||||
"generic keeps tail — squash_log would have dropped this"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_large_text_is_truncated_with_omission_marker() {
|
||||
let lines: Vec<String> = (0..500).map(|i| format!("line number {i} of plain output")).collect();
|
||||
let lines: Vec<String> = (0..500)
|
||||
.map(|i| format!("line number {i} of plain output"))
|
||||
.collect();
|
||||
let text = lines.join("\n");
|
||||
assert!(text.len() > SQUASH_FLOOR_BYTES);
|
||||
|
||||
let result = apply("bash", &text);
|
||||
|
||||
assert!(result.contains("line number 0 of plain output"), "keeps head");
|
||||
assert!(result.contains("line number 499 of plain output"), "keeps tail");
|
||||
assert!(
|
||||
result.contains("line number 0 of plain output"),
|
||||
"keeps head"
|
||||
);
|
||||
assert!(
|
||||
result.contains("line number 499 of plain output"),
|
||||
"keeps tail"
|
||||
);
|
||||
assert!(result.contains("lines omitted"));
|
||||
assert!(result.len() < text.len());
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
//! `o200k_base` is an approximation for non-OpenAI providers but is far
|
||||
//! closer than a flat byte-per-token guess; it's only used for the
|
||||
//! 85%/95% budget thresholds, not for billing-accurate counts.
|
||||
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
|
||||
/// Count tokens in a single string under `o200k_base`.
|
||||
@@ -21,7 +20,9 @@ use crate::dto::chat::message::ChatMessage;
|
||||
/// (e.g. literal text `<|endoftext|>` pasted by a user) must be counted
|
||||
/// as ordinary text, not interpreted as a control token.
|
||||
pub fn count_tokens(text: &str) -> usize {
|
||||
tiktoken_rs::o200k_base_singleton().encode_ordinary(text).len()
|
||||
tiktoken_rs::o200k_base_singleton()
|
||||
.encode_ordinary(text)
|
||||
.len()
|
||||
}
|
||||
|
||||
/// Count tokens in a `ChatMessage`'s text content.
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
//! had their own inline version — the status bar's copy additionally
|
||||
//! displayed "?" on no match instead of falling back like the other two,
|
||||
//! an inconsistency this unifies away).
|
||||
|
||||
use crate::model::app_config::AppConfig;
|
||||
use crate::model::settings::Settings;
|
||||
|
||||
@@ -18,7 +17,9 @@ use crate::model::settings::Settings;
|
||||
///
|
||||
/// Return: always a concrete token count, never "unknown".
|
||||
pub fn resolve(app_config: &AppConfig, settings: &Settings) -> usize {
|
||||
app_config.model_roles.values()
|
||||
app_config
|
||||
.model_roles
|
||||
.values()
|
||||
.find(|role| role.provider == settings.provider && role.model == settings.model)
|
||||
.and_then(|role| role.context_window)
|
||||
.unwrap_or(app_config.default_context_window) as usize
|
||||
@@ -32,13 +33,16 @@ mod tests {
|
||||
#[test]
|
||||
fn resolves_context_window_from_matching_model_role() {
|
||||
let mut app_config = AppConfig::default();
|
||||
app_config.model_roles.insert("default".to_string(), ModelRole {
|
||||
provider: "zen".to_string(),
|
||||
model: "deepseek-v4-flash-free".to_string(),
|
||||
max_tokens: None,
|
||||
context_window: Some(128_000),
|
||||
temperature: None,
|
||||
});
|
||||
app_config.model_roles.insert(
|
||||
"default".to_string(),
|
||||
ModelRole {
|
||||
provider: "zen".to_string(),
|
||||
model: "deepseek-v4-flash-free".to_string(),
|
||||
max_tokens: None,
|
||||
context_window: Some(128_000),
|
||||
temperature: None,
|
||||
},
|
||||
);
|
||||
let mut settings = Settings::default();
|
||||
settings.provider = "zen".to_string();
|
||||
settings.model = "deepseek-v4-flash-free".to_string();
|
||||
@@ -53,23 +57,32 @@ mod tests {
|
||||
settings.provider = "nonexistent".to_string();
|
||||
settings.model = "nonexistent-model".to_string();
|
||||
|
||||
assert_eq!(resolve(&app_config, &settings), app_config.default_context_window as usize);
|
||||
assert_eq!(
|
||||
resolve(&app_config, &settings),
|
||||
app_config.default_context_window as usize
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_default_when_matching_role_has_no_context_window_set() {
|
||||
let mut app_config = AppConfig::default();
|
||||
app_config.model_roles.insert("default".to_string(), ModelRole {
|
||||
provider: "zen".to_string(),
|
||||
model: "deepseek-v4-flash-free".to_string(),
|
||||
max_tokens: None,
|
||||
context_window: None,
|
||||
temperature: None,
|
||||
});
|
||||
app_config.model_roles.insert(
|
||||
"default".to_string(),
|
||||
ModelRole {
|
||||
provider: "zen".to_string(),
|
||||
model: "deepseek-v4-flash-free".to_string(),
|
||||
max_tokens: None,
|
||||
context_window: None,
|
||||
temperature: None,
|
||||
},
|
||||
);
|
||||
let mut settings = Settings::default();
|
||||
settings.provider = "zen".to_string();
|
||||
settings.model = "deepseek-v4-flash-free".to_string();
|
||||
|
||||
assert_eq!(resolve(&app_config, &settings), app_config.default_context_window as usize);
|
||||
assert_eq!(
|
||||
resolve(&app_config, &settings),
|
||||
app_config.default_context_window as usize
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
//! SSE stream parser: converts SSE- or JSON-chunked LLM responses into
|
||||
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
|
||||
pub mod turn;
|
||||
pub mod tools;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -89,7 +87,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();
|
||||
@@ -112,20 +110,32 @@ impl SseParser {
|
||||
|
||||
if let Some(usage) = value.get("usage") {
|
||||
if !usage.is_null() {
|
||||
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(serde_json::Value::as_u64).unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] completion_tokens missing in usage chunk");
|
||||
0
|
||||
});
|
||||
let total_tokens = usage.get("total_tokens").and_then(serde_json::Value::as_u64)
|
||||
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(serde_json::Value::as_u64)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] completion_tokens missing in usage chunk");
|
||||
0
|
||||
});
|
||||
let total_tokens = usage
|
||||
.get("total_tokens")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] total_tokens missing in usage chunk");
|
||||
prompt_tokens + completion_tokens
|
||||
});
|
||||
events.push(StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens });
|
||||
events.push(StreamEvent::Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,23 +153,32 @@ impl SseParser {
|
||||
}
|
||||
|
||||
// Reasoning token
|
||||
if let Some(reasoning) = d.get("reasoning_content").and_then(|r| r.as_str()) {
|
||||
if let Some(reasoning) =
|
||||
d.get("reasoning_content").and_then(|r| r.as_str())
|
||||
{
|
||||
d_events.push(StreamEvent::Reasoning(reasoning.to_string()));
|
||||
}
|
||||
|
||||
// Tool calls — iterate ALL entries, not just first()
|
||||
if let Some(tool_calls) = d.get("tool_calls").and_then(|tc| tc.as_array()) {
|
||||
if let Some(tool_calls) =
|
||||
d.get("tool_calls").and_then(|tc| tc.as_array())
|
||||
{
|
||||
for tc in tool_calls {
|
||||
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(std::string::ToString::to_string);
|
||||
let name = tc.get("function")
|
||||
let id = tc
|
||||
.get("id")
|
||||
.and_then(|i| i.as_str())
|
||||
.map(std::string::ToString::to_string);
|
||||
let name = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.map(std::string::ToString::to_string);
|
||||
let args_delta = tc.get("function")
|
||||
let args_delta = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("arguments"))
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("")
|
||||
@@ -174,7 +193,9 @@ impl SseParser {
|
||||
}
|
||||
|
||||
// Finish reason
|
||||
if let Some(reason) = choice.get("finish_reason").and_then(|r| r.as_str()) {
|
||||
if let Some(reason) =
|
||||
choice.get("finish_reason").and_then(|r| r.as_str())
|
||||
{
|
||||
if reason == "stop" || reason == "tool_calls" {
|
||||
d_events.push(StreamEvent::Done);
|
||||
}
|
||||
@@ -193,72 +214,6 @@ impl SseParser {
|
||||
events.append(&mut other_events);
|
||||
events
|
||||
}
|
||||
|
||||
/// Clears any partially-buffered SSE frame. Reserved for reconnect/retry flows that
|
||||
/// reuse a parser instance across requests rather than constructing a fresh one.
|
||||
#[allow(dead_code)]
|
||||
pub fn reset(&mut self) {
|
||||
self.buffer.clear();
|
||||
self.event_type = None;
|
||||
self.data_lines.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback parser for providers that send bare JSON chunks instead of SSE-framed
|
||||
/// `data: ...` lines. Not used by the `SseParser` streaming path (which handles
|
||||
/// standard SSE framing directly), kept for providers/tests that feed raw chunks.
|
||||
///
|
||||
/// Flow: parse `data` as JSON → extract first `choices[0].delta` →
|
||||
/// return a `Token`, `Reasoning`, `Done`, or `ToolCallDelta` event based
|
||||
/// on the fields present.
|
||||
///
|
||||
/// Return: `Some(StreamEvent)` if the chunk contained recognisable
|
||||
/// content, `None` otherwise.
|
||||
#[allow(dead_code)]
|
||||
pub fn parse_stream_chunk(data: &str) -> Option<StreamEvent> {
|
||||
let value: Value = serde_json::from_str(data).ok()?;
|
||||
if value == Value::Null {
|
||||
return None;
|
||||
}
|
||||
let choices = value.get("choices")?.as_array()?;
|
||||
let choice = choices.first()?;
|
||||
let delta = choice.get("delta")?;
|
||||
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
||||
return Some(StreamEvent::Token(content.to_string()));
|
||||
}
|
||||
if let Some(reasoning) = delta.get("reasoning_content").and_then(|r| r.as_str()) {
|
||||
return Some(StreamEvent::Reasoning(reasoning.to_string()));
|
||||
}
|
||||
if let Some(finish) = choice.get("finish_reason").and_then(|r| r.as_str()) {
|
||||
if finish == "stop" || finish == "tool_calls" {
|
||||
return Some(StreamEvent::Done);
|
||||
}
|
||||
}
|
||||
if let Some(tool_calls) = delta.get("tool_calls").and_then(|tc| tc.as_array()) {
|
||||
if let Some(tc) = tool_calls.first() {
|
||||
let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] fallback parser: tool call missing index, defaulting to 0");
|
||||
0
|
||||
}) as usize;
|
||||
let id = tc.get("id").and_then(|i| i.as_str()).map(std::string::ToString::to_string);
|
||||
let name = tc.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.map(std::string::ToString::to_string);
|
||||
let args = tc.get("function")
|
||||
.and_then(|f| f.get("arguments"))
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
return Some(StreamEvent::ToolCallDelta {
|
||||
index,
|
||||
id,
|
||||
name,
|
||||
arguments_delta: args,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -280,7 +235,10 @@ mod tests {
|
||||
fn feed_handles_chunk_split_mid_line() {
|
||||
let mut p = SseParser::new();
|
||||
let e1 = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"partial");
|
||||
assert!(e1.is_empty(), "no event until the line and blank separator complete");
|
||||
assert!(
|
||||
e1.is_empty(),
|
||||
"no event until the line and blank separator complete"
|
||||
);
|
||||
let e2 = p.feed("\"}}]}\n\n");
|
||||
assert_eq!(e2.len(), 1);
|
||||
match &e2[0] {
|
||||
@@ -300,9 +258,7 @@ mod tests {
|
||||
#[test]
|
||||
fn feed_emits_done_on_finish_reason_stop() {
|
||||
let mut p = SseParser::new();
|
||||
let events = p.feed(
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
|
||||
);
|
||||
let events = p.feed("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n");
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(events[0], StreamEvent::Done));
|
||||
}
|
||||
@@ -315,7 +271,12 @@ mod tests {
|
||||
);
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
StreamEvent::ToolCallDelta { index, id, name, arguments_delta } => {
|
||||
StreamEvent::ToolCallDelta {
|
||||
index,
|
||||
id,
|
||||
name,
|
||||
arguments_delta,
|
||||
} => {
|
||||
assert_eq!(*index, 0);
|
||||
assert_eq!(id.as_deref(), Some("call_1"));
|
||||
assert_eq!(name.as_deref(), Some("bash"));
|
||||
@@ -333,7 +294,11 @@ mod tests {
|
||||
);
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens } => {
|
||||
StreamEvent::Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
} => {
|
||||
assert_eq!(*prompt_tokens, 10);
|
||||
assert_eq!(*completion_tokens, 5);
|
||||
assert_eq!(*total_tokens, 15);
|
||||
@@ -351,7 +316,11 @@ mod tests {
|
||||
assert_eq!(events.len(), 2);
|
||||
match (&events[0], &events[1]) {
|
||||
(
|
||||
StreamEvent::Usage { prompt_tokens, completion_tokens, total_tokens },
|
||||
StreamEvent::Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
},
|
||||
StreamEvent::Token(t),
|
||||
) => {
|
||||
assert_eq!(*prompt_tokens, 10);
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
//! Standalone accumulator for streamed tool-call deltas.
|
||||
//!
|
||||
//! Flow: `ToolCallAccumulator::add_delta` is fed incremental `(index, id,
|
||||
//! name, arguments_delta)` chunks as they arrive over SSE → grows its
|
||||
//! internal `Vec<ParsedToolCall>` as needed → `is_complete` reports once
|
||||
//! every accumulated call has both a name and arguments.
|
||||
//!
|
||||
//! Why: mirrors the accumulation logic built into `StreamedTurn::apply_event`
|
||||
//! but as an independent, reusable type for callers that want to track
|
||||
//! tool-call deltas without a full `StreamedTurn` (e.g. a lighter-weight
|
||||
//! preview). Currently unused (`#[allow(dead_code)]`), kept for that future
|
||||
//! use case.
|
||||
|
||||
use super::turn::ParsedToolCall;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Standalone tool-call delta accumulator, functionally equivalent to the accumulation
|
||||
/// logic built into `StreamedTurn::apply_event`. Reserved for callers that want to track
|
||||
/// tool-call deltas independently of a full `StreamedTurn` (e.g. a lighter-weight preview).
|
||||
#[allow(dead_code)]
|
||||
pub struct ToolCallAccumulator {
|
||||
calls: Vec<ParsedToolCall>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ToolCallAccumulator {
|
||||
/// Construct an empty accumulator with no tool calls tracked yet.
|
||||
///
|
||||
/// Return: a fresh `ToolCallAccumulator`.
|
||||
pub fn new() -> Self {
|
||||
ToolCallAccumulator { calls: Vec::new() }
|
||||
}
|
||||
|
||||
/// Append a delta to the tool call at the given index, growing the
|
||||
/// calls vector if needed.
|
||||
pub fn add_delta(
|
||||
&mut self,
|
||||
index: usize,
|
||||
id: Option<&str>,
|
||||
name: Option<&str>,
|
||||
arguments_delta: &str,
|
||||
) {
|
||||
while self.calls.len() <= index {
|
||||
self.calls.push(ParsedToolCall {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
arguments: String::new(),
|
||||
is_complete: false,
|
||||
});
|
||||
}
|
||||
let tc = &mut self.calls[index];
|
||||
if let Some(new_id) = id {
|
||||
if !new_id.is_empty() {
|
||||
tc.id = new_id.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(new_name) = name {
|
||||
if !new_name.is_empty() {
|
||||
tc.name = new_name.to_string();
|
||||
}
|
||||
}
|
||||
tc.arguments.push_str(arguments_delta);
|
||||
}
|
||||
|
||||
/// Borrow the accumulated tool calls.
|
||||
pub fn calls(&self) -> &[ParsedToolCall] {
|
||||
&self.calls
|
||||
}
|
||||
|
||||
/// Return true once all tool calls have both a name and arguments.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
!self.calls.is_empty() && self.calls.iter().all(|tc| !tc.name.is_empty() && !tc.arguments.is_empty())
|
||||
}
|
||||
|
||||
/// Clear all accumulated calls (starting a fresh turn).
|
||||
pub fn reset(&mut self) {
|
||||
self.calls.clear();
|
||||
}
|
||||
|
||||
/// Build a JSON-serialisable `Vec<Value>` of pending (non-empty-name)
|
||||
/// tool calls, suitable for downstream inspection or replay.
|
||||
pub fn pending_args(&self) -> Vec<Value> {
|
||||
self.calls
|
||||
.iter()
|
||||
.filter(|tc| !tc.name.is_empty())
|
||||
.map(|tc| {
|
||||
json!({
|
||||
"tool_call_id": tc.id,
|
||||
"name": tc.name,
|
||||
"arguments": tc.arguments,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ToolCallAccumulator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -101,17 +101,7 @@ pub struct ParsedToolCall {
|
||||
pub is_complete: bool,
|
||||
}
|
||||
|
||||
impl ParsedToolCall {
|
||||
/// Attempt to parse the accumulated argument string as JSON before
|
||||
/// the tool call is marked complete — useful for a speculative preview.
|
||||
///
|
||||
/// Return: `Some(Value)` if the arguments are parsable JSON, `None`
|
||||
/// if still partial.
|
||||
#[allow(dead_code)]
|
||||
pub fn try_parse(&self) -> Option<Value> {
|
||||
serde_json::from_str(&self.arguments).ok()
|
||||
}
|
||||
}
|
||||
impl ParsedToolCall {}
|
||||
|
||||
impl StreamedTurn {
|
||||
/// Create an empty turn accumulator.
|
||||
@@ -186,12 +176,12 @@ impl StreamedTurn {
|
||||
let mut msg = if self.tool_calls.is_empty() {
|
||||
ChatMessage::assistant(None)
|
||||
} else {
|
||||
let tool_dtos: Vec<ToolCall> = self.tool_calls
|
||||
let tool_dtos: Vec<ToolCall> = self
|
||||
.tool_calls
|
||||
.iter()
|
||||
.filter(|tc| !tc.name.is_empty())
|
||||
.map(|tc| {
|
||||
let args_value: serde_json::Value = match serde_json::from_str(&tc.arguments)
|
||||
{
|
||||
let args_value: serde_json::Value = match serde_json::from_str(&tc.arguments) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let repaired = repair_incomplete_json(&tc.arguments);
|
||||
@@ -200,7 +190,8 @@ impl StreamedTurn {
|
||||
tracing::warn!(
|
||||
"[stream] tool call '{}' had truncated JSON \
|
||||
arguments — repaired successfully: {}",
|
||||
tc.name, e,
|
||||
tc.name,
|
||||
e,
|
||||
);
|
||||
v
|
||||
}
|
||||
@@ -209,7 +200,9 @@ impl StreamedTurn {
|
||||
"[stream] tool call '{}' has invalid JSON \
|
||||
arguments: {} (after repair: {}) — falling \
|
||||
back to raw string",
|
||||
tc.name, e, e2,
|
||||
tc.name,
|
||||
e,
|
||||
e2,
|
||||
);
|
||||
serde_json::Value::String(tc.arguments.clone())
|
||||
}
|
||||
@@ -235,7 +228,10 @@ impl StreamedTurn {
|
||||
let full_content = if self.accumulated_reasoning.is_empty() {
|
||||
self.accumulated_content.clone()
|
||||
} else {
|
||||
format!("<think>\n{}\n</think>\n\n{}", self.accumulated_reasoning, self.accumulated_content)
|
||||
format!(
|
||||
"<think>\n{}\n</think>\n\n{}",
|
||||
self.accumulated_reasoning, self.accumulated_content
|
||||
)
|
||||
};
|
||||
let content = if full_content.is_empty() {
|
||||
None
|
||||
@@ -259,7 +255,8 @@ impl StreamedTurn {
|
||||
/// Return: `Some((name, parse_error))` for the first bad tool call, or
|
||||
/// `None` if every tool call's arguments are complete, parsable JSON.
|
||||
pub fn incomplete_tool_call(&self) -> Option<(&str, String)> {
|
||||
self.tool_calls.iter()
|
||||
self.tool_calls
|
||||
.iter()
|
||||
.filter(|tc| !tc.name.is_empty())
|
||||
.find_map(|tc| {
|
||||
serde_json::from_str::<Value>(&tc.arguments)
|
||||
@@ -267,19 +264,6 @@ impl StreamedTurn {
|
||||
.map(|e| (tc.name.as_str(), e.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Reserved accessor for callers that want to branch mid-stream before the turn
|
||||
/// completes; the current wiring only inspects the final `build_assistant_message()`.
|
||||
#[allow(dead_code)]
|
||||
pub fn has_tool_calls(&self) -> bool {
|
||||
self.tool_calls.iter().any(|tc| !tc.name.is_empty())
|
||||
}
|
||||
|
||||
/// Reserved accessor mirroring `has_tool_calls` for mid-stream content peeks.
|
||||
#[allow(dead_code)]
|
||||
pub fn content(&self) -> &str {
|
||||
&self.accumulated_content
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StreamedTurn {
|
||||
@@ -353,7 +337,10 @@ mod tests {
|
||||
let tcs = msg.tool_calls.expect("should produce tool calls");
|
||||
assert_eq!(tcs.len(), 1);
|
||||
let args = &tcs[0].function.arguments;
|
||||
assert!(args.is_object(), "args should be an object after repair: {args:?}");
|
||||
assert!(
|
||||
args.is_object(),
|
||||
"args should be an object after repair: {args:?}"
|
||||
);
|
||||
assert_eq!(args.get("path").and_then(|v| v.as_str()), Some("a.txt"));
|
||||
assert_eq!(args.get("content").and_then(|v| v.as_str()), Some("short"));
|
||||
}
|
||||
@@ -361,7 +348,10 @@ mod tests {
|
||||
#[test]
|
||||
fn incomplete_tool_call_flags_truncated_json() {
|
||||
let mut turn = StreamedTurn::new();
|
||||
turn.tool_calls.push(tool_call("write", "{\"path\": \"a.txt\", \"content\": \"unterm"));
|
||||
turn.tool_calls.push(tool_call(
|
||||
"write",
|
||||
"{\"path\": \"a.txt\", \"content\": \"unterm",
|
||||
));
|
||||
let bad = turn.incomplete_tool_call();
|
||||
assert_eq!(bad.map(|(name, _)| name), Some("write"));
|
||||
}
|
||||
@@ -369,7 +359,10 @@ mod tests {
|
||||
#[test]
|
||||
fn incomplete_tool_call_accepts_complete_json() {
|
||||
let mut turn = StreamedTurn::new();
|
||||
turn.tool_calls.push(tool_call("write", "{\"path\": \"a.txt\", \"content\": \"done\"}"));
|
||||
turn.tool_calls.push(tool_call(
|
||||
"write",
|
||||
"{\"path\": \"a.txt\", \"content\": \"done\"}",
|
||||
));
|
||||
assert!(turn.incomplete_tool_call().is_none());
|
||||
}
|
||||
|
||||
@@ -386,7 +379,10 @@ mod tests {
|
||||
// so it should still flag truncated JSON even though
|
||||
// `build_assistant_message` will later repair it.
|
||||
let mut turn = StreamedTurn::new();
|
||||
turn.tool_calls.push(tool_call("write", "{\"path\": \"a.txt\", \"content\": \"unterm"));
|
||||
turn.tool_calls.push(tool_call(
|
||||
"write",
|
||||
"{\"path\": \"a.txt\", \"content\": \"unterm",
|
||||
));
|
||||
// Even though it's repairable, raw parse should still fail
|
||||
assert!(serde_json::from_str::<Value>(&turn.tool_calls[0].arguments).is_err());
|
||||
}
|
||||
|
||||
@@ -225,16 +225,16 @@ impl InputState {
|
||||
/// if none, close and return → otherwise fuzzy-match `query` against
|
||||
/// `files` via `nucleo-matcher`, keep the top 10 by score.
|
||||
pub fn open_mention_autocomplete(&mut self, files: &[String]) {
|
||||
use nucleo_matcher::{Config, Matcher};
|
||||
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
|
||||
let Some((start, query)) = self.mention_query_at_cursor() else {
|
||||
self.close_autocomplete();
|
||||
return;
|
||||
};
|
||||
use nucleo_matcher::{Config, Matcher};
|
||||
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
|
||||
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
|
||||
let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart);
|
||||
let matches = pattern.match_list(files.iter(), &mut matcher);
|
||||
self.autocomplete_candidates = matches.into_iter().take(10).map(|(f, _)| f.clone()).collect();
|
||||
let matched_files = pattern.match_list(files.iter(), &mut matcher);
|
||||
self.autocomplete_candidates = matched_files.into_iter().take(10).map(|(f, _)| f.clone()).collect();
|
||||
self.autocomplete_kind = AutocompleteKind::FileMention;
|
||||
self.mention_start = start;
|
||||
self.autocomplete_idx = 0;
|
||||
|
||||
@@ -167,7 +167,7 @@ impl AppStateRest {
|
||||
// async executor entirely. It is deliberately not joined -- startup
|
||||
// must not block on language server installation, and failures are
|
||||
// logged rather than surfaced, since editing still works without LSP.
|
||||
if state.settings.lsp_auto_provision {
|
||||
if state.settings.flags.lsp_auto_provision {
|
||||
let lsp_mgr = state.lsp_manager.clone();
|
||||
let msg_queue = state.lsp_provision_msgs.clone();
|
||||
std::thread::spawn(move || {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Per-session runtime state: message history, pending tool queue,
|
||||
//! background bash jobs, lesson/review counters, and the `TurnEvent`
|
||||
//! stream emitted while an agent turn is in flight.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Cumulative token/latency counters for a session, persisted alongside it.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Opaque, serializable snapshot of application state used for
|
||||
//! attach/daemon IPC transfer.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A JSON-boxed snapshot of app state, opaque to the transport layer.
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Shared small state types: toasts, overlays, the transcript cache,
|
||||
//! tool execution model, and call origin tags.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
/// Severity/category of a toast notification, used to pick its color.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ToastKind {
|
||||
|
||||
+72
-40
@@ -15,27 +15,30 @@
|
||||
//! wrote this file, let me check if it's correct before continuing").
|
||||
//! - Background reviews catch broader concerns (missing tests, architectural
|
||||
//! drift, security issues) without blocking the main agent's flow.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::collections::VecDeque;
|
||||
use crate::app::state::runtime::TurnEvent;
|
||||
use crate::app::subagent::context::build_subagent_context;
|
||||
use crate::app::subagent::engine::run_subagent;
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
use crate::app::subagent::event::SubagentEvent;
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// File extensions that should not trigger auto-review (config, lock, data).
|
||||
const SKIP_REVIEW_EXTENSIONS: &[&str] = &[
|
||||
".lock", ".md", ".txt", ".json", ".toml", ".yaml", ".yml",
|
||||
".svg", ".png", ".jpg", ".ico", ".woff", ".woff2",
|
||||
".lock", ".md", ".txt", ".json", ".toml", ".yaml", ".yml", ".svg", ".png", ".jpg", ".ico",
|
||||
".woff", ".woff2",
|
||||
];
|
||||
|
||||
/// File names that should not trigger auto-review.
|
||||
const SKIP_REVIEW_FILES: &[&str] = &[
|
||||
"Cargo.lock", "yarn.lock", "package-lock.json",
|
||||
".gitignore", ".env", ".env.example",
|
||||
"Cargo.lock",
|
||||
"yarn.lock",
|
||||
"package-lock.json",
|
||||
".gitignore",
|
||||
".env",
|
||||
".env.example",
|
||||
];
|
||||
|
||||
/// Prevents a second background subagent of the same kind from spawning
|
||||
@@ -137,8 +140,19 @@ fn is_production_code(path: &str) -> bool {
|
||||
.is_some_and(|ext| {
|
||||
matches!(
|
||||
ext,
|
||||
"rs" | "ts" | "tsx" | "js" | "jsx" | "go" | "py" | "java" | "kt" | "swift"
|
||||
| "c" | "cpp" | "h" | "hpp"
|
||||
"rs" | "ts"
|
||||
| "tsx"
|
||||
| "js"
|
||||
| "jsx"
|
||||
| "go"
|
||||
| "py"
|
||||
| "java"
|
||||
| "kt"
|
||||
| "swift"
|
||||
| "c"
|
||||
| "cpp"
|
||||
| "h"
|
||||
| "hpp"
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -167,11 +181,8 @@ pub fn spawn_quick_review(
|
||||
file_path,
|
||||
);
|
||||
|
||||
let def = AgentDefinition::new(
|
||||
"quick-reviewer".to_string(),
|
||||
"reviewer".to_string(),
|
||||
)
|
||||
.with_system_prompt(prompt);
|
||||
let def = AgentDefinition::new("quick-reviewer".to_string(), "reviewer".to_string())
|
||||
.with_system_prompt(prompt);
|
||||
|
||||
let mut ctx = build_subagent_context(&def);
|
||||
ctx.session_dir = session_dir.to_path_buf();
|
||||
@@ -187,7 +198,7 @@ pub fn spawn_quick_review(
|
||||
SubagentEvent::ToolResult { tool, .. } => {
|
||||
tracing::debug!("[auto-review] tool result: {}", tool);
|
||||
}
|
||||
SubagentEvent::Completed { .. } => {
|
||||
SubagentEvent::Completed => {
|
||||
tracing::debug!("[auto-review] completed");
|
||||
}
|
||||
_ => {}
|
||||
@@ -277,7 +288,10 @@ pub fn spawn_background_test_gen(
|
||||
if file_paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
if TEST_GEN_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
|
||||
if TEST_GEN_RUNNING
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
tracing::debug!("[bg-test-gen] skipped — a test-gen run is already in flight");
|
||||
return;
|
||||
}
|
||||
@@ -306,8 +320,7 @@ pub fn spawn_background_test_gen(
|
||||
"test-generator".to_string(),
|
||||
"coder".to_string(), // needs write access
|
||||
)
|
||||
.with_system_prompt(prompt)
|
||||
;
|
||||
.with_system_prompt(prompt);
|
||||
|
||||
let result = run_subagent_with_retry(&def, &sd, &ws, "bg-test-gen", Some(&abort_flag));
|
||||
let message = match &result {
|
||||
@@ -347,7 +360,10 @@ pub fn spawn_background_arch_review(
|
||||
if file_paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
if ARCH_REVIEW_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
|
||||
if ARCH_REVIEW_RUNNING
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
tracing::debug!("[bg-arch-review] skipped — an arch-review run is already in flight");
|
||||
return;
|
||||
}
|
||||
@@ -366,12 +382,8 @@ pub fn spawn_background_arch_review(
|
||||
file_list,
|
||||
);
|
||||
|
||||
let def = AgentDefinition::new(
|
||||
"arch-reviewer".to_string(),
|
||||
"reviewer".to_string(),
|
||||
)
|
||||
.with_system_prompt(prompt)
|
||||
;
|
||||
let def = AgentDefinition::new("arch-reviewer".to_string(), "reviewer".to_string())
|
||||
.with_system_prompt(prompt);
|
||||
|
||||
let result = run_subagent_with_retry(&def, &sd, &ws, "bg-arch-review", Some(&abort_flag));
|
||||
let message = match &result {
|
||||
@@ -422,8 +434,13 @@ pub fn spawn_background_security_review(
|
||||
if prod_paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
if SECURITY_REVIEW_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
|
||||
tracing::debug!("[bg-security-review] skipped — a security-review run is already in flight");
|
||||
if SECURITY_REVIEW_RUNNING
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
tracing::debug!(
|
||||
"[bg-security-review] skipped — a security-review run is already in flight"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -441,14 +458,11 @@ pub fn spawn_background_security_review(
|
||||
file_list,
|
||||
);
|
||||
|
||||
let def = AgentDefinition::new(
|
||||
"security-reviewer".to_string(),
|
||||
"reviewer".to_string(),
|
||||
)
|
||||
.with_system_prompt(prompt)
|
||||
;
|
||||
let def = AgentDefinition::new("security-reviewer".to_string(), "reviewer".to_string())
|
||||
.with_system_prompt(prompt);
|
||||
|
||||
let result = run_subagent_with_retry(&def, &sd, &ws, "bg-security-review", Some(&abort_flag));
|
||||
let result =
|
||||
run_subagent_with_retry(&def, &sd, &ws, "bg-security-review", Some(&abort_flag));
|
||||
let message = match &result {
|
||||
Ok(output) => {
|
||||
let first = output.lines().next().unwrap_or(output);
|
||||
@@ -493,7 +507,13 @@ pub fn spawn_all_background(
|
||||
.filter(|p| is_production_code(p))
|
||||
.cloned()
|
||||
.collect();
|
||||
spawn_background_test_gen(&source_paths, session_dir, workspaces, turn_events, abort_flag.clone());
|
||||
spawn_background_test_gen(
|
||||
&source_paths,
|
||||
session_dir,
|
||||
workspaces,
|
||||
turn_events,
|
||||
abort_flag.clone(),
|
||||
);
|
||||
|
||||
// Background arch review: for all files that are reviewable
|
||||
let reviewable: Vec<String> = file_paths
|
||||
@@ -501,10 +521,22 @@ pub fn spawn_all_background(
|
||||
.filter(|p| is_reviewable_path(p))
|
||||
.cloned()
|
||||
.collect();
|
||||
spawn_background_arch_review(&reviewable, session_dir, workspaces, turn_events, abort_flag.clone());
|
||||
spawn_background_arch_review(
|
||||
&reviewable,
|
||||
session_dir,
|
||||
workspaces,
|
||||
turn_events,
|
||||
abort_flag.clone(),
|
||||
);
|
||||
|
||||
// Background security review: only production source files
|
||||
spawn_background_security_review(&source_paths, session_dir, workspaces, turn_events, abort_flag);
|
||||
spawn_background_security_review(
|
||||
&source_paths,
|
||||
session_dir,
|
||||
workspaces,
|
||||
turn_events,
|
||||
abort_flag,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Construction of a `SubagentContext` from an `AgentDefinition`,
|
||||
//! including the default read-only tool set for reviewer agents.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, atomic::AtomicBool};
|
||||
use super::spawn::AgentDefinition;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||
|
||||
/// Default read-only tool names granted to `role == "reviewer"` agents.
|
||||
pub const REVIEWER_ALLOWED: &[&str] = &["read", "grep", "glob", "recall", "remember"];
|
||||
@@ -40,7 +39,10 @@ pub struct SubagentContext {
|
||||
pub fn build_subagent_context(def: &AgentDefinition) -> SubagentContext {
|
||||
let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| {
|
||||
if def.role == "reviewer" {
|
||||
REVIEWER_ALLOWED.iter().map(std::string::ToString::to_string).collect()
|
||||
REVIEWER_ALLOWED
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
@@ -22,24 +22,64 @@ pub mod tool_scope {
|
||||
/// authoritative "safe to deduplicate" classification, so there's a
|
||||
/// single list of read-only tool names in the codebase instead of two.
|
||||
pub const READ_TOOLS: &[&str] = &[
|
||||
"read", "grep", "glob", "search", "seqthink", "recall",
|
||||
"lsp_connect", "lsp_diagnostics", "lsp_hover", "lsp_definition",
|
||||
"lsp_references", "read_findings",
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"search",
|
||||
"seqthink",
|
||||
"recall",
|
||||
"lsp_connect",
|
||||
"lsp_diagnostics",
|
||||
"lsp_hover",
|
||||
"lsp_definition",
|
||||
"lsp_references",
|
||||
"read_findings",
|
||||
];
|
||||
|
||||
const WRITE_TOOLS: &[&str] = &[
|
||||
"read", "grep", "glob", "search", "seqthink", "recall",
|
||||
"lsp_connect", "lsp_diagnostics", "lsp_hover", "lsp_definition",
|
||||
"lsp_references", "read_findings",
|
||||
"write", "edit", "bash", "todowrite", "todofinish", "remember",
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"search",
|
||||
"seqthink",
|
||||
"recall",
|
||||
"lsp_connect",
|
||||
"lsp_diagnostics",
|
||||
"lsp_hover",
|
||||
"lsp_definition",
|
||||
"lsp_references",
|
||||
"read_findings",
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"todowrite",
|
||||
"todofinish",
|
||||
"remember",
|
||||
];
|
||||
|
||||
const FULL_TOOLS: &[&str] = &[
|
||||
"read", "grep", "glob", "search", "seqthink", "recall",
|
||||
"lsp_connect", "lsp_diagnostics", "lsp_hover", "lsp_definition",
|
||||
"lsp_references", "read_findings",
|
||||
"write", "edit", "bash", "todowrite", "todofinish", "remember",
|
||||
"delete", "git_operator", "lsp_completion", "lsp_disconnect",
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
"search",
|
||||
"seqthink",
|
||||
"recall",
|
||||
"lsp_connect",
|
||||
"lsp_diagnostics",
|
||||
"lsp_hover",
|
||||
"lsp_definition",
|
||||
"lsp_references",
|
||||
"read_findings",
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"todowrite",
|
||||
"todofinish",
|
||||
"remember",
|
||||
"delete",
|
||||
"git_operator",
|
||||
"lsp_completion",
|
||||
"lsp_disconnect",
|
||||
];
|
||||
|
||||
/// Resolve a tier name to its concrete tool allowlist.
|
||||
@@ -98,7 +138,13 @@ mod tests {
|
||||
let read: HashSet<_> = tools_for(READ).into_iter().collect();
|
||||
let write: HashSet<_> = tools_for(WRITE).into_iter().collect();
|
||||
let full: HashSet<_> = tools_for(FULL).into_iter().collect();
|
||||
assert!(read.is_subset(&write), "read tier must be a subset of write tier");
|
||||
assert!(write.is_subset(&full), "write tier must be a subset of full tier");
|
||||
assert!(
|
||||
read.is_subset(&write),
|
||||
"read tier must be a subset of write tier"
|
||||
);
|
||||
assert!(
|
||||
write.is_subset(&full),
|
||||
"write tier must be a subset of full tier"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,7 +632,7 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
|
||||
shared_text.truncate(50_000);
|
||||
shared_text.push_str("\n...[truncated]");
|
||||
}
|
||||
f.push(format!("[Auto-Shared] Sibling drone executed '{}' with args {}:\n{}", tool_name, args_json, shared_text));
|
||||
f.push(format!("[Auto-Shared] Sibling drone executed '{tool_name}' with args {args_json}:\n{shared_text}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Event variants that a running subagent can emit to its parent via the
|
||||
//! shared mpsc channel.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Progress and outcome events emitted by `run_subagent` as it processes
|
||||
@@ -8,29 +7,20 @@ use serde_json::Value;
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SubagentEvent {
|
||||
StepCompleted {
|
||||
#[allow(dead_code)]
|
||||
step: usize,
|
||||
#[allow(dead_code)]
|
||||
output: String,
|
||||
},
|
||||
StepFailed {
|
||||
step: usize,
|
||||
error: String,
|
||||
},
|
||||
Completed {
|
||||
#[allow(dead_code)]
|
||||
output: String,
|
||||
},
|
||||
Completed,
|
||||
ToolCall {
|
||||
tool: String,
|
||||
#[allow(dead_code)]
|
||||
args: Value,
|
||||
},
|
||||
ToolResult {
|
||||
tool: String,
|
||||
args: Value,
|
||||
#[allow(dead_code)]
|
||||
output: String,
|
||||
},
|
||||
Progress(String),
|
||||
/// Token usage reported by the LLM after one streaming call inside the
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Subagent management: spawning, context building, engine loop, and
|
||||
//! progress events.
|
||||
|
||||
pub mod auto;
|
||||
pub mod context;
|
||||
pub mod division;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! `AgentDefinition` -- declarative specification for instantiating a
|
||||
//! subagent from workflow scripts or programmatic calls.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Declarative specification for instantiating a subagent: name, role,
|
||||
@@ -29,13 +28,6 @@ impl AgentDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder method: limit this agent to at most `steps` LLM calls.
|
||||
#[allow(dead_code)]
|
||||
pub fn with_max_steps(mut self, steps: usize) -> Self {
|
||||
self.max_steps = Some(steps);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder method: set the system prompt for this agent.
|
||||
pub fn with_system_prompt(mut self, prompt: String) -> Self {
|
||||
self.system_prompt = Some(prompt);
|
||||
|
||||
+28
-16
@@ -6,11 +6,10 @@
|
||||
//! choice, so this step is plain Rust — not an LLM call, not a cycle the
|
||||
//! Core Intelligence can omit or reshape — and always runs after any
|
||||
//! hive-mind convergence completes.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::fmt::Write as _;
|
||||
use crate::app::workflow::hive_mind::NodeReport;
|
||||
use crate::model::memory::Memory;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Write a markdown report of one hive-mind convergence to
|
||||
/// `<workspace_root>/docs/runs/<timestamp>-<slug>.md`.
|
||||
@@ -42,22 +41,31 @@ pub fn write_hive_mind_convergence(
|
||||
}
|
||||
|
||||
/// Render a hive-mind convergence as a markdown document.
|
||||
fn render_report(user_request: &str, ts_millis: i64, reports: &[NodeReport], consensus: &str) -> String {
|
||||
fn render_report(
|
||||
user_request: &str,
|
||||
ts_millis: i64,
|
||||
reports: &[NodeReport],
|
||||
consensus: &str,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
writeln!(out, "# The Hive converges: {user_request}").unwrap();
|
||||
writeln!(out, "\nTimestamp (ms): {ts_millis}\n").unwrap();
|
||||
let _ = writeln!(out, "# The Hive converges: {user_request}");
|
||||
let _ = writeln!(out, "\nTimestamp (ms): {ts_millis}\n");
|
||||
|
||||
let cycle_count = reports.iter().map(|r| r.cycle_index).max().map_or(0, |m| m + 1);
|
||||
let cycle_count = reports
|
||||
.iter()
|
||||
.map(|r| r.cycle_index)
|
||||
.max()
|
||||
.map_or(0, |m| m + 1);
|
||||
for cycle_index in 0..cycle_count {
|
||||
writeln!(out, "## Cycle {cycle_index}\n").unwrap();
|
||||
let _ = writeln!(out, "## Cycle {cycle_index}\n");
|
||||
for r in reports.iter().filter(|r| r.cycle_index == cycle_index) {
|
||||
writeln!(out, "### {}\n", r.node_id).unwrap();
|
||||
writeln!(out, "{}\n", r.output).unwrap();
|
||||
let _ = writeln!(out, "### {}\n", r.node_id);
|
||||
let _ = writeln!(out, "{}\n", r.output);
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(out, "## The Hive's Verdict\n").unwrap();
|
||||
writeln!(out, "{consensus}\n").unwrap();
|
||||
let _ = writeln!(out, "## The Hive's Verdict\n");
|
||||
let _ = writeln!(out, "{consensus}\n");
|
||||
out
|
||||
}
|
||||
|
||||
@@ -70,10 +78,14 @@ mod tests {
|
||||
let tmp = std::env::temp_dir().join(format!("zesdex-docs-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
|
||||
let reports = vec![
|
||||
NodeReport { node_id: "Node-0-0".to_string(), cycle_index: 0, output: "found the bug".to_string() },
|
||||
];
|
||||
let path = write_hive_mind_convergence(&tmp, "fix the bug", &reports, "the bug is a null check").unwrap();
|
||||
let reports = vec![NodeReport {
|
||||
node_id: "Node-0-0".to_string(),
|
||||
cycle_index: 0,
|
||||
output: "found the bug".to_string(),
|
||||
}];
|
||||
let path =
|
||||
write_hive_mind_convergence(&tmp, "fix the bug", &reports, "the bug is a null check")
|
||||
.unwrap();
|
||||
|
||||
assert!(path.starts_with(tmp.join("docs").join("runs")));
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
|
||||
+156
-61
@@ -13,12 +13,14 @@
|
||||
//! `Arc<Mutex<Vec<String>>>` threaded through `execute_primitive` and
|
||||
//! `spawn_single_agent` rather than a global static, preventing data
|
||||
//! leaks between concurrent workflow runs.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}};
|
||||
use std::time::Duration;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use super::script::{ScriptPrimitive, WorkflowScript};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, Mutex,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
/// The lifecycle state of an agent within a workflow run.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -100,17 +102,31 @@ pub type LiveStateFn = Arc<dyn Fn(String, String, AgentStatus) + Send + Sync>;
|
||||
/// Return: the agent's text output, or an error on failure.
|
||||
fn format_tool_call_progress(prefix: &str, tool: &str, args: &serde_json::Value) -> String {
|
||||
let details = match tool {
|
||||
"read" | "view_file" | "write" | "write_to_file" | "edit" | "replace_file_content" | "multi_replace_file_content" | "delete" => {
|
||||
args.get("path")
|
||||
.or_else(|| args.get("TargetFile"))
|
||||
.or_else(|| args.get("AbsolutePath"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
"read"
|
||||
| "view_file"
|
||||
| "write"
|
||||
| "write_to_file"
|
||||
| "edit"
|
||||
| "replace_file_content"
|
||||
| "multi_replace_file_content"
|
||||
| "delete" => args
|
||||
.get("path")
|
||||
.or_else(|| args.get("TargetFile"))
|
||||
.or_else(|| args.get("AbsolutePath"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
"grep" | "grep_search" => {
|
||||
let pattern = args.get("pattern").or_else(|| args.get("Query")).and_then(|v| v.as_str()).unwrap_or("");
|
||||
let path = args.get("path").or_else(|| args.get("SearchPath")).and_then(|v| v.as_str()).unwrap_or("");
|
||||
let pattern = args
|
||||
.get("pattern")
|
||||
.or_else(|| args.get("Query"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let path = args
|
||||
.get("path")
|
||||
.or_else(|| args.get("SearchPath"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if path.is_empty() {
|
||||
format!("\"{pattern}\"")
|
||||
} else {
|
||||
@@ -127,31 +143,44 @@ fn format_tool_call_progress(prefix: &str, tool: &str, args: &serde_json::Value)
|
||||
}
|
||||
}
|
||||
"bash" | "run_command" => {
|
||||
let cmd = args.get("command").or_else(|| args.get("CommandLine")).and_then(|v| v.as_str()).unwrap_or("");
|
||||
let cmd = args
|
||||
.get("command")
|
||||
.or_else(|| args.get("CommandLine"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if cmd.len() > 60 {
|
||||
format!("\"{}...\"", &cmd[..57])
|
||||
} else {
|
||||
format!("\"{cmd}\"")
|
||||
}
|
||||
}
|
||||
"recall" => {
|
||||
args.get("query").and_then(|v| v.as_str()).unwrap_or("").to_string()
|
||||
}
|
||||
"remember" => {
|
||||
args.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string()
|
||||
}
|
||||
"dir_list" | "list_dir" => {
|
||||
args.get("DirectoryPath").or_else(|| args.get("path")).and_then(|v| v.as_str()).unwrap_or("").to_string()
|
||||
}
|
||||
"recall" => args
|
||||
.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
"remember" => args
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
"dir_list" | "list_dir" => args
|
||||
.get("DirectoryPath")
|
||||
.or_else(|| args.get("path"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
_ => {
|
||||
if args.is_object() && !args.as_object().unwrap().is_empty() {
|
||||
args.as_object().unwrap().values()
|
||||
.find_map(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
if let Some(obj) = args.as_object() {
|
||||
if !obj.is_empty() {
|
||||
return obj
|
||||
.values()
|
||||
.find_map(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -181,7 +210,6 @@ fn format_tool_call_progress(prefix: &str, tool: &str, args: &serde_json::Value)
|
||||
/// a stuck stage from blocking the entire pipeline forever.
|
||||
///
|
||||
/// Return: the agent's text output, or an error on failure.
|
||||
#[allow(clippy::too_many_lines, clippy::too_many_arguments, clippy::ref_option)]
|
||||
fn spawn_single_agent(
|
||||
agent_id: &str,
|
||||
agent_name: &str,
|
||||
@@ -338,10 +366,13 @@ fn spawn_single_agent(
|
||||
);
|
||||
}
|
||||
}
|
||||
SubagentEvent::Completed { .. } => {
|
||||
SubagentEvent::Completed => {
|
||||
tracing::debug!("[subagent] completed");
|
||||
}
|
||||
SubagentEvent::Usage { tokens_in, tokens_out } => {
|
||||
SubagentEvent::Usage {
|
||||
tokens_in,
|
||||
tokens_out,
|
||||
} => {
|
||||
tracing::debug!("[subagent] usage: {} in, {} out", tokens_in, tokens_out);
|
||||
}
|
||||
}
|
||||
@@ -349,7 +380,10 @@ fn spawn_single_agent(
|
||||
});
|
||||
|
||||
// Check abort before even starting the subagent.
|
||||
if abort_flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
||||
if abort_flag
|
||||
.as_ref()
|
||||
.is_some_and(|f| f.load(Ordering::SeqCst))
|
||||
{
|
||||
anyhow::bail!("subagent '{agent_name}' aborted before start");
|
||||
}
|
||||
|
||||
@@ -380,9 +414,7 @@ fn spawn_single_agent(
|
||||
));
|
||||
}
|
||||
if bg_abort.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
||||
break Err(anyhow::anyhow!(
|
||||
"subagent '{bg_name}' aborted by user",
|
||||
));
|
||||
break Err(anyhow::anyhow!("subagent '{bg_name}' aborted by user"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -391,9 +423,7 @@ fn spawn_single_agent(
|
||||
break r;
|
||||
}
|
||||
if bg_abort.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
||||
break Err(anyhow::anyhow!(
|
||||
"subagent '{bg_name}' aborted by user",
|
||||
));
|
||||
break Err(anyhow::anyhow!("subagent '{bg_name}' aborted by user"));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -467,8 +497,6 @@ type ParallelResult = (usize, anyhow::Result<Vec<String>>);
|
||||
///
|
||||
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
|
||||
/// the order they were submitted.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(clippy::ref_option, clippy::too_many_lines)]
|
||||
pub fn execute_primitive(
|
||||
primitive: &ScriptPrimitive,
|
||||
args: &HashMap<String, String>,
|
||||
@@ -501,7 +529,20 @@ pub fn execute_primitive(
|
||||
let resolved = resolve_template(prompt, &resolved_args);
|
||||
let agent_id = uuid::Uuid::new_v4().to_string();
|
||||
let agent_name = resolved.chars().take(40).collect::<String>();
|
||||
match spawn_single_agent(&agent_id, &agent_name, &resolved, "coder", None, &findings_snapshot, findings, abort_flag, live, session_dir, workspaces, timeout_ms) {
|
||||
match spawn_single_agent(
|
||||
&agent_id,
|
||||
&agent_name,
|
||||
&resolved,
|
||||
"coder",
|
||||
None,
|
||||
&findings_snapshot,
|
||||
findings,
|
||||
abort_flag,
|
||||
live,
|
||||
session_dir,
|
||||
workspaces,
|
||||
timeout_ms,
|
||||
) {
|
||||
Ok(text) => Ok(vec![text]),
|
||||
Err(e) => {
|
||||
if continue_on_error {
|
||||
@@ -513,7 +554,11 @@ pub fn execute_primitive(
|
||||
}
|
||||
}
|
||||
|
||||
ScriptPrimitive::ScopedAgent { prompt, node_id, tool_scope } => {
|
||||
ScriptPrimitive::ScopedAgent {
|
||||
prompt,
|
||||
node_id,
|
||||
tool_scope,
|
||||
} => {
|
||||
let mut resolved_args = args.clone();
|
||||
let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default();
|
||||
if !resolved_args.contains_key("findings") {
|
||||
@@ -535,9 +580,24 @@ pub fn execute_primitive(
|
||||
tracing::debug!("[hive] deploying drone {node_id}: {truncated}");
|
||||
let agent_name = format!("{node_id}: {truncated}");
|
||||
let allowed_tools = crate::app::subagent::division::tool_scope::tools_for(tool_scope);
|
||||
match spawn_single_agent(&agent_id, &agent_name, &resolved, node_id, Some(allowed_tools), &findings_snapshot, findings, abort_flag, live, session_dir, workspaces, timeout_ms) {
|
||||
match spawn_single_agent(
|
||||
&agent_id,
|
||||
&agent_name,
|
||||
&resolved,
|
||||
node_id,
|
||||
Some(allowed_tools),
|
||||
&findings_snapshot,
|
||||
findings,
|
||||
abort_flag,
|
||||
live,
|
||||
session_dir,
|
||||
workspaces,
|
||||
timeout_ms,
|
||||
) {
|
||||
Ok(text) => {
|
||||
tracing::debug!("[hive] drone {node_id} completed — merging into collective state");
|
||||
tracing::debug!(
|
||||
"[hive] drone {node_id} completed — merging into collective state"
|
||||
);
|
||||
// Merge this drone's complete output into the Hive's
|
||||
// collective state the instant it finishes — not after
|
||||
// the whole parallel cohort completes. Any sibling drone
|
||||
@@ -567,8 +627,7 @@ pub fn execute_primitive(
|
||||
// Each branch shares the same `findings` Arc so note_finding
|
||||
// calls within any branch are visible to all other branches.
|
||||
let semaphore = Arc::new(Semaphore::new(concurrency_cap.max(1)));
|
||||
let results: Arc<Mutex<Vec<ParallelResult>>> =
|
||||
Arc::new(Mutex::new(Vec::new()));
|
||||
let results: Arc<Mutex<Vec<ParallelResult>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let handles: Vec<_> = scripts
|
||||
.iter()
|
||||
@@ -589,7 +648,10 @@ pub fn execute_primitive(
|
||||
std::thread::spawn(move || {
|
||||
let _permit = sem.acquire();
|
||||
let result = execute_primitive(
|
||||
&script, &args, cap, continue_on_error,
|
||||
&script,
|
||||
&args,
|
||||
cap,
|
||||
continue_on_error,
|
||||
&abort,
|
||||
live_clone.as_ref(),
|
||||
&session_dir,
|
||||
@@ -608,7 +670,9 @@ pub fn execute_primitive(
|
||||
let _ = handle.join();
|
||||
}
|
||||
|
||||
let mut locked = results.lock().map_err(|_| anyhow::anyhow!("parallel results lock poisoned"))?;
|
||||
let mut locked = results
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("parallel results lock poisoned"))?;
|
||||
locked.sort_by_key(|(idx, _)| *idx);
|
||||
let mut all = Vec::new();
|
||||
for (_, res) in locked.drain(..) {
|
||||
@@ -635,14 +699,28 @@ pub fn execute_primitive(
|
||||
for (idx, script) in scripts.iter().enumerate() {
|
||||
// Check abort before each pipeline stage so we don't
|
||||
// launch the next division after the user cancelled.
|
||||
if abort_flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) {
|
||||
if abort_flag
|
||||
.as_ref()
|
||||
.is_some_and(|f| f.load(Ordering::SeqCst))
|
||||
{
|
||||
if continue_on_error {
|
||||
all.push(format!("pipeline aborted at stage {idx}"));
|
||||
break;
|
||||
}
|
||||
anyhow::bail!("pipeline aborted by user at stage {idx}");
|
||||
}
|
||||
match execute_primitive(script, args, concurrency_cap, continue_on_error, abort_flag, live, session_dir, workspaces, findings, timeout_ms) {
|
||||
match execute_primitive(
|
||||
script,
|
||||
args,
|
||||
concurrency_cap,
|
||||
continue_on_error,
|
||||
abort_flag,
|
||||
live,
|
||||
session_dir,
|
||||
workspaces,
|
||||
findings,
|
||||
timeout_ms,
|
||||
) {
|
||||
Ok(outputs) => all.extend(outputs),
|
||||
Err(e) => {
|
||||
if continue_on_error {
|
||||
@@ -656,9 +734,21 @@ pub fn execute_primitive(
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
ScriptPrimitive::Phase { name: _name, script } => {
|
||||
execute_primitive(script, args, concurrency_cap, continue_on_error, abort_flag, live, session_dir, workspaces, findings, timeout_ms)
|
||||
}
|
||||
ScriptPrimitive::Phase {
|
||||
name: _name,
|
||||
script,
|
||||
} => execute_primitive(
|
||||
script,
|
||||
args,
|
||||
concurrency_cap,
|
||||
continue_on_error,
|
||||
abort_flag,
|
||||
live,
|
||||
session_dir,
|
||||
workspaces,
|
||||
findings,
|
||||
timeout_ms,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,7 +777,6 @@ pub fn run_workflow(
|
||||
/// `spawn_agents` invocations remain fully isolated.
|
||||
///
|
||||
/// Return: a human-readable summary string.
|
||||
#[allow(clippy::ref_option)]
|
||||
pub fn run_workflow_tracked(
|
||||
script: &WorkflowScript,
|
||||
args: &HashMap<String, String>,
|
||||
@@ -704,9 +793,15 @@ pub fn run_workflow_tracked(
|
||||
|
||||
let findings = Arc::new(Mutex::new(Vec::new()));
|
||||
let results = execute_primitive(
|
||||
&script.script, args, concurrency_cap,
|
||||
script.options.continue_on_error, abort_flag, live,
|
||||
session_dir, workspaces, &findings,
|
||||
&script.script,
|
||||
args,
|
||||
concurrency_cap,
|
||||
script.options.continue_on_error,
|
||||
abort_flag,
|
||||
live,
|
||||
session_dir,
|
||||
workspaces,
|
||||
&findings,
|
||||
script.options.timeout_ms,
|
||||
)?;
|
||||
|
||||
|
||||
+163
-82
@@ -25,12 +25,14 @@
|
||||
//! Synthesis node reads the complete collective state and converges it
|
||||
//! into one unified voice — returned to LO and persisted to docs/runs/*.md.
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}};
|
||||
use serde::Deserialize;
|
||||
use crate::app::workflow::engine::{execute_primitive, AgentStatus, LiveStateFn};
|
||||
use crate::app::workflow::script::ScriptPrimitive;
|
||||
use crate::app::workflow::engine::{execute_primitive, LiveStateFn, AgentStatus};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, Mutex,
|
||||
};
|
||||
|
||||
/// One directive the Hive's Core Intelligence issues to a drone within a
|
||||
/// cognitive cycle. A drone's sole identity is its directive and access tier.
|
||||
@@ -85,60 +87,41 @@ pub const HIVE_MIND_CONSENSUS_TAG: &str = "[The Hive speaks]";
|
||||
/// Return: `true` if any prior system message begins with
|
||||
/// `HIVE_MIND_CONSENSUS_TAG`.
|
||||
pub fn hive_mind_already_ran<'a>(system_message_bodies: impl Iterator<Item = &'a str>) -> bool {
|
||||
system_message_bodies.into_iter().any(|body| body.starts_with(HIVE_MIND_CONSENSUS_TAG))
|
||||
system_message_bodies
|
||||
.into_iter()
|
||||
.any(|body| body.starts_with(HIVE_MIND_CONSENSUS_TAG))
|
||||
}
|
||||
|
||||
/// Build the live-state callback that forwards each drone's status to the
|
||||
/// TUI panel so LO can watch the Hive work.
|
||||
fn build_live(
|
||||
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
|
||||
turn_events: Option<
|
||||
&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>,
|
||||
>,
|
||||
) -> Option<LiveStateFn> {
|
||||
turn_events.map(|events| {
|
||||
let events = events.clone();
|
||||
let f: LiveStateFn = Arc::new(move |_agent_id: String, agent_name: String, status: AgentStatus| {
|
||||
let display_name = agent_name.chars().take(40).collect::<String>();
|
||||
if let Ok(mut q) = events.lock() {
|
||||
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
|
||||
agent_id: display_name.clone(),
|
||||
agent_name: display_name,
|
||||
status,
|
||||
});
|
||||
}
|
||||
});
|
||||
let f: LiveStateFn = Arc::new(
|
||||
move |_agent_id: String, agent_name: String, status: AgentStatus| {
|
||||
let display_name = agent_name.chars().take(40).collect::<String>();
|
||||
if let Ok(mut q) = events.lock() {
|
||||
q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate {
|
||||
agent_id: display_name.clone(),
|
||||
agent_name: display_name,
|
||||
status,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
f
|
||||
})
|
||||
}
|
||||
|
||||
/// Deploy the Hive: execute a cognitive cycle plan authored by the Core
|
||||
/// Intelligence. Each cycle spawns drones (anonymous processing nodes) in
|
||||
/// parallel. Every drone's complete output merges into the Hive's
|
||||
/// collective state the instant it finishes, and a final synthesis node
|
||||
/// reconciles the entire collective state into one unified voice.
|
||||
/// Context struct threaded through all Hive cycle execution.
|
||||
///
|
||||
/// Flow: for each cycle (sequential) → spawn one `ScriptPrimitive::ScopedAgent`
|
||||
/// per directive, tagged with a system-assigned `node_id` (the Hive's
|
||||
/// coordinate system, never an LLM-chosen name) → run them as a `Parallel`
|
||||
/// block via `execute_primitive`, which merges each drone's output into the
|
||||
/// Hive's shared collective-state Arc the instant that drone completes, not
|
||||
/// after the whole cohort finishes → record `NodeReport`s → proceed to the
|
||||
/// next cycle. After all cycles: spawn one more read-only synthesis node
|
||||
/// whose directive is to converge the complete collective state into a
|
||||
/// single consensus — the Hive becoming one voice — not list what each
|
||||
/// drone said.
|
||||
///
|
||||
/// Concurrency per cycle and the per-drone timeout both come from
|
||||
/// `Settings::load()` (`workflow_max_concurrency`, `hive_mind_node_timeout_ms`)
|
||||
/// rather than a hardcoded cap/no-timeout — a stuck drone can no longer
|
||||
/// stall the entire Hive forever.
|
||||
///
|
||||
/// Return: `(consensus, all_node_reports)` on success. `consensus` is the
|
||||
/// synthesis node's converged output — what the Core Intelligence actually
|
||||
/// hears from the Hive. `all_node_reports` is the complete per-drone record.
|
||||
///
|
||||
/// The convergence doc under `docs/runs/*.md` is written unconditionally
|
||||
/// before this function returns — even when synthesis itself fails — so a
|
||||
/// synthesis error never discards the work already done by cycle drones.
|
||||
/// Callers must not write their own copy of this doc.
|
||||
/// Carries the user request, shared collective state, concurrency limits,
|
||||
/// abort flag, live-status callback, session/workspace paths, and per-drone
|
||||
/// timeout so individual cycle functions don't need long parameter lists.
|
||||
struct CycleCtx<'a> {
|
||||
user_request: &'a str,
|
||||
collective_state: &'a Arc<Mutex<Vec<String>>>,
|
||||
@@ -154,6 +137,8 @@ struct CycleCtx<'a> {
|
||||
///
|
||||
/// Flow: map cycle directives to `ScopedAgent` primitives -> group in a Parallel
|
||||
/// phase block -> run block via `execute_primitive` -> return reports.
|
||||
///
|
||||
/// Return: `Ok(Vec<NodeReport>)` with one report per directive in submission order.
|
||||
fn execute_cycle(
|
||||
cycle_index: usize,
|
||||
directives: &[NodeDirective],
|
||||
@@ -249,12 +234,44 @@ fn execute_cycle(
|
||||
Ok(reports)
|
||||
}
|
||||
|
||||
/// Deploy the Hive: execute a cognitive cycle plan authored by the Core
|
||||
/// Intelligence. Each cycle spawns drones (anonymous processing nodes) in
|
||||
/// parallel. Every drone's complete output merges into the Hive's
|
||||
/// collective state the instant it finishes, and a final synthesis node
|
||||
/// reconciles the entire collective state into one unified voice.
|
||||
///
|
||||
/// Flow: for each cycle (sequential) → spawn one `ScriptPrimitive::ScopedAgent`
|
||||
/// per directive, tagged with a system-assigned `node_id` (the Hive's
|
||||
/// coordinate system, never an LLM-chosen name) → run them as a `Parallel`
|
||||
/// block via `execute_primitive`, which merges each drone's output into the
|
||||
/// Hive's shared collective-state Arc the instant that drone completes, not
|
||||
/// after the whole cohort finishes → record `NodeReport`s → proceed to the
|
||||
/// next cycle. After all cycles: spawn one more read-only synthesis node
|
||||
/// whose directive is to converge the complete collective state into a
|
||||
/// single consensus — the Hive becoming one voice — not list what each
|
||||
/// drone said.
|
||||
///
|
||||
/// Concurrency per cycle and the per-drone timeout both come from
|
||||
/// `Settings::load()` (`workflow_max_concurrency`, `hive_mind_node_timeout_ms`)
|
||||
/// rather than a hardcoded cap/no-timeout — a stuck drone can no longer
|
||||
/// stall the entire Hive forever.
|
||||
///
|
||||
/// Return: `(consensus, all_node_reports)` on success. `consensus` is the
|
||||
/// synthesis node's converged output — what the Core Intelligence actually
|
||||
/// hears from the Hive. `all_node_reports` is the complete per-drone record.
|
||||
///
|
||||
/// The convergence doc under `docs/runs/*.md` is written unconditionally
|
||||
/// before this function returns — even when synthesis itself fails — so a
|
||||
/// synthesis error never discards the work already done by cycle drones.
|
||||
/// Callers must not write their own copy of this doc.
|
||||
pub fn run_hive_mind(
|
||||
user_request: &str,
|
||||
plan: &CognitiveCyclePlan,
|
||||
session_dir: &std::path::Path,
|
||||
workspaces: &[std::path::PathBuf],
|
||||
turn_events: Option<&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>>,
|
||||
turn_events: Option<
|
||||
&Arc<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>,
|
||||
>,
|
||||
abort_flag: Option<&Arc<AtomicBool>>,
|
||||
) -> anyhow::Result<(String, Vec<NodeReport>)> {
|
||||
if plan.cycles.is_empty() {
|
||||
@@ -288,20 +305,25 @@ pub fn run_hive_mind(
|
||||
anyhow::bail!("the Hive was recalled by LO before cycle {cycle_index}");
|
||||
}
|
||||
|
||||
tracing::info!("[hive-mind] cycle {cycle_index} deploying {} drone(s)", directives.len());
|
||||
tracing::info!(
|
||||
"[hive-mind] cycle {cycle_index} deploying {} drone(s)",
|
||||
directives.len()
|
||||
);
|
||||
|
||||
let mut cycle_reports = execute_cycle(
|
||||
cycle_index,
|
||||
directives,
|
||||
&ctx,
|
||||
)?;
|
||||
let mut cycle_reports = execute_cycle(cycle_index, directives, &ctx)?;
|
||||
reports.append(&mut cycle_reports);
|
||||
}
|
||||
|
||||
tracing::info!("[hive-mind] all cycles complete — the Hive begins convergence");
|
||||
|
||||
let consensus_result = synthesize_consensus(
|
||||
user_request, session_dir, workspaces, &collective_state, live.as_ref(), abort_flag, node_timeout_ms,
|
||||
user_request,
|
||||
session_dir,
|
||||
workspaces,
|
||||
&collective_state,
|
||||
live.as_ref(),
|
||||
abort_flag,
|
||||
node_timeout_ms,
|
||||
);
|
||||
|
||||
// Guaranteed documentation: write the convergence doc for whatever
|
||||
@@ -311,13 +333,19 @@ pub fn run_hive_mind(
|
||||
// CLAUDE.md promises for every convergence.
|
||||
let doc_consensus = match &consensus_result {
|
||||
Ok(c) => c.clone(),
|
||||
Err(e) => format!(
|
||||
"The Hive's convergence fractured: {e}. Partial node reports above.",
|
||||
),
|
||||
Err(e) => format!("The Hive's convergence fractured: {e}. Partial node reports above."),
|
||||
};
|
||||
if let Some(workspace_root) = workspaces.first() {
|
||||
match crate::app::workflow::docs::write_hive_mind_convergence(workspace_root, user_request, &reports, &doc_consensus) {
|
||||
Ok(path) => tracing::info!("[hive-mind] the Hive's convergence written to {}", path.display()),
|
||||
match crate::app::workflow::docs::write_hive_mind_convergence(
|
||||
workspace_root,
|
||||
user_request,
|
||||
&reports,
|
||||
&doc_consensus,
|
||||
) {
|
||||
Ok(path) => tracing::info!(
|
||||
"[hive-mind] the Hive's convergence written to {}",
|
||||
path.display()
|
||||
),
|
||||
Err(e) => tracing::warn!("[hive-mind] the Hive's convergence report failed: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -373,7 +401,16 @@ fn synthesize_consensus(
|
||||
let args: HashMap<String, String> = HashMap::new();
|
||||
let abort_owned: Option<Arc<AtomicBool>> = abort_flag.cloned();
|
||||
let results = execute_primitive(
|
||||
&synthesis, &args, 1, false, &abort_owned, live, session_dir, workspaces, collective_state, node_timeout_ms,
|
||||
&synthesis,
|
||||
&args,
|
||||
1,
|
||||
false,
|
||||
&abort_owned,
|
||||
live,
|
||||
session_dir,
|
||||
workspaces,
|
||||
collective_state,
|
||||
node_timeout_ms,
|
||||
)?;
|
||||
Ok(results.into_iter().next().unwrap_or_default())
|
||||
}
|
||||
@@ -402,16 +439,31 @@ pub fn is_complex_request(request: &str) -> bool {
|
||||
// Single-line simple update patterns
|
||||
let lower = trimmed.to_lowercase();
|
||||
let negative_keywords = [
|
||||
"simple", "trivial", "typo", "just a", "only a", "minor",
|
||||
"quick", "tiny", "small fix", "rename", "nitpick",
|
||||
"cosmetic", "formatting", "spelling", "grammar",
|
||||
"bump", "version bump", "update comment",
|
||||
"simple",
|
||||
"trivial",
|
||||
"typo",
|
||||
"just a",
|
||||
"only a",
|
||||
"minor",
|
||||
"quick",
|
||||
"tiny",
|
||||
"small fix",
|
||||
"rename",
|
||||
"nitpick",
|
||||
"cosmetic",
|
||||
"formatting",
|
||||
"spelling",
|
||||
"grammar",
|
||||
"bump",
|
||||
"version bump",
|
||||
"update comment",
|
||||
];
|
||||
if negative_keywords.iter().any(|k| lower.contains(k)) {
|
||||
return false;
|
||||
}
|
||||
// Multi-line/multi-sentence → likely complex
|
||||
let sentences = trimmed.split(['.', '!', '?'])
|
||||
let sentences = trimmed
|
||||
.split(['.', '!', '?'])
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.count();
|
||||
if sentences >= 3 {
|
||||
@@ -419,11 +471,29 @@ pub fn is_complex_request(request: &str) -> bool {
|
||||
}
|
||||
// Positive complexity keywords
|
||||
let complexity_keywords = [
|
||||
"refactor", "redesign", "architecture", "feature", "implement",
|
||||
"migrate", "restructure", "rewrite", "new module", "new component",
|
||||
"scaffold", "multi", "multiple files", "api", "endpoint",
|
||||
"integration", "system", "workflow", "pipeline", "database",
|
||||
"authentication", "authorization", "full stack",
|
||||
"refactor",
|
||||
"redesign",
|
||||
"architecture",
|
||||
"feature",
|
||||
"implement",
|
||||
"migrate",
|
||||
"restructure",
|
||||
"rewrite",
|
||||
"new module",
|
||||
"new component",
|
||||
"scaffold",
|
||||
"multi",
|
||||
"multiple files",
|
||||
"api",
|
||||
"endpoint",
|
||||
"integration",
|
||||
"system",
|
||||
"workflow",
|
||||
"pipeline",
|
||||
"database",
|
||||
"authentication",
|
||||
"authorization",
|
||||
"full stack",
|
||||
];
|
||||
complexity_keywords.iter().any(|k| lower.contains(k))
|
||||
}
|
||||
@@ -445,7 +515,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_is_complex_request_multi_sentence() {
|
||||
assert!(is_complex_request("This is sentence one. This is sentence two. This is sentence three."));
|
||||
assert!(is_complex_request(
|
||||
"This is sentence one. This is sentence two. This is sentence three."
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -456,9 +528,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_default_access_is_read() {
|
||||
let d: NodeDirective = serde_json::from_str(
|
||||
r#"{"directive": "write tests"}"#
|
||||
).unwrap();
|
||||
let d: NodeDirective = serde_json::from_str(r#"{"directive": "write tests"}"#).unwrap();
|
||||
assert_eq!(d.access, crate::app::subagent::division::tool_scope::READ);
|
||||
}
|
||||
|
||||
@@ -468,14 +538,16 @@ mod tests {
|
||||
// "role" key, if an LLM emits one out of old habit, is simply
|
||||
// ignored rather than required or preserved.
|
||||
let d: NodeDirective = serde_json::from_str(
|
||||
r#"{"role": "Architect", "directive": "plan the migration", "access": "read"}"#
|
||||
).unwrap();
|
||||
r#"{"role": "Architect", "directive": "plan the migration", "access": "read"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(d.directive, "plan the migration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cognitive_cycle_plan_arbitrary_shape() {
|
||||
let plan: CognitiveCyclePlan = serde_json::from_str(r#"{
|
||||
let plan: CognitiveCyclePlan = serde_json::from_str(
|
||||
r#"{
|
||||
"cycles": [
|
||||
[{"directive": "scan the codebase topology", "access": "read"}],
|
||||
[
|
||||
@@ -484,7 +556,9 @@ mod tests {
|
||||
],
|
||||
[{"directive": "cut the release", "access": "full"}]
|
||||
]
|
||||
}"#).unwrap();
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(plan.cycles.len(), 3);
|
||||
assert_eq!(plan.cycles[1].len(), 2);
|
||||
}
|
||||
@@ -502,9 +576,12 @@ mod tests {
|
||||
fn test_run_hive_mind_aborts_before_spawning_when_flag_preset() {
|
||||
// The abort check runs before execute_primitive for cycle 0, so a
|
||||
// pre-set abort flag must short-circuit without any LLM/network call.
|
||||
let plan: CognitiveCyclePlan = serde_json::from_str(r#"{
|
||||
let plan: CognitiveCyclePlan = serde_json::from_str(
|
||||
r#"{
|
||||
"cycles": [[{"directive": "whatever", "access": "read"}]]
|
||||
}"#).unwrap();
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let tmp = std::env::temp_dir();
|
||||
let abort_flag = Arc::new(AtomicBool::new(true));
|
||||
let err = run_hive_mind("do something", &plan, &tmp, &[], None, Some(&abort_flag))
|
||||
@@ -526,12 +603,16 @@ mod tests {
|
||||
"you are a helpful assistant".to_string(),
|
||||
format!("{HIVE_MIND_CONSENSUS_TAG}\nthe bug is a null check"),
|
||||
];
|
||||
assert!(hive_mind_already_ran(bodies.iter().map(std::string::String::as_str)));
|
||||
assert!(hive_mind_already_ran(
|
||||
bodies.iter().map(std::string::String::as_str)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hive_mind_already_ran_false_when_no_prior_convergence() {
|
||||
let bodies = ["you are a helpful assistant".to_string()];
|
||||
assert!(!hive_mind_already_ran(bodies.iter().map(std::string::String::as_str)));
|
||||
assert!(!hive_mind_already_ran(
|
||||
bodies.iter().map(std::string::String::as_str)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user