docs(comments): rewrite comments across all crates to the guidelines

Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
This commit is contained in:
2026-07-23 16:55:39 -04:00
parent ff0fb56c67
commit a02b555e66
1458 changed files with 10729 additions and 21750 deletions
+5 -13
View File
@@ -2,7 +2,6 @@
use std::sync::Arc;
/// Bearer prefix length shared across crate boundaries.
pub const SENT_BEARER_PREFIX_LEN: usize = 12;
/// Which tool endpoint produced the 401.
@@ -19,19 +18,15 @@ impl ToolConsumer {
}
}
/// 401 attribution callback. Shell wires this to emit telemetry.
/// Implemented by the shell, which turns these events into telemetry.
pub trait Auth401AttributionCallback: Send + Sync + std::fmt::Debug {
/// `sent_bearer_prefix` is truncated to [`SENT_BEARER_PREFIX_LEN`]
/// before crossing this boundary. `None` = no bearer was sent.
fn record_401(&self, consumer: ToolConsumer, sent_bearer_prefix: Option<&str>);
}
/// Shared, cheap-to-clone alias for the attribution callback.
pub type SharedAttributionCallback = Arc<dyn Auth401AttributionCallback>;
/// Record a 401 attribution event if a callback is wired. Truncates
/// the bearer to [`SENT_BEARER_PREFIX_LEN`] before crossing the
/// trait boundary.
pub(crate) fn emit_401(
callback: Option<&SharedAttributionCallback>,
consumer: ToolConsumer,
@@ -43,14 +38,11 @@ pub(crate) fn emit_401(
}
}
/// Truncate a bearer string to the first [`SENT_BEARER_PREFIX_LEN`]
/// characters. Used by tool clients before passing the bearer across
/// the [`Auth401AttributionCallback`] boundary.
/// Truncate a bearer to [`SENT_BEARER_PREFIX_LEN`] bytes.
///
/// Bearer tokens are ASCII (per the `Authorization` header grammar)
/// so the byte index is always a char boundary; this function uses
/// `String::truncate` which would otherwise panic on a non-boundary
/// cut.
/// `String::truncate` panics when the cut is not a char boundary; bearer
/// tokens are ASCII per the `Authorization` header grammar, so every byte
/// index is a boundary.
pub(crate) fn truncate_to_prefix(mut bearer: String) -> String {
bearer.truncate(SENT_BEARER_PREFIX_LEN.min(bearer.len()));
bearer
+69 -135
View File
@@ -1,10 +1,5 @@
//! ToolBridge: adapter that wraps `kigi-tools`'s `ToolRegistry` and
//! exposes it through a session layer.
//!
//! The bridge:
//! 1. Owns a `ToolRegistry` with all built-in tools registered
//! 2. Dispatches tool calls via `call_new_tool()`
//! 3. Manages tool definitions, enable/disable, name overrides
//! Adapter that owns a finalized `ToolRegistry` and exposes tool dispatch,
//! definitions and tracker state to the session layer.
use std::sync::Arc;
@@ -23,16 +18,11 @@ use crate::types::resources::{OwnerSessionId, State, Terminal};
use crate::types::template_renderer::TemplateRenderer;
use crate::types::tool::ToolKind;
/// Result of executing a tool through the bridge.
///
/// Carries all the data the session needs to:
/// 1. Send ACP notifications (from `output`)
/// 2. Build the model prompt (from `prompt_text`)
#[derive(Debug)]
pub struct ToolBridgeResult {
/// Clean tool output — for JSON serialization, ACP conversion, hunk tracking.
pub output: ToolOutput,
/// Prompt-ready text — with system reminders appended.
/// Same output with system reminders appended, for the model prompt.
pub prompt_text: String,
}
@@ -45,17 +35,14 @@ impl From<ToolRunResult> for ToolBridgeResult {
}
}
/// Bridges the `ToolRegistry` into a session layer.
///
/// Owns the registry and dispatches tool calls via `call_new_tool()`.
/// All state lives in `Resources` on the registry — no separate `ToolState`.
/// All tool state lives in `Resources` on the registry — there is no separate
/// `ToolState`.
///
/// # Cancellation Safety
///
/// The `terminal` field is stored separately from the registry lock to enable
/// cancellation during tool execution. When a bash command is running, the
/// registry lock is held by `call()`. If the user cancels, `kill_foreground_commands()`
/// needs to access the terminal without blocking on the lock.
/// `terminal` is held here as well as in the registry: while a bash command
/// runs, `call()` holds the registry lock, and `kill_foreground_commands()`
/// must reach the terminal without blocking on it.
#[derive(Clone)]
pub struct ToolBridge {
registry: Arc<FinalizedToolset>,
@@ -98,15 +85,10 @@ impl ToolBridge {
self.registry.tool_definitions()
}
/// Returns the client-facing name of the tool registered with the given
/// `ToolKind`, if any. Looks up the kind->name map populated by
/// `FinalizedToolset` from each tool's `kind()`. Useful for "does this
/// agent have a way to do X?" checks where the X is identified by kind
/// rather than by namespaced id.
///
/// Example: `tool_for_kind(ToolKind::BackgroundTaskAction)` returns
/// `Some("get_task_output")` for the kigi agent and `None` for
/// agents that do not register a tool of that kind.
/// Client-facing name of the tool registered with the given `ToolKind`,
/// for "does this agent have a way to do X?" checks where X is identified
/// by kind rather than by namespaced id. `None` when no registered tool
/// has that kind.
pub async fn tool_for_kind(&self, kind: ToolKind) -> Option<String> {
self.registry
.resources
@@ -116,26 +98,21 @@ impl ToolBridge {
.and_then(|r| r.tool_for_kind(kind).map(str::to_string))
}
/// [`ToolKind`] for a registered tool by client-facing name, or
/// `None` for unknown names. Sync — uses the registry's
/// `RwLock::read`.
/// [`ToolKind`] for a registered tool by client-facing name, or `None` for
/// unknown names. Name matching is exact and case-sensitive.
pub fn tool_kind(&self, tool_name: &str) -> Option<ToolKind> {
self.registry.get_tool_metadata(tool_name).map(|m| m.kind())
}
/// Get only built-in tool definitions (exclude MCP tools).
pub async fn tool_definitions_builtins_only(&self) -> Vec<ToolDefinition> {
self.registry.tool_definitions_builtins_only()
}
/// Render a prompt template through [`TemplateRenderer`] with extra
/// agent-specific context fields.
/// The template may mix `${{ tools.by_kind.* }}`, resolved from the
/// finalized registry, with caller-provided fields such as
/// `${{ os_name }}` supplied in `placeholders`.
///
/// The template can use both `${{ tools.by_kind.* }}` (resolved from
/// the finalized tool registry) and caller-provided fields like
/// `${{ os_name }}`, `${{ memory_enabled }}`, etc.
///
/// Returns `None` if the renderer is not yet available.
/// `None` when the renderer is not yet available.
pub async fn render_prompt(
&self,
template: &str,
@@ -181,11 +158,6 @@ impl ToolBridge {
self.registry.unregister_tool_by_name(name)
}
/// Access the underlying `FinalizedToolset`.
///
/// Used by `WorkspaceOps::bind_local_session` to install the agent's
/// toolset on the workspace session so local-mode tool calls dispatch
/// through the workspace.
pub fn toolset(&self) -> Arc<FinalizedToolset> {
Arc::clone(&self.registry)
}
@@ -211,10 +183,8 @@ impl ToolBridge {
.await
}
/// Seed the AGENTS.md tracker.
///
/// `compat` gates which rules dirs and agent filenames runtime discovery
/// scans. Defaults to all-on at the caller for historical behavior.
/// scans; callers default it to all-on.
pub async fn seed_agents_md(
&self,
initial_paths: Vec<std::path::PathBuf>,
@@ -233,10 +203,8 @@ impl ToolBridge {
}
}
/// Restore announced skill names from persisted state.
///
/// Must be called BEFORE `seed_skill_discovery()` so that `seed()`
/// sees non-empty `announced_names` and skips the BaselineChange pending.
/// Must run BEFORE `seed_skill_discovery()` so that `seed()` sees
/// non-empty `announced_names` and skips the BaselineChange pending.
pub async fn restore_announced_skill_names(&self, names: std::collections::HashSet<String>) {
let registry = &*self.registry;
let mut res = registry.resources.lock().await;
@@ -244,7 +212,6 @@ impl ToolBridge {
tracker.restore_announced_names(names);
}
/// Get the current set of announced skill names (for persistence).
pub async fn get_announced_skill_names(&self) -> std::collections::HashSet<String> {
let registry = &*self.registry;
let res = registry.resources.lock().await;
@@ -264,13 +231,12 @@ impl ToolBridge {
.and_then(|t| t.listing_snapshot())
}
/// Seed the SkillDiscoveryTracker with session context and startup skills.
/// Must run at session start so the `SkillDiscoveryReminder` can discover
/// skills in subdirectories.
///
/// Must be called at session start so the `SkillDiscoveryReminder` can
/// discover skills in subdirectories.
/// `display_cwd`: If set (forked sessions), skill paths in
/// model-visible announcements are rewritten from real cwd to this
/// value. Runtime invocation uses the real path.
/// `display_cwd`: when set (forked sessions), skill paths in model-visible
/// announcements are rewritten from the real cwd to this value. Runtime
/// invocation still uses the real path.
pub async fn seed_skill_discovery(
&self,
cwd: Option<std::path::PathBuf>,
@@ -283,8 +249,8 @@ impl ToolBridge {
) {
let registry = &*self.registry;
let mut res = registry.resources.lock().await;
// Resolve client-facing tool names from the template renderer
// so listing headers and descriptions use the correct (possibly randomized) names.
// Listing headers and descriptions must use the client-facing names,
// which may be randomized per session.
let renderer = res.get::<TemplateRenderer>();
let skill_tool_name = renderer.and_then(|r| r.render("${{ tools.by_kind.skill }}").ok());
let read_tool_name = renderer.and_then(|r| r.render("${{ tools.by_kind.read }}").ok());
@@ -306,10 +272,8 @@ impl ToolBridge {
);
}
/// Enable XML formatting for mid-session skill announcements.
///
/// When set, `take_pending()` produces `<agent_skill>` XML rows instead of
/// markdown, matching the startup `<agent_skills>` preamble format.
/// When enabled, `take_pending()` produces `<agent_skill>` XML rows
/// instead of markdown, matching the startup `<agent_skills>` preamble.
pub async fn set_skill_listing_xml_format(&self, enabled: bool) {
let registry = &*self.registry;
let mut res = registry.resources.lock().await;
@@ -341,8 +305,8 @@ impl ToolBridge {
}
}
/// Clear `announced_names` and `checked_dirs` so skills get re-announced
/// and re-discovered after compaction.
/// Clears `announced_names` and `checked_dirs` so skills are re-announced
/// and re-discovered after compaction drops them from the conversation.
pub async fn on_skill_discovery_compaction(&self) {
let registry = &*self.registry;
let mut res = registry.resources.lock().await;
@@ -352,8 +316,8 @@ impl ToolBridge {
}
}
/// Full reset of skill discovery state for /clear.
/// Startup baseline is preserved; a pending reconciliation is queued.
/// Full reset of skill discovery state for /clear: the startup baseline
/// survives and a pending reconciliation is queued.
pub async fn on_skill_discovery_clear(&self) {
let registry = &*self.registry;
let mut res = registry.resources.lock().await;
@@ -363,8 +327,8 @@ impl ToolBridge {
}
}
/// Replace the startup baseline (plugin reload).
/// Dynamic discoveries are preserved; a pending reconciliation is queued.
/// Replace the startup baseline on plugin reload: dynamic discoveries
/// survive and a pending reconciliation is queued.
pub async fn update_skill_baseline(
&self,
new_skills: Vec<crate::implementations::skills::types::SkillInfo>,
@@ -377,17 +341,12 @@ impl ToolBridge {
}
}
/// Apply any pending skill updates.
/// Applies a pending change (discovery, baseline update, /clear) by
/// writing the runtime projection into `AvailableSkills` and handing back
/// the conversation/UI side-effects for the session to execute:
/// system-reminder injection, slash command refresh, prompt finalization.
///
/// If the tracker has a pending change (discovery, baseline update, /clear),
/// this method:
/// 1. Computes runtime and display projections internally.
/// 2. Writes the runtime projection into `AvailableSkills` in Resources.
/// 3. Returns `SkillUpdateEffects` with conversation/UI side-effects
/// for the session to execute (system-reminder injection, slash
/// command refresh, prompt finalization).
///
/// Returns `None` if nothing changed.
/// `None` when nothing is pending.
pub async fn apply_pending_skill_update(
&self,
) -> Option<crate::types::skill_discovery_tracker::SkillUpdateEffects> {
@@ -396,17 +355,16 @@ impl ToolBridge {
let tracker = res.get_mut::<crate::types::skill_discovery_tracker::SkillManager>()?;
let (runtime_skills, effects) = tracker.take_pending()?;
// Write the runtime projection directly -- the shell never sees this.
// The runtime projection stays inside Resources; the shell only ever
// sees the effects returned below.
res.insert(crate::types::resources::AvailableSkills(runtime_skills));
Some(effects)
}
/// Get the current display-deduped skill list for slash commands.
///
/// Returns the combined (startup + discovered) list with canonical-path
/// and name dedup applied. This is the authoritative source for slash
/// command advertisement — PromptContext is NOT used.
/// Combined startup + discovered skills with canonical-path and name
/// dedup applied. Authoritative source for slash command advertisement —
/// `PromptContext` is NOT used for it.
pub async fn slash_skills(&self) -> Vec<crate::implementations::skills::types::SkillInfo> {
let registry = &*self.registry;
let res = registry.resources.lock().await;
@@ -415,7 +373,6 @@ impl ToolBridge {
.unwrap_or_default()
}
/// Get the paths that have been reminded about.
pub async fn agents_md_reminded_paths(&self) -> std::collections::HashSet<std::path::PathBuf> {
let registry = &*self.registry;
let result;
@@ -431,11 +388,9 @@ impl ToolBridge {
result
}
/// Set the stable display path for forked sessions.
///
/// Inserts [`DisplayCwd`] into the tool registry's [`Resources`] so that
/// tools can use [`resolve_model_path`] and [`display_cwd_or_cwd`] to
/// rewrite model-provided paths and format output paths correctly.
/// Stable display path for forked sessions. Tools read the inserted
/// [`DisplayCwd`] through `resolve_model_path` / `display_cwd_or_cwd` to
/// rewrite model-provided paths and format output paths.
pub async fn set_display_cwd(&self, display_cwd: std::path::PathBuf) {
let registry = &*self.registry;
registry
@@ -445,8 +400,7 @@ impl ToolBridge {
.insert(crate::types::resources::DisplayCwd(display_cwd));
}
/// List all known background tasks from the terminal backend.
/// Used for context compaction to include task state in summaries.
/// Feeds context compaction, which folds task state into summaries.
pub async fn list_background_tasks(&self) -> Vec<crate::computer::types::TaskSnapshot> {
if let Some(terminal) = &self.terminal {
terminal.list_tasks().await
@@ -455,14 +409,12 @@ impl ToolBridge {
}
}
/// Kill all foreground terminal commands.
pub async fn kill_foreground_commands(&self) {
if let Some(terminal) = &self.terminal {
terminal.kill_foreground_commands().await;
}
}
/// Kill all running foreground processes owned by a specific session.
pub async fn kill_foreground_commands_by_owner(&self, owner_session_id: &str) {
if let Some(terminal) = &self.terminal {
terminal
@@ -471,15 +423,14 @@ impl ToolBridge {
}
}
/// Kill all running background tasks.
pub async fn kill_all_background_tasks(&self) {
if let Some(terminal) = &self.terminal {
terminal.kill_all_background_tasks().await;
}
}
/// Kill all running background tasks owned by a specific session.
/// Used during subagent teardown on a shared terminal backend.
/// Owner-scoped so subagent teardown spares the tasks of other sessions
/// sharing the terminal backend.
pub async fn kill_all_background_tasks_by_owner(&self, owner_session_id: &str) {
if let Some(terminal) = &self.terminal {
terminal
@@ -488,7 +439,6 @@ impl ToolBridge {
}
}
/// Reparent notification handles for tasks owned by `old_owner_session_id`.
pub async fn reparent_notifications(
&self,
old_owner_session_id: &str,
@@ -496,7 +446,8 @@ impl ToolBridge {
new_handle: crate::notification::types::ToolNotificationHandle,
) {
if let Some(terminal) = &self.terminal {
// Weak anchored by this bridge's backend `Arc` (lives as long as the session).
// Weak, so the reparented notifications cannot keep the backend
// alive past the session that owns this `Arc`.
let backend_weak = std::sync::Arc::downgrade(terminal);
terminal
.reparent_notifications(
@@ -509,27 +460,19 @@ impl ToolBridge {
}
}
/// Read a typed resource from the registry.
///
/// Returns `None` if the resource type has never been inserted.
/// The resource is cloned so no lock is held after this returns.
/// `None` if the resource type has never been inserted. The resource is
/// cloned so no lock is held once this returns.
pub async fn read_resource<T: Clone + Send + Sync + 'static>(&self) -> Option<T> {
self.registry.resources.lock().await.get::<T>().cloned()
}
/// Get the shared resources handle for direct access.
/// Used by the skill reconciliation helper which needs to update
/// `AvailableSkills` in the Resources directly.
pub async fn shared_resources(&self) -> crate::types::resources::SharedResources {
self.registry.resources.clone()
}
/// Insert a typed resource into the registry's `Resources`.
/// Used by the host session to inject `ToolIndex` for search_tool.
pub async fn update_resource<T: Send + Sync + 'static>(&self, resource: T) {
let _ = self.registry.update_resource(resource).await;
}
/// Kill any background task
pub async fn kill_background_task(
&self,
task_id: &str,
@@ -571,7 +514,6 @@ impl ToolBridge {
})
}
/// Move a foreground command to background by tool_call_id.
/// Returns `true` if a matching foreground process was found and unblocked.
pub async fn background_foreground_command(&self, tool_call_id: &str) -> bool {
if let Some(terminal) = &self.terminal {
@@ -581,7 +523,8 @@ impl ToolBridge {
}
}
/// Gives the output of all terminal tasks which are managed by the tool bridge
/// `None` when this bridge has no terminal backend at all, as opposed to
/// an empty task list.
pub async fn list_tasks(&self) -> Option<Vec<TaskSnapshot>> {
if let Some(terminal) = &self.terminal {
Some(terminal.list_tasks().await)
@@ -590,9 +533,9 @@ impl ToolBridge {
}
}
/// Drain newly-completed bash background tasks not yet reported.
/// Marks returned tasks in [`ReportedTaskCompletions`] to prevent
/// duplicate reminders from [`TaskCompletionReminder`].
/// Drain newly-completed bash background tasks not yet reported. Returned
/// tasks are marked in [`ReportedTaskCompletions`] so
/// [`TaskCompletionReminder`] does not report them a second time.
pub async fn drain_between_turn_bash_completions(&self) -> Vec<TaskSnapshot> {
let tasks = match self.list_tasks().await {
Some(t) => t,
@@ -610,11 +553,9 @@ impl ToolBridge {
let mut res = self.registry.resources.lock().await;
// Subagents share the parent's terminal backend, so `list_tasks()`
// returns tasks owned by other sessions. Scope the between-turn
// "While you were idle, … background task completed" drain to tasks
// this session owns, mirroring the per-tool-call
// `TaskCompletionReminder` filter — otherwise a parent (or sibling)
// bash task that finished mid-subagent-turn leaks its completion
// also returns tasks owned by other sessions; without the owner scope
// a parent or sibling bash task finishing mid-subagent-turn leaks its
// "While you were idle, … background task completed"
// `<system-reminder>` into the subagent's conversation. The owner
// filter runs before `mark_reported` so the owning session still
// reports the task on its own next turn.
@@ -627,11 +568,9 @@ impl ToolBridge {
.collect()
}
/// Construct a minimal bridge for tests. Has no tools registered.
///
/// Bypasses `ToolRegistryBuilder::finalize()` entirely so this can
/// be called from sync `#[test]` functions that lack a tokio runtime.
/// (`finalize()` spawns background tasks via `tokio::spawn`.)
/// Minimal bridge with no tools registered. Bypasses
/// `ToolRegistryBuilder::finalize()`, which `tokio::spawn`s background
/// tasks, so sync `#[test]` functions without a runtime can call it.
pub fn for_test() -> Self {
let toolset = FinalizedToolset::empty_for_test();
Self {
@@ -703,9 +642,8 @@ mod tests {
let bridge = ToolBridge::for_test();
let toolset = bridge.toolset();
// PascalCase + kigi's snake_case in one registry
// to exercise the lookup on the literal name strings each
// namespace ships.
// PascalCase and snake_case in one registry, to exercise the lookup on
// the literal name strings each namespace ships.
register_fixture(&toolset, "Write", ToolKind::Write, "fixture_write");
register_fixture(
&toolset,
@@ -730,13 +668,9 @@ mod tests {
);
assert_eq!(bridge.tool_kind("not_a_registered_tool"), None);
// Exact client-name lookup is case-sensitive.
assert_eq!(bridge.tool_kind("write"), None);
}
// ── drain_between_turn_bash_completions owner scoping (the "While you
// were idle, … background task completed" path) ──
#[derive(Debug)]
struct MockTerminal {
tasks: Vec<TaskSnapshot>,
@@ -1,7 +1,5 @@
//! Cgroup v2 memory-high monitor for graceful OOM handling.
//!
//! Approach:
//!
//! 1. On startup we create a child cgroup under the current process's cgroup and
//! configure:
//! - `memory.high` = soft limit (the "desired" ceiling)
@@ -34,10 +32,6 @@
/// Matches the POSIX convention: 128 + signal-number (SIGKILL = 9).
pub const PROCESS_OOM_EXIT_CODE: i32 = 137;
// ============================================================================
// Public types (cross-platform)
// ============================================================================
/// Event emitted when `memory.high` is breached and RSS is still above
/// the 90 % buffer threshold.
#[derive(Debug, Clone)]
@@ -48,7 +42,6 @@ pub struct MemoryHighEvent {
pub memory_high_threshold: u64,
}
/// Configuration for cgroup memory limits.
#[derive(Debug, Clone)]
pub struct CgroupMemoryConfig {
/// Soft memory limit (`memory.high`). When a process inside the cgroup
@@ -61,17 +54,12 @@ pub struct CgroupMemoryConfig {
}
impl CgroupMemoryConfig {
/// memory.max = memory.high + headroom
#[cfg(target_os = "linux")]
fn memory_max(&self) -> u64 {
self.memory_high_bytes.saturating_add(self.headroom_bytes)
}
}
// ============================================================================
// Linux implementation
// ============================================================================
#[cfg(target_os = "linux")]
mod linux {
use super::*;
@@ -82,8 +70,6 @@ mod linux {
use tokio::io::unix::AsyncFd;
use tokio::sync::watch;
// ── inotify FFI ──────────────────────────────────────────────────────
unsafe fn inotify_init1(flags: libc::c_int) -> std::io::Result<i32> {
#[allow(clippy::cast_possible_truncation)]
let ret = unsafe { libc::syscall(libc::SYS_inotify_init1, flags) as libc::c_int };
@@ -110,8 +96,6 @@ mod linux {
const IN_MODIFY: u32 = 0x0000_0002;
// ── Inotify wrapper ──────────────────────────────────────────────────
struct Inotify {
fd: AsyncFd<OwnedFd>,
}
@@ -119,6 +103,8 @@ mod linux {
impl Inotify {
fn new() -> std::io::Result<Self> {
let raw = unsafe { inotify_init1(libc::O_NONBLOCK | libc::O_CLOEXEC) }?;
// SAFETY: `raw` comes from a successful `inotify_init1`, so it is a
// live fd that nothing else owns.
let owned = unsafe { OwnedFd::from_raw_fd(raw) };
let fd = AsyncFd::with_interest(owned, Interest::READABLE)?;
Ok(Inotify { fd })
@@ -126,7 +112,9 @@ mod linux {
fn add_watch(&self, path: &std::path::Path) -> std::io::Result<i32> {
let mut bytes = path.as_os_str().as_bytes().to_vec();
bytes.push(0); // NUL-terminate
bytes.push(0);
// SAFETY: `bytes` is NUL-terminated above and outlives the call,
// so the pointer is a valid C string for the kernel to read.
let wd = unsafe {
inotify_add_watch(
self.fd.get_ref().as_raw_fd(),
@@ -139,9 +127,12 @@ mod linux {
async fn wait_and_drain(&self) -> std::io::Result<()> {
let mut guard = self.fd.readable().await?;
// Drain all pending inotify events
// Drain all pending inotify events; the fd is non-blocking, so the
// read returns <= 0 once the queue is empty.
let mut buf = [0u8; 4096];
loop {
// SAFETY: writing at most `buf.len()` bytes into `buf`, through
// an fd kept alive by `guard`.
let n = unsafe {
libc::read(
guard.get_inner().as_raw_fd(),
@@ -159,8 +150,6 @@ mod linux {
}
}
// ── Cgroup helpers ───────────────────────────────────────────────────
/// Read `/proc/self/cgroup` to find our own cgroup path (cgroup v2 unified).
fn read_self_cgroup() -> std::io::Result<String> {
let contents = std::fs::read_to_string("/proc/self/cgroup")?;
@@ -176,7 +165,6 @@ mod linux {
))
}
/// Parse the `high <N>` counter from `memory.events` contents.
fn parse_memory_events_high(contents: &str) -> Option<u64> {
for line in contents.lines() {
if let Some(value) = line.strip_prefix("high ") {
@@ -186,16 +174,12 @@ mod linux {
None
}
// ── CgroupHandle ─────────────────────────────────────────────────────
/// Owns the lifecycle of a child cgroup directory.
pub(crate) struct CgroupHandle {
fs_path: PathBuf,
}
impl CgroupHandle {
/// Create a child cgroup under the current process's cgroup and
/// configure memory limits.
pub(crate) async fn create(config: &CgroupMemoryConfig) -> std::io::Result<Self> {
let self_cgroup = read_self_cgroup()?;
let name = format!("kigi-tools-{}", uuid::Uuid::now_v7());
@@ -203,18 +187,16 @@ mod linux {
tokio::fs::create_dir_all(&fs_path).await?;
// Enable memory + cpu controllers in the child cgroup's parent
// (the parent's subtree_control must list the controllers).
// A controller only works in a child cgroup if the parent's
// subtree_control lists it. Best-effort: it may already be enabled,
// or we may lack permission to touch the parent.
let parent = fs_path.parent().unwrap();
let subtree_ctl = parent.join("cgroup.subtree_control");
// Best-effort; may already be enabled or not permitted.
let _ = tokio::fs::write(&subtree_ctl, "+memory +cpu").await;
// Configure memory.high (soft limit)
let memory_high_path = fs_path.join("memory.high");
tokio::fs::write(&memory_high_path, config.memory_high_bytes.to_string()).await?;
// Configure memory.max (hard limit = high + headroom)
let memory_max_path = fs_path.join("memory.max");
tokio::fs::write(&memory_max_path, config.memory_max().to_string()).await?;
@@ -228,13 +210,11 @@ mod linux {
Ok(CgroupHandle { fs_path })
}
/// Move a process (by PID) into this cgroup.
pub(crate) async fn add_process(&self, pid: u32) -> std::io::Result<()> {
let procs_path = self.fs_path.join("cgroup.procs");
tokio::fs::write(&procs_path, pid.to_string()).await
}
/// Read `memory.current` from this cgroup.
#[allow(dead_code)]
pub(crate) async fn memory_current(&self) -> std::io::Result<u64> {
let s: String = tokio::fs::read_to_string(self.fs_path.join("memory.current")).await?;
@@ -243,7 +223,6 @@ mod linux {
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
/// Filesystem path to this cgroup.
pub(crate) fn path(&self) -> &std::path::Path {
&self.fs_path
}
@@ -252,9 +231,8 @@ mod linux {
impl Drop for CgroupHandle {
fn drop(&mut self) {
let path = self.fs_path.clone();
// Always use tokio::spawn: the cleanup future is Send and Drop
// can fire after the LocalSet has shut down, making spawn_local
// unsafe here.
// `tokio::spawn` rather than `spawn_local`: the cleanup future is
// Send, and Drop can fire after the LocalSet has shut down.
tokio::spawn(async move {
let kill_path = path.join("cgroup.kill");
let _ = tokio::fs::write(&kill_path, "1").await;
@@ -269,8 +247,6 @@ mod linux {
}
}
// ── MemoryHighMonitor ────────────────────────────────────────────────
/// Watches `memory.events` via inotify and signals when the `high`
/// counter increments while RSS is still above 90% of the threshold.
pub(crate) struct MemoryHighMonitor {
@@ -280,7 +256,6 @@ mod linux {
}
impl MemoryHighMonitor {
/// Start monitoring the given cgroup for memory.high events.
pub(crate) async fn start(
cgroup_path: PathBuf,
memory_high_threshold: u64,
@@ -324,23 +299,21 @@ mod linux {
let events_path = cgroup_path.join("memory.events");
let current_path = cgroup_path.join("memory.current");
// Read baseline high counter
// The `high` counter is cumulative and may already be non-zero, so
// only increments past this baseline count as a breach.
let mut last_high_count = Self::read_high_counter(&events_path).await.unwrap_or(0);
loop {
// Block until inotify fires (memory.events was modified)
if inotify.wait_and_drain().await.is_err() {
break;
}
// Read new high counter
let current_high = Self::read_high_counter(&events_path).await.unwrap_or(0);
if current_high <= last_high_count {
continue;
}
last_high_count = current_high;
// Read current memory usage
let memory_current = match tokio::fs::read_to_string(&current_path).await {
Ok(s) => {
let s: String = s;
@@ -357,8 +330,10 @@ mod linux {
memory_current,
memory_high_threshold,
};
// A send error means every receiver is gone, so there is
// nobody left to warn.
if tx.send(Some(event)).is_err() {
break; // receiver dropped
break;
}
}
}
@@ -377,26 +352,17 @@ mod linux {
}
}
// ============================================================================
// Cross-platform re-exports
// ============================================================================
/// Cgroup handle — owns the child cgroup's lifecycle.
///
/// On Linux, this creates a real cgroupv2 directory with memory limits.
/// On other platforms, this is a no-op.
/// Owns the child cgroup's lifecycle: a real cgroupv2 directory with memory
/// limits on Linux, a no-op elsewhere.
pub struct CgroupGuard {
#[cfg(target_os = "linux")]
inner: Option<linux::CgroupHandle>,
}
impl CgroupGuard {
/// Try to create a cgroup with the given memory config.
/// Returns a guard that cleans up the cgroup on drop.
///
/// On non-Linux platforms this always returns a no-op guard.
/// On Linux, if cgroup creation fails (e.g., not running as root,
/// cgroupv2 not available), it logs a warning and returns a no-op guard.
/// The returned guard cleans up the cgroup on drop. Never fails: when
/// creation is impossible (non-Linux, no root, no cgroupv2) the caller
/// gets a no-op guard and the process simply runs unlimited.
pub async fn try_create(config: &CgroupMemoryConfig) -> Self {
#[cfg(target_os = "linux")]
{
@@ -417,7 +383,6 @@ impl CgroupGuard {
}
}
/// No-op guard with no backing cgroup.
pub fn noop() -> Self {
#[cfg(target_os = "linux")]
{
@@ -429,8 +394,7 @@ impl CgroupGuard {
}
}
/// Move a process into this cgroup by PID.
/// No-op if cgroup was not created.
/// No-op, and still `Ok`, when there is no backing cgroup.
pub async fn add_process(&self, _pid: u32) -> std::io::Result<()> {
#[cfg(target_os = "linux")]
{
@@ -441,7 +405,6 @@ impl CgroupGuard {
Ok(())
}
/// Returns the cgroup filesystem path, if available.
#[allow(dead_code)]
pub fn path(&self) -> Option<&std::path::Path> {
#[cfg(target_os = "linux")]
@@ -453,7 +416,6 @@ impl CgroupGuard {
None
}
/// Returns true if this guard has a real cgroup backing it.
pub fn is_active(&self) -> bool {
#[cfg(target_os = "linux")]
{
@@ -466,18 +428,16 @@ impl CgroupGuard {
}
}
/// Memory-high monitor — watches for memory pressure events.
///
/// On Linux, uses inotify on `memory.events`.
/// On other platforms, this is a no-op that never fires.
/// Watches for memory pressure through inotify on `memory.events`; on
/// non-Linux platforms it never fires.
pub struct MemoryMonitor {
#[cfg(target_os = "linux")]
inner: Option<linux::MemoryHighMonitor>,
}
impl MemoryMonitor {
/// Start monitoring the given cgroup guard for memory.high events.
/// Returns a no-op monitor if the guard has no backing cgroup.
/// Yields a no-op monitor when the guard has no backing cgroup or the
/// inotify watch cannot be established.
pub async fn start(
guard: &CgroupGuard,
config: &CgroupMemoryConfig,
@@ -512,7 +472,6 @@ impl MemoryMonitor {
}
}
/// No-op monitor that never fires.
pub fn noop() -> Self {
#[cfg(target_os = "linux")]
{
@@ -1,31 +1,27 @@
//! Shadow `find`→`bfs` and `grep`→`ugrep` when those binaries resolve.
//!
//! Per-tool enable state (default on) is resolved by the host via the shared
//! config helper `kigi-shell::util::config::resolve_search_tools_enabled`
//! (requirements > env `KIGI_TOOLS_FIND_BFS` / `KIGI_TOOLS_GREP_UGREP` (+
//! `KIGI_FIND_BFS` / `KIGI_GREP_UGREP` aliases, `DISABLE_EMBEDDED_SEARCH_TOOLS`
//! master) > `[toolset.bash]` config.toml > managed > default), baked into the
//! Per-tool enable state (default on) is resolved by the host via
//! `kigi-shell::util::config::resolve_search_tools_enabled` (requirements > env
//! `KIGI_TOOLS_FIND_BFS` / `KIGI_TOOLS_GREP_UGREP` (+ `KIGI_FIND_BFS` /
//! `KIGI_GREP_UGREP` aliases, `DISABLE_EMBEDDED_SEARCH_TOOLS` master) >
//! `[toolset.bash]` config.toml > managed > default), baked into the
//! `LocalTerminalBackend` as a [`SearchShadowConfig`] and passed to
//! [`search_injection`] per command. The enable state lives on the backend (not
//! a process-global): a subagent that reuses the parent's backend inherits the
//! parent's shadows instead of clobbering a shared static. This module no longer
//! parses the flags itself.
//! [`search_injection`] per command.
//!
//! Resolve (host side, memoized): env override if a regular file → bundled binary
//! (release builds, self-extracted to `~/.kigi/vendor/<name>-<ver>-<target>`) →
//! `~/.kigi/vendor/{name}` if a regular file → `which` on the agent `$PATH`.
//! Env/vendor only require `is_file()` as a lenient hint (no `--version` probe).
//! This memoized path is only a *hint*: the injected shadow re-resolves at
//! **call time** — it uses the hint when it's still *executable* (`[ -x ]`), else
//! `command -v {bin}` on the live shell `PATH` (which includes login/rc additions
//! the agent process may lack), else falls back to the OS `{name}`. So a removed
//! or non-executable binary self-heals to OS `find`/`grep`, and a binary
//! reachable only through the login shell is still found.
//! Host-side resolution (memoized) is only a *hint*: env override if a regular
//! file → bundled binary (release builds, self-extracted to
//! `~/.kigi/vendor/<name>-<ver>-<target>`) → `~/.kigi/vendor/{name}` if a
//! regular file → `which` on the agent `$PATH`. The injected shadow re-resolves
//! at **call time**, trusting the hint only while it is *executable* (`[ -x ]`),
//! else `command -v {bin}` on the live shell `PATH` (which includes login/rc
//! additions the agent process may lack), else the OS `{name}`. So a deleted or
//! non-executable binary self-heals to OS `find`/`grep`, and a binary reachable
//! only through the login shell is still found.
//!
//! Inject is **always** non-empty on Unix callers: either install a shadow
//! function (which tags itself with a `__kigi_shadow_{name}` marker) or a
//! marker-gated `unalias`+`unset -f` that drops *only* a prior harness shadow —
//! never a user-defined `find`/`grep` function replayed from the snapshot.
//! Inject is **always** non-empty on Unix callers: either a shadow function
//! (which tags itself with a `__kigi_shadow_{name}` marker) or a marker-gated
//! `unalias`+`unset -f` that drops *only* a prior harness shadow — never a
//! user-defined `find`/`grep` function replayed from the snapshot.
use super::SearchShadowConfig;
use std::path::{Path, PathBuf};
@@ -69,22 +65,13 @@ const UGREP_BYTES: &[u8] = include_bytes!(concat!(
));
/// Oneline inject for shell wrappers; always ends with `"; "`.
///
/// `cfg` is the backend's resolved per-tool enable state (see module docs); it
/// is passed in per command rather than read from a process-global so subagents
/// sharing a backend can't clobber each other's shadows.
pub fn search_injection(cfg: SearchShadowConfig) -> String {
build_injection(cfg.find_bfs, cfg.grep_ugrep, resolved_tools())
}
/// Compose the inject from per-tool enable flags + resolved binaries. An enabled
/// tool installs a self-resolving shadow (the memoized `tools` path is only a
/// fast-path hint; the shadow re-resolves at call time and falls back to the OS
/// binary — see [`shell_function`]). A disabled tool emits a marker-gated
/// `restore` that drops only a prior harness shadow. Kept pure (flags/tools
/// passed in) so tests need no process-global env mutation — that is UB against
/// the `shell_state` integration tests that read env / spawn children
/// concurrently.
/// Flags and tools are parameters rather than globals so tests need no
/// process-global env mutation — that is UB against the `shell_state`
/// integration tests, which read env and spawn children concurrently.
fn build_injection(find_on: bool, grep_on: bool, tools: &ResolvedTools) -> String {
let find = if find_on {
shell_function("find", "bfs", tools.bfs.as_deref(), &[])
@@ -99,11 +86,11 @@ fn build_injection(find_on: bool, grep_on: bool, tools: &ResolvedTools) -> Strin
format!("{find}; {grep}; ")
}
/// Drop a *previously installed harness* shadow so command-word `{name}` uses the
/// OS binary again. Gated on the `__kigi_shadow_{name}` marker that
/// [`shell_function`] sets, so a user-defined `{name}` function replayed from the
/// shell snapshot is left intact — only the harness's own shadow is removed.
/// `set -u`/`set -e` safe and idempotent (`unset -f` is bash + zsh).
/// Drop a harness shadow so command-word `{name}` reaches the OS binary again.
/// Gated on the `__kigi_shadow_{name}` marker that [`shell_function`] sets, so a
/// user-defined `{name}` function replayed from the shell snapshot survives —
/// only the harness's own shadow goes. `set -u`/`set -e` safe and idempotent
/// (`unset -f` is bash + zsh).
fn restore_command(name: &str) -> String {
format!(
"if [ -n \"${{__kigi_shadow_{name}-}}\" ]; then \
@@ -128,7 +115,7 @@ fn resolved_tools() -> &'static ResolvedTools {
}
/// Write embedded `bytes` to `~/.kigi/vendor/<versioned_name>` (chmod 755) on
/// first use and return the path; reused on later runs. Versioned so bumping the
/// first use; later runs reuse it. The version in the name means bumping the
/// bundled version writes a fresh file instead of reusing a stale one.
#[cfg(any(bundle_bfs, bundle_ugrep))]
fn extract_bundled(versioned_name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
@@ -164,7 +151,6 @@ fn extract_bundled(versioned_name: &str, bytes: &[u8]) -> std::io::Result<PathBu
Ok(dest)
}
/// Path to the bundled `bfs` (extracted on first use), or `None` when not bundled.
fn bundled_bfs() -> Option<PathBuf> {
#[cfg(bundle_bfs)]
{
@@ -185,7 +171,6 @@ fn bundled_bfs() -> Option<PathBuf> {
}
}
/// Path to the bundled `ugrep` (extracted on first use), or `None` when not bundled.
fn bundled_ugrep() -> Option<PathBuf> {
#[cfg(bundle_ugrep)]
{
@@ -215,11 +200,10 @@ fn resolve_tool(bin_name: &str, env_override: &str, bundled: Option<PathBuf>) ->
)
}
/// Resolution order: explicit env path → bundled (self-extracted) →
/// `~/.kigi/vendor/<bin>` → `which`. Env and vendor only require `is_file()` here
/// (a lenient hint, no `+x` probe) so an odd-permission copy still resolves; the
/// injected shadow gates on `[ -x ]` at call time and falls back to the OS binary
/// if the hint isn't executable, so a non-exec path can't hard-fail `find`/`grep`.
/// Env and vendor candidates only need `is_file()` — no `+x` probe — so an
/// odd-permission copy still resolves as a hint; the injected shadow gates on
/// `[ -x ]` at call time and reaches the OS binary when the hint isn't
/// executable, so a non-exec path can't hard-fail `find`/`grep`.
fn resolve_tool_from(
env_path: Option<PathBuf>,
bundled: Option<PathBuf>,
@@ -251,22 +235,14 @@ fn bash_safe_quote(s: &str) -> String {
/// Oneline `name() { … }` for `-c` inject — a *self-resolving* shadow.
///
/// At call time it picks the binary: the host-resolved `preferred` path
/// (bundled/env/vendor/which) when that file still exists, else `command -v
/// {bin_name}` on the live shell `PATH` (which carries login/rc additions the
/// agent process may not have), else it falls back to the OS `{name}`. This keeps
/// the fast hard-coded path for the common case while self-healing when the
/// binary was removed (revalidation) or is only reachable through the shell's
/// richer `PATH`.
///
/// `exec -a` runs inside a subshell so a top-level call can't replace the wrapper
/// shell (it must survive to dump state); a call already inside a subshell
/// (`BASH_SUBSHELL > 0`, bash) execs directly to skip a fork. `${ZSH_VERSION-}`
/// keeps the probe `set -u`-safe (a bare `$ZSH_VERSION` aborts bash under
/// nounset). `exec -a` gives the binary the `find`/`grep` argv0 (ps display +
/// ugrep grep-personality) in both bash and zsh. The trailing
/// `exec -a` runs inside a subshell so a top-level call can't replace the
/// wrapper shell, which must survive to dump state; a call already inside a
/// subshell (`BASH_SUBSHELL > 0`, bash) execs directly to skip a fork. `exec -a`
/// also gives the binary the `find`/`grep` argv0 (ps display + ugrep
/// grep-personality) in both bash and zsh. `${ZSH_VERSION-}` keeps the probe
/// `set -u`-safe — a bare `$ZSH_VERSION` aborts bash under nounset. The trailing
/// `__kigi_shadow_{name}=1` marks this as a harness shadow so `restore_command`
/// only ever removes our own function never a user's.
/// only ever drops our own function, never a user's.
fn shell_function(
name: &str,
bin_name: &str,
@@ -284,16 +260,15 @@ fn shell_function(
format!("{} ", qargs.join(" "))
}
};
// `local __kigi_bin` is re-resolved every call. The host hint is trusted
// only when it's *executable* (`[ -x ]`, not just `[ -f ]`): the resolver
// accepts any regular file as a hint, but `exec` needs `+x`, so a non-exec
// hint must fall through rather than hard-fail with no OS fallback. Then
// `command -v` on the live shell PATH (returns an executable), else the OS
// binary. `|| __kigi_bin=''` keeps the lookup `set -e`-safe (a failed
// `command -v` would otherwise abort the function under errexit). The OS
// fallback uses `command {name}` to bypass this function. `{prepend}` is
// empty for find, the ugrep default flags for grep (and is omitted from the
// OS fallback, which gets the original args).
// `__kigi_bin` re-resolves on every call. The host hint counts only while
// *executable* (`[ -x ]`, not just `[ -f ]`): the resolver accepts any
// regular file as a hint, but `exec` needs `+x`, so a non-exec hint must
// fall through rather than hard-fail with no OS fallback. Then `command -v`
// on the live shell PATH, else the OS binary via `command {name}`, which
// bypasses this function. `|| __kigi_bin=''` keeps the lookup `set -e`-safe:
// a failed `command -v` would otherwise abort the function under errexit.
// `{prepend}` is empty for find and the ugrep default flags for grep; the OS
// fallback omits it and gets the original args.
format!(
"unalias {name} 2>/dev/null || true; \
{name}() {{ \
@@ -314,7 +289,6 @@ fn shell_function(
mod tests {
use super::*;
/// Both binaries resolved, for `build_injection` shape tests.
fn both_tools() -> ResolvedTools {
ResolvedTools {
bfs: Some(PathBuf::from("/tmp/bfs")),
@@ -326,28 +300,23 @@ mod tests {
fn shell_function_shape() {
let fn_body = shell_function("find", "bfs", Some(Path::new("/tmp/bfs")), &[]);
assert!(fn_body.contains("unalias find"));
// Preferred path is the fast-path hint; the shadow execs `$__kigi_bin`.
assert!(fn_body.contains("local __kigi_bin=/tmp/bfs"));
assert!(fn_body.contains("exec -a find \"$__kigi_bin\" \"$@\""));
// Hint is trusted only when executable (`[ -x ]`, not `[ -f ]`), so a
// non-exec hint falls through instead of hard-failing exec.
// `[ -f ]` would hard-fail exec on a non-executable hint.
assert!(fn_body.contains("[ -x \"$__kigi_bin\" ]"));
assert!(!fn_body.contains("[ -f \"$__kigi_bin\" ]"));
// Self-heal: live-PATH lookup + OS fallback.
assert!(fn_body.contains("command -v bfs"));
assert!(fn_body.contains("command find \"$@\""));
assert!(fn_body.contains("BASH_SUBSHELL > 0"));
assert!(fn_body.contains("(exec -a find"));
// Marker so `restore_command` only removes our own shadow.
assert!(fn_body.contains("__kigi_shadow_find=1"));
// set -u-safe zsh probe (a bare $ZSH_VERSION aborts bash under nounset).
// A bare `$ZSH_VERSION` aborts bash under nounset.
assert!(fn_body.contains("${ZSH_VERSION-}"));
assert!(!fn_body.contains("[[ -n $ZSH_VERSION ]]"));
}
#[test]
fn shell_function_unresolved_uses_empty_hint() {
// No host-resolved path → empty hint, relies on live-PATH `command -v`.
let fn_body = shell_function("find", "bfs", None, &[]);
assert!(fn_body.contains("local __kigi_bin=''"));
assert!(fn_body.contains("command -v bfs"));
@@ -380,7 +349,6 @@ mod tests {
#[test]
fn restore_command_is_marker_gated() {
let r = restore_command("find");
// Only removes the harness shadow when our marker is set.
assert!(r.contains("if [ -n \"${__kigi_shadow_find-}\" ]"));
assert!(r.contains("unalias find"));
assert!(r.contains("unset -f find"));
@@ -389,7 +357,6 @@ mod tests {
#[test]
fn config_default_is_both_on() {
// Standalone/no-host backends default to shadowing both tools.
let cfg = SearchShadowConfig::default();
assert!(cfg.find_bfs);
assert!(cfg.grep_ugrep);
@@ -397,8 +364,6 @@ mod tests {
#[test]
fn build_injection_off_emits_marker_gated_restore_not_function() {
// Disabled tools emit a marker-gated restore so a stale harness shadow
// from a prior snapshot is dropped, but a user function is left intact.
let inject = build_injection(false, false, &both_tools());
assert!(inject.ends_with("; "));
assert!(inject.contains("if [ -n \"${__kigi_shadow_find-}\" ]"));
@@ -420,8 +385,7 @@ mod tests {
#[test]
fn build_injection_enabled_unresolved_still_self_heals() {
// Enabled but no host-resolved path still install a self-resolving
// shadow (live-PATH `command -v` + OS fallback), never a bare restore.
// No host-resolved path must still yield a shadow, not a bare restore.
let tools = ResolvedTools {
bfs: None,
ugrep: None,
@@ -431,16 +395,14 @@ mod tests {
assert!(inject.contains("grep()"));
assert!(inject.contains("command -v bfs"));
assert!(inject.contains("command -v ugrep"));
// OS fallback present; not a marker-gated restore.
assert!(inject.contains("command find \"$@\""));
assert!(!inject.contains("if [ -n \"${__kigi_shadow_find-}\" ]"));
}
#[test]
fn injection_always_nonempty_and_trailing_sep() {
// Structural invariants that hold regardless of host flags/binaries:
// never empty (so a stale snapshot shadow is always overwritten) and a
// trailing `"; "` separator before the user command.
// Never empty regardless of flags, so a stale snapshot shadow is always
// overwritten; trailing `"; "` separates it from the user command.
for cfg in [
SearchShadowConfig {
find_bfs: true,
@@ -477,7 +439,6 @@ mod tests {
perms.set_mode(0o644);
std::fs::set_permissions(&bin, perms).unwrap();
}
// Bundled + vendor intentionally absent so the env override is what wins.
let got = resolve_tool_from(
Some(bin.clone()),
None,
@@ -506,7 +467,6 @@ mod tests {
std::fs::write(p, b"x").unwrap();
}
// env override beats everything.
assert_eq!(
resolve_tool_from(
Some(envp.clone()),
@@ -517,12 +477,10 @@ mod tests {
.as_deref(),
Some(envp.as_path())
);
// bundled beats the manual vendor copy.
assert_eq!(
resolve_tool_from(None, Some(bundled.clone()), vendor.clone(), "bfs").as_deref(),
Some(bundled.as_path())
);
// vendor used when neither env nor bundled is present.
assert_eq!(
resolve_tool_from(None, None, vendor.clone(), "bfs").as_deref(),
Some(vendor.as_path())
@@ -536,9 +494,8 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Only compiled when the binaries are actually bundled (release pipeline, or
/// `KIGI_TOOLS_BUNDLE_{BFS,UGREP}_PATH` at build time). Verifies the embedded
/// bytes self-extract under `~/.kigi/vendor` and the extracted `bfs` runs.
/// Only compiles in the release pipeline, or when
/// `KIGI_TOOLS_BUNDLE_{BFS,UGREP}_PATH` is set at build time.
#[cfg(all(bundle_bfs, bundle_ugrep))]
#[test]
fn bundled_binaries_extract_and_run() {
@@ -4,7 +4,6 @@ use tokio::{fs, time::sleep};
use crate::computer::types::{AsyncFileSystem, ComputerError};
/// Creates a local FS access which allows writing and reading from the local files
pub struct LocalFs;
// Keep the window short: these retries absorb brief Windows editor/indexer/AV
@@ -21,8 +20,8 @@ const WINDOWS_ERROR_SHARING_VIOLATION: i32 = 32;
#[cfg(any(windows, test))]
const WINDOWS_ERROR_LOCK_VIOLATION: i32 = 33;
/// Check if an IO error is a permission denial (EACCES or EPERM),
/// which indicates a sandbox violation.
/// EACCES/EPERM here means the sandbox denied the operation, so callers report
/// it to `kigi_sandbox` rather than treating it as an ordinary IO failure.
fn is_permission_error(e: &io::Error) -> bool {
matches!(e.kind(), io::ErrorKind::PermissionDenied)
}
@@ -48,6 +47,9 @@ fn is_transient_write_lock_error(e: &io::Error) -> bool {
}
}
/// Classifies the Windows codes on every platform so the retry-hook tests below
/// can exercise the retry loop off Windows, where `is_transient_write_lock_error`
/// is hardwired to `false`.
#[cfg(test)]
fn is_test_transient_write_lock_error(e: &io::Error) -> bool {
is_windows_transient_write_lock_raw_os_error(e.raw_os_error())
@@ -146,7 +148,6 @@ impl AsyncFileSystem for LocalFs {
#[tracing::instrument(name = "fs.write_file", skip_all)]
async fn write_file(&self, path: &Path, data: &[u8]) -> Result<(), ComputerError> {
// implicitly creates the missing directories if any
if let Some(dir) = path.parent()
&& let Err(e) = fs::create_dir_all(dir).await
{
@@ -1,5 +1,3 @@
//! Mock file system implementation for testing.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -8,7 +6,6 @@ use tokio::sync::RwLock;
use crate::computer::types::{AsyncFileSystem, ComputerError};
/// In-memory file system for testing.
/// Thread-safe and async-compatible.
pub struct MockFs {
files: Arc<RwLock<HashMap<PathBuf, Vec<u8>>>>,
}
@@ -20,14 +17,12 @@ impl Default for MockFs {
}
impl MockFs {
/// Create a new empty mock file system.
pub fn new() -> Self {
Self {
files: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Set a file's contents directly (for test setup).
pub async fn set_file(&self, path: impl AsRef<Path>, content: &[u8]) {
self.files
.write()
@@ -35,17 +30,14 @@ impl MockFs {
.insert(path.as_ref().to_path_buf(), content.to_vec());
}
/// Get a file's contents directly (for test assertions).
pub async fn get_file(&self, path: impl AsRef<Path>) -> Option<Vec<u8>> {
self.files.read().await.get(path.as_ref()).cloned()
}
/// Check if a file exists.
pub async fn exists(&self, path: impl AsRef<Path>) -> bool {
self.files.read().await.contains_key(path.as_ref())
}
/// List all files in the mock filesystem.
pub async fn list_files(&self) -> Vec<PathBuf> {
self.files.read().await.keys().cloned().collect()
}
@@ -84,15 +76,12 @@ mod tests {
async fn test_mock_fs_read_write() {
let fs = MockFs::new();
// File doesn't exist initially
assert!(fs.read_file(Path::new("/test.txt")).await.is_err());
// Write a file
fs.write_file(Path::new("/test.txt"), b"hello world")
.await
.unwrap();
// Read it back
let content = fs.read_file(Path::new("/test.txt")).await.unwrap();
assert_eq!(content, b"hello world");
}
@@ -19,8 +19,8 @@ pub use terminal::{ExitStatus, LocalTerminalBackend};
/// and baked into a [`LocalTerminalBackend`] at creation. Keeping it on the
/// backend instead of a process-global means a subagent that reuses the parent's
/// `LocalTerminalBackend` inherits the parent's shadows — it can't overwrite the
/// enable state for bash that later runs on the shared backend. Defaults to
/// both-on for standalone backends with no host wiring.
/// enable state for bash that later runs on the shared backend. Standalone
/// backends with no host wiring get the both-on default.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SearchShadowConfig {
pub find_bfs: bool,
@@ -19,10 +19,6 @@ use command_fds::FdMapping;
use nix::libc;
use tokio::io::AsyncReadExt;
// ============================================================================
// Marker constants
// ============================================================================
const BASH_STATE_START_MARKER: &str = "__KIGI_BASH_STATE_START__";
const BASH_STATE_END_MARKER: &str = "__KIGI_BASH_STATE_END__";
const ZSH_STATE_START_MARKER: &str = "__KIGI_ZSH_STATE_START__";
@@ -32,12 +28,11 @@ const ZSH_STATE_END_MARKER: &str = "__KIGI_ZSH_STATE_END__";
/// from the actual state dump on stdout.
const INIT_STATE_MARKER: &str = "__KIGI_INIT_STATE_MARKER__";
/// Maximum time to wait for a shell state init (login shell + rc files).
const INIT_TIMEOUT: Duration = Duration::from_secs(15);
/// Maximum time to wait for the dump reader task after the child process exits.
/// Uses a 5s close timeout. If a background process inherits fd 4,
/// the reader would hang forever without this.
/// Cap on the dump reader task after the child process exits: a background
/// process that inherited fd 4 keeps the pipe open, so the reader would
/// otherwise wait forever.
const DUMP_READ_TIMEOUT: Duration = Duration::from_secs(5);
/// Environment overrides applied to every agent terminal / persistent shell spawn.
@@ -56,9 +51,8 @@ pub fn shell_env_overrides() -> HashMap<String, String> {
])
}
/// Returns the sudo alias injection string if `SUDO_ASKPASS` is configured.
/// When set, `alias sudo='sudo -A'` makes any `sudo` in the user's command
/// use the askpass helper instead of blocking on tty input.
/// Aliasing `sudo` to `sudo -A` makes any `sudo` in the user's command use the
/// askpass helper instead of blocking on tty input. Empty when no helper is set.
fn sudo_alias_injection() -> String {
match std::env::var("SUDO_ASKPASS") {
Ok(val) if !val.is_empty() => "alias sudo='sudo -A'; ".to_string(),
@@ -66,12 +60,8 @@ fn sudo_alias_injection() -> String {
}
}
// ============================================================================
// Dump scripts (embedded as const strings)
// ============================================================================
/// Bash state dump script. Captures env vars, POSIX options, bash options,
/// functions, and aliases as base64-encoded replayable shell snippets.
/// Captures env vars, POSIX options, bash options, functions, and aliases as
/// base64-encoded replayable shell snippets.
const DUMP_BASH_STATE_SCRIPT: &str = r##"
dump_bash_state() {
set -euo pipefail
@@ -125,8 +115,8 @@ dump_bash_state() {
}
"##;
/// Zsh state dump script. Captures env vars, zsh options, functions, and aliases
/// as base64-encoded replayable shell snippets.
/// Captures env vars, zsh options, functions, and aliases as base64-encoded
/// replayable shell snippets.
const DUMP_ZSH_STATE_SCRIPT: &str = r##"
function dump_zsh_state() {
emulate -L zsh -o errreturn -o pipefail
@@ -175,10 +165,6 @@ function dump_zsh_state() {
}
"##;
// ============================================================================
// Shell kind
// ============================================================================
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellKind {
Bash,
@@ -195,9 +181,8 @@ impl ShellKind {
}
/// Resolved absolute path to the shell binary. Falls back from `$SHELL` →
/// `which` → common dirs → `/bin/<name>`. Result is cached process-wide
/// in `kigi_config::shell::unix_shell_path`. See that function for
/// the full cascade. Returns `&'static str`.
/// `which` → common dirs → `/bin/<name>`, cached process-wide in
/// `kigi_config::shell::unix_shell_path`; see that function for the full cascade.
pub fn binary_path(&self) -> &'static str {
let kind = match self {
Self::Bash => kigi_config::shell::UnixShellKind::Bash,
@@ -243,10 +228,6 @@ impl ShellKind {
}
}
// ============================================================================
// ShellState
// ============================================================================
/// Persistent shell state: a serialized snapshot that can be replayed to restore
/// env vars, cwd, functions, aliases, and shell options in a fresh shell process.
#[derive(Debug, Clone)]
@@ -255,7 +236,6 @@ pub struct ShellState {
pub cwd: PathBuf,
/// Replayable shell script (everything after the cwd line, minus markers).
pub snapshot: String,
/// Which shell produced this state.
pub shell: ShellKind,
}
@@ -271,8 +251,6 @@ impl ShellState {
let dump_script = shell.dump_script();
let dump_fn = shell.dump_function_name();
// Build the one-liner: define the dump function, print a marker (to separate
// login noise from our output), then call the dump function.
let script = format!("{dump_script} builtin printf '{INIT_STATE_MARKER}\\n'; {dump_fn}");
let args: Vec<&str> = match shell {
@@ -301,7 +279,7 @@ impl ShellState {
let mut full_output = String::new();
if let Some(ref mut stdout) = child.stdout {
// Apply init timeout to prevent hangs from slow rc files (e.g. network mounts).
// Slow rc files (e.g. on network mounts) must not hang the init.
match tokio::time::timeout(INIT_TIMEOUT, stdout.read_to_string(&mut full_output)).await
{
Ok(Ok(_)) => {}
@@ -320,7 +298,6 @@ impl ShellState {
let _ = child.wait().await;
// Extract output after our marker (skip MOTD, bashrc echo, etc.)
let snapshot_raw = parse_after_marker(&full_output, INIT_STATE_MARKER);
match parse_dump(shell, snapshot_raw) {
@@ -330,7 +307,6 @@ impl ShellState {
shell,
}),
None => {
// Dump failed or was killed — use empty state with the given cwd.
tracing::warn!("shell state init: dump markers missing, using empty state");
Ok(Self {
cwd: cwd.to_path_buf(),
@@ -367,21 +343,17 @@ impl ShellState {
let sudo_inject = sudo_alias_injection();
let search_inject = super::embedded_search_tools::search_injection(search_shadows);
// Create two OS pipes: one for state-in (fd 3), one for state-out (fd 4).
// os_pipe() creates fds with O_CLOEXEC (atomically on Linux,
// best-effort on macOS) so concurrent forks can't leak them.
// One pipe for state-in (fd 3), one for state-out (fd 4).
let (state_in_read, state_in_write) = os_pipe()?;
let (state_out_read, state_out_write) = os_pipe()?;
// Ensure the parent-only ends have CLOEXEC (redundant on Linux
// where os_pipe uses pipe2, but needed as a safety net on macOS
// where pipe+fcntl has a small race window).
// Redundant on Linux where os_pipe uses pipe2, but a safety net on macOS
// where pipe+fcntl leaves a small race window.
set_cloexec(&state_in_write)?;
set_cloexec(&state_out_read)?;
// The child-bound ends (state_in_read, state_out_write) also have
// CLOEXEC from os_pipe(). This is fine: fd_mappings uses dup2()
// which clears CLOEXEC on the target fd (3/4), so the child keeps
// them across exec. The originals are closed on exec by CLOEXEC.
// The child-bound ends (state_in_read, state_out_write) carry CLOEXEC too.
// That is fine: fd_mappings uses dup2(), which clears CLOEXEC on the target
// fd (3/4), so the child keeps those across exec while the originals close.
// The wrapper command:
// 1. Read prior snapshot from fd 3, eval it (restores env/funcs/aliases/opts)
@@ -390,18 +362,16 @@ impl ShellState {
// 4. Exit with the user command's exit code
let wrapper = match self.shell {
ShellKind::Bash => format!(
// Merge the user command's stderr into its
// stdout via `2>&1` so the captured byte stream preserves
// chronological write order. Without this, the bash tool's
// separate stdout/stderr pipes (each read in lockstep)
// emit all-of-stdout-then-all-of-stderr in a single poll
// tick, so a command like `echo X 1>&2 && echo Y` shows
// up as `Y\nX\n` instead of the chronological `X\nY\n`.
// Shell-level diagnostics (eval syntax errors, etc.) still
// land on the outer shell's stderr — those are unaffected.
// Re-export KIGI_AGENT=1 after snapshot eval so agent-definition
// selectors (or other values) from prior shells cannot clear the
// agent sentinel (process env alone is insufficient).
// `2>&1` merges the user command's stderr into its stdout so the
// captured byte stream preserves chronological write order. With
// separate stdout/stderr pipes read in lockstep, a single poll tick
// emits all-of-stdout-then-all-of-stderr, so `echo X 1>&2 && echo Y`
// surfaces as `Y\nX\n` instead of `X\nY\n`. Shell-level diagnostics
// (eval syntax errors, etc.) still land on the outer shell's stderr.
//
// KIGI_AGENT=1 is re-exported after the snapshot eval so values from
// prior shells cannot clear the agent sentinel; process env alone is
// insufficient because the snapshot replays over it.
"{dump_script} \
snap=$(command cat <&3) && builtin shopt -s extglob && builtin eval -- \"$snap\" && \
{{ builtin set +u 2>/dev/null || true; \
@@ -488,7 +458,6 @@ pub struct PreparedCommand {
/// or `/run/current-system/sw/bin/bash` on NixOS). See
/// [`ShellKind::binary_path`] for the resolution cascade.
pub binary: String,
/// Full argument list for the shell.
pub args: Vec<String>,
/// Fd mappings to pass to `CommandFdExt::fd_mappings()`.
pub fd_mappings: Vec<FdMapping>,
@@ -496,15 +465,10 @@ pub struct PreparedCommand {
pub state_in_write: OwnedFd,
/// Read end of the state-output pipe. Caller reads the new dump from here after exit.
pub state_out_read: OwnedFd,
/// Working directory for the child process.
pub cwd: PathBuf,
}
// ============================================================================
// Helpers
// ============================================================================
/// Create an OS pipe, returning `(read_end, write_end)` as `OwnedFd`.
/// Returns `(read_end, write_end)`.
///
/// On Linux, uses `nix::unistd::pipe2(O_CLOEXEC)` to atomically set
/// close-on-exec, eliminating the race window between `pipe()` and
@@ -515,31 +479,25 @@ pub struct PreparedCommand {
/// in 10.15 but `nix`'s cfg gate hasn't caught up). Falls back to
/// `pipe()` + `fcntl(FD_CLOEXEC)` with a best-effort race window.
fn os_pipe() -> std::io::Result<(OwnedFd, OwnedFd)> {
// Linux: atomic O_CLOEXEC via pipe2.
#[cfg(target_os = "linux")]
{
nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC)
.map_err(|e| std::io::Error::from_raw_os_error(e as i32))
}
// macOS (and other non-Linux Unix): pipe() + fcntl best-effort.
#[cfg(not(target_os = "linux"))]
{
let (read_fd, write_fd) =
nix::unistd::pipe().map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
// Best-effort CLOEXEC — small race window on macOS between pipe()
// and these fcntl calls, but unavoidable without pipe2.
let _ = set_cloexec(&read_fd);
let _ = set_cloexec(&write_fd);
Ok((read_fd, write_fd))
}
}
/// Set FD_CLOEXEC on a file descriptor so it is NOT inherited by child processes.
///
/// This is critical for pipe fds that should stay parent-only: without CLOEXEC,
/// the child inherits both ends of a pipe after fork, preventing EOF from being
/// signaled when the parent closes its end.
/// Critical for pipe fds that must stay parent-only: without FD_CLOEXEC the child
/// inherits both ends of the pipe after fork, so closing the parent's end never
/// signals EOF.
fn set_cloexec(fd: &OwnedFd) -> std::io::Result<()> {
let raw = fd.as_raw_fd();
let flags = unsafe { libc::fcntl(raw, libc::F_GETFD) };
@@ -566,13 +524,13 @@ fn parse_dump(shell: ShellKind, raw: &str) -> Option<(PathBuf, String)> {
return None;
}
// Strip markers
let without_markers = &raw[start_line.len()..raw.len() - end_line.len()];
// First line is $PWD
// The dump's first line is $PWD.
let newline_pos = without_markers.find('\n')?;
let cwd = &without_markers[..newline_pos];
let rest = &without_markers[newline_pos..]; // includes the leading \n
// Keeps the leading \n.
let rest = &without_markers[newline_pos..];
Some((PathBuf::from(cwd), rest.to_string()))
}
@@ -598,35 +556,32 @@ pub async fn write_snapshot_to_pipe(snapshot: &str, fd: OwnedFd) -> std::io::Res
std::mem::forget(fd);
file.write_all(data.as_bytes())?;
file.flush()?;
drop(file); // closes the fd child sees EOF on its read end
// Closing the fd is what makes the child see EOF on its read end.
drop(file);
Ok(())
})
.await
.map_err(std::io::Error::other)?
}
/// Read the full dump output from the state-out pipe with a timeout.
///
/// If a background process inherits fd 4, the pipe never closes and the reader
/// hangs. The timeout (5s close timeout) prevents this from
/// blocking the actor loop forever. On timeout, returns whatever was read so far
/// (which is typically empty, so marker validation will fail and prior state is kept).
/// On timeout, returns whatever was read so far — typically empty, so marker
/// validation fails and the caller keeps the prior state.
pub async fn read_dump_from_pipe(fd: OwnedFd) -> std::io::Result<String> {
// Read until either of the END markers appears, *not* until EOF.
// Read until either END marker appears, *not* until EOF.
//
// When the user's command backgrounds a subprocess (`cmd &`), the bg
// shell inherits fd 4 (the dump pipe's write-end) and keeps it open
// until *it* exits. The parent shell finishes its dump and exits, but
// the kernel doesn't close the read-end's EOF until every write-end
// the kernel signals EOF on the read-end only once every write-end
// holder closes theirs. Without marker-driven termination we'd block
// on `read_to_string` for the entire bg lifetime, hit the 5s safety
// on `read_to_string` for the entire bg lifetime, hit the safety
// timeout, and discard the (perfectly complete) dump — which manifests
// as `cd` / function / alias state silently failing to persist after
// any command that backgrounds something. (See harness scenarios
// "State persistence after backgrounded command" and the cd-roundtrip
// tests for shell state persistence parity.)
//
// We additionally cap on `DUMP_READ_TIMEOUT` so a shell that crashed
// `DUMP_READ_TIMEOUT` additionally caps the wait so a shell that crashed
// before emitting the END marker doesn't wedge the actor.
match tokio::time::timeout(
DUMP_READ_TIMEOUT,
@@ -639,13 +594,12 @@ pub async fn read_dump_from_pipe(fd: OwnedFd) -> std::io::Result<String> {
loop {
let n = file.read(&mut chunk)?;
if n == 0 {
// EOF: every write-end holder closed fd 4 (the
// expected path when no bg subprocess was spawned).
// EOF: every write-end holder closed fd 4, the expected
// path when no bg subprocess was spawned.
break;
}
buf.push_str(&String::from_utf8_lossy(&chunk[..n]));
// Either marker suffices; we accept whichever shell the
// child happens to be (bash vs zsh).
// Either marker suffices; the child may be bash or zsh.
if buf.contains(BASH_STATE_END_MARKER) || buf.contains(ZSH_STATE_END_MARKER) {
break;
}
@@ -667,10 +621,6 @@ pub async fn read_dump_from_pipe(fd: OwnedFd) -> std::io::Result<String> {
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
@@ -753,18 +703,14 @@ mod tests {
assert_eq!(result, output);
}
/// Returns true iff a usable bash binary exists at the resolved path.
/// Used to gate integration tests so they're skipped (rather than failing)
/// on systems where bash isn't installed (e.g. minimal containers).
/// On NixOS the resolver returns the nix-store / profile path, so this
/// guard works there too.
/// Gates the integration tests so they skip, rather than fail, on systems
/// without bash (e.g. minimal containers). The resolved path works on NixOS
/// too, where it points into the nix store / profile.
fn bash_available() -> bool {
std::path::Path::new(ShellKind::Bash.binary_path()).exists()
}
/// Returns true iff a usable zsh binary exists at the resolved path.
/// Mirrors [`bash_available`] so zsh integration tests skip (rather than
/// fail) on systems without zsh installed.
/// Mirrors [`bash_available`] for the zsh integration tests.
fn zsh_available() -> bool {
std::path::Path::new(ShellKind::Zsh.binary_path()).exists()
}
@@ -822,15 +768,12 @@ mod tests {
#[tokio::test]
async fn test_init_bash() {
// Integration test: actually runs bash and captures state.
// Skip in environments without bash.
if !bash_available() {
return;
}
let cwd = std::env::current_dir().unwrap();
let state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
assert!(state.cwd.is_absolute());
// The snapshot should contain at least some env var exports
assert!(
state.snapshot.contains("kigi_snap_") || state.snapshot.is_empty(),
"snapshot should contain encoded blocks or be empty: {:?}",
@@ -842,14 +785,12 @@ mod tests {
async fn test_prepare_command_and_roundtrip() {
use command_fds::CommandFdExt;
// Integration test: prepare a command, spawn it, verify state roundtrip.
if !bash_available() {
return;
}
let cwd = std::env::current_dir().unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
// Run "export KIGI_TEST_VAR=hello" and capture the new state
let prep = state
.prepare_command(
"export KIGI_TEST_VAR=hello",
@@ -869,19 +810,17 @@ mod tests {
cmd.fd_mappings(prep.fd_mappings).unwrap();
let child = cmd.spawn().unwrap();
// Drop cmd to release the FdMapping OwnedFds held in its pre_exec closure.
// Without this, the parent keeps the write-end of the state-out pipe open,
// and the read task never sees EOF.
// Releases the FdMapping OwnedFds held in cmd's pre_exec closure. Otherwise
// the parent keeps the write-end of the state-out pipe open and the read
// task never sees EOF.
drop(cmd);
// Write snapshot to fd 3
let snapshot = state.snapshot.clone();
let write_handle =
tokio::spawn(
async move { write_snapshot_to_pipe(&snapshot, prep.state_in_write).await },
);
// Read new dump from fd 4
let read_handle =
tokio::spawn(async move { read_dump_from_pipe(prep.state_out_read).await });
@@ -896,8 +835,8 @@ mod tests {
"dump should have valid markers, got: {:?}",
&dump[..dump.len().min(500)]
);
// The snapshot contains base64-encoded env vars, so the variable name
// won't appear in plaintext. Verify the dump was valid and non-empty.
// Env vars are base64-encoded in the snapshot, so the variable name never
// appears in plaintext; a valid non-empty dump is all we can assert.
assert!(
!state.snapshot.is_empty(),
"snapshot should be non-empty after a successful command"
@@ -905,7 +844,8 @@ mod tests {
assert!(state.cwd.is_absolute(), "cwd should be absolute");
}
/// Helper: run a command against a ShellState, update state, return (exit_code, stdout).
/// Runs a command against a ShellState, updates the state, and returns
/// `(exit_code, stdout)`.
async fn run_command(state: &mut ShellState, command: &str) -> (i32, String) {
use command_fds::CommandFdExt;
@@ -953,11 +893,10 @@ mod tests {
let cwd = std::env::current_dir().unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
// cd to /tmp (macOS resolves to /private/tmp via symlink)
// macOS resolves /tmp to /private/tmp via symlink.
let (code, _) = run_command(&mut state, "cd /tmp").await;
assert_eq!(code, 0);
// Next command should see the resolved /tmp as cwd
let (code, stdout) = run_command(&mut state, "pwd").await;
assert_eq!(code, 0);
let actual_pwd = stdout.trim();
@@ -976,11 +915,9 @@ mod tests {
let cwd = std::env::current_dir().unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
// Export a variable
let (code, _) = run_command(&mut state, "export MY_TEST_VAR=persistent_value").await;
assert_eq!(code, 0);
// Next command should see it
let (code, stdout) = run_command(&mut state, "echo $MY_TEST_VAR").await;
assert_eq!(code, 0);
assert_eq!(stdout.trim(), "persistent_value");
@@ -1079,11 +1016,9 @@ mod tests {
let cwd = std::env::current_dir().unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
// Define a function
let (code, _) = run_command(&mut state, "greet() { echo \"hello $1\"; }").await;
assert_eq!(code, 0);
// Call it in the next command
let (code, stdout) = run_command(&mut state, "greet world").await;
assert_eq!(code, 0);
assert_eq!(stdout.trim(), "hello world");
@@ -1097,13 +1032,11 @@ mod tests {
let cwd = std::env::current_dir().unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
// Define an alias
let (code, _) = run_command(&mut state, "alias ll='ls -la'").await;
assert_eq!(code, 0);
// Verify the alias survives by checking the dump itself (base64-encoded).
// We can't check plaintext in the snapshot since it's base64, but we can
// verify the snapshot is valid and non-empty (alias was captured in the dump).
// The alias is base64-encoded inside the snapshot, so only its presence as
// a non-empty dump can be asserted here.
assert!(
!state.snapshot.is_empty(),
"snapshot should be non-empty after alias"
@@ -1163,15 +1096,13 @@ mod tests {
let cwd = std::env::current_dir().unwrap();
let mut state = ShellState::init(ShellKind::Bash, &cwd).await.unwrap();
// Set up some state
let (_, _) = run_command(&mut state, "export SURVIVE_TEST=yes").await;
let prev_cwd = state.cwd.clone();
// Run a failing command — state should still update (dump runs regardless)
// The dump runs regardless of the command's exit code.
let (code, _) = run_command(&mut state, "false").await;
assert_ne!(code, 0);
// Previous state should still be there
assert_eq!(state.cwd, prev_cwd);
let (_, stdout) = run_command(&mut state, "echo $SURVIVE_TEST").await;
assert_eq!(stdout.trim(), "yes");
@@ -28,7 +28,6 @@ use super::SearchShadowConfig;
#[cfg(unix)]
use super::shell_state;
/// Result of spawning a shell command (persistent or plain).
struct SpawnResult {
child: tokio::process::Child,
process_group: crate::util::ProcessGroup,
@@ -41,17 +40,15 @@ const DEFAULT_NOTIFICATION_INTERVAL_MS: u64 = 100;
const COMMAND_CHANNEL_SIZE: usize = 32;
/// How long to keep completed background tasks in memory before eviction.
/// The output file on disk persists for the session lifetime.
const COMPLETED_TASK_TTL: Duration = Duration::from_secs(300); // 5 minutes
/// SIGTERM → SIGKILL grace period. Uses a 1-second grace.
const COMPLETED_TASK_TTL: Duration = Duration::from_secs(300);
/// SIGTERM → SIGKILL grace period.
const SIGTERM_GRACE: Duration = Duration::from_secs(1);
/// Maximum lifetime for a background task. After this, the actor
/// will gracefully kill it. Set to 10 hours to support long
/// background monitor and bash runs.
/// Maximum lifetime for a background task; the actor kills it once exceeded.
/// 10 hours, so long background monitor and bash runs survive.
const BACKGROUND_MAX_RUNTIME: Duration = Duration::from_secs(36_000);
/// Max time an *auto-backgroundable* foreground command blocks the turn before
/// it's moved to the background (kept running, never killed), independent of its
/// requested `timeout`. A short second timer for the auto-background budget.
/// Env override: `KIGI_FOREGROUND_BLOCK_BUDGET_MS`.
/// requested `timeout`. Env override: `KIGI_FOREGROUND_BLOCK_BUDGET_MS`.
const FOREGROUND_BLOCK_BUDGET: Duration = Duration::from_secs(15);
fn foreground_block_budget_from_env() -> Duration {
@@ -66,7 +63,7 @@ fn foreground_block_budget_from_env() -> Duration {
/// size analogue of [`BACKGROUND_MAX_RUNTIME`], stopping an unbounded writer
/// (`yes`, a runaway log) from filling the disk. Env override:
/// `KIGI_MAX_OUTPUT_FILE_BYTES`.
const MAX_OUTPUT_FILE_BYTES: u64 = 5 * 1024 * 1024 * 1024; // 5 GiB
const MAX_OUTPUT_FILE_BYTES: u64 = 5 * 1024 * 1024 * 1024;
fn output_file_cap_from_env() -> u64 {
std::env::var("KIGI_MAX_OUTPUT_FILE_BYTES")
@@ -79,24 +76,21 @@ fn output_file_cap_from_env() -> u64 {
const DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
/// Max bytes retained in the output file after process exit. Truncated
/// so `to_task_snapshot` / `read_file` don't materialize huge strings.
const MAX_RETAINED_OUTPUT_FILE_BYTES: u64 = 64 * 1024 * 1024; // 64 MiB
/// Maximum number of completed-task tombstones to keep. When exceeded,
/// the oldest entries are evicted. Each tombstone is lightweight (metadata
/// only, no output), so 100 entries is ~10 KB.
const MAX_RETAINED_OUTPUT_FILE_BYTES: u64 = 64 * 1024 * 1024;
/// Maximum number of completed-task tombstones to keep; the oldest entries are
/// evicted past this. Each tombstone is metadata only (no output), ~10 KB total.
const MAX_COMPLETED_TASK_SNAPSHOTS: usize = 100;
fn notification_interval() -> Duration {
Duration::from_millis(DEFAULT_NOTIFICATION_INTERVAL_MS)
}
/// Exit status of a terminal process
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExitStatus {
pub exit_code: Option<i32>,
pub signal: Option<String>,
}
/// Commands that can be sent to the LocalTerminalActor
enum TerminalCommand {
/// Foreground: spawn process, block until exit or timeout, reply with result.
Run {
@@ -117,7 +111,6 @@ enum TerminalCommand {
reply: oneshot::Sender<Option<TaskSnapshot>>,
},
/// Kill a background task.
Kill {
task_id: String,
reply: oneshot::Sender<KillOutcome>,
@@ -126,26 +119,22 @@ enum TerminalCommand {
/// Kill foregrounded processes. Called on turn cancellation.
KillForegroundCommands,
/// Move a foreground command to background by tool_call_id.
/// Unblocks the completion waiter with signal="backgrounded".
BackgroundForeground {
tool_call_id: String,
reply: oneshot::Sender<bool>,
},
/// Wait for a background task to finish, with optional timeout.
WaitForCompletion {
task_id: String,
timeout: Option<Duration>,
reply: oneshot::Sender<Option<TaskSnapshot>>,
},
/// List all known background tasks.
ListTasks {
reply: oneshot::Sender<Vec<TaskSnapshot>>,
},
/// Query the persistent shell's current working directory.
GetShellCwd {
reply: oneshot::Sender<Option<PathBuf>>,
},
@@ -154,12 +143,11 @@ enum TerminalCommand {
cwd: PathBuf,
},
/// Kill all running foreground processes owned by a specific session.
KillForegroundCommandsByOwner {
owner_session_id: String,
},
/// Kill all running background tasks owned by a specific session.
/// Kills only the session's *background* tasks.
KillTasksByOwner {
owner_session_id: String,
reply: oneshot::Sender<()>,
@@ -180,10 +168,6 @@ enum TerminalCommand {
},
}
// ============================================================================
// Per-process state (for each running command)
// ============================================================================
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BackgroundStatus {
Foreground { auto_bg_on_timeout: bool },
@@ -215,9 +199,7 @@ impl BackgroundReason {
}
}
/// State for a single running process
struct ProcessState {
/// The child process
child: tokio::process::Child,
/// Process-tree teardown handle, shared (`Arc`) with the process-global
/// `ProcessScope` so the TUI exit paths can reap it if this actor never runs
@@ -239,44 +221,35 @@ struct ProcessState {
/// Once the total char count exceeds the limit, the first half of the
/// budget is frozen here and only the tail is kept in `output_buffer`.
front_buffer: Option<Vec<u8>>,
/// Whether output was truncated
truncated: bool,
/// Total bytes written to file (before truncation)
/// Total bytes written to file, before any truncation.
total_bytes: usize,
/// Exit status once process completes
exit_status: Option<ExitStatus>,
/// Whether process was backgrounded and how
bg_status: BackgroundStatus,
/// Waiters for this process to complete (foreground only)
/// Foreground only.
completion_waiters: Vec<oneshot::Sender<Result<TerminalRunResult, ComputerError>>>,
/// Configuration
output_byte_limit: usize,
timeout: Duration,
/// When auto_bg_on_timeout: max FG block before auto-bg (per-request or backend default).
foreground_block_budget: Duration,
start_time: Instant,
/// Path to output file (always written to)
/// Every byte of output lands here, even for foreground commands.
output_file: PathBuf,
/// Open file handle for incremental writes
file_handle: Option<File>,
/// The command that was executed (may be isolation-wrapped)
/// The command as executed (may be isolation-wrapped).
command: String,
/// Original user command before isolation wrapping (for display)
/// Original user command before isolation wrapping, for display.
display_command: Option<String>,
/// Working directory where command was run
cwd: String,
/// Wall-clock start time (for TaskSnapshot)
/// Wall-clock counterpart to `start_time`, for `TaskSnapshot`.
start_wall_time: std::time::SystemTime,
/// When the process completed (for TTL-based eviction of background tasks)
/// Drives TTL-based eviction of background tasks.
completed_at: Option<Instant>,
/// Wall-clock end time (for TaskSnapshot duration calculation)
/// Wall-clock counterpart to `completed_at`, for `TaskSnapshot` durations.
end_wall_time: Option<std::time::SystemTime>,
/// Notification handle for streaming output chunks.
notification_handle: ToolNotificationHandle,
/// Tool call ID for correlating notifications with the tool invocation.
tool_call_id: String,
/// Task kind: bash or monitor.
kind: crate::computer::types::TaskKind,
/// Monotonic `total_bytes` at the time of the last chunk notification.
/// Used to detect "new output since last tick" — only send a chunk
@@ -358,7 +331,6 @@ impl ProcessState {
}
let half = self.output_byte_limit / 2;
// Capture the front half once — on the first truncation.
if self.front_buffer.is_none() {
let front_end = s
.char_indices()
@@ -368,7 +340,6 @@ impl ProcessState {
self.front_buffer = Some(s[..front_end].as_bytes().to_vec());
}
// Keep only the last `half` chars in the tail buffer.
let tail_start_char = char_count.saturating_sub(half);
let tail_start_byte = s
.char_indices()
@@ -395,8 +366,6 @@ impl ProcessState {
self.start_time.elapsed() > self.timeout
}
/// Build a snapshot of this process's current state.
/// Uses async I/O to read output from disk for completed background tasks.
async fn to_task_snapshot(&self, task_id: &str) -> TaskSnapshot {
// For completed background tasks, the in-memory buffer is cleared to free
// memory. Fall back to reading from the output file (non-blocking).
@@ -423,8 +392,6 @@ impl ProcessState {
cwd: self.cwd.clone(),
start_time: self.start_wall_time,
end_time: if self.exit_status.is_some() {
// Use the recorded wall-clock end time if available,
// otherwise fall back to now (process just completed this tick).
Some(
self.end_wall_time
.unwrap_or_else(std::time::SystemTime::now),
@@ -446,10 +413,6 @@ impl ProcessState {
}
}
// ============================================================================
// Actor
// ============================================================================
/// Waiter registered by WaitForCompletion commands.
/// Instead of blocking the actor loop, we store the reply sender and deadline,
/// then check on each poll tick whether to fire it.
@@ -458,12 +421,9 @@ struct CompletionWaiter {
deadline: Instant,
}
/// The actor that owns all terminal state and processes commands
struct LocalTerminalActor {
/// Command receiver
cmd_rx: mpsc::Receiver<TerminalCommand>,
/// Cancellation token for graceful shutdown
cancel_token: CancellationToken,
/// Reaper for spawned child trees. Each spawned process enrolls its
@@ -473,10 +433,10 @@ struct LocalTerminalActor {
/// their own to avoid latching the global.
scope: crate::util::ProcessScope,
/// Active processes: task_id -> ProcessState
/// task_id -> live process state.
processes: HashMap<String, ProcessState>,
/// task_id -> list of waiters registered by WaitForCompletion commands
/// task_id -> waiters registered by WaitForCompletion commands.
completion_waiters: HashMap<String, Vec<CompletionWaiter>>,
/// Lightweight snapshots of completed background tasks that were evicted
@@ -502,10 +462,9 @@ struct LocalTerminalActor {
/// are moved into this cgroup so their memory is bounded.
_cgroup_guard: CgroupGuard,
/// Memory-high monitor — polls for memory pressure events from the cgroup.
/// Polls for memory pressure events from the cgroup.
memory_monitor: MemoryMonitor,
/// Whether persistent shell state is enabled.
persistent_shell: bool,
/// Per-backend `find`→`bfs` / `grep`→`ugrep` shadow enable state, resolved
@@ -630,22 +589,17 @@ impl LocalTerminalActor {
self.ensure_persistent_shell_initialized(cwd).await;
let shell_state = self.shell_state.as_ref().unwrap();
// When the persistent shell already tracks a
// model-set cwd (the model ran a `cd`), honor it unconditionally.
// The bash tool always populates `request.working_directory` with
// the workspace's resolved Cwd, even when no per-call override is
// intended; treating that as "explicit override and reset" was the
// bug that made `cd` not persist across consecutive Shell calls.
// The shell's own tracked cwd always wins, so there is never an override.
// The bash tool populates `request.working_directory` with the workspace's
// resolved Cwd even when no per-call override is intended; honoring that as
// an explicit override resets the model's `cd` on every Shell call.
//
// Per-call working_directory overrides arrive through the
// shell adapter, which prefixes a subshell `(cd <wd> &&
// …)` to the command string — that mechanism is local to a single
// call and does NOT mutate the parent shell's `$PWD`, so we never
// need to surface it as a `cwd_override` here.
// Genuine per-call overrides arrive through the shell adapter, which
// prefixes a subshell `(cd <wd> && …)` to the command string — local to one
// call, and it does not mutate the parent shell's `$PWD`.
let cwd_override: Option<&std::path::Path> = None;
// Silence the unused-binding lint on the inbound `cwd` parameter:
// it's still threaded into `spawn_command` (the non-persistent
// fallback path) below.
// `cwd` is unused on this path; the non-persistent path in `spawn_command`
// still needs it, so it stays in the signature.
let _ = cwd;
let prep = shell_state
.prepare_command(command, cwd_override, self.search_shadows)
@@ -659,9 +613,8 @@ impl LocalTerminalActor {
.stderr(Stdio::piped())
.kill_on_drop(true);
// Apply SHELL_ENV_OVERRIDES (TERM=dumb, NO_COLOR, KIGI_AGENT=1, etc.)
// + request env + pager env. Agent marker is re-applied last so request
// env cannot clear it.
// Overrides (TERM=dumb, NO_COLOR, KIGI_AGENT=1, …), then request env, then
// pager env. The agent marker is applied last so request env cannot clear it.
cmd.envs(shell_state::shell_env_overrides());
for (key, value) in env {
@@ -697,7 +650,7 @@ impl LocalTerminalActor {
tracing::debug!("Failed to attach persistent-shell child to ProcessGroup: {e}");
}
// Write prior snapshot to fd 3 (state input pipe) in a background task.
// fd 3 is the state input pipe.
let snapshot = shell_state.snapshot.clone();
tokio::spawn(async move {
if let Err(e) =
@@ -707,7 +660,7 @@ impl LocalTerminalActor {
}
});
// Read new dump from fd 4 (state output pipe) in a background task.
// fd 4 is the state output pipe.
let dump_handle =
tokio::spawn(
async move { shell_state::read_dump_from_pipe(prep.state_out_read).await },
@@ -720,7 +673,6 @@ impl LocalTerminalActor {
})
}
/// Main actor loop
async fn run(mut self) {
let mut ticker = tokio::time::interval(notification_interval());
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
@@ -732,25 +684,21 @@ impl LocalTerminalActor {
// when poll_all_processes was slow (e.g. drain timeouts).
biased;
// Check for cancellation
_ = self.cancel_token.cancelled() => {
self.shutdown_all().await;
break;
}
// Handle incoming commands
cmd = self.cmd_rx.recv() => {
match cmd {
Some(cmd) => self.handle_command(cmd).await,
None => {
// Channel closed, all senders dropped
self.shutdown_all().await;
break;
}
}
}
// Periodic maintenance: check timeouts, read output, etc.
// Gated on live processes: the actor exists for the whole
// session lifetime, and an idle session must not wake 10x/sec
// to poll an empty map (one actor per open session/tab adds
@@ -875,8 +823,7 @@ impl LocalTerminalActor {
request: TerminalRunRequest,
reply: oneshot::Sender<Result<TerminalRunResult, ComputerError>>,
) {
// Generate an internal ID — foreground callers never see this; the reply
// goes back on the oneshot channel.
// Foreground callers never see this id; the reply goes back on the oneshot.
let internal_id = uuid::Uuid::now_v7().to_string();
let SpawnResult {
@@ -901,7 +848,6 @@ impl LocalTerminalActor {
tracing::debug!("Failed to add pid {pid} to cgroup (non-fatal): {e}");
}
// Open output file for writing (create parent dirs if needed)
let file_handle = match open_output_file(&request.output_file).await {
Ok(file) => Some(file),
Err(e) => {
@@ -982,10 +928,10 @@ impl LocalTerminalActor {
// exit watcher's snapshot carries the flag.
process.explicitly_killed = true;
// Kill the process and finalize its state in one shot.
let outcome = kill_and_finalize(process).await;
// Resolve completion waiters immediately, so callers blocked on wait_for_completion() unblock right away.
// Resolve waiters here so callers blocked on wait_for_completion() unblock
// without waiting for the next poll tick.
if let Some(waiters) = self.completion_waiters.remove(terminal_id) {
let snapshot = match self.processes.get(terminal_id) {
Some(p) => Some(p.to_task_snapshot(terminal_id).await),
@@ -999,8 +945,6 @@ impl LocalTerminalActor {
outcome
}
/// Handle a background execution request.
/// Spawns the process, registers it under a generated task_id, and replies immediately.
async fn handle_run_background(
&mut self,
request: TerminalRunRequest,
@@ -1029,7 +973,6 @@ impl LocalTerminalActor {
tracing::debug!("Failed to add pid {pid} to cgroup (non-fatal): {e}");
}
// Open output file for writing (create parent dirs if needed)
let file_handle = match open_output_file(&request.output_file).await {
Ok(file) => Some(file),
Err(e) => {
@@ -1042,7 +985,7 @@ impl LocalTerminalActor {
}
};
// Generate task_id — the actor owns the identity
// The actor owns task identity; callers only ever receive it.
let task_id = uuid::Uuid::now_v7().to_string();
let process_state = ProcessState {
@@ -1056,7 +999,7 @@ impl LocalTerminalActor {
bg_status: BackgroundStatus::Backgrounded {
reason: BackgroundReason::Explicit,
},
completion_waiters: vec![], // no foreground waiter
completion_waiters: vec![],
output_byte_limit: request.output_byte_limit,
timeout: request.timeout,
// Unused for already-backgrounded tasks; keep a defined value.
@@ -1084,8 +1027,8 @@ impl LocalTerminalActor {
// Detach the dump reader so its result is discarded (the task
// continues independently until EOF or DUMP_READ_TIMEOUT).
state_dump_handle: if self.persistent_shell {
// Still spawn with the state wrapping (so bg commands inherit
// the session env), but discard the dump reader.
// The spawn still carries the state wrapping so bg commands
// inherit the session env; only the dump reader is discarded.
drop(state_dump_handle);
None
} else {
@@ -1094,11 +1037,9 @@ impl LocalTerminalActor {
owner_session_id: request.owner_session_id.clone(),
};
// Store under task_id — this is the key that get_task/kill_task will use
let pid = process_state.child.id();
self.processes.insert(task_id.clone(), process_state);
// Reply immediately
let _ = reply.send(Ok(BackgroundHandle {
task_id,
output_file: request.output_file,
@@ -1115,14 +1056,11 @@ impl LocalTerminalActor {
reply: oneshot::Sender<Option<TaskSnapshot>>,
) {
let Some(process) = self.processes.get_mut(&task_id) else {
// Check completed snapshots (task already evicted from processes).
// Mark `block_waited=true` in-place so any downstream consumer
// (e.g. list_tasks, get_task) reflects that the model awaited
// the result. Without this, a late-arriving wait would not
// imprint the flag on the tombstone, leaving auto-wake noise
// suppressed only on this one reply. Imprint only when the
// waiter actually receives the reply — a dropped receiver
// (cancelled turn) means the model never saw the result.
// The task is already evicted from `processes`, so serve the tombstone
// and imprint `block_waited=true` on it in place, so other readers
// (list_tasks, get_task) also see that the model awaited the result.
// Imprint only when the waiter actually receives the reply — a dropped
// receiver (cancelled turn) means the model never saw it.
let snapshot = self.completed_task_snapshots.get(&task_id).map(|s| {
let mut s = s.clone();
s.block_waited = true;
@@ -1157,7 +1095,6 @@ impl LocalTerminalActor {
return;
}
// Register as a completion waiter and return control to the actor loop.
let timeout = timeout.unwrap_or(Duration::from_secs(30));
self.completion_waiters
.entry(task_id)
@@ -1166,15 +1103,11 @@ impl LocalTerminalActor {
reply,
deadline: Instant::now() + timeout,
});
// Return immediately — actor loop resumes processing other commands.
}
/// Poll all processes for output and completion
async fn poll_all_processes(&mut self) {
// 0a. Check if the memory monitor detected a memory.high breach.
// If so, kill the *newest* running foreground process (kill the
// most recent command first).
// 0a. On a memory.high breach, kill the newest running process — the
// most recent command is the likeliest culprit.
if let Some(event) = self.memory_monitor.try_recv() {
tracing::warn!(
memory_current = event.memory_current,
@@ -1182,7 +1115,6 @@ impl LocalTerminalActor {
"Memory high threshold breached — killing newest running process"
);
// Find the newest running (non-exited) process by start_time.
let newest_id = self
.processes
.iter()
@@ -1333,7 +1265,8 @@ impl LocalTerminalActor {
.processes
.get(&task_id)
.map(|p| p.exit_status.is_some())
.unwrap_or(true); // process gone = treat as completed
// A process that is gone counts as completed.
.unwrap_or(true);
if completed && let Some(waiters) = self.completion_waiters.remove(&task_id) {
let snapshot = match self.processes.get(&task_id) {
@@ -1381,7 +1314,6 @@ impl LocalTerminalActor {
}
}
}
// Remove empty waiter lists
self.completion_waiters.retain(|_, v| !v.is_empty());
// Clear block_waited for tasks where all waiters timed out without
@@ -1395,8 +1327,7 @@ impl LocalTerminalActor {
}
}
// 3. Set completed_at and clear output buffer for completed background tasks
// First pass: mark completed and clear buffers, collect IDs for notification
// 3. Set completed_at and clear output buffer for completed background tasks.
let mut newly_completed: Vec<String> = Vec::new();
for (task_id, process) in self.processes.iter_mut() {
if process.exit_status.is_some()
@@ -1412,13 +1343,11 @@ impl LocalTerminalActor {
newly_completed.push(task_id.clone());
}
}
// Second pass: send completion notifications (requires async file read).
//
// The `block_waited` gate that suppresses the redundant auto-wake
// synthetic prompt for awaited tasks lives in
// `tools/notification_bridge.rs` (the `TaskCompleted` arm checks
// `task_snapshot.block_waited` before the auto-wake injection
// branch — see the comment there). This pass must still fire
// branch — see the comment there). This loop must still fire
// `send_task_complete` unconditionally for newly-completed
// background tasks so the pager UI, persistence, and
// `AutoWakeDeliveredIds` bookkeeping all still get the snapshot.
@@ -1437,10 +1366,11 @@ impl LocalTerminalActor {
.iter()
.filter(|(_, p)| {
if p.exit_status.is_none() {
return false; // still running, keep
return false;
}
// Foreground processes have already replied to their caller.
if !p.bg_status.is_backgrounded() {
return true; // foreground, already replied, evict
return true;
}
// Backgrounded + completed: evict after TTL
matches!(p.completed_at, Some(t) if t.elapsed() >= self.completed_task_ttl)
@@ -1502,16 +1432,13 @@ impl LocalTerminalActor {
// output once it exits.
if process.exit_status.is_some() {
if process.drained {
// Already drained — nothing left to do for this process.
return;
}
match process.child.try_wait() {
Ok(None) => {
// Process was told to die but is still running — escalate to SIGKILL
send_sigkill_to_group(process);
}
Ok(Some(_)) => {
// Process finally exited — drain any remaining output
drain_remaining_output(process).await;
process.flush_and_truncate_output_file().await;
process.drained = true;
@@ -1525,20 +1452,13 @@ impl LocalTerminalActor {
return;
}
// ── Non-blocking reads ──────────────────────────────────────────
//
// Read all *currently available* bytes from stdout and stderr using
// non-blocking `poll_read`. This avoids the old 10 ms timeout-per-
// stream approach which cost 20 ms per process even when idle —
// with N processes that compounded to N×20 ms per tick, easily
// exceeding the 100 ms tick interval and delaying file writes.
//
// Data from both streams is collected into `new_bytes`, then written
// to the output file in a single batch + flush at the end.
// Both streams are read with non-blocking `poll_read`, taking only what is
// available right now. A timeout-per-stream read costs that timeout per
// process on every tick even when idle, which for N processes overruns the
// 100 ms tick interval and delays the output-file writes.
let mut new_bytes: Vec<u8> = Vec::new();
// Read all available stdout (non-blocking)
let mut stdout_eof = false;
if let Some(stdout) = process.child.stdout.as_mut() {
loop {
@@ -1555,12 +1475,11 @@ impl LocalTerminalActor {
stdout_eof = true;
break;
}
None => break, // No data available right now — move on
None => break,
}
}
}
// Read all available stderr (non-blocking)
let mut stderr_eof = false;
if let Some(stderr) = process.child.stderr.as_mut() {
loop {
@@ -1577,7 +1496,7 @@ impl LocalTerminalActor {
stderr_eof = true;
break;
}
None => break, // No data available right now — move on
None => break,
}
}
}
@@ -1596,10 +1515,6 @@ impl LocalTerminalActor {
// Truncate in-memory buffer if needed (file has full output)
process.maybe_truncate();
// Send output chunk notification if there's new output since last tick.
// This happens every ~100ms (the actor's tick interval).
// If the handle is noop(), send() silently drops — no performance cost.
//
// Keyed off the monotonic `total_bytes` (not `output_buffer.len()`):
// after `maybe_truncate` freezes the front half and keeps only the
// shrinking tail, a length-based gate would go false and stay false,
@@ -1639,7 +1554,6 @@ impl LocalTerminalActor {
return;
}
// Check for timeout.
if process.is_timed_out() && process.exit_status.is_none() {
if matches!(
process.bg_status,
@@ -1651,7 +1565,6 @@ impl LocalTerminalActor {
return;
}
// Default: kill the process on timeout.
send_sigterm_to_group(process);
process.exit_status = Some(ExitStatus {
exit_code: None,
@@ -1664,14 +1577,12 @@ impl LocalTerminalActor {
return;
}
// Check if process exited (both streams at EOF or process exited)
let process_done = stdout_eof && stderr_eof;
match process.child.try_wait() {
Ok(Some(status)) => {
// Process exited — drain any remaining stdout/stderr that arrived
// after the timeout-based reads above. This fixes a race where fast
// commands (e.g. `python3 -c "print('x')"`) exit before their pipe
// buffers are read, resulting in empty output.
// Drain what arrived after the reads above: fast commands (e.g.
// `python3 -c "print('x')"`) exit before their pipe buffers are
// read, and without this drain their output comes back empty.
drain_remaining_output(process).await;
process.exit_status = Some(extract_exit_status(status));
@@ -1766,9 +1677,8 @@ impl LocalTerminalActor {
if let Some(process) = self.processes.get_mut(id) {
send_sigkill_to_group(process);
// Wait for the child to actually exit so the kernel reclaims
// its memory. Bounded to 5 s — SIGKILL is unconditional so
// this should resolve almost instantly in practice.
// Bounded at 5 s; SIGKILL is unconditional so this resolves
// almost instantly in practice.
let _ =
tokio::time::timeout(std::time::Duration::from_secs(5), process.child.wait())
.await;
@@ -1795,7 +1705,6 @@ impl LocalTerminalActor {
}
}
// Remove dead foreground entries
for id in &fg_ids {
self.processes.remove(id);
}
@@ -1967,14 +1876,8 @@ impl LocalTerminalActor {
}
}
// ============================================================================
// Handle (public API)
// ============================================================================
/// Handle to interact with the terminal actor.
///
/// This is the public API that implements `TerminalBackend`.
/// It sends commands to the actor via channels - no mutex locks needed.
/// Handle to the terminal actor: the public `TerminalBackend` implementation,
/// which reaches the actor's state only by sending commands over a channel.
#[derive(Clone)]
pub struct LocalTerminalBackend {
cmd_tx: mpsc::Sender<TerminalCommand>,
@@ -1982,37 +1885,28 @@ pub struct LocalTerminalBackend {
}
impl LocalTerminalBackend {
/// Create a new LocalTerminalBackend and spawn the actor task.
///
/// The actor runs in a spawned task and processes commands from the channel.
/// If `memory_config` is provided, a cgroupv2 memory limit is enforced on
/// all spawned commands (Linux only; silently degrades to no-op elsewhere).
/// Spawns the actor task that owns all terminal state.
pub fn new() -> Self {
Self::new_inner(None, false, false, SearchShadowConfig::default())
}
/// Create a new LocalTerminalBackend with persistent shell state.
///
/// When enabled, environment variables, working directory, functions, aliases,
/// and shell options persist across command invocations. The user's login shell
/// (bash or zsh) is detected and its rc files are loaded once on first command.
/// Environment variables, working directory, functions, aliases, and shell
/// options persist across command invocations. The user's login shell (bash or
/// zsh) is detected and its rc files are loaded once on the first command.
pub fn with_persistent_shell() -> Self {
Self::new_inner(None, false, true, SearchShadowConfig::default())
}
/// Create a new LocalTerminalBackend with cgroup memory limits.
///
/// See [`CgroupMemoryConfig`] for details on the soft/hard limit model.
pub fn with_memory_limit(config: CgroupMemoryConfig) -> Self {
Self::new_inner(Some(config), false, false, SearchShadowConfig::default())
}
/// Create a new LocalTerminalBackend with both memory limits and persistent shell.
pub fn with_memory_limit_and_persistent_shell(config: CgroupMemoryConfig) -> Self {
Self::new_inner(Some(config), false, true, SearchShadowConfig::default())
}
/// Create a new LocalTerminalBackend using spawn_local (for single-threaded runtimes).
/// Runs the actor on `spawn_local`, for single-threaded runtimes.
///
/// `search_shadows` is the host-resolved `find`→`bfs` / `grep`→`ugrep` enable
/// state, baked into this backend (see [`SearchShadowConfig`]).
@@ -2020,15 +1914,12 @@ impl LocalTerminalBackend {
Self::new_inner(None, true, false, search_shadows)
}
/// Create a new LocalTerminalBackend using spawn_local with persistent shell.
///
/// `search_shadows` is the host-resolved `find`→`bfs` / `grep`→`ugrep` enable
/// state, baked into this backend (see [`SearchShadowConfig`]).
pub fn new_local_with_persistent_shell(search_shadows: SearchShadowConfig) -> Self {
Self::new_inner(None, true, true, search_shadows)
}
/// Create a new LocalTerminalBackend using spawn_local with memory limits.
pub fn new_local_with_memory_limit(config: CgroupMemoryConfig) -> Self {
Self::new_inner(Some(config), true, false, SearchShadowConfig::default())
}
@@ -2053,7 +1944,6 @@ impl LocalTerminalBackend {
)
}
/// Create a backend with a custom completed-task TTL (for testing).
#[cfg(test)]
pub(crate) fn new_with_completed_task_ttl(ttl: Duration) -> Self {
Self::new_with_ttl(
@@ -2068,7 +1958,6 @@ impl LocalTerminalBackend {
)
}
/// Backend with a custom foreground budget (test-only).
#[cfg(test)]
pub(crate) fn new_with_foreground_budget(budget: Duration) -> Self {
Self::new_with_ttl(
@@ -2083,7 +1972,6 @@ impl LocalTerminalBackend {
)
}
/// Backend with a custom output-file size cap (test-only).
#[cfg(test)]
pub(crate) fn new_with_output_cap(output_file_cap: u64) -> Self {
Self::new_with_ttl(
@@ -2378,18 +2266,12 @@ impl TerminalBackend for LocalTerminalBackend {
}
}
// ============================================================================
// Helper functions
// ============================================================================
/// Non-blocking read: returns `Some(Ok(n))` if data is available,
/// `Some(Err(e))` on I/O error, `Some(Ok(0))` on EOF, or `None` if
/// no data is ready right now.
///
/// Uses `Waker::noop()` — safe because the actor runs a periodic
/// polling loop and doesn't need wake-up notifications from the pipe.
/// This eliminates the 10 ms timeout-per-read that previously caused
/// O(N × 20 ms) per-tick overhead for N processes.
fn try_read_nonblocking(
reader: &mut (impl tokio::io::AsyncRead + Unpin),
buf: &mut [u8],
@@ -2500,16 +2382,14 @@ async fn drain_remaining_output(process: &mut ProcessState) {
async fn graceful_kill_and_wait(process: &mut ProcessState) {
send_sigterm_to_group(process);
// Wait up to SIGTERM_GRACE (1s) for graceful exit
if tokio::time::timeout(SIGTERM_GRACE, process.child.wait())
.await
.is_ok()
{
drain_remaining_output(process).await;
return; // exited cleanly
return;
}
// Escalate to SIGKILL
send_sigkill_to_group(process);
// Wait for reap — bounded at 5s. SIGKILL is unconditional so this
@@ -2555,10 +2435,10 @@ async fn kill_and_finalize(process: &mut ProcessState) -> KillOutcome {
finalize_process(process, None).await;
return KillOutcome::AlreadyExited;
}
Ok(None) => {} // still running, proceed to kill
// Still running, proceed to kill.
Ok(None) => {}
}
// Two-phase kill: SIGTERM → 1s grace → SIGKILL (bounded waits)
graceful_kill_and_wait(process).await;
// The child is reaped now, so drop the scope's reaping handle immediately
@@ -2581,7 +2461,6 @@ async fn kill_and_finalize(process: &mut ProcessState) -> KillOutcome {
KillOutcome::Killed
}
/// Set exit_status, flush the output file, and notify foreground waiters.
async fn finalize_process(process: &mut ProcessState, status: Option<std::process::ExitStatus>) {
if process.exit_status.is_some() {
return;
@@ -2607,7 +2486,6 @@ async fn finalize_process(process: &mut ProcessState, status: Option<std::proces
/// Open an output file for writing, creating parent directories if needed.
#[tracing::instrument(name = "fs.open_output_file", skip_all)]
async fn open_output_file(path: &std::path::Path) -> std::io::Result<File> {
// Create parent directories if they don't exist
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
@@ -2738,7 +2616,7 @@ fn spawn_shell_command(
// detach_from_tty() handles both session and process group creation.
.kill_on_drop(true);
// Apply env vars from the request (e.g., .envrc, color vars, ACP-provided vars).
// Request env carries .envrc, color vars, ACP-provided vars.
cmd.envs(shell_state::shell_env_overrides());
for (key, value) in env {
cmd.env(key, value);
@@ -2870,10 +2748,6 @@ fn extract_exit_status(status: std::process::ExitStatus) -> ExitStatus {
ExitStatus { exit_code, signal }
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
@@ -2881,7 +2755,6 @@ mod tests {
use std::path::PathBuf;
fn make_request(command: &str) -> TerminalRunRequest {
// Use a unique temp file for each test
let output_file = std::env::temp_dir().join(format!(
"terminal-test-{}-{}.out",
std::process::id(),
@@ -3282,7 +3155,8 @@ mod tests {
std::env::temp_dir().join(format!("terminal-test-size-{}.out", std::process::id()));
let request = TerminalRunRequest {
command: "yes".to_string(), // floods stdout forever
// `yes` floods stdout forever.
command: "yes".to_string(),
working_directory: PathBuf::from("/tmp"),
env: HashMap::new(),
// Long timeout: the SIZE guard, not the timeout, must fire.
@@ -3442,10 +3316,6 @@ mod tests {
let _ = tokio::fs::remove_file(&output_file).await;
}
// -----------------------------------------------------------------------
// BashOutputChunk streaming tests
// -----------------------------------------------------------------------
#[tokio::test]
async fn chunk_notifications_sent_during_execution() {
// Create a real notification channel (not noop)
@@ -3772,10 +3642,6 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// CS2: Graceful kill tests
// -----------------------------------------------------------------------
#[tokio::test]
async fn test_timeout_uses_sigterm_then_sigkill() {
// Run a command with a short timeout. Verify the result has timed_out == true
@@ -3804,10 +3670,6 @@ mod tests {
assert_eq!(result.signal.as_deref(), Some("timeout"));
}
// -----------------------------------------------------------------------
// CS3: Output drain tests
// -----------------------------------------------------------------------
#[tokio::test]
#[ignore = "flaky: output buffer sometimes not flushed before kill completes"]
async fn test_output_preserved_on_kill() {
@@ -3930,10 +3792,6 @@ mod tests {
assert_eq!(DRAIN_TIMEOUT, Duration::from_secs(2));
}
// -----------------------------------------------------------------------
// CS4: Background guardrails tests
// -----------------------------------------------------------------------
#[tokio::test]
async fn test_sigterm_grace_is_one_second() {
// Static assertion: guard against someone changing the constant
@@ -3946,10 +3804,6 @@ mod tests {
assert_eq!(BACKGROUND_MAX_RUNTIME, Duration::from_secs(36_000));
}
// -----------------------------------------------------------------------
// CS1: Process group tests (pre-existing)
// -----------------------------------------------------------------------
#[tokio::test]
#[ignore = "flaky: pgrep sees sleep processes from other sandbox co-tenants"]
async fn test_kill_kills_child_processes() {
@@ -4161,10 +4015,6 @@ mod tests {
});
}
// ================================================================
// Persistent shell tests
// ================================================================
#[tokio::test]
async fn test_persistent_shell_cd_persists() {
let backend = LocalTerminalBackend::with_persistent_shell();
@@ -4339,10 +4189,6 @@ mod tests {
assert!(snap_wait.completed);
}
// -----------------------------------------------------------------------
// Auto-wake suppression tests (TOCTOU race fixes)
// -----------------------------------------------------------------------
/// Fix 3 — when wait_for_completion is called AFTER the task was
/// evicted from `processes` (snapshot-only branch), the returned
/// snapshot must reflect `block_waited=true` AND the in-place
@@ -4535,10 +4381,6 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// Owner-scoped kill and reparent tests
// -----------------------------------------------------------------------
/// Helper: create a request owned by a specific session.
fn make_owned_request(command: &str, owner: &str) -> TerminalRunRequest {
let mut req = make_request(command);
@@ -1,3 +1,2 @@
pub mod local;
/// Contains the computer implementation
pub mod types;
+59 -115
View File
@@ -7,10 +7,6 @@ use std::{
use crate::notification::types::ToolNotificationHandle;
// ============================================================================
// Error types
// ============================================================================
#[derive(thiserror::Error, Debug, Clone)]
pub enum ComputerError {
#[error("IO Error: {0}")]
@@ -20,7 +16,6 @@ pub enum ComputerError {
}
impl ComputerError {
/// Create an IO error from a message string with no preserved error kind.
pub fn io(msg: impl Into<String>) -> Self {
Self::IOError(msg.into(), None)
}
@@ -29,7 +24,6 @@ impl ComputerError {
Self::IOError(msg.into(), Some(kind))
}
/// Returns the underlying `io::ErrorKind` or `None`
pub fn io_error_kind(&self) -> Option<std::io::ErrorKind> {
match self {
Self::IOError(_, kind) => *kind,
@@ -44,10 +38,6 @@ impl From<std::io::Error> for ComputerError {
}
}
// ============================================================================
// File system trait
// ============================================================================
#[async_trait::async_trait]
pub trait AsyncFileSystem: Send + Sync {
async fn read_file(&self, path: &Path) -> Result<Vec<u8>, ComputerError>;
@@ -57,38 +47,28 @@ pub trait AsyncFileSystem: Send + Sync {
async fn delete_file(&self, path: &Path) -> Result<(), ComputerError>;
}
// ============================================================================
// Terminal types
// ============================================================================
pub struct TerminalRunRequest {
pub command: String,
pub working_directory: PathBuf,
pub env: HashMap<String, String>,
pub timeout: Duration,
pub output_byte_limit: usize,
/// File path to write output incrementally as it arrives.
/// This ensures full output is always available even after in-memory buffer is truncated.
/// For background tasks, this allows retrieval of output after the agent has moved on.
/// Output is written here incrementally, so the full text stays retrievable
/// after the in-memory buffer is truncated or the agent has moved on.
pub output_file: PathBuf,
/// Notification handle for streaming output chunks during execution.
/// The backend sends `BashOutputChunk` notifications every ~100ms.
/// Callers that don't need streaming pass `ToolNotificationHandle::noop()`
/// — messages are silently dropped. No `Option` wrapper needed.
/// Receives `BashOutputChunk` notifications every ~100ms during execution.
/// Callers that don't want streaming pass `ToolNotificationHandle::noop()`,
/// which drops them — hence no `Option` wrapper.
pub notification_handle: ToolNotificationHandle,
/// Tool call ID for correlating notifications with the tool invocation.
/// Flows from `ToolContext::tool_call_id()` through the actor to
/// `BashOutputChunk.base.tool_call_id`.
pub tool_call_id: String,
/// Original user command before isolation wrapping.
///
/// When set, the terminal actor stores this on the `ProcessEntry` so
/// `get_task()` returns it in `TaskSnapshot.display_command`. This
/// ensures model-facing `get_task_output` shows the user's command
/// instead of the `unshare`/mount wrapper.
/// Original user command before isolation wrapping. Surfaces through
/// `TaskSnapshot.display_command` so model-facing `get_task_output` shows
/// the user's command instead of the `unshare`/mount wrapper.
pub display_command: Option<String>,
/// Auto-background on timeout instead of killing (default `false`).
@@ -103,16 +83,14 @@ pub struct TerminalRunRequest {
/// - `Some(d)` → auto-bg after `d` if still running.
pub foreground_block_budget: Option<Duration>,
/// Task kind for distinguishing monitor tasks from regular bash tasks.
pub kind: TaskKind,
/// Session that owns this process. Used to scope kill operations so
/// `kill_all_background_tasks_by_owner` only targets the requesting
/// session's processes — not the parent's or sibling's.
/// Scopes kill operations so `kill_all_background_tasks_by_owner` only
/// targets the requesting session's processes — not the parent's or
/// a sibling's.
pub owner_session_id: Option<String>,
}
/// Distinguishes different types of background tasks.
#[derive(
Debug,
Clone,
@@ -126,7 +104,6 @@ pub struct TerminalRunRequest {
)]
#[serde(rename_all = "snake_case")]
pub enum TaskKind {
/// Regular bash command.
#[default]
Bash,
/// Monitor tool — streams stdout events with rate limiting.
@@ -140,47 +117,39 @@ pub struct TerminalRunResult {
pub truncated: bool,
pub signal: Option<String>,
pub timed_out: bool,
/// Path to the output file where full output is stored.
/// Use read_file tool to retrieve full output when truncated.
/// Holds the full output; read it back with the read_file tool when
/// `combined_output` is truncated.
pub output_file: PathBuf,
/// Total bytes of output (before truncation).
/// When truncated, combined_output contains the first and last portions up to output_byte_limit chars.
/// Byte count before truncation. When truncated, `combined_output` holds
/// the first and last portions up to `output_byte_limit` chars.
pub total_bytes: usize,
/// PID of the spawned shell process, when available. Set by the
/// local terminal backend at spawn time. Useful for foreground
/// commands that auto-background on timeout: the resulting
/// `BackgroundTaskStarted` can carry the real PID instead of a
/// placeholder. `None` for backends that cannot surface a local
/// PID (e.g. ACP/remote terminals) or when the process exited
/// before `child.id()` could be queried.
/// PID of the spawned shell process. Lets a foreground command that
/// auto-backgrounds on timeout report a real PID rather than a placeholder.
/// `None` for backends without a local PID (e.g. ACP/remote terminals) or
/// when the process exited before `child.id()` could be queried.
pub pid: Option<u32>,
}
/// Returned by `TerminalBackend::run_background` — gives the caller the task_id
/// to use for subsequent queries via `get_task`, `kill_task`, `wait_for_completion`.
/// Returned by `TerminalBackend::run_background` — the `task_id` is the key for
/// subsequent `get_task`, `kill_task`, `wait_for_completion` calls.
pub struct BackgroundHandle {
pub task_id: String,
pub output_file: PathBuf,
/// PID of the spawned shell process, when available. `None` for
/// backends that do not surface a local PID (e.g. ACP/remote
/// gateways) or when the process exited before the PID could be
/// captured.
/// `None` for backends without a local PID (e.g. ACP/remote gateways) or
/// when the process exited before the PID could be captured.
pub pid: Option<u32>,
}
/// Full snapshot of a task's state.
/// Used by both local and ACP backends.
#[derive(
Debug, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
pub struct TaskSnapshot {
pub task_id: String,
/// The actual command that was executed (may be isolation-wrapped).
/// The command as executed, which may be isolation-wrapped.
pub command: String,
/// The original user command before isolation wrapping.
///
/// When set, model/user-facing output should prefer this over `command`
/// to avoid exposing internal isolation mechanics (unshare/mount wrapper).
/// The original user command before isolation wrapping. Model/user-facing
/// output should prefer this over `command` to avoid exposing internal
/// isolation mechanics (unshare/mount wrapper).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_command: Option<String>,
pub cwd: String,
@@ -192,33 +161,29 @@ pub struct TaskSnapshot {
pub exit_code: Option<i32>,
pub signal: Option<String>,
pub completed: bool,
/// Task kind: bash (default) or monitor.
#[serde(default)]
pub kind: TaskKind,
/// Whether a block-waiter (`block=true`) consumed this task's result.
/// When set, the notification bridge skips auto-wake synthetic prompts
/// because the blocking caller already received the result directly.
/// Set when a block-waiter (`block=true`) consumed this task's result, so
/// the notification bridge skips auto-wake synthetic prompts — the blocking
/// caller already received the result directly.
#[serde(default)]
pub block_waited: bool,
/// Whether this task was explicitly killed via the `kill_command_or_subagent` tool.
/// When set, auto-wake synthetic prompts are suppressed because the model
/// already received the kill result via `KillTaskResult`.
/// Also set during `kill_all_background_tasks` teardown (e.g. subagent
/// cleanup), where auto-wake suppression is irrelevant since the session
/// is shutting down.
/// Set when the task was killed via the `kill_command_or_subagent` tool,
/// suppressing auto-wake synthetic prompts because the model already
/// received the kill result via `KillTaskResult`. Also set during
/// `kill_all_background_tasks` teardown (e.g. subagent cleanup), where
/// auto-wake suppression is irrelevant since the session is shutting down.
#[serde(default)]
pub explicitly_killed: bool,
/// Session that owns this task. Used for scoped kill operations so
/// subagent teardown only kills the subagent's own tasks, not
/// the parent's or sibling's.
/// Scopes kill operations so subagent teardown only kills the subagent's
/// own tasks, not the parent's or a sibling's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_session_id: Option<String>,
}
impl TaskSnapshot {
/// Calculate duration in seconds.
/// If task is still running, returns time since start.
/// For a task still running, this is the time since start.
pub fn duration_secs(&self) -> f64 {
let end = self.end_time.unwrap_or_else(std::time::SystemTime::now);
end.duration_since(self.start_time)
@@ -226,10 +191,8 @@ impl TaskSnapshot {
.unwrap_or(0.0)
}
/// True iff the task has NOT yet completed — covers bash AND
/// monitor task kinds (the `kind` field doesn't change this
/// predicate; the runtime turn-end TodoGate counts both as
/// backing work).
/// Deliberately kind-agnostic: the runtime turn-end TodoGate counts both
/// bash and monitor tasks as backing work.
pub fn is_outstanding(&self) -> bool {
!self.completed
}
@@ -248,10 +211,6 @@ pub enum KillOutcome {
NotFound,
}
// ============================================================================
// TerminalBackend trait
// ============================================================================
/// The single abstraction over terminal execution backends.
///
/// Implemented by:
@@ -259,48 +218,41 @@ pub enum KillOutcome {
/// - `AcpTerminalBackend` (in kigi-shell, calls ACP protocol)
#[async_trait::async_trait]
pub trait TerminalBackend: Send + Sync {
/// Run a command. Blocks until completion or timeout.
/// Blocks until completion or timeout.
async fn run(&self, request: TerminalRunRequest) -> Result<TerminalRunResult, ComputerError>;
/// Start a command in the background. Returns immediately with a handle ID.
/// The process continues running; use get_task/kill_task/wait_for_completion to manage it.
/// Returns immediately while the process keeps running; manage it via
/// `get_task`/`kill_task`/`wait_for_completion`.
async fn run_background(
&self,
request: TerminalRunRequest,
) -> Result<BackgroundHandle, ComputerError>;
/// Get current snapshot of a background task.
async fn get_task(&self, task_id: &str) -> Option<TaskSnapshot>;
/// Kill a background task.
async fn kill_task(&self, task_id: &str) -> KillOutcome;
/// Kill all running foreground processes.
async fn kill_foreground_commands(&self) {}
/// Kill all running foreground processes owned by a specific session.
/// Used on a shared terminal backend so a subagent's cancel doesn't
/// kill the parent's foreground commands.
/// Owner-scoped variant for a shared terminal backend, so a subagent's
/// cancel doesn't kill the parent's foreground commands.
async fn kill_foreground_commands_by_owner(&self, _owner_session_id: &str) {}
/// Kill all running background tasks.
/// Used during subagent teardown to clean up orphaned processes.
async fn kill_all_background_tasks(&self) {}
/// Kill all running background tasks owned by a specific session.
/// Used during subagent teardown on a shared terminal backend so
/// only the subagent's own tasks are killed — not the parent's.
/// Owner-scoped variant for a shared terminal backend, so subagent teardown
/// kills only the subagent's own tasks — not the parent's.
async fn kill_all_background_tasks_by_owner(&self, _owner_session_id: &str) {}
/// Fire-and-forget prewarm of the persistent login shell; default no-op for
/// backends without one (ACP/remote, non-persistent).
async fn warm_persistent_shell(&self, _cwd: &std::path::Path) {}
/// Reparent notification handles for all tasks owned by `old_owner_session_id`.
/// Swaps the dead child session's notification handle with the parent's
/// live handle so events from surviving processes route correctly.
/// Also re-spawns monitor pipelines on the caller's runtime so monitor
/// events continue streaming to the parent.
/// Swaps the dead child session's notification handle for the parent's live
/// handle on every task owned by `old_owner_session_id`, so events from
/// surviving processes keep routing correctly. Also re-spawns monitor
/// pipelines on the caller's runtime so monitor events reach the parent.
///
/// `backend_weak` is a [`Weak`](std::sync::Weak) to *this* backend (anchored
/// by the parent session's `Arc`); it drives re-spawned monitor pipelines
@@ -314,37 +266,29 @@ pub trait TerminalBackend: Send + Sync {
) {
}
/// Move a foreground command to background by tool_call_id.
/// The process keeps running but the foreground waiter is unblocked.
/// Returns `true` if a matching foreground process was found.
/// Unblocks the foreground waiter for `tool_call_id` while the process keeps
/// running. Returns `true` if a matching foreground process was found.
async fn background_foreground_command(&self, _tool_call_id: &str) -> bool {
false
}
/// Wait for a background task to complete, with optional timeout.
async fn wait_for_completion(
&self,
task_id: &str,
timeout: Option<Duration>,
) -> Option<TaskSnapshot>;
/// List all known background tasks (running and completed).
/// Used for context compaction to include task state in summaries.
/// Includes completed tasks; context compaction uses this to put task state
/// into summaries.
async fn list_tasks(&self) -> Vec<TaskSnapshot>;
/// Return the persistent shell's current working directory, if persistent
/// shell state is enabled. Returns `None` when persistence is off or the
/// backend doesn't support it (e.g. ACP/remote).
/// `None` when persistent shell state is off or unsupported by the backend
/// (e.g. ACP/remote).
async fn get_shell_cwd(&self) -> Option<std::path::PathBuf> {
None
}
}
// ============================================================================
// Computer struct
// ============================================================================
/// Contains the computer struct which provides access to both the terminal and the fs
pub struct Computer {
pub terminal: Arc<dyn TerminalBackend>,
pub file_system: Arc<dyn AsyncFileSystem>,
@@ -406,12 +350,12 @@ mod tests {
#[test]
fn io_with_kind_matches_local_fs_dispatch_for_not_found() {
// Simulate what LocalFs produces for a missing file
// What LocalFs produces for a missing file.
let local_err = ComputerError::from(std::io::Error::new(
std::io::ErrorKind::NotFound,
"No such file or directory (os error 2)",
));
// Simulate what AcpFsAdapter now produces for RESOURCE_NOT_FOUND
// What AcpFsAdapter produces for RESOURCE_NOT_FOUND.
let acp_err =
ComputerError::io_with_kind("Resource not found", std::io::ErrorKind::NotFound);
+1 -10
View File
@@ -8,13 +8,9 @@
use ignore::gitignore::Gitignore;
use std::path::Path;
/// Check if a path is ignored by the given gitignore rules.
///
/// Strips `git_root` prefix before matching — gitignore patterns are
/// Strips the `git_root` prefix before matching — gitignore patterns are
/// repo-relative, so `/repo/build/out.o` becomes `build/out.o` when
/// `git_root` is `/repo`.
///
/// This is a pure function — no filesystem access, just `Gitignore::matched()`.
pub fn is_ignored(gitignore: &Gitignore, path: &Path, git_root: Option<&Path>) -> bool {
let check_path = match git_root {
Some(root) => match path.strip_prefix(root) {
@@ -78,9 +74,7 @@ mod tests {
let root = tmp.path();
let root = &dunce::canonicalize(root).unwrap();
let gi = build_gitignore(root, &["build/"]);
// With root: strips prefix, matches build/out.o
assert!(is_ignored(&gi, &root.join("build/out.o"), Some(root)));
// Without root: relative path still matches
assert!(is_ignored(
&gi,
&std::path::PathBuf::from("build/out.o"),
@@ -94,8 +88,6 @@ mod tests {
let root = tmp.path();
let root = &dunce::canonicalize(root).unwrap();
let gi = build_gitignore(root, &["build/", "*.md"]);
// A path completely outside the git root should not be checked
// against the repo's .gitignore (e.g., ~/.kigi/Agents.md).
let outside_path = std::path::PathBuf::from("/some/other/path/Agents.md");
assert!(!is_ignored(&gi, &outside_path, Some(root)));
}
@@ -115,7 +107,6 @@ mod tests {
.is_err()
);
// Our wrapper guards against it.
assert!(!is_ignored(&gi, abs_path, None));
}
}
@@ -10,11 +10,7 @@ use super::errors::ApplyPatchError;
use super::parser::UpdateFileChunk;
use super::seek_sequence::seek_sequence;
/// Given the original file content as a `&str` and the list of update chunks,
/// compute and return the new file contents as a `String`.
///
/// This is the main entry point for the apply logic. It does NOT read from or
/// write to the filesystem.
/// Main entry point for the apply logic.
pub fn derive_new_contents(
original_content: &str,
path: &Path,
@@ -31,7 +27,7 @@ pub fn derive_new_contents(
let replacements = compute_replacements(&original_lines, path, chunks)?;
let mut new_lines = apply_replacements(original_lines, &replacements);
// Ensure the file ends with a trailing newline.
// A trailing empty element makes `join` emit the final newline.
if !new_lines.last().is_some_and(String::is_empty) {
new_lines.push(String::new());
}
@@ -39,9 +35,7 @@ pub fn derive_new_contents(
Ok(new_lines.join("\n"))
}
/// Compute a list of replacements needed to transform `original_lines` into the
/// new lines, given the patch `chunks`. Each replacement is returned as
/// `(start_index, old_len, new_lines)`.
/// Each replacement is `(start_index, old_len, new_lines)`.
pub fn compute_replacements(
original_lines: &[String],
path: &Path,
@@ -51,8 +45,6 @@ pub fn compute_replacements(
let mut line_index: usize = 0;
for chunk in chunks {
// If a chunk has a `change_context`, use seek_sequence to find it,
// then adjust our `line_index` to continue from there.
if let Some(ctx_line) = &chunk.change_context {
if let Some(idx) = seek_sequence(
original_lines,
@@ -71,8 +63,8 @@ pub fn compute_replacements(
}
if chunk.old_lines.is_empty() {
// Pure addition (no old lines). Add at the end or just before the
// final empty line if one exists.
// Pure addition: append, but stay ahead of the trailing newline
// sentinel so the file doesn't grow a blank line at the end.
let insertion_idx = if original_lines.last().is_some_and(String::is_empty) {
original_lines.len() - 1
} else {
@@ -82,9 +74,8 @@ pub fn compute_replacements(
continue;
}
// Try to match the existing lines in the file with the old lines from
// the chunk. If the pattern ends with a trailing empty string (final
// newline), retry without it.
// A pattern ending in an empty string (the chunk's final newline) may
// not match a file that lacks one, so it is retried without it below.
let mut pattern: &[String] = &chunk.old_lines;
let mut found = seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file);
@@ -115,9 +106,6 @@ pub fn compute_replacements(
Ok(replacements)
}
/// Apply the `(start_index, old_len, new_lines)` replacements to
/// `original_lines`, returning the modified file contents as a vector of lines.
///
/// Replacements are applied in **reverse order** so that earlier replacements
/// don't shift the positions of later ones.
pub fn apply_replacements(
@@ -128,14 +116,12 @@ pub fn apply_replacements(
let start_idx = *start_idx;
let old_len = *old_len;
// Remove old lines.
for _ in 0..old_len {
if start_idx < lines.len() {
lines.remove(start_idx);
}
}
// Insert new lines.
for (offset, new_line) in new_segment.iter().enumerate() {
lines.insert(start_idx + offset, new_line.clone());
}
@@ -144,8 +130,6 @@ pub fn apply_replacements(
lines
}
// ─── Tests ───────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use std::path::PathBuf;
@@ -153,7 +137,6 @@ mod tests {
use super::*;
use crate::implementations::codex::apply_patch::parser::{Hunk, parse_patch};
/// Helper to construct a patch string with the given body.
fn wrap_patch(body: &str) -> String {
format!("*** Begin Patch\n{body}\n*** End Patch")
}
@@ -4,7 +4,6 @@ use std::path::PathBuf;
use thiserror::Error;
/// Errors encountered while parsing a patch.
#[derive(Debug, PartialEq, Clone, Error)]
pub enum ParseError {
#[error("invalid patch: {0}")]
@@ -13,10 +12,8 @@ pub enum ParseError {
InvalidHunkError { message: String, line_number: usize },
}
/// Errors encountered while applying a parsed patch to file contents.
#[derive(Debug, Error)]
pub enum ApplyPatchError {
/// The patch text could not be parsed.
#[error(transparent)]
Parse(#[from] ParseError),
@@ -24,9 +21,8 @@ pub enum ApplyPatchError {
#[error("{0}")]
ComputeReplacements(String),
/// An I/O error occurred while reading or writing a file.
/// Stored as a string so that the type remains `PartialEq`-friendly in
/// tests (std::io::Error is not PartialEq).
/// The underlying `std::io::Error` is flattened into strings so that this
/// enum can implement `PartialEq` for tests.
#[error("{context}: {message}")]
Io {
context: String,
@@ -1,16 +1,8 @@
//! Codex `apply_patch` — core patch engine (pure library, no I/O).
//! Codex `apply_patch`.
//!
//! This module ports the codex patch parser, fuzzy matcher, and replacement
//! logic as pure functions with zero filesystem dependencies. All I/O
//! (reading/writing files) is handled by the tool layer in a later milestone.
//!
//! # Submodules
//!
//! - [`apply`] — `derive_new_contents()`, `compute_replacements()`,
//! `apply_replacements()` — all accept `&str` input.
//! - [`errors`] — `ApplyPatchError`, `ParseError`.
//! - [`parser`] — `parse_patch()`, `Hunk`, `UpdateFileChunk`.
//! - [`seek_sequence`] — 4-tier fuzzy line matcher.
//! Ports the codex patch parser, fuzzy matcher, and replacement logic. The
//! [`parser`], [`seek_sequence`], and [`apply`] layers are pure functions over
//! `&str`; [`tool`] is the only layer that touches the filesystem.
pub mod apply;
pub mod errors;
@@ -18,7 +10,6 @@ pub mod parser;
pub mod seek_sequence;
pub mod tool;
// Re-exports for convenience.
pub use apply::derive_new_contents;
pub use errors::{ApplyPatchError, ParseError};
pub use parser::{Hunk, ParsedPatch, UpdateFileChunk, parse_patch};
@@ -33,8 +33,6 @@ use std::path::PathBuf;
use super::errors::ParseError;
use ParseError::*;
// ─── Marker constants ────────────────────────────────────────────────
const BEGIN_PATCH_MARKER: &str = "*** Begin Patch";
const END_PATCH_MARKER: &str = "*** End Patch";
const ADD_FILE_MARKER: &str = "*** Add File: ";
@@ -45,19 +43,16 @@ const EOF_MARKER: &str = "*** End of File";
const CHANGE_CONTEXT_MARKER: &str = "@@ ";
const EMPTY_CHANGE_CONTEXT_MARKER: &str = "@@";
/// We always use lenient mode (matching the codex default).
/// Lenient mode matches the codex default.
const PARSE_IN_STRICT_MODE: bool = false;
// ─── Public types ────────────────────────────────────────────────────
/// A parsed patch: the list of hunks plus the normalised patch text.
/// `patch` is the normalised patch text: trimmed and re-joined with `\n`.
#[derive(Debug, PartialEq)]
pub struct ParsedPatch {
pub hunks: Vec<Hunk>,
pub patch: String,
}
/// A single hunk within a parsed patch.
#[derive(Debug, PartialEq, Clone)]
#[allow(clippy::enum_variant_names)]
pub enum Hunk {
@@ -77,7 +72,6 @@ pub enum Hunk {
},
}
/// A single contiguous edit within an `UpdateFile` hunk.
#[derive(Debug, PartialEq, Clone)]
pub struct UpdateFileChunk {
/// A single line of context used to narrow down the position of the chunk
@@ -93,9 +87,6 @@ pub struct UpdateFileChunk {
pub is_end_of_file: bool,
}
// ─── Public entry point ──────────────────────────────────────────────
/// Parse a patch string into a [`ParsedPatch`].
pub fn parse_patch(patch: &str) -> Result<ParsedPatch, ParseError> {
let mode = if PARSE_IN_STRICT_MODE {
ParseMode::Strict
@@ -105,10 +96,7 @@ pub fn parse_patch(patch: &str) -> Result<ParsedPatch, ParseError> {
parse_patch_text(patch, mode)
}
// ─── Internal helpers ────────────────────────────────────────────────
enum ParseMode {
/// Parse the patch text argument as-is.
Strict,
/// In lenient mode we strip heredoc wrappers (`<<EOF` / `<<'EOF'` /
/// `<<"EOF"`) before trying strict parsing.
@@ -194,12 +182,10 @@ fn check_start_and_end_lines_strict(
}
}
/// Parse a single hunk from the start of `lines`.
/// Returns the parsed hunk and the number of lines consumed.
/// Returns the parsed hunk and the number of lines it consumed.
fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), ParseError> {
let first_line = lines[0].trim();
if let Some(path) = first_line.strip_prefix(ADD_FILE_MARKER) {
// ── Add File ─────────────────────────────────────────────
let mut contents = String::new();
let mut parsed_lines = 1;
for add_line in &lines[1..] {
@@ -219,7 +205,6 @@ fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), P
parsed_lines,
));
} else if let Some(path) = first_line.strip_prefix(DELETE_FILE_MARKER) {
// ── Delete File ──────────────────────────────────────────
return Ok((
Hunk::DeleteFile {
path: PathBuf::from(path),
@@ -227,11 +212,9 @@ fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), P
1,
));
} else if let Some(path) = first_line.strip_prefix(UPDATE_FILE_MARKER) {
// ── Update File ──────────────────────────────────────────
let mut remaining_lines = &lines[1..];
let mut parsed_lines = 1;
// Optional: move-to line.
let move_path = remaining_lines
.first()
.and_then(|x| x.strip_prefix(MOVE_TO_MARKER));
@@ -243,13 +226,12 @@ fn parse_one_hunk(lines: &[&str], line_number: usize) -> Result<(Hunk, usize), P
let mut chunks = Vec::new();
while !remaining_lines.is_empty() {
// Skip blank lines between chunks.
if remaining_lines[0].trim().is_empty() {
parsed_lines += 1;
remaining_lines = &remaining_lines[1..];
continue;
}
// Stop at the next hunk header.
// Any `***` line belongs to the next hunk or the patch trailer.
if remaining_lines[0].starts_with("***") {
break;
}
@@ -302,7 +284,6 @@ fn parse_update_file_chunk(
});
}
// Check for explicit @@ context marker.
let (change_context, start_index) = if lines[0] == EMPTY_CHANGE_CONTEXT_MARKER {
(None, 1)
} else if let Some(context) = lines[0].strip_prefix(CHANGE_CONTEXT_MARKER) {
@@ -348,8 +329,8 @@ fn parse_update_file_chunk(
break;
}
line_contents => match line_contents.chars().next() {
// An empty line carries no ' ' prefix but is still context.
None => {
// Interpret empty line as a context line.
chunk.old_lines.push(String::new());
chunk.new_lines.push(String::new());
}
@@ -385,8 +366,6 @@ fn parse_update_file_chunk(
Ok((chunk, parsed_lines + start_index))
}
// ─── Tests ───────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
@@ -584,7 +563,6 @@ mod tests {
let expected_error =
InvalidPatchError("The first line of the patch must be '*** Begin Patch'".to_string());
// <<EOF variant
let patch_in_heredoc = format!("<<EOF\n{patch_text}\nEOF\n");
assert_eq!(
parse_patch_text(&patch_in_heredoc, ParseMode::Strict),
@@ -597,7 +575,6 @@ mod tests {
expected_hunks
);
// <<'EOF' variant
let patch_in_sq_heredoc = format!("<<'EOF'\n{patch_text}\nEOF\n");
assert_eq!(
parse_patch_text(&patch_in_sq_heredoc, ParseMode::Strict),
@@ -610,7 +587,6 @@ mod tests {
expected_hunks
);
// <<"EOF" variant
let patch_in_dq_heredoc = format!("<<\"EOF\"\n{patch_text}\nEOF\n");
assert_eq!(
parse_patch_text(&patch_in_dq_heredoc, ParseMode::Strict),
@@ -623,7 +599,7 @@ mod tests {
expected_hunks
);
// Mismatched quotes fail even in lenient mode
// Mismatched heredoc quotes must fail even in lenient mode.
let patch_in_mismatch = format!("<<\"EOF'\n{patch_text}\nEOF\n");
assert_eq!(
parse_patch_text(&patch_in_mismatch, ParseMode::Strict),
@@ -634,7 +610,6 @@ mod tests {
Err(expected_error)
);
// Missing closing heredoc marker
let patch_missing_close =
"<<EOF\n*** Begin Patch\n*** Update File: file2.py\nEOF\n".to_string();
assert_eq!(
@@ -2,27 +2,15 @@
//!
//! Ported verbatim from `codex-rs/apply-patch/src/seek_sequence.rs`.
//!
//! Attempts to find a sequence of `pattern` lines within `lines` beginning at
//! or after `start`. Matches are attempted with decreasing strictness:
//!
//! 1. **Exact match**
//! 2. **rstrip** — ignore trailing whitespace
//! 3. **trim** — ignore leading and trailing whitespace
//! 4. **Unicode normalise** — normalise common Unicode punctuation to ASCII
//! equivalents (typographic dashes → `-`, smart quotes → `'`/`"`, etc.)
//! The whole input is rescanned once per pass, in decreasing order of
//! strictness, so an exact match anywhere in the file always wins over a
//! whitespace- or punctuation-insensitive match earlier in the file.
/// Find `pattern` within `lines` starting at `start`.
/// Returns the index in `lines` where `pattern` starts, or `None`.
///
/// When `eof` is `true`, the search begins at the end of the file (so that
/// patterns intended to match file endings are applied at the end), falling
/// back to searching from `start` if needed.
///
/// Returns the starting index of the match, or `None` if not found.
///
/// # Edge cases
///
/// - Empty `pattern` → returns `Some(start)` (no-op match).
/// - `pattern.len() > lines.len()` → returns `None`.
/// When `eof` is `true` the search window begins at the last position where
/// `pattern` could still fit, so patterns anchored to the end of a file match
/// their final occurrence rather than the first.
pub fn seek_sequence(
lines: &[String],
pattern: &[String],
@@ -33,8 +21,6 @@ pub fn seek_sequence(
return Some(start);
}
// When the pattern is longer than the available input there is no
// possible match.
if pattern.len() > lines.len() {
return None;
}
@@ -45,14 +31,14 @@ pub fn seek_sequence(
start
};
// ── Pass 1: exact match ──────────────────────────────────────────
// Pass 1: exact.
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
if lines[i..i + pattern.len()] == *pattern {
return Some(i);
}
}
// ── Pass 2: rstrip match ─────────────────────────────────────────
// Pass 2: ignoring trailing whitespace.
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
let mut ok = true;
for (p_idx, pat) in pattern.iter().enumerate() {
@@ -66,7 +52,7 @@ pub fn seek_sequence(
}
}
// ── Pass 3: trim both sides ──────────────────────────────────────
// Pass 3: ignoring leading and trailing whitespace.
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
let mut ok = true;
for (p_idx, pat) in pattern.iter().enumerate() {
@@ -80,10 +66,9 @@ pub fn seek_sequence(
}
}
// ── Pass 4: Unicode normalise ────────────────────────────────────
// Normalise common Unicode punctuation to ASCII equivalents so that
// diffs authored with plain ASCII characters can still be applied to
// source files that contain typographic dashes / quotes, etc.
// Pass 4: normalising Unicode punctuation to ASCII, so that a diff
// authored with plain ASCII still applies to a source file containing
// typographic dashes, smart quotes, or exotic spaces.
fn normalise(s: &str) -> String {
s.trim()
.chars()
@@ -120,8 +105,6 @@ pub fn seek_sequence(
None
}
// ─── Tests ───────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::seek_sequence;
@@ -177,9 +160,8 @@ mod tests {
#[test]
fn test_unicode_normalise_matches_typographic_dashes() {
// Line contains EN DASH (\u{2013}).
// \u{2013} is EN DASH.
let lines = to_vec(&["hello \u{2013} world"]);
// Pattern uses plain ASCII dash.
let pattern = to_vec(&["hello - world"]);
assert_eq!(seek_sequence(&lines, &pattern, 0, false), Some(0));
}
@@ -20,8 +20,6 @@ use super::errors::ParseError;
use super::parser::{self, Hunk};
use super::{apply, errors::ApplyPatchError};
// ─── Description ─────────────────────────────────────────────────────
/// Tool description derived from the codex `apply_patch_tool_instructions.md`.
const DESCRIPTION: &str = r#"Use the `apply_patch` tool to edit files.
Your patch language is a strippeddown, fileoriented diff format designed to be easy to parse and safe to apply. You can think of it as a highlevel envelope:
@@ -92,8 +90,6 @@ It is important to remember:
- File references can only be relative, NEVER ABSOLUTE.
"#;
// ─── Input ───────────────────────────────────────────────────────────
/// Input for the `apply_patch` tool.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct ApplyPatchInput {
@@ -101,15 +97,10 @@ pub struct ApplyPatchInput {
pub patch: String,
}
// ─── Tool ────────────────────────────────────────────────────────────
/// ApplyPatch tool — applies multi-file patches in the codex patch format.
#[derive(Debug, Default)]
pub struct ApplyPatchTool;
// ─── Internal types ──────────────────────────────────────────────────
/// A computed file change — all content determined in-memory, ready to write.
/// A change whose content is fully resolved in memory, ready to write.
enum FileChange {
Add {
path: PathBuf,
@@ -132,9 +123,6 @@ enum FileChange {
},
}
// ─── Helpers ─────────────────────────────────────────────────────────
/// Create parent directories for a file path if they don't exist.
async fn ensure_parent_dirs(path: &std::path::Path) -> Result<(), kigi_tool_runtime::ToolError> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
@@ -149,8 +137,8 @@ async fn ensure_parent_dirs(path: &std::path::Path) -> Result<(), kigi_tool_runt
Ok(())
}
/// Compute all file changes in memory without writing anything.
/// Returns an error string if any hunk can't be applied.
/// Resolves every hunk against the filesystem without writing, so a patch that
/// fails partway through leaves nothing on disk.
async fn compute_all_changes(
cwd: &std::path::Path,
fs: &Arc<dyn AsyncFileSystem>,
@@ -215,7 +203,6 @@ async fn compute_all_changes(
Ok(changes)
}
/// Read a file via AsyncFileSystem and convert to String.
async fn read_file_as_string(
fs: &Arc<dyn AsyncFileSystem>,
path: &std::path::Path,
@@ -224,7 +211,6 @@ async fn read_file_as_string(
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
/// Build the codex-style summary string.
fn build_summary(results: &[ApplyPatchFileResult]) -> String {
let mut out = String::from("Success. Updated the following files:\n");
for r in results {
@@ -232,15 +218,14 @@ fn build_summary(results: &[ApplyPatchFileResult]) -> String {
"added" => "A",
"deleted" => "D",
"moved" => "M",
_ => "M", // "modified"
// "modified"
_ => "M",
};
let _ = writeln!(out, "{prefix} {}", r.path.display());
}
out
}
// ─── Tests ───────────────────────────────────────────────────────────
impl crate::types::tool_metadata::ToolMetadata for ApplyPatchTool {
fn kind(&self) -> ToolKind {
ToolKind::Edit
@@ -307,7 +292,6 @@ impl kigi_tool_runtime::Tool for ApplyPatchTool {
}
let tool_call_id = ctx.call_id.as_str().to_owned();
// ── Phase 1: Parse ───────────────────────────────────────
let parsed = match parser::parse_patch(&input.patch) {
Ok(p) => p,
Err(e) => {
@@ -328,19 +312,16 @@ impl kigi_tool_runtime::Tool for ApplyPatchTool {
));
}
// ── Phase 2: Compute all changes in memory (no writes yet) ───
let changes = match compute_all_changes(&cwd, &fs, &parsed.hunks).await {
Ok(c) => c,
Err(msg) => return Ok(ApplyPatchOutput::ApplicationError(msg)),
};
// ── Phase 3: Apply all changes (write to filesystem) ─────
let mut file_results = Vec::new();
for change in &changes {
match change {
FileChange::Add { path, content } => {
// Create parent directories if needed.
ensure_parent_dirs(path).await?;
fs.write_file(path, content.as_bytes()).await.map_err(|e| {
kigi_tool_runtime::ToolError::execution(
@@ -428,7 +409,6 @@ impl kigi_tool_runtime::Tool for ApplyPatchTool {
original_content,
new_content,
} => {
// Create parent dirs for destination.
ensure_parent_dirs(dest_path).await?;
fs.write_file(dest_path, new_content.as_bytes())
.await
@@ -445,7 +425,8 @@ impl kigi_tool_runtime::Tool for ApplyPatchTool {
)
})?;
// Notify destination (new file at new location).
// A move surfaces as two notifications: a creation at the
// destination and a deletion at the source.
notification_handle.send_file_written(FileWritten {
tool_call_id: tool_call_id.clone(),
absolute_path: dest_path.clone(),
@@ -453,7 +434,6 @@ impl kigi_tool_runtime::Tool for ApplyPatchTool {
previous_content: None,
is_new_file: true,
});
// Notify source (deleted).
notification_handle.send_file_written(FileWritten {
tool_call_id: tool_call_id.clone(),
absolute_path: source_path.clone(),
@@ -473,7 +453,6 @@ impl kigi_tool_runtime::Tool for ApplyPatchTool {
}
}
// ── Phase 4: Build summary ───────────────────────────────
let tool_output_for_prompt = build_summary(&file_results);
Ok(ApplyPatchOutput::Success {
@@ -492,7 +471,6 @@ mod tests {
use crate::types::tool_metadata::test_ctx;
use tempfile::TempDir;
/// Set up Resources with real filesystem for tests.
fn test_resources(cwd: &std::path::Path) -> Resources {
let mut resources = Resources::new();
resources.insert(Cwd(cwd.to_path_buf()));
@@ -501,7 +479,6 @@ mod tests {
resources
}
/// Build a runtime `ToolCallContext` with the given shared resources.
fn make_input(patch: &str) -> ApplyPatchInput {
ApplyPatchInput {
patch: patch.to_string(),
@@ -512,8 +489,6 @@ mod tests {
format!("*** Begin Patch\n{body}\n*** End Patch")
}
// ── Add file ─────────────────────────────────────────────────
#[tokio::test]
async fn add_file_creates_with_correct_content() {
let tmp = TempDir::new().unwrap();
@@ -542,8 +517,6 @@ mod tests {
}
}
// ── Delete file ──────────────────────────────────────────────
#[tokio::test]
async fn delete_file_removes_file() {
let tmp = TempDir::new().unwrap();
@@ -573,8 +546,6 @@ mod tests {
}
}
// ── Update file ──────────────────────────────────────────────
#[tokio::test]
async fn update_file_modifies_content() {
let tmp = TempDir::new().unwrap();
@@ -605,8 +576,6 @@ mod tests {
}
}
// ── Move file ────────────────────────────────────────────────
#[tokio::test]
async fn move_file_renames_and_modifies() {
let tmp = TempDir::new().unwrap();
@@ -635,8 +604,6 @@ mod tests {
}
}
// ── Multiple files in one patch ──────────────────────────────
#[tokio::test]
async fn multiple_files_in_one_patch() {
let tmp = TempDir::new().unwrap();
@@ -675,8 +642,6 @@ mod tests {
}
}
// ── Parse error ──────────────────────────────────────────────
#[tokio::test]
async fn parse_error_returns_no_changes() {
let tmp = TempDir::new().unwrap();
@@ -700,8 +665,6 @@ mod tests {
}
}
// ── Application error ────────────────────────────────────────
#[tokio::test]
async fn application_error_on_missing_lines() {
let tmp = TempDir::new().unwrap();
@@ -725,8 +688,6 @@ mod tests {
}
}
// ── Empty patch ──────────────────────────────────────────────
#[tokio::test]
async fn empty_patch_returns_empty_patch_output() {
let tmp = TempDir::new().unwrap();
@@ -1,9 +1,7 @@
//! `CodexGrepFilesTool` — file-path-only regex search via ripgrep.
//!
//! This is a faithful port of `codex-rs/core/src/tools/handlers/grep_files.rs`.
//! It returns **file paths only** (`--files-with-matches`), sorted by
//! modification time. See the plan document for the full diff vs the
//! kigi `GrepTool`.
//! Faithful port of `codex-rs/core/src/tools/handlers/grep_files.rs`: returns
//! file paths only (`--files-with-matches`), sorted by modification time.
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -19,19 +17,13 @@ use crate::types::requirements::Expr;
use crate::types::resources::Cwd;
use crate::types::tool::{ToolKind, ToolNamespace};
// ─── Constants ──────────────────────────────────────────────────────
const DEFAULT_LIMIT: usize = 100;
const MAX_LIMIT: usize = 2000;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
// ─── Description ────────────────────────────────────────────────────
const DESCRIPTION: &str =
"Finds files whose contents match the pattern and lists them by modification time.";
// ─── Input ──────────────────────────────────────────────────────────
fn default_limit() -> usize {
DEFAULT_LIMIT
}
@@ -55,10 +47,6 @@ pub struct CodexGrepFilesInput {
pub limit: usize,
}
// ─── Tool ───────────────────────────────────────────────────────────
/// Codex-namespace grep_files tool — file-path-only regex search.
///
/// Shares `ToolKind::Search` with the kigi `GrepTool`. These tools are
/// namespace-exclusive — consumers enable either `Kigi` or `Codex` search,
/// never both simultaneously. This follows the same pattern as
@@ -67,10 +55,6 @@ pub struct CodexGrepFilesInput {
#[derive(Debug, Default)]
pub struct CodexGrepFilesTool;
// ─── rg execution ───────────────────────────────────────────────────
/// Run `rg --files-with-matches` and return matching file paths.
///
/// Direct port from `codex-rs/core/src/tools/handlers/grep_files.rs`.
async fn run_rg_search(
pattern: &str,
@@ -114,8 +98,6 @@ async fn run_rg_search(
}
}
/// Parse newline-separated file paths from rg stdout.
///
/// Direct port from `codex-rs/core/src/tools/handlers/grep_files.rs`.
fn parse_results(stdout: &[u8], limit: usize) -> Vec<String> {
let mut results = Vec::new();
@@ -136,8 +118,6 @@ fn parse_results(stdout: &[u8], limit: usize) -> Vec<String> {
results
}
// ─── Tests ──────────────────────────────────────────────────────────
impl crate::types::tool_metadata::ToolMetadata for CodexGrepFilesTool {
fn kind(&self) -> ToolKind {
ToolKind::Search
@@ -193,7 +173,6 @@ impl kigi_tool_runtime::Tool for CodexGrepFilesTool {
let cwd = crate::types::tool_metadata::resolve_cwd(&ctx, &resources).await?;
// Validation (exact codex rules)
let pattern = input.pattern.trim().to_string();
if pattern.is_empty() {
return Ok(CodexGrepFilesOutput::Error(
@@ -208,7 +187,6 @@ impl kigi_tool_runtime::Tool for CodexGrepFilesTool {
let limit = input.limit.min(MAX_LIMIT);
// Resolve search path
let search_path = match &input.path {
Some(p) if !p.is_empty() => {
let p = PathBuf::from(p);
@@ -217,7 +195,6 @@ impl kigi_tool_runtime::Tool for CodexGrepFilesTool {
_ => cwd.clone(),
};
// Verify path exists
if let Err(err) = tokio::fs::metadata(&search_path).await {
return Ok(CodexGrepFilesOutput::Error(format!(
"unable to access `{}`: {err}",
@@ -225,7 +202,6 @@ impl kigi_tool_runtime::Tool for CodexGrepFilesTool {
)));
}
// Clean up include glob
let include = input.include.as_deref().map(str::trim).and_then(|v| {
if v.is_empty() {
None
@@ -234,7 +210,6 @@ impl kigi_tool_runtime::Tool for CodexGrepFilesTool {
}
});
// Run rg
let results = run_rg_search(&pattern, include.as_deref(), &search_path, limit, &cwd).await;
match results {
@@ -260,7 +235,6 @@ mod tests {
use std::process::Command as StdCommand;
use tempfile::TempDir;
/// Build a runtime `ToolCallContext` with the given resources.
fn test_ctx(cwd: &Path) -> kigi_tool_runtime::ToolCallContext {
let mut resources = Resources::new();
resources.insert(Cwd(cwd.to_path_buf()));
@@ -276,9 +250,6 @@ mod tests {
.unwrap_or(false)
}
/// Build a runtime `ToolCallContext` with the given resources.
// ── Unit tests (parse_results) ──────────────────────────────
#[test]
fn parses_basic_results() {
let stdout = b"/tmp/file_a.rs\n/tmp/file_b.rs\n";
@@ -316,8 +287,6 @@ mod tests {
assert!(parsed.is_empty());
}
// ── Integration tests (run_rg_search) ───────────────────────
#[tokio::test]
async fn run_search_returns_results() {
if !rg_available() {
@@ -380,8 +349,6 @@ mod tests {
assert!(results.is_empty());
}
// ── Tool-level tests ────────────────────────────────────────
#[tokio::test]
async fn tool_reports_empty_pattern_error() {
let tmp = TempDir::new().unwrap();
@@ -532,7 +499,8 @@ mod tests {
pattern: "needle".to_string(),
include: None,
path: None,
limit: 5000, // exceeds MAX_LIMIT (2000)
// exceeds MAX_LIMIT (2000)
limit: 5000,
};
let result = kigi_tool_runtime::Tool::run(&tool, test_ctx(tmp.path()), input)
@@ -1,9 +1,8 @@
//! `CodexListDirTool` — paginated, depth-limited, BFS directory listing.
//!
//! This is a faithful port of `codex-rs/core/src/tools/handlers/list_dir.rs`.
//! It does NOT respect `.gitignore`, does NOT exclude hidden files, and requires
//! absolute paths. See the plan document for the full diff vs the kigi
//! `ListDirTool`.
//! Ported from `codex-rs/core/src/tools/handlers/list_dir.rs`. Unlike the kigi
//! `ListDirTool`, it does not respect `.gitignore`, does not exclude hidden
//! files, and requires absolute paths.
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
@@ -12,21 +11,13 @@ use crate::types::output::{ListDirContent, ListDirOutput};
use crate::types::requirements::Expr;
use crate::types::tool::{ToolKind, ToolNamespace};
// ─── Constants ──────────────────────────────────────────────────────
/// Maximum length (in bytes) for a single entry name before truncation.
const MAX_ENTRY_LENGTH: usize = 500;
/// Number of spaces per depth level for indentation.
const INDENTATION_SPACES: usize = 2;
// ─── Description ────────────────────────────────────────────────────
const DESCRIPTION: &str =
"Lists entries in a local directory with 1-indexed entry numbers and simple type labels.";
// ─── Input ──────────────────────────────────────────────────────────
fn default_offset() -> usize {
1
}
@@ -56,17 +47,14 @@ pub struct CodexListDirInput {
pub depth: usize,
}
// ─── Internal types ─────────────────────────────────────────────────
#[derive(Clone)]
struct DirEntry {
/// Full relative path from the listing root (used for sorting).
/// Full relative path from the listing root; the sort key.
name: String,
/// Just the file/directory component name (used for display).
/// Only the final path component.
display_name: String,
/// Depth level (0 = root's direct children).
/// 0 for the root's direct children.
depth: usize,
/// Entry type.
kind: DirEntryKind,
}
@@ -80,10 +68,9 @@ enum DirEntryKind {
impl From<&std::fs::FileType> for DirEntryKind {
fn from(ft: &std::fs::FileType) -> Self {
// Check is_symlink() FIRST — on Unix, a symlink to a directory has
// both is_symlink() and is_dir() true when the file_type is obtained
// via tokio::fs::DirEntry::file_type() (which follows symlinks).
// Codex checks symlink first so these are rendered with `@`, not `/`.
// Symlink must be tested first: on Unix a symlink to a directory
// answers true to both `is_symlink()` and `is_dir()`, and codex renders
// such an entry with `@` rather than `/`.
if ft.is_symlink() {
DirEntryKind::Symlink
} else if ft.is_dir() {
@@ -96,15 +83,10 @@ impl From<&std::fs::FileType> for DirEntryKind {
}
}
// ─── Tool ───────────────────────────────────────────────────────────
/// Codex-namespace list_dir tool — paginated, depth-limited directory listing.
#[derive(Debug, Default)]
pub struct CodexListDirTool;
// ─── Core BFS logic ─────────────────────────────────────────────────
/// Orchestrator: collect entries via BFS → sort → paginate → format.
async fn list_dir_slice(
dir_path: &Path,
offset: usize,
@@ -116,30 +98,28 @@ async fn list_dir_slice(
.await
.map_err(|e| format!("Failed to read directory: {e}"))?;
// Sort by full relative path (slash-normalized), case-sensitive.
// Slash-normalized and case-sensitive, matching codex.
entries.sort_unstable_by(|a, b| a.name.cmp(&b.name));
// Empty directory is a valid success case, not an error.
// An empty directory is a success case, not an error.
if entries.is_empty() {
return Ok(Vec::new());
}
let total = entries.len();
// offset is 1-indexed
// offset is 1-indexed.
let start_index = offset - 1;
if start_index >= total {
return Err("offset exceeds directory entry count".to_string());
}
// Compute end index, saturating to avoid overflow with large limits.
let end_index = start_index.saturating_add(limit).min(total);
let page = &entries[start_index..end_index];
let mut lines: Vec<String> = page.iter().map(format_entry_line).collect();
// Overflow message when more entries exist beyond the page.
// Use capped_limit (actual number of entries returned) to match codex behavior.
// Codex reports the page size actually returned, not the requested limit.
if end_index < total {
let capped_limit = end_index - start_index;
lines.push(format!("More than {} entries found", capped_limit));
@@ -148,15 +128,12 @@ async fn list_dir_slice(
Ok(lines)
}
/// BFS walker using `tokio::fs::read_dir`.
///
/// Collects entries breadth-first up to `max_depth` levels. Directories
/// beyond the depth limit are listed but not descended into.
///
/// Uses `PathBuf` for the relative prefix (matching codex's `prefix: &Path`
/// + `prefix.join(&file_name)`). The raw `PathBuf` is kept for recursion so
/// that subdirectory prefixes are never affected by `format_entry_name`
/// truncation or normalization.
/// The prefix carried through the queue is the raw joined path, never the
/// formatted name, so that `format_entry_name` truncation and separator
/// normalization cannot leak into deeper prefixes.
async fn collect_entries(
dir_path: &Path,
relative_prefix: &Path,
@@ -164,7 +141,7 @@ async fn collect_entries(
max_depth: usize,
entries: &mut Vec<DirEntry>,
) -> Result<(), std::io::Error> {
// Queue items: (absolute path, raw relative prefix, depth)
// (absolute path, raw relative prefix, depth)
let mut queue: VecDeque<(PathBuf, PathBuf, usize)> = VecDeque::new();
queue.push_back((
dir_path.to_path_buf(),
@@ -175,8 +152,7 @@ async fn collect_entries(
while let Some((abs_path, rel_prefix, depth)) = queue.pop_front() {
let mut read_dir = tokio::fs::read_dir(&abs_path).await?;
// Collect children first so we can sort them.
// Each item: (DirEntry, absolute path, raw relative path for recursion)
// (entry, absolute path, raw relative path for the next level)
let mut children: Vec<(DirEntry, PathBuf, PathBuf)> = Vec::new();
while let Some(entry) = read_dir.next_entry().await? {
let file_type = entry.file_type().await?;
@@ -185,9 +161,8 @@ async fn collect_entries(
let raw_name = entry.file_name();
let display_name = format_entry_component(&raw_name);
// Build the raw relative path using Path::join (matches codex).
let entry_relative_path = rel_prefix.join(&raw_name);
// The sort key is the formatted (slash-normalized, truncated) name.
// Sorting is done on the formatted name, not the raw path.
let name = format_entry_name(&entry_relative_path.to_string_lossy());
children.push((
@@ -202,16 +177,13 @@ async fn collect_entries(
));
}
// Sort children by relative path for deterministic ordering.
// Sort children so the traversal order is deterministic.
children.sort_unstable_by(|a, b| a.0.name.cmp(&b.0.name));
for (dir_entry, child_abs_path, child_raw_rel) in children {
let is_dir = dir_entry.kind == DirEntryKind::Directory;
entries.push(dir_entry);
// Descend into directories if we haven't reached max depth.
// Pass the raw relative path (not the formatted name) as the
// prefix for the next level, matching codex behavior.
if is_dir && depth + 1 < max_depth {
queue.push_back((child_abs_path, child_raw_rel, depth + 1));
}
@@ -221,7 +193,6 @@ async fn collect_entries(
Ok(())
}
/// Format a single entry line: `"{indent}{display_name}{suffix}"`.
fn format_entry_line(entry: &DirEntry) -> String {
let indent = " ".repeat(entry.depth * INDENTATION_SPACES);
let suffix = match entry.kind {
@@ -233,26 +204,23 @@ fn format_entry_line(entry: &DirEntry) -> String {
format!("{}{}{}", indent, entry.display_name, suffix)
}
/// Normalize path separators (backslash forward slash) and truncate.
/// Normalizes backslashes to forward slashes, then truncates.
fn format_entry_name(path: &str) -> String {
let normalized = path.replace('\\', "/");
take_at_char_boundary(&normalized, MAX_ENTRY_LENGTH).to_string()
}
/// Truncate an `OsStr` file name component at `MAX_ENTRY_LENGTH` bytes.
fn format_entry_component(name: &std::ffi::OsStr) -> String {
let s = name.to_string_lossy();
take_at_char_boundary(&s, MAX_ENTRY_LENGTH).to_string()
}
/// Truncate a string at a char boundary, returning at most `max_bytes` bytes.
/// Yields at most `max_bytes` bytes, cut back to a char boundary.
fn take_at_char_boundary(s: &str, max_bytes: usize) -> &str {
let end = crate::util::floor_char_boundary(s, max_bytes);
&s[..end]
}
// ─── Tests ──────────────────────────────────────────────────────────
impl crate::types::tool_metadata::ToolMetadata for CodexListDirTool {
fn kind(&self) -> ToolKind {
ToolKind::ListDir
@@ -358,12 +326,9 @@ mod tests {
use super::*;
use tempfile::TempDir;
// ── Unit tests (core logic) ─────────────────────────────────
#[tokio::test]
async fn lists_directory_entries() {
let tmp = TempDir::new().unwrap();
// Create files and dirs.
std::fs::write(tmp.path().join("file.txt"), "content").unwrap();
std::fs::create_dir(tmp.path().join("subdir")).unwrap();
#[cfg(unix)]
@@ -371,7 +336,6 @@ mod tests {
let result = list_dir_slice(tmp.path(), 1, 25, 2).await.unwrap();
// Should contain file.txt, subdir/, and on unix: link@
let joined = result.join("\n");
assert!(joined.contains("file.txt"), "missing file.txt in: {joined}");
assert!(joined.contains("subdir/"), "missing subdir/ in: {joined}");
@@ -396,7 +360,6 @@ mod tests {
std::fs::create_dir_all(&subsub).unwrap();
std::fs::write(subsub.join("deep.txt"), "").unwrap();
// depth=1: only top-level entries (the dir "a" but not its children)
let depth1 = list_dir_slice(tmp.path(), 1, 100, 1).await.unwrap();
let joined1 = depth1.join("\n");
assert!(joined1.contains("a/"), "should see dir a/");
@@ -406,7 +369,6 @@ mod tests {
"should NOT see deep.txt at depth 1"
);
// depth=2: top-level + children of a
let depth2 = list_dir_slice(tmp.path(), 1, 100, 2).await.unwrap();
let joined2 = depth2.join("\n");
assert!(joined2.contains("a/"), "should see dir a/");
@@ -416,7 +378,6 @@ mod tests {
"should NOT see deep.txt at depth 2"
);
// depth=3: everything
let depth3 = list_dir_slice(tmp.path(), 1, 100, 3).await.unwrap();
let joined3 = depth3.join("\n");
assert!(joined3.contains("a/"), "should see dir a/");
@@ -434,7 +395,6 @@ mod tests {
std::fs::write(tmp.path().join("a.txt"), "").unwrap();
std::fs::write(tmp.path().join("b.txt"), "").unwrap();
// First page: limit=2, should get a.txt, b.txt + overflow message
let page1 = list_dir_slice(tmp.path(), 1, 2, 1).await.unwrap();
assert!(page1[0].contains("a.txt"), "first entry should be a.txt");
assert!(page1[1].contains("b.txt"), "second entry should be b.txt");
@@ -443,7 +403,6 @@ mod tests {
"should have overflow message"
);
// Second page: offset=3 → c.txt only, no overflow
let page2 = list_dir_slice(tmp.path(), 3, 2, 1).await.unwrap();
assert_eq!(page2.len(), 1, "second page should have 1 entry");
assert!(page2[0].contains("c.txt"), "should be c.txt");
@@ -454,7 +413,6 @@ mod tests {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("only.txt"), "").unwrap();
// usize::MAX as limit should not panic
let result = list_dir_slice(tmp.path(), 1, usize::MAX, 1).await.unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].contains("only.txt"));
@@ -463,7 +421,6 @@ mod tests {
#[tokio::test]
async fn indicates_truncated_results() {
let tmp = TempDir::new().unwrap();
// Create 40 files
for i in 0..40 {
std::fs::write(tmp.path().join(format!("file_{:03}.txt", i)), "").unwrap();
}
@@ -485,13 +442,10 @@ mod tests {
std::fs::write(tmp.path().join("a.txt"), "").unwrap();
std::fs::write(tmp.path().join("m.txt"), "").unwrap();
// limit=2, offset=2 → should get m.txt (2nd sorted entry)
let result = list_dir_slice(tmp.path(), 2, 1, 1).await.unwrap();
assert!(result[0].contains("m.txt"), "offset=2 should land on m.txt");
}
// ── Tool integration tests ──────────────────────────────────
#[tokio::test]
async fn tool_lists_directory() {
let tmp = TempDir::new().unwrap();
@@ -654,7 +608,6 @@ mod tests {
let result = list_dir_slice(tmp.path(), 1, 25, 2).await.unwrap();
assert!(result.is_empty(), "empty dir should return empty vec");
// Also verify the tool-level wrapper returns Content, not Error.
let tool = CodexListDirTool;
let ctx = kigi_tool_runtime::ToolCallContext::default();
let input = CodexListDirInput {
@@ -1,22 +1,19 @@
//! Indentation-mode reader — exact port of codex `indentation::*`.
//!
//! Loads all file lines, computes effective indents (blank lines inherit
//! from previous non-blank), then expands bidirectionally from an anchor
//! line using the codex interleaved single-loop algorithm with inline
//! sibling filtering and inline header-comment handling.
//! from the previous non-blank line), then expands bidirectionally from an
//! anchor line. Sibling filtering and header-comment inclusion happen inline
//! during that expansion rather than as separate passes.
use std::collections::VecDeque;
use super::text_utils::format_display;
/// Tab width used for indent measurement (spaces per tab).
const TAB_WIDTH: usize = 4;
/// Comment prefixes recognized for `include_header`.
/// Prefixes that make a line eligible for `include_header`.
const COMMENT_PREFIXES: &[&str] = &["#", "//", "--"];
/// Configuration for indentation-mode reading.
///
/// Mirrors codex `IndentationModeOptions`.
#[derive(Debug, Clone)]
pub(crate) struct IndentationOptions {
@@ -27,47 +24,35 @@ pub(crate) struct IndentationOptions {
pub max_lines: Option<usize>,
}
// ─── LineRecord ──────────────────────────────────────────────────────
/// Per-line record: number, raw (untruncated), display (truncated), indent.
///
/// Matches codex `LineRecord { number, raw, display, indent }`.
/// `raw` is used for `trimmed()` / `is_blank()` / `is_comment()`;
/// `display` is used for output formatting.
/// Matches codex `LineRecord`. Classification (`trimmed`, `is_blank`,
/// `is_comment`) reads `raw`; only output formatting reads `display`.
#[derive(Debug)]
struct LineRecord {
/// 1-indexed line number.
number: usize,
/// Raw untruncated line content (UTF-8 lossy).
/// Untruncated line content (UTF-8 lossy).
raw: String,
/// Display string (UTF-8 lossy, truncated at MAX_LINE_LENGTH).
/// Line content truncated at MAX_LINE_LENGTH.
display: String,
/// Raw indent level (number of leading spaces, tabs counted as TAB_WIDTH).
/// Leading spaces, counting each tab as TAB_WIDTH.
indent: usize,
}
impl LineRecord {
/// Leading-whitespace-stripped raw content. Codex uses `raw.trim_start()`.
fn trimmed(&self) -> &str {
self.raw.trim_start()
}
/// Whether the line is blank (only whitespace).
fn is_blank(&self) -> bool {
self.trimmed().is_empty()
}
/// Whether the line is a comment (starts with a known prefix).
/// Codex uses `self.raw.trim().starts_with(prefix)`.
fn is_comment(&self) -> bool {
let t = self.raw.trim();
COMMENT_PREFIXES.iter().any(|p| t.starts_with(p))
}
}
// ─── Core functions ──────────────────────────────────────────────────
/// Collect line records from raw file bytes.
fn collect_lines(bytes: &[u8]) -> Vec<LineRecord> {
if bytes.is_empty() {
return vec![];
@@ -98,7 +83,7 @@ fn collect_lines(bytes: &[u8]) -> Vec<LineRecord> {
}
}
// Handle remaining content after last \n (or file without trailing \n).
// Trailing content after the last \n, i.e. a file with no final newline.
if start < bytes.len() {
line_num += 1;
let mut end = bytes.len();
@@ -120,7 +105,6 @@ fn collect_lines(bytes: &[u8]) -> Vec<LineRecord> {
records
}
/// Measure indent: count leading spaces (tabs = TAB_WIDTH spaces).
fn measure_indent(line: &str) -> usize {
let mut indent = 0;
for ch in line.chars() {
@@ -133,8 +117,8 @@ fn measure_indent(line: &str) -> usize {
indent
}
/// Compute effective indents: blank lines inherit the indent of the
/// previous non-blank line. Returns a vec parallel to `records`.
/// Blank lines inherit the indent of the previous non-blank line. The result
/// is parallel to `records`.
fn compute_effective_indents(records: &[LineRecord]) -> Vec<usize> {
let mut effective = Vec::with_capacity(records.len());
let mut last_non_blank_indent = 0usize;
@@ -151,14 +135,12 @@ fn compute_effective_indents(records: &[LineRecord]) -> Vec<usize> {
effective
}
/// Read a block of lines using indentation-based expansion from an anchor.
/// Read a block of lines by expanding outward from an anchor line, following
/// the indentation structure around it.
///
/// This is the main entry point for indentation mode.
///
/// Ported from codex `indentation::read_block` — uses the codex interleaved
/// single-loop algorithm with two cursors (i going up, j going down) that
/// alternate. Sibling filtering and header-comment inclusion are handled
/// **inline** during expansion, not as post-processing passes.
/// Ported from codex `indentation::read_block`: one loop drives two cursors
/// (`i` upward, `j` downward) that alternate, with sibling filtering and
/// header-comment inclusion applied inline rather than as later passes.
pub(crate) fn read_block(
bytes: &[u8],
offset: usize,
@@ -179,57 +161,48 @@ pub(crate) fn read_block(
let effective = compute_effective_indents(&collected);
// guard_limit = max_lines.unwrap_or(limit). Codex validates this > 0.
let guard_limit = options.max_lines.unwrap_or(limit);
if guard_limit == 0 {
return Err("max_lines must be greater than zero".to_string());
}
// final_limit = min(limit, guard_limit, collected.len())
let final_limit = limit.min(guard_limit).min(collected.len());
let anchor_idx = anchor - 1; // 0-indexed
// anchor is 1-indexed.
let anchor_idx = anchor - 1;
let anchor_indent = effective[anchor_idx];
// Compute min_indent threshold.
let min_indent = if options.max_levels == 0 {
0
} else {
anchor_indent.saturating_sub(options.max_levels * TAB_WIDTH)
};
// Early return: final_limit == 1 → just the anchor line.
if final_limit == 1 {
let rec = &collected[anchor_idx];
return Ok(vec![format!("L{}: {}", rec.number, rec.display)]);
}
// ── Interleaved bidirectional expansion ──────────────────────
//
// Codex algorithm (lines 293357): single `while out.len() < final_limit`
// loop. BOTH cursors are tried every iteration (up first, then down).
// A `progressed` counter tracks whether either direction added a line;
// if 0, both are exhausted and we break.
//
// `i` starts at anchor_idx - 1 going down to 0 (or -1 = exhausted).
// `j` starts at anchor_idx + 1 going up to collected.len() (= exhausted).
// Interleaved bidirectional expansion, per codex lines 293-357: both
// cursors are tried on every iteration, up first, and the loop ends once
// neither direction contributed a line.
let mut out: VecDeque<usize> = VecDeque::new();
out.push_back(anchor_idx);
// Use isize for i so we can represent -1 as "exhausted"
// `i` is signed so that -1 can mark the upward cursor exhausted; `j`
// reaching `n` marks the downward one exhausted.
let mut i: isize = anchor_idx as isize - 1;
let mut j: usize = anchor_idx + 1;
let n = collected.len();
// Counters: track boundary-level lines accepted in each direction.
// Boundary-level lines accepted in each direction.
let mut i_counter_min_indent: usize = 0;
let mut j_counter_min_indent: usize = 0;
while out.len() < final_limit {
let mut progressed = 0usize;
// ── ALWAYS try upward cursor (if available) ─────────────
if i >= 0 {
let added = expand_up(
&collected,
@@ -244,13 +217,12 @@ pub(crate) fn read_block(
if added {
progressed += 1;
}
// Short-cut: codex breaks after up if limit reached.
// Codex bails out here without trying the downward cursor.
if out.len() >= final_limit {
break;
}
}
// ── ALWAYS try downward cursor (if available) ───────────
if j < n {
let added = expand_down(
&effective,
@@ -271,10 +243,8 @@ pub(crate) fn read_block(
}
}
// Trim leading/trailing blank lines.
trim_empty_lines(&collected, &mut out);
// Format output.
let lines: Vec<String> = out
.iter()
.map(|&idx| {
@@ -286,16 +256,11 @@ pub(crate) fn read_block(
Ok(lines)
}
/// Expand the upward cursor by one step. Returns true if a line was
/// added to `out` (net gain — not reverted).
/// Advance the upward cursor one step, returning true only if the line
/// survived — a line that is pushed and then reverted counts as no gain.
///
/// Codex logic (lines 296320):
/// 1. If `eff >= min_indent`: push_front (line 300).
/// 2. If `eff == min_indent && !include_siblings`:
/// - `can_take_line = allow_header_comment || counter == 0`
/// - If can_take_line: increment counter (line is kept).
/// - If !can_take_line: pop_front (revert THIS just-pushed line), stop cursor.
/// 3. If `eff < min_indent`: stop cursor, return false.
/// Codex (lines 296-320) pushes the candidate before deciding whether the
/// sibling filter rejects it, so the revert pops the line just pushed.
#[allow(clippy::too_many_arguments)]
fn expand_up(
collected: &[LineRecord],
@@ -315,41 +280,34 @@ fn expand_up(
let eff = effective[iu];
if eff < min_indent {
// Below threshold — stop cursor.
*i = -1;
return false;
}
// eff >= min_indent — push first (codex line 300), then filter.
// Push first (codex line 300), filter afterwards.
out.push_front(iu);
*i -= 1;
// Sibling filter: only applies when eff == min_indent && !include_siblings.
if eff == min_indent && !include_siblings {
let allow_header_comment = include_header && collected[iu].is_comment();
let can_take_line = allow_header_comment || *counter == 0;
if can_take_line {
*counter += 1; // line is kept, increment counter
*counter += 1;
} else {
// Revert THIS just-pushed line and stop cursor.
out.pop_front();
*i = -1;
return false; // net: no line added
return false;
}
}
true
}
/// Expand the downward cursor by one step. Returns true if a line was
/// added to `out` (net gain — not reverted).
/// Advance the downward cursor one step, returning true only if the line
/// survived — a line that is pushed and then reverted counts as no gain.
///
/// Codex logic (lines 332348):
/// 1. If `eff >= min_indent`: push_back (line 334).
/// 2. If `eff == min_indent && !include_siblings`:
/// - If `counter > 0`: pop_back (revert THIS just-pushed line), stop cursor.
/// - Always increment counter (line 346).
/// 3. If `eff < min_indent`: stop cursor, return false.
/// Codex (lines 332-348) pushes the candidate before deciding whether the
/// sibling filter rejects it, so the revert pops the line just pushed.
fn expand_down(
effective: &[usize],
out: &mut VecDeque<usize>,
@@ -367,32 +325,29 @@ fn expand_down(
let eff = effective[ju];
if eff < min_indent {
// Below threshold — stop cursor.
*j = n;
return false;
}
// eff >= min_indent — push first (codex line 334), then filter.
// Push first (codex line 334), filter afterwards.
out.push_back(ju);
*j += 1;
// Sibling filter: only applies when eff == min_indent && !include_siblings.
if eff == min_indent && !include_siblings {
if *counter > 0 {
// Second+ boundary-level line — revert THIS just-pushed line.
// A second boundary-level line ends the downward walk, but codex
// line 346 counts it anyway.
out.pop_back();
*j = n; // stop cursor
// Still increment counter (codex line 346: always increments).
*j = n;
*counter += 1;
return false; // net: no line added
return false;
}
*counter += 1; // always increment (codex line 346)
*counter += 1;
}
true
}
/// Trim leading and trailing blank lines from the result deque.
fn trim_empty_lines(records: &[LineRecord], deque: &mut VecDeque<usize>) {
while let Some(&idx) = deque.front() {
if records[idx].is_blank() {
@@ -410,8 +365,6 @@ fn trim_empty_lines(records: &[LineRecord], deque: &mut VecDeque<usize>) {
}
}
// ─── Tests ───────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
@@ -432,27 +385,20 @@ mod tests {
}
}
// ── Exact-output tests ───────────────────────────────────────
#[test]
fn captures_function_block_with_limit() {
// anchor=2 (x=1, indent 4), max_levels=1, min_indent = 4-4 = 0.
// With min_indent=0, the entire file is reachable (no indent is below 0).
// Sibling filter: going up, def foo is first boundary (counter=1, accepted).
// Going down: y, return, blank (effective=4 > 0), def bar (effective=0 == min,
// counter=1, accepted), pass (effective=4 > 0, accepted). No second boundary hit,
// so downward includes everything.
// anchor=2 (x = 1, indent 4), max_levels=1, so min_indent = 4-4 = 0 and
// every line is reachable. Each direction meets only one boundary-level
// line (def foo upward, def bar downward), so nothing is filtered out.
let content =
b"def foo():\n x = 1\n y = 2\n return x + y\n\ndef bar():\n pass\n";
// Without limit: entire file (minus blank trim)
let opts_full = make_opts(Some(2), 1, false, true, None);
let result_full = read_block(content, 1, 2000, opts_full).unwrap();
assert!(result_full.iter().any(|l| l.contains("def foo():")));
assert!(result_full.iter().any(|l| l.contains("x = 1")));
assert!(result_full.iter().any(|l| l.contains("return x + y")));
// With max_lines=4: capped to 4 lines
let opts_limited = make_opts(Some(2), 1, false, true, Some(4));
let result = read_block(content, 1, 4, opts_limited).unwrap();
assert_eq!(
@@ -476,11 +422,8 @@ mod tests {
// L6: (blank, effective=8)
// L7: def other(self): (indent 4)
// L8: pass (indent 8)
// anchor=3, max_levels=2, min_indent = 8-8 = 0.
// Both directions try every iteration. Up first: class MyClass (indent 0,
// boundary counter=1, kept). Down: y (eff=8>0, kept). Up: exhausted (i=-1).
// Down: return, blank, def other (boundary counter=1, kept since counter was 0),
// pass. All accepted because min_indent=0.
// anchor=3, max_levels=2, so min_indent = 8-8 = 0 and every line is
// reachable; the one boundary line each direction meets is kept.
let content = b"class MyClass:\n def method(self):\n x = 1\n y = 2\n return x + y\n\n def other(self):\n pass\n";
let opts = make_opts(Some(3), 2, false, true, None);
let result = read_block(content, 1, 2000, opts).unwrap();
@@ -521,25 +464,10 @@ class C:
def e(self):
pass
";
// Without siblings: up hits def anchor (boundary, counter 0→1, kept),
// then def b (boundary, counter==1, can_take_line=false → REVERT anchor, stop).
// Wait: up goes from anchor_idx=6 upward. i starts at 5 (def anchor line).
// L6 (idx 5) = " def anchor(self):" → eff=4 == min=4. counter==0, can_take=true.
// Push. counter=1. i=4.
// L5 (idx 4) = " pass" → eff=8 > 4. Push. i=3.
// L4 (idx 3) = " def b(self):" → eff=4 == min=4. counter==1, can_take=false.
// REVERT (pop front = L4 just pushed). Stop. i=-1.
// Wait that's not right. Let me retrace...
// Actually: push L4 first, THEN check. can_take_line = false (counter==1, not comment).
// Revert = pop front = L4 (the just-pushed one). i=-1.
//
// Down: j starts at 7 (def d).
// L8 (idx 7) = " def d(self):" → eff=4 == min=4. counter==0 → kept. counter=1.
// L9 (idx 8) = " pass" → eff=8>4 → kept.
// L10 (idx 9) = " def e(self):" → eff=4 == min=4. counter>0 → REVERT L10, stop.
//
// Result (before trim): [L5:pass, L6:def anchor, L7:x=1, L8:def d, L9:pass]
// After blank trim (no blanks): same.
// Without siblings each direction accepts exactly one boundary line:
// upward L6 (def anchor), downward L8 (def d). The next boundary line
// in each direction (L4, L10) is pushed, then reverted, ending that
// cursor — so the block spans L5..L9.
let opts_no_sibs = make_opts(Some(7), 1, false, true, None);
let result_no_sibs = read_block(content, 1, 2000, opts_no_sibs).unwrap();
@@ -554,7 +482,7 @@ class C:
]
);
// With siblings: all methods at indent 4 should be included
// With siblings, every method at indent 4 survives the filter.
let opts_sibs = make_opts(Some(7), 1, true, true, None);
let result_sibs = read_block(content, 1, 2000, opts_sibs).unwrap();
@@ -571,8 +499,8 @@ class C:
// L3: def compute(x): (indent 0)
// L4: return x * 2 (indent 4) ← ANCHOR
//
// anchor=4, max_levels=1, min_indent = 4-4 = 0.
// With include_header=true: comments at indent 0 pass via allow_header_comment.
// anchor=4, max_levels=1, min_indent = 4-4 = 0. The comments sit at the
// boundary indent, so only include_header lets them through.
let content = b"# Helper function\n# for computation\ndef compute(x):\n return x * 2\n";
let opts_header = make_opts(Some(4), 1, false, true, None);
@@ -587,7 +515,6 @@ class C:
]
);
// Without header: comments at boundary are rejected by sibling filter
let opts_no_header = make_opts(Some(4), 1, false, false, None);
let result_no = read_block(content, 1, 2000, opts_no_header).unwrap();
assert_eq!(
@@ -598,11 +525,8 @@ class C:
#[test]
fn limit_caps_output_size() {
// anchor=3 (b=2), max_levels=0, limit=3.
// Codex: both up+down each iteration. final_limit = min(3, 3, 6) = 3.
// Iter 1: up: push a=1 → [a,b,c...wait]
// out starts as [b]. Iter 1: up push foo → [foo, b]. down push c → [foo, b, c].
// out.len()=3 → done.
// anchor=3 (b = 2), final_limit = min(3, 3, 6) = 3. The first iteration
// takes one line upward and one downward, filling the budget.
let content = b"def foo():\n a = 1\n b = 2\n c = 3\n d = 4\n e = 5\n";
let opts = make_opts(Some(3), 0, false, true, Some(3));
let result = read_block(content, 1, 3, opts).unwrap();
@@ -621,8 +545,6 @@ class C:
assert_eq!(result, vec!["L2: line2"]);
}
// ── Edge cases ───────────────────────────────────────────────
#[test]
fn anchor_exceeds_file_length_error() {
let content = b"one\ntwo\n";
@@ -651,19 +573,16 @@ class C:
#[test]
fn trims_leading_trailing_blank_lines() {
// Blank lines at the edges of the expansion should be trimmed.
let content = b"\ndef foo():\n x = 1\n\n";
let opts = make_opts(Some(3), 1, false, true, None);
let result = read_block(content, 1, 2000, opts).unwrap();
// First and last lines of result should not be blank
// A trimmed result never starts or ends with a bare "Ln: " line.
assert!(!result.first().unwrap().ends_with(": "));
assert!(!result.last().unwrap().ends_with(": "));
}
#[test]
fn trimmed_uses_trim_start() {
// Verify that trimmed() strips only leading whitespace.
// A line like " hello " should have trimmed() = "hello "
let rec = LineRecord {
number: 1,
raw: " hello ".to_string(),
@@ -676,7 +595,6 @@ class C:
#[test]
fn is_comment_uses_raw_trim() {
// Verify is_comment uses raw.trim() (both sides), not trim_start().
let rec = LineRecord {
number: 1,
raw: " // comment ".to_string(),
@@ -692,7 +610,6 @@ class C:
// anchor=6 (std::cout << "one", indent 12), max_levels=1, min_indent=12-4=8.
let opts = make_opts(Some(6), 1, false, true, None);
let result = read_block(content, 1, 2000, opts).unwrap();
// Should include case 1: and its body
assert!(result.iter().any(|l| l.contains("case 1:")));
assert!(result.iter().any(|l| l.contains("\"one\"")));
}
@@ -1,22 +1,14 @@
//! Codex `read_file` — text file reader in codex `L{n}: {content}` format.
//!
//! This module ports the codex read_file tool as a separate tool under
//! Port of the codex read_file tool, exposed as its own tool under
//! `ToolNamespace::Codex`. It supports two modes:
//!
//! - **Slice mode** — reads a contiguous range of lines (default).
//! - **Indentation mode** — reads a block based on indentation structure.
//!
//! # Submodules
//!
//! - [`text_utils`] — shared text helpers (char-boundary truncation).
//! - [`slice`] — slice-mode reader (exact port of codex `slice::read()`).
//! - [`indentation`] — indentation-mode reader (exact port of codex `indentation::*`).
//! - [`tool`] — `CodexReadFileTool` implementation, input types, description.
pub mod indentation;
pub mod slice;
pub(crate) mod text_utils;
pub mod tool;
// Re-exports for convenience.
pub use tool::{CodexReadFileInput, CodexReadFileTool};
@@ -1,16 +1,8 @@
//! Slice-mode reader — exact port of codex `slice::read()`.
//!
//! Reads lines from `offset` (1-indexed) up to `limit`, formatting each
//! as `L{line_number}: {content}`. Lines are truncated at `MAX_LINE_LENGTH`
//! at a char boundary.
/// Maximum number of characters per line before truncation.
pub(crate) const MAX_LINE_LENGTH: usize = 500;
/// Read a contiguous range of lines from file bytes in slice mode.
///
/// Returns formatted lines as `L{n}: {content}`, or an error string if
/// `offset` exceeds the number of lines in the file.
/// Errs when `offset` lies past the last line of the file.
pub(crate) fn read_slice(
file_bytes: &[u8],
offset: usize,
@@ -44,17 +36,10 @@ pub(crate) fn read_slice(
Ok(collected)
}
/// Format a raw byte line: decode as UTF-8 (lossy) and truncate at
/// `MAX_LINE_LENGTH` at a char boundary.
fn format_line(bytes: &[u8]) -> String {
super::text_utils::format_display(bytes)
}
/// Split raw bytes into lines, stripping `\n` and `\r\n` line endings.
///
/// Every byte sequence separated by `\n` becomes a line. Trailing `\r`
/// on each line is also stripped. A final `\n` produces an empty trailing
/// entry (matching codex `BufReader::read_until(b'\n')` behavior).
fn split_lines(bytes: &[u8]) -> Vec<&[u8]> {
if bytes.is_empty() {
return vec![];
@@ -66,7 +51,6 @@ fn split_lines(bytes: &[u8]) -> Vec<&[u8]> {
for i in 0..bytes.len() {
if bytes[i] == b'\n' {
let mut end = i;
// Strip trailing \r for \r\n endings.
if end > start && bytes[end - 1] == b'\r' {
end -= 1;
}
@@ -75,7 +59,6 @@ fn split_lines(bytes: &[u8]) -> Vec<&[u8]> {
}
}
// Remaining bytes after the last \n (or all bytes if no \n found).
if start < bytes.len() {
let mut end = bytes.len();
if end > start && bytes[end - 1] == b'\r' {
@@ -83,19 +66,14 @@ fn split_lines(bytes: &[u8]) -> Vec<&[u8]> {
}
lines.push(&bytes[start..end]);
} else if start == bytes.len() && !bytes.is_empty() && bytes[bytes.len() - 1] == b'\n' {
// File ends with \n — BufReader::read_until would NOT produce an
// empty trailing line for this case. The codex implementation reads
// until EOF and each read_until(b'\n') call consumes the delimiter.
// A trailing \n means the last read produces the line before it;
// no additional empty line is generated.
// So we do NOT push an empty trailing entry here.
// Intentionally empty: codex's `read_until(b'\n')` loop consumes the
// delimiter along with the line before it, so a file ending in `\n`
// yields no empty final line.
}
lines
}
// ─── Tests ───────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
@@ -120,7 +98,6 @@ mod tests {
let content = b"\xff\xfe\n";
let result = read_slice(content, 1, 10).unwrap();
assert_eq!(result.len(), 1);
// Non-UTF8 bytes should be replaced with U+FFFD
assert!(result[0].contains('\u{FFFD}'));
}
@@ -145,7 +122,6 @@ mod tests {
let content = format!("{}\n", long_line);
let result = read_slice(content.as_bytes(), 1, 10).unwrap();
assert_eq!(result.len(), 1);
// Line content should be truncated to MAX_LINE_LENGTH
let expected_content = &long_line[..MAX_LINE_LENGTH];
assert_eq!(result[0], format!("L1: {}", expected_content));
}
@@ -166,7 +142,8 @@ mod tests {
#[test]
fn empty_file_returns_error() {
// Codex behavior: empty file has 0 lines, offset=1 exceeds file length.
// Codex treats an empty file as zero lines, so even offset=1 is past
// the end.
let content = b"";
let result = read_slice(content, 1, 10);
assert!(result.is_err());
@@ -175,14 +152,14 @@ mod tests {
#[test]
fn truncation_at_multibyte_char_boundary() {
// Create a string that has multi-byte chars near the 500 boundary
// Straddle MAX_LINE_LENGTH with a 2-byte char so the cut lands
// mid-character.
let mut s = "a".repeat(498);
s.push('é'); // 2 bytes in UTF-8
s.push('é');
s.push('x');
assert!(s.len() > MAX_LINE_LENGTH);
let content = format!("{}\n", s);
let result = read_slice(content.as_bytes(), 1, 10).unwrap();
// The truncated line should be valid UTF-8 and <= MAX_LINE_LENGTH bytes
let line_content = result[0].strip_prefix("L1: ").unwrap();
assert!(line_content.len() <= MAX_LINE_LENGTH);
assert!(line_content.is_char_boundary(line_content.len()));
@@ -2,8 +2,8 @@
use super::slice::MAX_LINE_LENGTH;
/// Truncate a string at a char boundary, returning at most `max_bytes`
/// bytes. Port of codex `take_bytes_at_char_boundary`.
/// Port of codex `take_bytes_at_char_boundary`: at most `max_bytes` bytes,
/// cut on a char boundary.
pub(crate) fn take_at_char_boundary(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
@@ -19,7 +19,6 @@ pub(crate) fn take_at_char_boundary(s: &str, max_bytes: usize) -> &str {
&s[..last_ok]
}
/// UTF-8 lossy decode + truncate at MAX_LINE_LENGTH.
pub(crate) fn format_display(raw: &[u8]) -> String {
let decoded = String::from_utf8_lossy(raw);
if decoded.len() > MAX_LINE_LENGTH {
@@ -1,7 +1,5 @@
//! `CodexReadFileTool` — Tool trait implementation for the codex read_file format.
//!
//! Reads files via `AsyncFileSystem` and produces output in the codex
//! `L{n}: {content}` format. Supports both slice mode and indentation mode.
//! `CodexReadFileTool` — reads files in the codex `L{n}: {content}` format,
//! in either slice or indentation mode.
use std::path::PathBuf;
@@ -13,14 +11,10 @@ use crate::types::tool::{ToolKind, ToolNamespace};
use super::{indentation, slice};
// ─── Description ─────────────────────────────────────────────────────
/// Tool description — word-for-word copy from codex `create_read_file_tool()` in
/// `codex-rs/core/src/tools/spec.rs` (line 1233).
const DESCRIPTION: &str = "Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes.";
// ─── Input ───────────────────────────────────────────────────────────
/// Input for the codex `read_file` tool.
///
/// Field descriptions match codex `create_read_file_tool()` parameter descriptions.
@@ -118,14 +112,9 @@ impl Default for IndentationArgs {
}
}
// ─── Tool ────────────────────────────────────────────────────────────
/// Codex read_file tool — reads files in the codex `L{n}: {content}` format.
#[derive(Debug, Default)]
pub struct CodexReadFileTool;
// ─── Tests ───────────────────────────────────────────────────────────
impl crate::types::tool_metadata::ToolMetadata for CodexReadFileTool {
fn kind(&self) -> ToolKind {
ToolKind::Read
@@ -179,12 +168,10 @@ impl kigi_tool_runtime::Tool for CodexReadFileTool {
use crate::types::tool_metadata::shared_resources;
let resources = shared_resources(&ctx)?;
// 1. Validate. Codex raises here, but we surface these as a structured
// `FileReadError` (a model-facing error) instead of a hard `Err`, so
// otherwise-benign validation failures (empty/short files, relative
// paths) do not surface as tool-execution failures.
// `FileReadError` rides the structured-output path and maps cleanly to
// `ReadFileErrorTypes::FILE_READ_ERROR`.
// Codex raises on these, but we return a structured `FileReadError`
// (which maps to `ReadFileErrorTypes::FILE_READ_ERROR`) instead of a
// hard `Err`, so benign failures empty/short files, relative paths,
// out-of-range offsets — reach the model rather than aborting the call.
if input.offset == 0 {
return Ok(ReadFileOutput::FileReadError(
"offset must be a 1-indexed line number".to_string(),
@@ -202,7 +189,6 @@ impl kigi_tool_runtime::Tool for CodexReadFileTool {
));
}
// 2. Read file via AsyncFileSystem.
let fs;
{
fs = resources.lock().await.require::<FileSystem>()?.0.clone();
@@ -217,8 +203,6 @@ impl kigi_tool_runtime::Tool for CodexReadFileTool {
}
};
// 3. Branch on mode. Out-of-range / empty-file reads return a structured
// `FileReadError` (see note above) instead of a hard `Err`.
let collected = match input.mode {
ReadMode::Slice => match slice::read_slice(&file_bytes, input.offset, input.limit) {
Ok(lines) => lines,
@@ -240,15 +224,12 @@ impl kigi_tool_runtime::Tool for CodexReadFileTool {
}
};
// 4. Build formatted output (L{n}: {content} lines joined by \n).
let content = collected.join("\n");
// 5. Build raw_output — the unformatted file content for the read
// range. This matches the kigi ReadFileTool semantics where
// raw_output is the actual file text without line-number prefixes.
// Kigi's `ReadFileTool` semantics: `raw_output` is the file text with
// no line-number prefixes.
let raw_output = String::from_utf8_lossy(&file_bytes).into_owned();
// 6. Compute total lines.
let total_lines = file_bytes.iter().filter(|&&b| b == b'\n').count()
+ if file_bytes.last() != Some(&b'\n') && !file_bytes.is_empty() {
1
@@ -256,7 +237,6 @@ impl kigi_tool_runtime::Tool for CodexReadFileTool {
0
};
// 7. Return.
Ok(ReadFileOutput::FileContent(FileContent {
content,
content_concise: None,
@@ -280,7 +260,6 @@ mod tests {
use std::sync::Arc;
use tempfile::TempDir;
/// Set up Resources with real filesystem for tests.
fn test_resources(cwd: &std::path::Path) -> Resources {
let mut resources = Resources::new();
resources.insert(Cwd(cwd.to_path_buf()));
@@ -289,9 +268,6 @@ mod tests {
resources
}
/// Build a runtime `ToolCallContext` with the given shared resources.
// ── Slice mode tests ─────────────────────────────────────────
#[tokio::test]
async fn slice_reads_requested_range() {
let tmp = TempDir::new().unwrap();
@@ -337,8 +313,6 @@ mod tests {
indentation: None,
};
// Out-of-range reads are surfaced as a structured `FileReadError` (a
// model-facing error), not a hard `Err`.
let result = kigi_tool_runtime::Tool::run(&tool, test_ctx(shared.clone()), input)
.await
.unwrap();
@@ -352,9 +326,8 @@ mod tests {
#[tokio::test]
async fn empty_file_returns_read_error() {
// An empty file has 0 lines, so the default offset=1 is out of range.
// Codex treats this as a read error; we surface it as a structured
// FileReadError instead of a hard Err.
// An empty file has 0 lines, so even the default offset=1 is out of
// range.
let tmp = TempDir::new().unwrap();
let file_path = tmp.path().join("empty.txt");
std::fs::write(&file_path, "").unwrap();
@@ -437,8 +410,6 @@ mod tests {
}
}
// ── Indentation mode tests ───────────────────────────────────
#[tokio::test]
async fn indentation_mode_captures_block() {
let tmp = TempDir::new().unwrap();
@@ -480,13 +451,8 @@ mod tests {
}
}
// ── Validation tests ─────────────────────────────────────────
#[tokio::test]
async fn indentation_anchor_past_eof_returns_read_error() {
// Indentation-mode range errors flow through the same read_block match
// arm as slice mode, so they must also surface as a structured
// FileReadError rather than a hard Err.
let tmp = TempDir::new().unwrap();
let file_path = tmp.path().join("code.py");
std::fs::write(&file_path, "def foo():\n x = 1\n").unwrap();
@@ -367,6 +367,9 @@ fn rule_matches_read_path(
read_path: &Path,
) -> bool {
match &rule.kind {
// An always-apply rule at the workspace root applies everywhere and so
// has no read scope; only a nested one is a reminder, and only for
// reads beneath its own directory.
CursorRuleKind::Global => {
rule.scope_dir != workspace_root && read_path.starts_with(&rule.scope_dir)
}
@@ -1,10 +1,8 @@
//! File operation lock manager — serializes concurrent file operations.
//!
//! diagnostics for each file. Multiple reads for *different* paths can proceed
//! concurrently; reads for the *same* path are serialized.
//! - **Exclusive lock** (`wait_for_exclusive_lock`): used by `Write` and
//! `StrReplace` before mutating files. Blocks all per-path locks and
//! vice-versa.
//! A per-path lock (`wait_for_lock`) lets operations on *different* paths run
//! concurrently while serializing those on the *same* path. An exclusive lock
//! (`wait_for_exclusive_lock`) blocks all per-path locks and vice-versa.
//!
//! The queue is FIFO with priority inversion avoidance: per-path waiters
//! will not jump ahead of a queued exclusive waiter, preventing writer
@@ -14,7 +12,6 @@ use std::collections::{HashSet, VecDeque};
use std::sync::Arc;
use tokio::sync::{Mutex, oneshot};
/// Shared file operation lock manager stored in tool shared resources.
#[derive(Clone)]
pub struct FileOperationLockManager {
inner: Arc<Mutex<LockInner>>,
@@ -47,12 +44,7 @@ impl FileOperationLockManager {
}
}
/// Acquire a per-path lock. Blocks if:
/// - An exclusive lock is active, OR
/// - The same path is already locked, OR
/// - An exclusive waiter is ahead in the queue.
///
/// Returns a guard that releases the lock on drop.
/// Acquire a per-path lock; the returned guard releases it on drop.
pub async fn wait_for_lock(&self, path: &str) -> FileOperationLockGuard {
let rx = {
let mut inner = self.inner.lock().await;
@@ -74,7 +66,8 @@ impl FileOperationLockManager {
};
if let Some(rx) = rx {
// Wait for our turn (ignore error — sender dropped means lock manager was dropped).
// A send error means the manager was dropped, so there is no lock
// left to wait for.
let _ = rx.await;
}
@@ -84,10 +77,7 @@ impl FileOperationLockManager {
}
}
/// Acquire an exclusive lock. Blocks until all per-path locks are released
/// and no other exclusive lock is active.
///
/// Returns a guard that releases the lock on drop.
/// Acquire an exclusive lock; the returned guard releases it on drop.
pub async fn wait_for_exclusive_lock(&self) -> FileOperationLockGuard {
let rx = {
let mut inner = self.inner.lock().await;
@@ -125,7 +115,6 @@ enum LockKind {
Exclusive,
}
/// RAII guard that releases the lock when dropped.
pub struct FileOperationLockGuard {
manager: FileOperationLockManager,
kind: LockKind,
@@ -135,7 +124,7 @@ impl Drop for FileOperationLockGuard {
fn drop(&mut self) {
let manager = self.manager.clone();
let kind = std::mem::replace(&mut self.kind, LockKind::Exclusive);
// Use `spawn` to release asynchronously — `drop` can't be async.
// `drop` cannot be async, so release on a spawned task.
tokio::spawn(async move {
let mut inner = manager.inner.lock().await;
match kind {
@@ -158,11 +147,9 @@ impl LockInner {
.any(|w| matches!(w, QueuedWaiter::Exclusive { .. }))
}
/// Process the wait queue, granting locks to eligible waiters.
///
/// If a waiter's receiver has been dropped (task cancelled), the send
/// will fail. In that case, we undo the lock grant and continue to the
/// next waiter. This prevents phantom locks from cancelled tool calls.
/// A failing send means the waiter's receiver is gone (its tool call was
/// cancelled); the grant is undone and the next waiter tried, so cancelled
/// calls cannot leave phantom locks behind.
fn process_queue(&mut self) {
while let Some(front) = self.wait_queue.front() {
match front {
@@ -173,12 +160,10 @@ impl LockInner {
if let Some(QueuedWaiter::Exclusive { tx }) = self.wait_queue.pop_front() {
self.exclusive_lock_active = true;
if tx.send(()).is_err() {
// Receiver dropped (cancelled) — undo the grant.
self.exclusive_lock_active = false;
continue;
}
}
// Exclusive lock granted — stop processing.
break;
}
QueuedWaiter::File { path, .. } => {
@@ -189,7 +174,6 @@ impl LockInner {
if let Some(QueuedWaiter::File { tx, .. }) = self.wait_queue.pop_front() {
self.locked_files.insert(path.clone());
if tx.send(()).is_err() {
// Receiver dropped (cancelled) — undo the grant.
self.locked_files.remove(&path);
continue;
}
@@ -218,7 +202,7 @@ mod tests {
order2.lock().await.push("2-acquired");
});
// Give spawned task time to queue.
// Let the spawned task reach the queue before the guard is released.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
order.lock().await.push("1-releasing");
drop(guard1);
@@ -233,7 +217,6 @@ mod tests {
let mgr = FileOperationLockManager::new();
let _guard_a = mgr.wait_for_lock("a.ts").await;
// Different path should acquire immediately.
let mgr2 = mgr.clone();
let handle = tokio::spawn(async move {
let _guard_b = mgr2.wait_for_lock("b.ts").await;
@@ -1,10 +1,8 @@
//! Formatting functions for AskUserQuestion tool results.
//!
//! Each function produces the **exact** model-visible string for one of the
//! four user-action paths.
//!
//! The tests below pin the exact output strings and serve as the
//! source-of-truth specification.
//! four user-action paths: A accepted, B chat about this, C skip interview,
//! D cancel. The tests below pin those strings and are the source of truth.
use std::collections::HashMap;
@@ -13,32 +11,25 @@ use indexmap::IndexMap;
use super::Question;
use super::types::QuestionAnnotation;
// ── Path D: Cancel ──────────────────────────────────────────────────────
/// Tool result text when the user cancels / dismisses the question UI.
///
/// Cancel is a normal user decision, not a tool failure, so this is a
/// purpose-built message rather than a generic permission-denial string.
pub const CANCEL_TEXT: &str = "User declined to answer the questions. Continue with the task using your best judgment, or ask different questions.";
// ── Path A: Accepted ────────────────────────────────────────────────────
/// Format the tool result for Path A (user accepted and submitted answers).
///
/// Produces the accepted-answers tool result:
///
/// ```text
/// User has answered your questions: "<q>"="<label>" ..., "<q>"="<label>" .... You can now continue with the user's answers in mind.
/// ```
///
/// Rules:
/// - Only answered questions appear (unanswered are omitted by the caller).
/// Wire-format expectations:
/// - Unanswered questions are omitted by the caller, so `answers` holds only
/// answered ones.
/// - Multi-select: each selected label is its own `Vec` element on the
/// wire; this function joins them with `, ` at format time.
/// - Freeform-only: a single-element vec containing `"Other"`, free text
/// in `annotations[q].notes`.
/// - Preview is appended only when present in annotations.
/// - Notes are appended only when present in annotations.
/// - Questions/labels are interpolated raw (no escaping).
pub fn format_accepted_tool_result(
answers: &IndexMap<String, Vec<String>>,
@@ -71,8 +62,6 @@ pub fn format_accepted_tool_result(
)
}
// ── Alternate id-keyed tool-result formatting ────
/// Format the tool result in the alternate id-keyed shape (Path A).
///
/// Answers are keyed by **id**, one question per line, with no trailing
@@ -84,28 +73,15 @@ pub fn format_accepted_tool_result(
/// Question <qid>: Selected option(s) <oid>(, <oid>)*
/// ```
///
/// Examples:
///
/// - Single question, single-select:
/// `User questions responses:\nQuestion demo_pick: Selected option(s) a`
/// - Three questions, last with `allow_multiple: true` (one selection):
/// `User questions responses:\nQuestion q1: Selected option(s) tea\nQuestion q2: Selected option(s) code\nQuestion q3: Selected option(s) tests`
///
/// Multi-select labels arrive as separate `Vec` elements; this function
/// joins their resolved ids with `, ` (`Selected option(s) a, b, c`).
/// The multi-select join shape is exercised by the test below.
///
/// `input_questions` carries both `id` and the option `label`/`id` map
/// so we can resolve the answer values (which arrive label-keyed from
/// the client) back to the option ids.
/// the client) back to the option ids. It also fixes the output order;
/// questions missing from `answers` are skipped.
///
/// `annotations` carries per-question freeform notes (the text the user
/// typed when picking the freeform "Other" path or dismissing). When no
/// option labels resolve to ids and `notes` is non-empty, the result is
/// `Question <qid>: <raw_text>` (no `Selected option(s)` prefix).
///
/// Question order follows `input_questions`. Unanswered questions are
/// omitted -- only answered questions appear in the result.
pub fn format_id_keyed_accepted_tool_result(
input_questions: &[super::Question],
answers: &IndexMap<String, Vec<String>>,
@@ -116,10 +92,9 @@ pub fn format_id_keyed_accepted_tool_result(
.filter_map(|q| {
let qid = q.id.as_ref()?;
let labels = answers.get(&q.question)?;
// Each selected label is its own `Vec` element (the wire
// format no longer joins labels with `", "`), so we look each
// one up directly. No splitting, no ambiguity around labels
// that contain commas or share substrings with other labels.
// Each selected label is its own `Vec` element, so each is looked
// up whole: labels containing `", "` or sharing a substring with
// another label cannot be mismatched.
let oids: Vec<&str> = labels
.iter()
.filter_map(|label| {
@@ -130,8 +105,8 @@ pub fn format_id_keyed_accepted_tool_result(
})
.collect();
if oids.is_empty() {
// Freeform / dismissed: emit the raw text from the freeform
// input directly after `Question <qid>: `.
// Freeform or dismissed: the typed text stands in for an
// option id.
let notes = annotations
.as_ref()
.and_then(|m| m.get(&q.question))
@@ -159,13 +134,8 @@ pub fn format_id_keyed_accepted_tool_result(
)
}
// ── Path B: Chat about this (plan mode) ─────────────────────────────────
/// Format the tool result for Path B ("Chat about this" / respond-to-agent).
///
/// Iterates ALL original questions. Answered questions show their label;
/// unanswered questions show "(No answer provided)".
///
/// Whitespace is intentional:
/// - Lines 2-4 and "Questions asked:" have 4-space indentation.
/// - Question bullets have no indentation.
@@ -197,11 +167,10 @@ pub fn format_chat_about_this(
)
}
// ── Path C: Skip interview (plan mode) ──────────────────────────────────
/// Format the tool result for Path C ("Skip interview and plan immediately").
///
/// Same per-question format as Path B, but different header and NO indentation.
/// Same per-question format as Path B, but a different header and no
/// indentation.
pub fn format_skip_interview(
questions: &[Question],
partial_answers: &HashMap<String, String>,
@@ -227,15 +196,11 @@ pub fn format_skip_interview(
)
}
// ── Tests ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::super::QuestionOption;
use super::*;
// -- Helpers --
fn make_question(text: &str, labels: &[&str]) -> Question {
Question {
question: text.to_string(),
@@ -253,8 +218,6 @@ mod tests {
}
}
// ── Path A: format_accepted_tool_result ──────────────────────────────
#[test]
fn format_accepted_single_no_annotations() {
let mut answers = IndexMap::new();
@@ -316,7 +279,6 @@ mod tests {
#[test]
fn format_accepted_freeform_only() {
// Freeform-only: label is "Other", typed text in annotations.notes
let mut answers = IndexMap::new();
answers.insert("Which database?".to_string(), vec!["Other".to_string()]);
@@ -369,11 +331,8 @@ mod tests {
#[test]
fn format_accepted_partial() {
// Only answered questions appear. Unanswered questions are omitted by the caller
// (the answers IndexMap simply doesn't contain them).
let mut answers = IndexMap::new();
answers.insert("Which database?".to_string(), vec!["Redis".to_string()]);
// "Which framework?" is unanswered => not in the map
let result = format_accepted_tool_result(&answers, &None);
assert_eq!(
@@ -382,11 +341,6 @@ mod tests {
);
}
// ── Alternate id-keyed formatter tests ────────────────────
//
// Pin both result strings (single question and three questions) so any
// drift in the formatter trips a deterministic failure. Update the
// literal strings deliberately if the wire format ever changes.
fn id_keyed_q(qid: &str, prompt: &str, opts: &[(&str, &str)]) -> super::super::Question {
super::super::Question {
question: prompt.to_string(),
@@ -477,9 +431,6 @@ mod tests {
#[test]
fn format_id_keyed_multi_select_inferred_csv() {
// Multi-select with multiple selections joins option ids with ", "
// at format time. Each selected label arrives as its own Vec
// element (the wire format no longer joins them).
let questions = vec![id_keyed_q(
"q3",
"What do you lean on before a push? (pick any)",
@@ -528,12 +479,8 @@ mod tests {
assert_eq!(result, "User questions responses:");
}
/// Freeform/dismiss:
/// when the user dismisses or types freeform text instead of picking
/// an option, the wire format emits the raw text directly after
/// `Question <qid>: ` with NO `Selected option(s)` prefix. The pager
/// sends `answers["..."] = ["Other"]` plus the typed text in
/// `annotations[q].notes`; the formatter falls through to the notes.
/// The pager sends `answers["..."] = ["Other"]` plus the typed text in
/// `annotations[q].notes`, which is what makes the notes fallback fire.
#[test]
fn format_id_keyed_freeform_dismissal_uses_notes_without_selected_prefix() {
let questions = vec![id_keyed_q(
@@ -562,9 +509,8 @@ mod tests {
);
}
/// Freeform with no notes (just `["Other"]` and no annotation) is
/// indistinguishable from a no-answer to the formatter, so the
/// question is dropped (matching the `oids.is_empty()` branch).
/// Freeform with no notes is indistinguishable from a no-answer to the
/// formatter, so the question is dropped.
#[test]
fn format_id_keyed_freeform_without_notes_is_dropped() {
let questions = vec![id_keyed_q("q1", "Pick", &[("a", "A")])];
@@ -576,7 +522,6 @@ mod tests {
#[test]
fn format_accepted_special_chars() {
// Quotes and newlines in labels appear verbatim (no escaping)
let mut answers = IndexMap::new();
answers.insert(
"Which \"option\"?".to_string(),
@@ -590,8 +535,6 @@ mod tests {
);
}
// ── Path B: format_chat_about_this ───────────────────────────────────
#[test]
fn format_chat_about_this_mixed() {
let questions = vec![
@@ -639,8 +582,6 @@ The user wants to clarify these questions.
assert!(result.contains("- \"Q2?\"\n (No answer provided)"));
}
// ── Path C: format_skip_interview ────────────────────────────────────
#[test]
fn format_skip_interview_all_answered() {
let questions = vec![
@@ -683,24 +624,18 @@ Questions asked and answers provided:
#[test]
fn format_skip_interview_no_indentation() {
// Path C has NO indentation on any header lines (unlike Path B)
let questions = vec![make_question("Q?", &["A"])];
let result = format_skip_interview(&questions, &HashMap::new());
// First line has no leading spaces
let first_line = result.lines().next().unwrap();
assert!(!first_line.starts_with(' '));
// Second line has no leading spaces
let second_line = result.lines().nth(1).unwrap();
assert!(!second_line.starts_with(' '));
// "Questions asked" line has no leading spaces
assert!(result.contains("\nQuestions asked and answers provided:\n"));
}
// ── Path D: CANCEL_TEXT ─────────────────────────────────────────────
#[test]
fn format_cancel() {
assert_eq!(
@@ -1,31 +1,20 @@
//! `AskUserQuestion` tool — new architecture (`Tool` trait).
//! `AskUserQuestion` tool.
//!
//! Interactive Q&A tool that presents the user with structured questions and
//! option sets. In plan mode it serves as the **interview mechanism** — the
//! agent clarifies requirements, disambiguates approaches, and gets user input
//! on design decisions before finalizing the plan. Outside plan mode it is a
//! general-purpose tool for gathering user preferences during implementation.
//! Presents the user with structured questions and option sets. In plan mode
//! it is the interview mechanism the agent uses to clarify requirements before
//! finalizing a plan; outside plan mode it gathers preferences during
//! implementation.
//!
//! ## How It Works
//! Flow: the tool hands a [`UserQuestionRequest`] to the session-owned
//! coordinator in `kigi-shell` over an mpsc channel, emits a
//! `UserQuestionAsked` notification for observers, then blocks on a oneshot
//! until the coordinator's ACP `ext_method` round-trip with the client
//! resolves (or the wait budget elapses).
//!
//! 1. The agent calls `AskUserQuestion` with an array of structured questions
//! (each with options, optional preview, optional multi_select).
//! 2. The tool sends a `UserQuestionAsked` **notification** to the gateway/client
//! carrying the full question payload as JSON.
//! 3. The tool returns `AskUserQuestionOutput::QuestionsSent` to the model as
//! an immediate confirmation.
//! 4. The client presents the question UI, collects user answers, and injects
//! them back into the conversation as the tool result. This client-side
//! round-trip is handled by the orchestration layer, not by this tool.
//!
//! ## Plan-Mode Interview Actions
//!
//! When called during plan mode, the client can present two extra buttons:
//! - **"Respond to agent"** — partial answers, agent reformulates questions
//! - **"Finish plan interview"** — agent stops asking, proceeds with what it has
//!
//! These are client-side behaviors that produce different tool-result strings;
//! the tool itself is identical in and out of plan mode.
//! In plan mode the client offers two extra actions — "Chat about this"
//! (partial answers, agent reformulates) and "Skip interview" (agent proceeds
//! with what it has). Those only change the tool-result text; the tool itself
//! behaves identically in and out of plan mode.
pub mod format;
pub mod types;
@@ -42,37 +31,27 @@ use crate::types::requirements::{Expr, ToolRequirement};
use crate::types::resources::{NotificationHandle, SharedResources};
use crate::types::tool::{ToolKind, ToolNamespace};
/// Migration fallback: when `true`, a missing `UserQuestionSender` falls
/// back to the old fire-and-forget `QuestionsSent` behavior with a warning.
/// Set to `false` (or delete entirely) once the shell coordinator is wired
/// up in TS-03 and confirmed working.
/// TODO: set to `false` and drop [`AskUserQuestionTool::fallback_fire_and_forget`]
/// once the shell coordinator (TS-03) is wired up. While `true`, a missing
/// `UserQuestionSender` degrades to fire-and-forget `QuestionsSent` instead of
/// failing the tool call.
const MIGRATION_FALLBACK: bool = true;
/// Default max time to wait for the user to answer the questionnaire (all
/// questions in this tool call share one timer): 30 minutes. On expiry the
/// tool returns the same skipped/cancel text as a user dismiss
/// (`CANCEL_TEXT`), not a tool failure.
///
/// The shell resolves `[toolset.ask_user_question]` across its config tiers
/// and injects the result as [`AskUserQuestionParams`]; when no resolved
/// params are injected, `KIGI_ASK_USER_QUESTION_TIMEOUT_SECS` (positive
/// integer seconds) still overrides this default directly —
/// e.g. `KIGI_ASK_USER_QUESTION_TIMEOUT_SECS=8` for tests / TUI repro.
/// Default wait budget for one questionnaire. On expiry the tool returns the
/// same text as a user dismiss (`CANCEL_TEXT`), not a tool failure.
pub const RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30 * 60);
/// Default for `timeout_enabled` across every resolver tier and settings
/// surface: the questionnaire timer is armed unless something disarms it.
/// Single source — the shell resolver's `.default(...)` and the pager's
/// settings registry both anchor on this const.
/// surface. Single source — the shell resolver's `.default(...)` and the
/// pager's settings registry both anchor on this const.
pub const DEFAULT_ASK_USER_QUESTION_TIMEOUT_ENABLED: bool = true;
/// Env var: override [`RESPONSE_TIMEOUT`] with a duration in **seconds**.
/// Env var overriding [`RESPONSE_TIMEOUT`], in **seconds**.
pub const RESPONSE_TIMEOUT_ENV: &str = "KIGI_ASK_USER_QUESTION_TIMEOUT_SECS";
/// Parse the [`RESPONSE_TIMEOUT_ENV`] override (positive integer seconds).
/// Invalid or non-positive values are warned and treated as unset. Single
/// source for this parse — the shell's env tier calls it too, so the two
/// resolutions can't drift.
/// Invalid or non-positive values are warned about and treated as unset.
/// Single source for this parse — the shell's env tier calls it too, so the
/// two resolutions can't drift.
pub fn response_timeout_env_secs() -> Option<u64> {
let raw = std::env::var(RESPONSE_TIMEOUT_ENV).ok()?;
match raw.trim().parse::<u64>() {
@@ -88,29 +67,28 @@ pub fn response_timeout_env_secs() -> Option<u64> {
}
}
/// Effective wait budget for one questionnaire (env override or default).
/// Env override if set, otherwise [`RESPONSE_TIMEOUT`].
pub fn response_timeout() -> std::time::Duration {
response_timeout_env_secs()
.map(std::time::Duration::from_secs)
.unwrap_or(RESPONSE_TIMEOUT)
}
/// Runtime-configurable parameters for the `ask_user_question` tool,
/// injected via `Params<AskUserQuestionParams>` in `SharedResources`.
/// Runtime-configurable parameters, injected via
/// `Params<AskUserQuestionParams>` in `SharedResources`.
///
/// The shell resolves `[toolset.ask_user_question]` across requirements >
/// env > user `config.toml` > managed > remote feature config and injects the
/// concrete result. All fields are optional `None` means "unset", which
/// preserves the legacy env→default budget, so registry consumers that never
/// resolve config (workspace toolset) keep today's behavior.
/// concrete result. Every field is optional, and `None` means "unset" — that
/// falls back to the env→default budget, so registry consumers that never
/// resolve config (workspace toolset) still get a sane timeout.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AskUserQuestionParams {
/// `Some(false)` disarms the questionnaire timer entirely (wait forever
/// for an answer). `None`/`Some(true)` keep the timer armed.
#[serde(default)]
pub timeout_enabled: Option<bool>,
/// Wait budget in seconds when the timer is armed (positive integer).
/// `None` falls back to the env override / [`RESPONSE_TIMEOUT`].
/// Positive integer; `None` falls back to [`response_timeout`].
#[serde(default)]
pub timeout_secs: Option<u64>,
}
@@ -118,7 +96,7 @@ pub struct AskUserQuestionParams {
crate::register_resource!("kigi", "AskUserQuestion", AskUserQuestionParams);
impl AskUserQuestionParams {
/// Effective wait budget: `Some(duration)` = bounded, `None` = wait forever.
/// `Some(duration)` = bounded wait, `None` = wait forever.
pub fn wait_budget(&self) -> Option<std::time::Duration> {
if !self
.timeout_enabled
@@ -144,23 +122,19 @@ impl AskUserQuestionParams {
/// A single option within a question.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct QuestionOption {
/// Option text shown to the user; a few words at most.
#[schemars(description = "Option text shown to the user. A few words at most.")]
pub label: String,
/// What picking this option means or implies.
#[schemars(description = "What picking this option means or implies.")]
pub description: String,
/// Optional content shown while the option is focused — mockups, code
/// snippets, anything the user should compare. Single-select only.
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(
description = "Optional content shown while the option is focused — mockups, code snippets, anything the user should compare. Single-select questions only."
)]
pub preview: Option<String>,
/// Opaque id; hidden from the model. Kigi callers leave it `None`.
/// Opaque id, hidden from the model. Kigi callers leave it `None`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(skip)]
pub id: Option<String>,
@@ -170,17 +144,14 @@ pub struct QuestionOption {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct Question {
/// The question to ask, phrased as a full question.
#[schemars(description = "The question to ask, phrased as a full question.")]
pub question: String,
/// The choices for this question.
#[schemars(description = "The choices for this question.")]
pub options: Vec<QuestionOption>,
/// Let the user pick more than one option (default false).
// Model-facing schema name is snake_case (`multi_select`); deserialize also
// accepts the legacy/ACP `multiSelect` so the shared `Question` type stays
// The model-facing schema name is snake_case (`multi_select`), but
// deserialization also accepts `multiSelect` so this shared type stays
// wire-compatible with the camelCase ACP ext_method.
#[serde(
default,
@@ -193,40 +164,28 @@ pub struct Question {
)]
pub multi_select: Option<bool>,
/// See `QuestionOption.id`. Hidden from the JSON schema.
/// See `QuestionOption::id`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(skip)]
pub id: Option<String>,
}
/// Input for the `AskUserQuestion` tool.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct AskUserQuestionInput {
/// The questions to ask, each with its own options. At least one question
/// is required.
#[schemars(description = "The questions to ask, each with its own options.")]
pub questions: Vec<Question>,
/// Internal flag: when `true`, the tool result is formatted in the
/// alternate shape (referenced by id, not label).
/// Skipped on the wire and from the JSON schema so the model never
/// sees or controls this field.
/// Formats the tool result keyed by option id rather than label. Skipped
/// on the wire and from the JSON schema so the model never sees or
/// controls it.
#[serde(default, skip)]
#[schemars(skip)]
pub use_id_keyed_format: bool,
}
/// `AskUserQuestion` tool.
///
/// Blocks inside `run()` until the user responds or the configured wait
/// budget elapses for the whole questionnaire (default [`RESPONSE_TIMEOUT`],
/// 30 minutes). Sends a request over an in-process mpsc channel to a
/// session-owned coordinator (in kigi-shell), which performs an ACP
/// `ext_method` round-trip to the client/pager. The response is sent back
/// over a oneshot channel and formatted into the model-visible tool result.
///
/// Params: [`AskUserQuestionParams`] — timeout policy resolved by the shell
/// across its config tiers; unset fields keep the legacy env→default budget.
/// `run()` blocks until the user responds or the wait budget elapses for the
/// whole questionnaire, and the timeout policy comes from
/// [`AskUserQuestionParams`].
#[derive(Debug, Default)]
pub struct AskUserQuestionTool;
@@ -259,12 +218,9 @@ impl crate::types::tool_metadata::ToolMetadata for AskUserQuestionTool {
}
impl AskUserQuestionTool {
/// Fire-and-forget fallback used during migration when
/// `UserQuestionSender` is not yet injected by the shell.
///
/// This preserves the old behavior: send a notification, return
/// `QuestionsSent`. Remove this method when `MIGRATION_FALLBACK` is
/// set to `false`.
/// Fallback for when the shell has not injected a `UserQuestionSender`:
/// send the notification and return `QuestionsSent` without waiting for an
/// answer. Gated on [`MIGRATION_FALLBACK`].
async fn fallback_fire_and_forget(
&self,
input: &AskUserQuestionInput,
@@ -363,7 +319,6 @@ impl kigi_tool_runtime::Tool for AskUserQuestionTool {
});
}
// ── Step 1: Validate unique question text ───────────────────────
{
let mut seen = std::collections::HashSet::new();
for q in &input.questions {
@@ -376,7 +331,6 @@ impl kigi_tool_runtime::Tool for AskUserQuestionTool {
}
}
// ── Step 2: Obtain UserQuestionSender ───────────────────────────
let sender = {
let res = resources.lock().await;
res.get::<UserQuestionSender>().cloned()
@@ -401,10 +355,8 @@ impl kigi_tool_runtime::Tool for AskUserQuestionTool {
}
};
// ── Step 3: Create oneshot ──────────────────────────────────────
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
// ── Step 4: Send UserQuestionRequest ────────────────────────────
let request = types::UserQuestionRequest {
tool_call_id: ctx.call_id.as_str().to_owned(),
questions: input.questions.clone(),
@@ -418,7 +370,6 @@ impl kigi_tool_runtime::Tool for AskUserQuestionTool {
));
}
// ── Step 5: Emit UserQuestionAsked + read the wait budget ───────
let wait = {
let questions_json = serde_json::to_value(&input.questions)
.unwrap_or_else(|_| serde_json::Value::Array(vec![]));
@@ -429,8 +380,6 @@ impl kigi_tool_runtime::Tool for AskUserQuestionTool {
questions_json,
});
}
// Shell-injected params win; absent or unset fields keep the legacy
// env→default budget so non-shell registry consumers are unchanged.
res.get::<crate::types::resources::Params<AskUserQuestionParams>>()
.map(|p| p.0)
.unwrap_or_default()
@@ -442,11 +391,10 @@ impl kigi_tool_runtime::Tool for AskUserQuestionTool {
"Asked user questions, blocking for response"
);
// ── Step 6: Block on the oneshot result (whole batch, one timer) ─
// A single pending-decision timeout covers the questionnaire, not per
// question: N questions in one call share one wait.
// A `None` budget (`timeout_enabled = false`) runs the same await with
// no timer, normalized into the timed shape so one match handles both.
// One timer covers the whole questionnaire, not each question: N
// questions in one call share a single wait. A `None` budget
// (`timeout_enabled = false`) awaits with no timer at all, wrapped in
// the timed shape so one match arm handles both.
let outcome = match wait {
Some(dur) => tokio::time::timeout(dur, result_rx).await,
None => Ok(result_rx.await),
@@ -465,17 +413,16 @@ impl kigi_tool_runtime::Tool for AskUserQuestionTool {
timeout_secs = ?wait.map(|d| d.as_secs()),
"User question timed out; continuing without answers"
);
// Drop the oneshot receiver on return. The shell coordinator
// races `result_tx.closed()` against ACP so it unblocks and
// can open the next questionnaire (stale UI is cancelled when
// a new ext_method arrives). Same model text as cancel.
// Returning drops the oneshot receiver. The shell coordinator
// races `result_tx.closed()` against ACP, so it unblocks and
// can open the next questionnaire; stale UI is dismissed when
// the new ext_method arrives.
return Ok(AskUserQuestionOutput::UserAnswered {
message: format::CANCEL_TEXT.to_string(),
});
}
};
// ── Step 7: Map result to formatter or error ────────────────────
match result {
Ok(UserQuestionResponse::Accepted {
answers,
@@ -550,8 +497,6 @@ mod tests {
}
}
/// Create resources with a UserQuestionSender injected.
/// Returns (shared_resources, rx) where rx receives UserQuestionRequests.
fn resources_with_sender() -> (
SharedResources,
mpsc::UnboundedReceiver<types::UserQuestionRequest>,
@@ -562,7 +507,6 @@ mod tests {
(resources.into_shared(), rx)
}
/// Like [`resources_with_sender`], with shell-resolved params injected.
fn resources_with_sender_and_params(
params: AskUserQuestionParams,
) -> (
@@ -576,8 +520,6 @@ mod tests {
(resources.into_shared(), rx)
}
// ── Basic tool metadata tests ────────────────────────────────────────
#[test]
fn tool_name_and_description() {
let tool = AskUserQuestionTool;
@@ -655,8 +597,6 @@ mod tests {
assert_eq!(input.questions[0].multi_select, Some(true));
}
// ── Migration fallback tests (no UserQuestionSender) ─────────────────
#[tokio::test]
async fn fallback_ask_single_question() {
let resources = Resources::new();
@@ -744,8 +684,6 @@ mod tests {
}
}
// ── Validation tests ─────────────────────────────────────────────────
#[tokio::test]
async fn validate_duplicate_question_text() {
let resources = Resources::new();
@@ -770,8 +708,6 @@ mod tests {
assert!(msg.contains("Same question?"), "got: {msg}");
}
// ── Blocking round-trip tests ────────────────────────────────────────
#[tokio::test]
async fn blocking_round_trip_accepted() {
let (shared, mut rx) = resources_with_sender();
@@ -848,9 +784,8 @@ mod tests {
}
}
/// Whole questionnaire (multi-question batch) shares one 6-minute timer.
/// No `Params` injected — pins the legacy env→default budget for
/// consumers that never resolve `[toolset.ask_user_question]`.
/// No `Params` injected, which pins the env→default budget for consumers
/// that never resolve `[toolset.ask_user_question]`.
#[tokio::test(start_paused = true)]
async fn blocking_times_out_after_default_budget_for_batch() {
let (shared, mut rx) = resources_with_sender();
@@ -878,7 +813,7 @@ mod tests {
let request = rx.recv().await.expect("should receive request");
assert_eq!(request.questions.len(), 2);
// Advance past the *effective* budget (honors env override if set).
// The effective budget, so the test still passes under an env override.
let wait = response_timeout();
tokio::time::advance(wait + std::time::Duration::from_secs(1)).await;
@@ -913,7 +848,7 @@ mod tests {
});
let request = rx.recv().await.expect("should receive request");
// Stay well under the effective timeout (env override or default budget).
// Stay well under the effective budget, whatever an env override made it.
let advance = response_timeout()
.checked_div(6)
.unwrap_or(std::time::Duration::from_secs(1))
@@ -939,14 +874,10 @@ mod tests {
}
}
// ── Configured timeout params tests ──────────────────────────────────
/// Unset params reproduce the legacy env→default budget; `timeout_enabled
/// = false` disarms the timer; `0` never means "wait forever".
#[test]
fn wait_budget_mapping() {
// Compared against `response_timeout()` rather than the raw constant so
// the assertions pin the legacy delegation and hold under a dev's env override.
// the assertions pin the delegation and hold under a dev's env override.
assert_eq!(
AskUserQuestionParams::default().wait_budget(),
Some(response_timeout()),
@@ -973,8 +904,6 @@ mod tests {
);
}
/// A short shell-resolved budget fires with the same silent-skip text as
/// a user dismiss.
#[tokio::test(start_paused = true)]
async fn short_params_timeout_fires_with_cancel_text() {
let (shared, mut rx) = resources_with_sender_and_params(AskUserQuestionParams {
@@ -1012,8 +941,6 @@ mod tests {
}
}
/// `timeout_enabled = false` waits arbitrarily long — an answer far past
/// the default budget still succeeds instead of timing out.
#[tokio::test(start_paused = true)]
async fn timeout_disabled_waits_beyond_default_budget() {
let (shared, mut rx) = resources_with_sender_and_params(AskUserQuestionParams {
@@ -1062,8 +989,6 @@ mod tests {
}
}
// ── Failure path tests ───────────────────────────────────────────────
#[tokio::test]
async fn channel_drop_returns_error() {
let (shared, mut rx) = resources_with_sender();
@@ -1,14 +1,13 @@
//! Shared protocol and channel types for the AskUserQuestion blocking flow.
//! Shared protocol and channel types for the `AskUserQuestion` blocking flow.
//!
//! These types define the request/response contract between three crates:
//! These types are the request/response contract between three crates, all of
//! which import them from `kigi-tools`:
//!
//! - **`kigi-tools`** — tool blocks on a oneshot, formats the result.
//! - **`kigi-shell`** — coordinator receives requests over mpsc, calls the
//! client via ACP `ext_method`, sends results back over the oneshot.
//! - **`kigi-tui`** — handles the `ExtMethod`, renders UI, returns a
//! typed response.
//!
//! All three crates import these types from `kigi-tools`.
use std::collections::HashMap;
@@ -19,13 +18,12 @@ use tokio::sync::{mpsc, oneshot};
use super::Question;
use crate::register_resource;
// ── ACP wire-format types ────────────────────────────────────────────────
/// Annotation on a single question's answer.
/// Extra context carried alongside a selected label in the `accepted`
/// response.
///
/// Carried inside the `accepted` response alongside the selected label.
/// - `preview`: verbatim `Option.preview` of the selected option (single-select only).
/// - `notes`: free-text the user typed in the freeform input.
/// - `preview`: verbatim `QuestionOption::preview` of the selected option
/// (single-select only).
/// - `notes`: free text the user typed into the freeform input.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct QuestionAnnotation {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -34,34 +32,29 @@ pub struct QuestionAnnotation {
pub notes: Option<String>,
}
/// Mode context for the question UI.
///
/// Sent as part of the ACP `ext_method` request so the pager knows whether
/// to show plan-mode-only actions (Chat about this / Skip interview).
/// Tells the pager which actions to offer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AskUserQuestionMode {
/// Normal mode. Client shows only Accept and Cancel.
/// Accept and Cancel only.
Default,
/// Plan mode. Client shows Accept, Cancel, Chat about this, Skip interview.
/// Accept, Cancel, Chat about this, Skip interview.
Plan,
}
/// ACP `ext_method` request payload (shell coordinator sends to client/pager).
///
/// Serialized as `camelCase` for the ACP JSON-RPC wire format.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AskUserQuestionExtRequest {
pub session_id: String,
pub tool_call_id: String,
pub questions: Vec<Question>,
/// Controls whether the client shows plan-mode-only actions.
pub mode: AskUserQuestionMode,
}
/// Accepts both `"value"` (old wire format) and `["value"]` (new wire format)
/// for each answer entry, normalizing strings into single-element vectors.
/// Accepts an answer entry as either `"value"` or `["value"]`, normalizing the
/// bare string into a single-element vector. Clients that predate
/// multi-select send the scalar form.
fn deserialize_string_or_vec_answers<'de, D>(
deserializer: D,
) -> Result<IndexMap<String, Vec<String>>, D::Error>
@@ -92,64 +85,54 @@ where
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum AskUserQuestionExtResponse {
/// User accepted and submitted answers (Path A).
Accepted {
/// Answered questions in original order; unanswered omitted.
/// One element per selected option; freeform-only is `["Other"]`
/// with typed text in `annotations[q].notes`.
/// Answered questions in their original order, unanswered ones
/// omitted, one element per selected option. A freeform-only answer
/// is `["Other"]` with the typed text in `annotations[q].notes`.
#[serde(deserialize_with = "deserialize_string_or_vec_answers")]
answers: IndexMap<String, Vec<String>>,
/// Per-question annotations (preview, notes). Absent when empty.
#[serde(default, skip_serializing_if = "Option::is_none")]
annotations: Option<HashMap<String, QuestionAnnotation>>,
},
/// User chose "Chat about this" (Path B, plan mode only).
/// Plan mode only.
ChatAboutThis {
/// Partial answers: answered questions only, label only (no notes).
/// Freeform-only => `"Other"` (notes dropped in plan-mode paths).
/// Answered questions only, label only. A freeform-only answer is
/// `"Other"` — the plan-mode paths drop the notes.
#[serde(default)]
partial_answers: HashMap<String, String>,
},
/// User chose "Skip interview and plan immediately" (Path C, plan mode only).
/// Plan mode only.
SkipInterview {
/// Same partial-answer rules as `ChatAboutThis`.
/// Same rules as `ChatAboutThis::partial_answers`.
#[serde(default)]
partial_answers: HashMap<String, String>,
},
/// User cancelled / dismissed (Path D). NOT an error.
/// User dismissed the questionnaire. NOT an error.
Cancelled,
}
// ── In-process types (coordinator <-> tool) ──────────────────────────────
/// In-process result: coordinator -> tool.
///
/// Uses `Result` so the tool can distinguish user actions from infrastructure
/// failures:
/// - `Ok(UserQuestionResponse)` for all 4 user paths (accepted, chat, skip, cancel).
/// - `Err(UserQuestionError)` for transport failures or malformed responses.
/// In-process result, coordinator -> tool. The `Result` separates user actions
/// from infrastructure failures — every user path, cancellation included, is
/// an `Ok`.
pub type UserQuestionResult = Result<UserQuestionResponse, UserQuestionError>;
/// Successful user response (all 4 user paths).
///
/// Every variant here produces `Ok(UserAnswered { message })` at the tool
/// level with `ToolCall` status `Completed`.
/// Every variant produces `Ok(UserAnswered { message })` at the tool level,
/// with `ToolCall` status `Completed`.
#[derive(Debug, Clone)]
pub enum UserQuestionResponse {
/// User accepted and submitted answers (Path A).
Accepted {
/// See `AskUserQuestionExtResponse::Accepted::answers`.
answers: IndexMap<String, Vec<String>>,
annotations: Option<HashMap<String, QuestionAnnotation>>,
},
/// User chose "Chat about this" (Path B, plan mode only).
/// Carries the original questions so the formatter can iterate all of them.
/// Plan mode only. Carries the original questions so the formatter can
/// iterate all of them, not just the answered ones.
ChatAboutThis {
questions: Vec<Question>,
partial_answers: HashMap<String, String>,
},
/// User chose "Skip interview" (Path C, plan mode only).
/// Carries the original questions so the formatter can iterate all of them.
/// Plan mode only. Carries the original questions so the formatter can
/// iterate all of them, not just the answered ones.
SkipInterview {
questions: Vec<Question>,
partial_answers: HashMap<String, String>,
@@ -158,10 +141,9 @@ pub enum UserQuestionResponse {
Cancelled,
}
/// Infrastructure failure (NOT a user action).
///
/// These produce `Err(ToolError::ExecutionError { .. })` at the tool level
/// with `ToolCall` status `Failed`.
/// Infrastructure failure, never a user action. These produce
/// `Err(ToolError::ExecutionError { .. })` at the tool level, with `ToolCall`
/// status `Failed`.
#[derive(Debug, Clone)]
pub enum UserQuestionError {
/// ACP `ext_method` call failed (client disconnect, timeout, etc.).
@@ -171,10 +153,9 @@ pub enum UserQuestionError {
MalformedResponse(String),
}
/// In-process request: tool -> coordinator (carries oneshot for reply).
///
/// Sent over the `mpsc` channel. The coordinator receives this, performs the
/// ACP `ext_method` round-trip, and sends the result back on `result_tx`.
/// In-process request, tool -> coordinator, sent over the `mpsc` channel. The
/// coordinator performs the ACP `ext_method` round-trip and sends the result
/// back on `result_tx`.
#[derive(Educe)]
#[educe(Debug)]
pub struct UserQuestionRequest {
@@ -184,13 +165,8 @@ pub struct UserQuestionRequest {
pub result_tx: oneshot::Sender<UserQuestionResult>,
}
// ── Resource type ────────────────────────────────────────────────────────
/// Resource: `mpsc` sender injected into `SharedResources`.
///
/// Same injection pattern as `SubagentEventSender`. Cloned into each
/// session so that any `AskUserQuestionTool` invocation can emit a
/// `UserQuestionRequest` to the session's coordinator.
/// Injected into `SharedResources` and cloned into each session, so that any
/// `AskUserQuestionTool` invocation can reach that session's coordinator.
#[derive(Clone, Educe)]
#[educe(Debug)]
pub struct UserQuestionSender(
@@ -199,15 +175,11 @@ pub struct UserQuestionSender(
register_resource!("kigi", "UserQuestionSender", UserQuestionSender);
// ── Conversion helper ────────────────────────────────────────────────────
impl AskUserQuestionExtResponse {
/// Convert the wire-format ACP response into the in-process response type.
///
/// Called by the shell coordinator after deserializing the client's JSON.
/// The `questions` parameter carries the original question list so that
/// `ChatAboutThis` and `SkipInterview` responses can iterate all questions
/// (answered and unanswered) when formatting the tool result.
/// `questions` is the original question list, which `ChatAboutThis` and
/// `SkipInterview` need so the formatter can walk answered and unanswered
/// questions alike.
pub fn into_response(self, questions: Vec<Question>) -> UserQuestionResponse {
match self {
Self::Accepted {
@@ -230,14 +202,10 @@ impl AskUserQuestionExtResponse {
}
}
// ── Tests ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// -- Helpers --
fn sample_questions() -> Vec<Question> {
vec![
Question {
@@ -281,8 +249,6 @@ mod tests {
]
}
// -- AskUserQuestionMode serde --
#[test]
fn mode_serializes_as_snake_case() {
assert_eq!(
@@ -304,8 +270,6 @@ mod tests {
}
}
// -- AskUserQuestionExtRequest serde --
#[test]
fn ext_request_serializes_camel_case() {
let req = AskUserQuestionExtRequest {
@@ -315,7 +279,6 @@ mod tests {
mode: AskUserQuestionMode::Plan,
};
let json = serde_json::to_value(&req).unwrap();
// camelCase field names
assert!(json.get("sessionId").is_some());
assert!(json.get("toolCallId").is_some());
assert_eq!(json["mode"], "plan");
@@ -337,8 +300,6 @@ mod tests {
assert_eq!(back.mode, AskUserQuestionMode::Default);
}
// -- AskUserQuestionExtResponse serde --
#[test]
fn ext_response_accepted_serializes_tagged() {
let mut answers = IndexMap::new();
@@ -408,7 +369,6 @@ mod tests {
#[test]
fn ext_response_round_trips_all_variants() {
// Accepted
let mut answers = IndexMap::new();
answers.insert("Q1?".to_string(), vec!["A1".to_string()]);
let accepted = AskUserQuestionExtResponse::Accepted {
@@ -419,7 +379,6 @@ mod tests {
let back: AskUserQuestionExtResponse = serde_json::from_str(&json).unwrap();
assert!(matches!(back, AskUserQuestionExtResponse::Accepted { .. }));
// ChatAboutThis
let chat = AskUserQuestionExtResponse::ChatAboutThis {
partial_answers: HashMap::new(),
};
@@ -430,7 +389,6 @@ mod tests {
AskUserQuestionExtResponse::ChatAboutThis { .. }
));
// SkipInterview
let skip = AskUserQuestionExtResponse::SkipInterview {
partial_answers: HashMap::new(),
};
@@ -441,15 +399,12 @@ mod tests {
AskUserQuestionExtResponse::SkipInterview { .. }
));
// Cancelled
let cancel = AskUserQuestionExtResponse::Cancelled;
let json = serde_json::to_string(&cancel).unwrap();
let back: AskUserQuestionExtResponse = serde_json::from_str(&json).unwrap();
assert!(matches!(back, AskUserQuestionExtResponse::Cancelled));
}
// -- into_response conversion --
#[test]
fn into_response_accepted() {
let mut answers = IndexMap::new();
@@ -534,8 +489,6 @@ mod tests {
assert!(matches!(resp, UserQuestionResponse::Cancelled));
}
// -- QuestionAnnotation serde --
#[test]
fn annotation_omits_none_fields() {
let ann = QuestionAnnotation {
@@ -558,8 +511,6 @@ mod tests {
assert_eq!(json["notes"], "note");
}
// -- Backwards-compatible deserialization (string -> vec) --
#[test]
fn deserialize_accepted_old_string_format() {
let raw = r#"{
@@ -594,8 +545,6 @@ mod tests {
}
}
// -- Deserialization from raw JSON (simulating pager responses) --
#[test]
fn deserialize_accepted_from_raw_json() {
let raw = r#"{
@@ -656,8 +605,6 @@ mod tests {
assert!(matches!(resp, AskUserQuestionExtResponse::Cancelled));
}
// -- IndexMap ordering preservation --
#[test]
fn accepted_answers_preserve_insertion_order() {
let mut answers = IndexMap::new();
@@ -670,7 +617,6 @@ mod tests {
annotations: None,
};
// Round-trip through JSON
let json = serde_json::to_string(&resp).unwrap();
let back: AskUserQuestionExtResponse = serde_json::from_str(&json).unwrap();
@@ -1,4 +1,4 @@
//! `run_terminal_cmd` (Bash) tool — new architecture (`Tool` trait).
//! `run_terminal_cmd` (Bash) tool.
//!
//! Executes bash commands in a persistent shell session with optional timeout.
//! Supports both foreground (blocking) and background (returns task_id) execution.
@@ -118,10 +118,6 @@ fn terminal_notification_base(notif: &ToolNotification) -> Option<&BashNotificat
}
}
// ───────────────────────────────────────────────────────────────────────────
// Params
// ───────────────────────────────────────────────────────────────────────────
/// Configuration for the bash tool, stored as `Params<BashParams>` in Resources.
///
/// All fields are optional — `None` means "use the built-in default".
@@ -149,8 +145,8 @@ pub struct BashParams {
/// This never bounds background tasks: `timeout: 0` / omitted in background
/// mode always resolves to `Duration::MAX` regardless of this value (the
/// model owns their lifetime via the background-task tooling; the terminal
/// backend's own hard cap is the only backstop). Unset reproduces prior
/// behavior with a 5-minute foreground default.
/// backend's own hard cap is the only backstop). Unset means a 5-minute
/// foreground default.
#[serde(default)]
pub max_timeout_secs: Option<f64>,
/// Max output chars. None → DEFAULT_TOOL_OUTPUT_CHARS (20k).
@@ -239,10 +235,6 @@ impl crate::types::resources::ResourceType for BashParams {
}
}
// ───────────────────────────────────────────────────────────────────────────
// Input
// ───────────────────────────────────────────────────────────────────────────
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -288,10 +280,6 @@ pub struct BashToolInput {
pub is_background: bool,
}
// ───────────────────────────────────────────────────────────────────────────
// Output
// ───────────────────────────────────────────────────────────────────────────
/// The bash tool can produce either a foreground result or a background task handle.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
#[serde(tag = "type")]
@@ -320,10 +308,6 @@ impl From<BashToolOutput> for crate::types::output::ToolOutput {
}
}
// ───────────────────────────────────────────────────────────────────────────
// DEFAULT prompt formatting
// ───────────────────────────────────────────────────────────────────────────
use crate::util::truncate::format_bytes;
/// Reason a `run_terminal_cmd` child was terminated before it could exit
@@ -442,10 +426,6 @@ pub(crate) fn format_default_prompt(bash: &BashOutput) -> String {
}
}
// ───────────────────────────────────────────────────────────────────────────
// Constants
// ───────────────────────────────────────────────────────────────────────────
// Default upper bound for model-provided *foreground* command timeouts when
// `BashParams.max_timeout_secs` is unset: a transport-safe **5 minutes**.
// Consumers that want longer opt in per session via `max_timeout_secs` — in
@@ -454,14 +434,14 @@ pub(crate) fn format_default_prompt(bash: &BashOutput) -> String {
// ceiling; background tasks (`timeout: 0` / omitted in background mode) are
// always unbounded regardless of this value — the model owns their lifetime via
// the background-task tooling. Absolute safety clamp for configured maxes: 10h.
pub(crate) const DEFAULT_MAX_TIMEOUT_MS: u64 = 300_000; // 5 minutes
pub(crate) const DEFAULT_MAX_TIMEOUT_MS: u64 = 300_000;
const ABSOLUTE_MAX_TIMEOUT_MS: u64 = 36_000_000;
/// Default short FG block before auto-bg when auto_background_on_timeout is on.
/// Matches terminal `FOREGROUND_BLOCK_BUDGET`.
///
/// Currently used by tests / `effective_auto_bg_wait_ms` (description follow-up);
/// production runtime uses the terminal backend default when budget is unset.
#[allow(dead_code)] // description follow-up + tests (not yet model-facing)
/// Only tests and `effective_auto_bg_wait_ms` read it; the production runtime
/// uses the terminal backend default when the budget is unset.
#[allow(dead_code)]
pub(crate) const DEFAULT_FOREGROUND_BLOCK_BUDGET_MS: u64 = 15_000;
/// Internal version discriminant for run_terminal_cmd.
@@ -489,10 +469,6 @@ impl BashVersion {
}
}
// ───────────────────────────────────────────────────────────────────────────
// Background operator detection
// ───────────────────────────────────────────────────────────────────────────
/// Detects only a trailing `&` (after trimming), excluding `&&` and `>&`.
///
/// Used where only a trailing `&` backgrounds a command: the legacy-0.4.10 bash
@@ -643,7 +619,8 @@ fn ends_with_wait_builtin(command: &str) -> bool {
/// The heredoc *body* is consumed separately by `skip_heredoc_body`.
fn parse_heredoc_start(chars: &[char], start: usize) -> Option<(String, bool, usize)> {
let len = chars.len();
let mut i = start + 2; // skip `<<`
// Skip `<<`.
let mut i = start + 2;
// `<<-` strips leading tabs from the body and the closing delimiter.
let strip_tabs = i < len && chars[i] == '-';
@@ -656,8 +633,9 @@ fn parse_heredoc_start(chars: &[char], start: usize) -> Option<(String, bool, us
i += 1;
}
// No delimiter word.
if i >= len || chars[i] == '\n' {
return None; // no delimiter
return None;
}
let delimiter: String;
@@ -668,11 +646,13 @@ fn parse_heredoc_start(chars: &[char], start: usize) -> Option<(String, bool, us
while i < len && chars[i] != '\'' {
i += 1;
}
// Unclosed quote.
if i >= len {
return None; // unclosed quote
return None;
}
delimiter = chars[d_start..i].iter().collect();
i += 1; // skip closing quote
// Skip the closing quote.
i += 1;
} else if chars[i] == '"' {
// Double-quoted delimiter: << "WORD"
i += 1;
@@ -732,14 +712,15 @@ fn skip_heredoc_body(chars: &[char], start: usize, delimiter: &str, strip_tabs:
};
if check == delimiter {
// Skip the `\n` after the delimiter.
if i < len {
i += 1; // skip the `\n` after the delimiter
i += 1;
}
return i;
}
if i < len {
i += 1; // skip `\n`
i += 1;
}
}
@@ -760,13 +741,12 @@ fn contains_background_operator(command: &str) -> bool {
while i < len {
let ch = chars[i];
// ── Backslash escape (honoured everywhere except inside single quotes) ──
// A backslash escape is honoured everywhere except inside single quotes.
if ch == '\\' && !in_single_quote {
i += 2; // skip the escaped character
i += 2;
continue;
}
// ── Quote tracking ──
if ch == '\'' && !in_double_quote {
in_single_quote = !in_single_quote;
i += 1;
@@ -780,7 +760,7 @@ fn contains_background_operator(command: &str) -> bool {
// Newline: if heredocs are pending, skip their bodies.
if ch == '\n' && !in_single_quote && !in_double_quote && !pending_heredocs.is_empty() {
i += 1; // skip the newline
i += 1;
for (delim, strip_tabs) in pending_heredocs.drain(..) {
i = skip_heredoc_body(&chars, i, &delim, strip_tabs);
}
@@ -837,10 +817,6 @@ fn contains_background_operator(command: &str) -> bool {
false
}
// ───────────────────────────────────────────────────────────────────────────
// Self-matching pkill/pgrep detection
// ───────────────────────────────────────────────────────────────────────────
/// Matches a `pkill` / `pgrep` invocation at a command-word position with a
/// `-f`-bearing flag bundle (short cluster `-X*fX*` or long `--full`) and
/// captures the command word plus the first positional argument (the
@@ -958,14 +934,14 @@ fn self_matching_pkill_pattern(command: &str) -> Option<SelfMatchingPkill> {
}
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
const BACKGROUND_TIMEOUT: Duration = Duration::from_secs(86400); // 24 hours
const BACKGROUND_TIMEOUT: Duration = Duration::from_secs(86400);
/// Max time a *non-backgroundable* foreground command may block the turn. Such
/// a command has only its requested `timeout` (up to 10h), so a long timeout
/// would wedge the turn; we clamp and kill at this cap instead. Backgroundable
/// commands use the terminal's `FOREGROUND_BLOCK_BUDGET` instead. Long work
/// should use `background: true`. Env override: `KIGI_MAX_FOREGROUND_BLOCK_MS`.
const MAX_FOREGROUND_BLOCK: Duration = Duration::from_secs(300); // 5 minutes
const MAX_FOREGROUND_BLOCK: Duration = Duration::from_secs(300);
fn max_foreground_block() -> Duration {
std::env::var("KIGI_MAX_FOREGROUND_BLOCK_MS")
@@ -985,10 +961,6 @@ fn clamp_foreground_block(
timeout.min(max_block.max(config_timeout))
}
// ───────────────────────────────────────────────────────────────────────────
// Bare `echo "<msg>"` detection (for statistics + hints in kigi bash)
// ───────────────────────────────────────────────────────────────────────────
/// Tracks usage of bare `echo "<msg>"` (and close variants) inside the bash tool.
///
/// These are often used by the model as a substitute for direct output or
@@ -999,16 +971,6 @@ struct BareEchoHintState {
call_count: usize,
}
/// Returns true for simple "bare echo" commands whose primary purpose appears
/// to be emitting a short literal message (as opposed to scripting, logging
/// with complex formatting, or part of a larger pipeline).
///
/// Heuristics (conservative to start):
/// - Starts with `echo` (possibly with output-control flags -n/-e/-E).
/// - Followed by what looks like a single message token (quoted or bare).
/// - No shell metacharacters indicating chaining, redirection, substitution,
/// or complex post-processing after the message.
///
/// Check whether `rest` (the portion after the command name and any flags)
/// is a simple narration message with no shell metacharacters.
///
@@ -1075,7 +1037,6 @@ fn is_bare_printf(command: &str) -> bool {
return false;
}
let rest = after_prefix.trim_start();
// printf -v var ... is variable assignment, not narration.
if rest.starts_with("-v") || rest.starts_with("--") {
return false;
}
@@ -1136,8 +1097,6 @@ mod bare_echo_tests {
assert!(!is_bare_echo("echotool"));
}
// ── is_bare_printf tests ──
#[test]
fn printf_simple() {
assert!(is_bare_printf(r#"printf "hello\n""#));
@@ -1172,12 +1131,6 @@ mod bare_echo_tests {
}
}
// ───────────────────────────────────────────────────────────────────────────
// Tool implementation
// ───────────────────────────────────────────────────────────────────────────
/// Bash tool — new architecture.
///
/// Executes bash commands via the `Terminal` backend. Supports foreground
/// (blocking) and background (returns task_id) execution modes.
#[derive(Debug, Default)]
@@ -1321,8 +1274,8 @@ impl BashTool {
/// backend's documented default (15s) for this helper — the real process
/// still honors `KIGI_FOREGROUND_BLOCK_BUDGET_MS` via `None` on the request.
///
/// Not yet used in model-facing descriptions (historical auto-bg copy only).
#[allow(dead_code)] // description follow-up + unit tests
/// Not used in model-facing descriptions; only unit tests read it.
#[allow(dead_code)]
pub(crate) fn effective_auto_bg_wait_ms(params: &BashParams) -> Option<u64> {
if !Self::auto_background_on_timeout_enabled(params) {
return None;
@@ -1356,9 +1309,7 @@ impl BashTool {
// `timeout: 0` is always unbounded, so this note is
// unconditional.
let bg_zero = "`timeout: 0` in background mode disables the wrapper timeout entirely; the task runs until it exits or is killed via the kill task tool.";
// Keep main-style auto-bg wording (no FG-budget ms advertised).
// Follow-up: surface effective_auto_bg_wait_ms / FG budget here
// once we deliberately change model-facing copy.
// Auto-bg wording does not advertise the FG block budget ms.
let desc = if auto_bg {
format!(
"Optional timeout in milliseconds (max {max_ms}). Default: {default_ms}. If not specified, commands exceeding the default timeout will be automatically backgrounded. {bg_zero}"
@@ -1394,8 +1345,8 @@ impl BashTool {
Some(desc) => desc,
None => Self::default_description_template(background_enabled),
};
// Template only interpolates max/default timeout numbers + auto_bg flag.
// Do not advertise FG block budget ms here yet (follow-up PR).
// Template only interpolates max/default timeout numbers + auto_bg flag,
// never the FG block budget ms.
let extras = serde_json::json!({
"auto_background_on_timeout": auto_bg,
"max_timeout_ms": Self::effective_max_timeout_ms(params),
@@ -1419,9 +1370,8 @@ impl BashTool {
}
fn default_description_template_enabled() -> &'static str {
// NOTE: auto-bg wording is intentionally the historical main copy (no
// FG-block-budget ms). Runtime auto-bg uses min(timeout, FG budget);
// advertising that wait is a separate description PR.
// The auto-bg wording intentionally omits the FG block budget ms, even
// though runtime auto-bg waits min(timeout, FG budget).
r#"Run a ${%- if is_windows %} shell command${%- else %} bash command${%- endif %} and return its output.
Usage notes:
@@ -1788,7 +1738,6 @@ impl kigi_tool_runtime::Tool for BashTool {
let cwd = crate::types::tool_metadata::resolve_cwd(&ctx, &resources).await?;
let tool_call_id = ctx.call_id.clone();
// --- Read resources ---
let (backend, session_folder, env, notification_handle, owner_session_id) = {
let res = resources.lock().await;
(
@@ -1809,7 +1758,6 @@ impl kigi_tool_runtime::Tool for BashTool {
None => notification_handle,
};
// Read params
let params = resources
.lock()
.await
@@ -1824,7 +1772,6 @@ impl kigi_tool_runtime::Tool for BashTool {
.output_byte_limit
.unwrap_or(DEFAULT_TOOL_OUTPUT_CHARS);
// Use truncation config override if available
let output_byte_limit = resources
.lock()
.await
@@ -1835,7 +1782,6 @@ impl kigi_tool_runtime::Tool for BashTool {
})
.unwrap_or(config_output_byte_limit);
// --- Validate: reject commands that use `&` as a background operator ---
let version = BashVersion::from_contract(
crate::types::tool_metadata::behavior_version(&ctx).as_deref(),
);
@@ -1878,7 +1824,6 @@ impl kigi_tool_runtime::Tool for BashTool {
return Err(kigi_tool_runtime::ToolError::invalid_arguments(message));
}
// --- Validate: reject self-matching pkill/pgrep -f <pat> ---
// `pkill -f` matches the wrapper bash's full argv (which contains
// the literal pattern), causing the wrapper to SIGTERM itself
// before the rest of the script runs. Reject obvious cases at
@@ -1904,21 +1849,18 @@ impl kigi_tool_runtime::Tool for BashTool {
));
}
// --- Prefix ---
let command = Self::get_prefixed_command(&params.cmd_prefix, &input.command);
// No command-wrapping is performed here today; `display_command` is
// No command-wrapping is performed here; `display_command` is
// populated only by tools that intentionally surface a friendlier form
// to the model (e.g. the monitor tool). Bash commands run as-is.
let display_command: Option<String> = None;
// --- Route to foreground or background ---
let output_file = session_folder
.join("terminal")
.join(format!("{}.log", tool_call_id.as_str()));
if input.is_background {
// ─── Background execution ───
// Force unbuffered Python stdout so output reaches files and
// pipes in real time rather than buffering ~8 KB.
let mut env = env;
@@ -1969,7 +1911,6 @@ impl kigi_tool_runtime::Tool for BashTool {
let bg_output_file = handle.output_file;
let bg_pid = handle.pid;
// Send backgrounded notification
let base = BashNotificationBase {
tool_call_id: tool_call_id.as_str().to_owned(),
command: input.command.clone(),
@@ -2010,7 +1951,6 @@ impl kigi_tool_runtime::Tool for BashTool {
pid: bg_pid,
}))
} else {
// ─── Foreground execution ───
let timeout = Self::resolve_effective_timeout_for_params(
input.timeout,
false,
@@ -2037,18 +1977,11 @@ impl kigi_tool_runtime::Tool for BashTool {
notification_handle: notification_handle.clone(),
tool_call_id: tool_call_id.as_str().to_owned(),
display_command,
// Honour `auto_background_on_timeout` regardless of
// whether the model supplied an explicit `timeout`.
// The previous gate (`input.timeout.is_none()`) hard-
// timed out commands with explicit timeouts even when
// the session had opted into auto-bg behavior.
// The relaxed semantics matter here:
// every Shell call carries an explicit `block_until_ms`
// and the harness's observed behavior is to auto-background
// past that deadline rather than kill the process.
// Backwards compatible because
// `auto_background_on_timeout` defaults to `false`;
// existing kigi callers that never opted in are
// Honour `auto_background_on_timeout` even when the model
// supplied an explicit `timeout`: every Shell call carries an
// explicit `block_until_ms`, and the harness auto-backgrounds
// past that deadline rather than killing the process. The flag
// defaults to `false`, so callers that never opt in are
// unaffected.
auto_background_on_timeout: Self::auto_background_on_timeout_enabled(&params),
foreground_block_budget: Self::effective_foreground_block_budget(&params),
@@ -2070,7 +2003,7 @@ impl kigi_tool_runtime::Tool for BashTool {
}
};
// ─── Backgrounded (user Ctrl+G or auto-timeout): return BackgroundTaskStarted ───
// Backgrounded (user Ctrl+G or auto-timeout): return BackgroundTaskStarted.
let auto_backgrounded = result.signal.as_deref() == Some("auto_backgrounded");
if auto_backgrounded || result.signal.as_deref() == Some("backgrounded") {
let base = BashNotificationBase {
@@ -2131,7 +2064,6 @@ impl kigi_tool_runtime::Tool for BashTool {
}));
}
// Send completion notification
let base = BashNotificationBase {
tool_call_id: tool_call_id.as_str().to_owned(),
command: input.command.clone(),
@@ -2172,9 +2104,8 @@ impl kigi_tool_runtime::Tool for BashTool {
};
bash.output_for_prompt = format_default_prompt(&bash);
// Bare `echo "<msg>"` usage (common model anti-pattern for "just output something").
// We tag it for statistics (kigi backend) and can surface an educational
// hint on repeated use.
// Bare `echo "<msg>"` is a common model anti-pattern for "just
// output something"; tag it for statistics and repeated-use hints.
bash.was_bare_echo = is_bare_echo(&bash.command) || is_bare_printf(&bash.command);
if bash.was_bare_echo {
let mut res = resources.lock().await;
@@ -2208,10 +2139,6 @@ impl kigi_tool_runtime::Tool for BashTool {
}
}
// ───────────────────────────────────────────────────────────────────────────
// Tests
// ───────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
@@ -2296,7 +2223,8 @@ mod tests {
// requested `timeout`.
#[test]
fn foreground_block_clamps_explicit_long_timeout() {
let cfg = DEFAULT_TIMEOUT; // 120s session default
// 120s session default.
let cfg = DEFAULT_TIMEOUT;
let cap = MAX_FOREGROUND_BLOCK;
// 10h explicit timeout → clamped to the cap.
assert_eq!(
@@ -2316,8 +2244,6 @@ mod tests {
);
}
// ─── Mock terminal ───
/// Configurable mock terminal backend for testing.
///
/// Stores foreground results as clonable `TerminalRunResult`, and
@@ -2457,8 +2383,6 @@ mod tests {
}
}
// ─── Test helpers ───
fn make_resources(mock: MockTerminal) -> Resources {
make_resources_with_params(mock, BashParams::default())
}
@@ -2473,7 +2397,6 @@ mod tests {
resources.insert(NotificationHandle(ToolNotificationHandle::noop()));
resources.insert(Params(params));
// Add TemplateRenderer with default mappings for background hint and validation
let execute_params =
HashMap::from([("is_background".to_string(), "is_background".to_string())]);
let bg_params = HashMap::from([("task_id".to_string(), "task_id".to_string())]);
@@ -2521,8 +2444,6 @@ mod tests {
}
}
// ─── Streaming (BashTool::execute) test scaffolding ───
//
// `test_ctx` stamps `WorkspaceViewerContext { stream_tool_progress:
// true }` by default, so these tests exercise the streaming path.
@@ -2579,8 +2500,6 @@ mod tests {
}
}
// ─── Streaming tests ───
/// Deterministic regression guard for Key Decision 6 (delta math keyed off
/// the monotonic `total_bytes`, not buffer length). Exercises the common
/// suffix-slice case, the no-new-bytes case, the post-truncation shrinking
@@ -2636,7 +2555,8 @@ mod tests {
fn bash_output_chunk_progress_caps_oversized_delta() {
// Multi-byte chars (`€` = 3 bytes) ensure the cap lands mid-char so we
// exercise the char-boundary back-off, not just a clean byte cut.
let payload_str = "".repeat(7_000); // 21_000 bytes, well over the cap.
// 21_000 bytes, well over the cap.
let payload_str = "".repeat(7_000);
let total = payload_str.len();
assert!(total > MAX_PROGRESS_DELTA_BYTES);
let chunk = BashOutputChunk {
@@ -2966,8 +2886,6 @@ mod tests {
}
}
// ─── Tests ───
#[tokio::test]
async fn foreground_command_success() {
let resources = make_resources(MockTerminal::success("hello world\n", 0));
@@ -3010,7 +2928,8 @@ mod tests {
match result {
BashToolOutput::Foreground(bash) => {
assert!(bash.timed_out);
assert_eq!(bash.exit_code, -1); // no exit code when timed out
// No exit code when timed out.
assert_eq!(bash.exit_code, -1);
}
BashToolOutput::Background(_) => panic!("Expected foreground output"),
}
@@ -3110,8 +3029,7 @@ mod tests {
let resources = make_resources_reject_bg_op(MockTerminal::success("", 0));
let tool = BashTool;
// Previously this would pass because the old check only looked at
// trailing `&`. Now the parser detects mid-command `&` too.
// The parser detects a mid-command `&`, not just a trailing one.
let result = kigi_tool_runtime::Tool::run(
&tool,
test_ctx(resources.into_shared()),
@@ -3202,9 +3120,8 @@ mod tests {
}
/// Enabled-background `&` rejection must name the real param resolved from
/// the template (`is_background`), never a blank "set =true". Regression: the
/// template previously used the non-existent `params.execute.background` key,
/// which resolved to "".
/// the template (`is_background`), never a blank "set =true". Regression
/// guard: the non-existent `params.execute.background` key resolves to "".
#[cfg(unix)]
#[tokio::test]
async fn background_operator_rejection_names_is_background_param() {
@@ -3229,7 +3146,6 @@ mod tests {
#[tokio::test]
async fn cmd_prefix_prepended() {
// We can test this via the static helper
assert_eq!(
BashTool::get_prefixed_command(&Some("source ~/.bashrc".to_string()), "ls"),
"source ~/.bashrc && ls"
@@ -3297,10 +3213,6 @@ mod tests {
}
}
// -----------------------------------------------------------------------
// format_default_prompt tests
// -----------------------------------------------------------------------
fn make_bash_output(exit_code: i32, output: &str) -> BashOutput {
let mut bash = BashOutput {
output: output.as_bytes().to_vec(),
@@ -3456,16 +3368,12 @@ mod tests {
);
}
// ─── contains_background_operator unit tests ───
mod background_operator_tests {
use super::super::{
command_has_bash_background_operator, contains_background_operator,
contains_unwaited_background_operator,
};
// ── Should detect (true) ──
#[test]
fn trailing_ampersand() {
assert!(contains_background_operator("sleep 600 &"));
@@ -3527,8 +3435,6 @@ mod tests {
assert!(contains_background_operator("cmd > out.txt &"));
}
// ── Should NOT detect (false) ──
#[test]
fn no_ampersand() {
assert!(!contains_background_operator("echo hello"));
@@ -3616,8 +3522,6 @@ mod tests {
assert!(!contains_background_operator("\"\\&\""));
}
// ── Mixed cases ──
#[test]
fn logical_and_then_background() {
// `echo a && sleep 10 &` — the trailing `&` IS a background op.
@@ -3636,8 +3540,6 @@ mod tests {
assert!(contains_background_operator("echo '&' & echo \"&\""));
}
// ── contains_unwaited_background_operator (combined check) ──
#[test]
fn unwaited_trailing_ampersand_rejected() {
assert!(contains_unwaited_background_operator("sleep 600 &"));
@@ -3700,7 +3602,7 @@ mod tests {
assert!(contains_unwaited_background_operator("sleep 600 & await"));
}
// ── Heredoc: `&` inside heredoc bodies must not be detected ──
// A `&` inside a heredoc body must not be detected.
#[test]
fn heredoc_single_quoted_delimiter() {
@@ -3819,8 +3721,6 @@ mod tests {
}
}
// ─── resolve_effective_timeout unit tests ───
mod resolve_effective_timeout_tests {
use super::super::{BashParams, BashTool, DEFAULT_MAX_TIMEOUT_MS, DEFAULT_TIMEOUT};
use std::time::Duration;
@@ -3878,7 +3778,8 @@ mod tests {
assert_eq!(
BashTool::resolve_effective_timeout_for_params(
Some(20 * 60 * 1_000),
true, // background
// background
true,
super::super::BACKGROUND_TIMEOUT,
&params,
),
@@ -3982,8 +3883,6 @@ mod tests {
}
}
// ─── FG block budget + schema description unit tests ───
mod foreground_block_budget_tests {
use super::*;
use std::time::Duration;
@@ -4172,8 +4071,6 @@ mod tests {
}
}
// ─── self_matching_pkill_pattern unit tests ───
mod self_matching_pkill_tests {
use super::super::self_matching_pkill_pattern;
@@ -4181,8 +4078,6 @@ mod tests {
self_matching_pkill_pattern(command).map(|h| (h.cmd, h.pattern))
}
// ── Should detect (Some) ──
#[test]
fn pkill_then_run_self() {
assert_eq!(
@@ -4231,8 +4126,6 @@ mod tests {
);
}
// ── Should NOT detect (None) ──
#[test]
fn pkill_alone_no_later_reference() {
assert!(self_matching_pkill_pattern("pkill -f ./clavitor-web").is_none());
@@ -4303,8 +4196,6 @@ mod tests {
}
}
// ─── Legacy trailing-only `&` detection ───
mod legacy_trailing_ampersand {
use super::*;
@@ -4345,8 +4236,6 @@ mod tests {
}
}
// ─── PowerShell `&` detection + remediation ───
mod powershell_background_check {
use super::*;
@@ -4400,8 +4289,6 @@ mod tests {
}
}
// ─── Shell-aware gate decision (pure, all platforms) ───
mod gate_decision {
use super::*;
@@ -4526,8 +4413,6 @@ mod tests {
}
}
// ─── Versioned `&` detection integration ───
mod versioned_background_check {
use super::*;
@@ -4621,8 +4506,6 @@ mod tests {
}
}
// ─── Description template shell-awareness tests ───
//
// Exercises the `${%- if has_unix_utilities %}` branch — fix for
// `'grep' is not recognized` on PowerShell / cmd.exe. We can't
// toggle the host shell from a unit test, so we render the template
@@ -1,6 +1,5 @@
//! Stub surface when the deploy feature is off.
/// Placeholder config — deploy is unavailable in this build.
#[derive(Debug, Clone, Default)]
pub enum AppBuilderDeployerConfig {
#[default]
@@ -1,19 +1,13 @@
//! `EnterPlanMode` tool — new architecture (`Tool` trait).
//!
//! Gateway tool that the agent calls when it decides a task is complex enough
//! to warrant a planning phase before writing code. This is the
//! **agent-initiated** entry path into plan mode.
//! `EnterPlanMode` tool: the agent-initiated entry path into plan mode.
//!
//! On success it notifies orchestration (`PlanModeEntered`) and seeds an empty
//! session plan file if missing (never truncating existing content), so the
//! model can read it before writing. Read-only enforcement and plan-file gating
//! stay in orchestration.
//!
//! ## User Consent
//!
//! This tool requires user approval before executing. The UI should present a
//! confirmation dialog. If the user declines, the tool result is rejected and
//! the model receives `"User declined to enter plan mode."`.
//! Execution requires user approval: the UI presents a confirmation dialog, and
//! on decline the tool result is rejected and the model receives
//! `"User declined to enter plan mode."`.
use crate::computer::types::AsyncFileSystem;
use crate::notification::types::PlanModeEntered;
@@ -27,18 +21,12 @@ use crate::types::tool::{ToolKind, ToolNamespace};
use std::path::Path;
use std::sync::Arc;
/// Input for the `EnterPlanMode` tool.
///
/// Empty object — no parameters. The decision to enter plan mode is a binary
/// gate. All configuration (workflow variant, explore agent count, etc.) comes
/// from feature flags and environment variables, not from the tool call.
/// Deliberately empty: entering plan mode is a binary gate, and every knob
/// (workflow variant, explore agent count, etc.) comes from feature flags and
/// environment variables rather than the tool call.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct EnterPlanModeInput {}
/// `EnterPlanMode` tool: signals plan mode entry and seeds the session plan
/// file, returning a [`PlanFileSeedStatus`].
///
/// Params: `()` — no per-tool configuration.
#[derive(Debug, Default)]
pub struct EnterPlanModeTool;
@@ -111,7 +99,6 @@ impl kigi_tool_runtime::Tool for EnterPlanModeTool {
let (seed_target, plan_file_path, tool_hints, fs) = {
let res = resources.lock().await;
// Send notification first.
if let Some(handle) = res.get::<NotificationHandle>() {
handle.0.send_plan_mode_entered(PlanModeEntered {
tool_call_id: ctx.call_id.as_str().to_owned(),
@@ -120,7 +107,6 @@ impl kigi_tool_runtime::Tool for EnterPlanModeTool {
let (seed_target, plan_file_path) = resolve_plan_file_path(&res);
// Resolve client-facing tool names via TemplateRenderer.
let hints = if let Some(renderer) = res.get::<TemplateRenderer>() {
EnterPlanModeToolHints {
ask_user: renderer
@@ -171,10 +157,8 @@ impl kigi_tool_runtime::Tool for EnterPlanModeTool {
}
}
/// Probe the plan file; create an empty one only on not-found.
///
/// Never truncates existing content. Non-NotFound read errors fail closed as
/// [`PlanFileSeedStatus::Missing`] without calling `write_file`.
/// Never truncates existing content: a read error other than `NotFound` fails
/// closed as [`PlanFileSeedStatus::Missing`] without calling `write_file`.
async fn probe_or_create_empty_plan_file(
fs: &dyn AsyncFileSystem,
path: &Path,
@@ -196,8 +180,8 @@ async fn probe_or_create_empty_plan_file(
}
}
Err(e) => {
// Non-NotFound read error: a directory at the path reads as IsADirectory;
// anything else is treated as inaccessible. Never write (avoid truncate risk).
// The path may hold real content we simply could not read, so
// report the failure instead of writing over it.
let reason = match e.io_error_kind() {
Some(std::io::ErrorKind::IsADirectory) => PlanFileSeedFailure::NotAFile,
_ => PlanFileSeedFailure::Inaccessible,
@@ -234,8 +218,8 @@ mod tests {
(resources, plan)
}
/// Parametrized FS mock: injects the read/write outcomes and counts calls
/// so a test can assert the tool never touched the FS.
/// Injects fixed read/write outcomes and counts calls, so a test can assert
/// the tool never touched the FS.
struct ProbeMockFs {
read: Result<Vec<u8>, ComputerError>,
write: Result<(), ComputerError>,
@@ -587,8 +571,6 @@ mod tests {
);
}
// -- PlanFilePath resource tests --
#[tokio::test]
async fn uses_plan_file_path_resource_when_set() {
let mut resources = Resources::new();
@@ -1,23 +1,9 @@
//! `ExitPlanMode` tool — new architecture (`Tool` trait).
//! `ExitPlanMode` tool: signals that the agent has finished planning.
//!
//! Signals that the agent has finished planning and is ready for the user to
//! review and approve the plan. The tool reads the plan file from disk (it does
//! NOT accept plan content as input) and surfaces it via:
//!
//! 1. A `PlanModeExited` **notification** sent to the gateway/client, carrying
//! the plan content so the client can present it for user approval.
//! 2. A structured **`ExitPlanModeOutput`** returned to the model, containing
//! the plan content (or an empty-plan message).
//!
//! The actual approval flow (yes/no with feedback, context clear, mode
//! transition) happens on the client side — this tool just says "I'm done,
//! here's the plan."
//!
//! ## Plan File
//!
//! The plan file path defaults to `.kigi/plan.md` relative to the session
//! `Cwd`. The tool reads it via the `FileSystem` resource (the same async FS
//! abstraction used by `ReadFile` and `SearchReplace`).
//! The plan is read from disk (path defaults to `.kigi/plan.md` relative to the
//! session `Cwd`) and surfaced both as a `PlanModeExited` notification and as
//! the structured output returned to the model. The approval flow itself
//! (yes/no with feedback, context clear, mode transition) lives on the client.
pub mod types;
@@ -29,23 +15,12 @@ use crate::types::requirements::{Expr, ToolRequirement};
use crate::types::resources::{FileSystem, NotificationHandle, require_plan_file_path};
use crate::types::tool::{ToolKind, ToolNamespace};
/// Input for the `ExitPlanMode` tool.
///
/// Empty object — the plan is read from the plan file on disk, NOT passed as
/// a parameter. This ensures the user sees exactly what was written to disk,
/// preventing divergence between the model's in-context plan and the actual
/// file content.
/// Deliberately empty: the plan is read from disk rather than passed in, so the
/// user sees exactly what was written and the model's in-context plan cannot
/// diverge from the file.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct ExitPlanModeInput {}
/// `ExitPlanMode` tool.
///
/// Reads the plan file from disk and signals to the orchestration layer that
/// the agent is done planning. The client receives a `PlanModeExited`
/// notification with the plan content and is responsible for presenting the
/// approval UI.
///
/// Params: `()` — no per-tool configuration.
#[derive(Debug, Default)]
pub struct ExitPlanModeTool;
@@ -123,7 +98,6 @@ impl kigi_tool_runtime::Tool for ExitPlanModeTool {
let (plan_path, plan_file_path_display) = require_plan_file_path(&res)?;
// Read the plan file from disk via the FileSystem abstraction.
let content = if let Some(fs) = res.get::<FileSystem>() {
match fs.0.read_file(&plan_path).await {
Ok(bytes) => {
@@ -137,7 +111,6 @@ impl kigi_tool_runtime::Tool for ExitPlanModeTool {
Err(_) => None,
}
} else {
// Fallback: try tokio::fs if no FileSystem resource is available.
match tokio::fs::read_to_string(&plan_path).await {
Ok(text) if !text.trim().is_empty() => Some(text),
_ => None,
@@ -147,7 +120,6 @@ impl kigi_tool_runtime::Tool for ExitPlanModeTool {
(plan_file_path_display, content)
};
// Notify the gateway / client.
{
let res = resources.lock().await;
if let Some(handle) = res.get::<NotificationHandle>() {
@@ -263,7 +235,6 @@ mod tests {
assert!(message.contains("start coding"));
assert!(plan_content.contains("Do thing A"));
assert!(plan_content.contains("Do thing B"));
// Cwd fallback now displays the resolved absolute path (shared resolver).
assert!(plan_file_path.ends_with(".kigi/plan.md"));
}
other => panic!("Expected PlanReady, got {:?}", other),
@@ -405,8 +376,6 @@ mod tests {
assert!(prompt.contains("## Plan:"));
}
// -- PlanFilePath resource tests --
#[tokio::test]
async fn reads_from_plan_file_path_resource() {
let tmp = TempDir::new().unwrap();
@@ -3,9 +3,7 @@
//! Shared between the shell (serializer) and the pager/desktop/VS Code
//! (deserializer) so both sides stay in sync.
/// ACP `ext_method` request payload (shell coordinator sends to client/pager).
///
/// Serialized as `camelCase` for the ACP JSON-RPC wire format.
/// Request payload the shell coordinator sends to the client/pager.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExitPlanModeExtRequest {
@@ -14,7 +12,7 @@ pub struct ExitPlanModeExtRequest {
pub plan_content: Option<String>,
}
/// ACP `ext_method` response payload (client/pager returns to shell coordinator).
/// Response payload the client/pager returns to the shell coordinator.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ExitPlanModeExtResponse {
/// `"approved"`, `"cancelled"`, or `"abandoned"`.
@@ -39,7 +37,6 @@ mod tests {
assert!(json.get("sessionId").is_some());
assert!(json.get("toolCallId").is_some());
assert!(json.get("planContent").is_some());
// Must NOT contain snake_case keys
assert!(json.get("session_id").is_none());
assert!(json.get("tool_call_id").is_none());
assert!(json.get("plan_content").is_none());
@@ -1,10 +1,5 @@
//! `grep` tool — new architecture (`Tool` trait).
//!
//! Wraps ripgrep to search file contents. Reads `Cwd` from Resources and
//! truncation settings from its own `Params<GrepParams>`.
//!
//! The ripgrep binary resolution logic (`rg_path()`) is shared with the
//! old implementation via `implementations::grep::ripgrep`.
//! `grep` tool: wraps ripgrep to search file contents. Reads `Cwd` from
//! Resources and truncation settings from its own `Params<GrepParams>`.
use std::process::Stdio;
use std::sync::LazyLock;
@@ -23,15 +18,10 @@ use crate::types::resources::{
use crate::types::tool::{ToolKind, ToolNamespace};
use crate::util::truncate::truncate_line;
// ───────────────────────────────────────────────────────────────────────────
// Input
// ───────────────────────────────────────────────────────────────────────────
use serde::{Deserialize, Serialize};
pub mod ripgrep;
// Re-export the shared KigiIntegerSchema from types module
pub use crate::types::KigiIntegerSchema;
use ripgrep::rg_path;
@@ -127,28 +117,17 @@ pub struct GrepSearchInput {
pub multiline: Option<bool>,
}
// ───────────────────────────────────────────────────────────────────────────
// Params
// ───────────────────────────────────────────────────────────────────────────
/// Per-tool configuration for `grep`.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GrepParams {
/// Maximum output size in bytes before truncation.
/// Defaults to `DEFAULT_TOOL_OUTPUT_BYTES` (40 KB) when `None`.
/// `None` means `DEFAULT_TOOL_OUTPUT_BYTES` (40 KB).
pub max_output_bytes: Option<usize>,
/// Maximum characters per line before truncation.
/// Defaults to 1000 when `None`.
/// `None` means `DEFAULT_MAX_CHARS_PER_LINE`.
pub max_chars_per_line: Option<usize>,
}
crate::register_resource!("kigi", "Grep", GrepParams);
// ───────────────────────────────────────────────────────────────────────────
// Constants
// ───────────────────────────────────────────────────────────────────────────
/// Hard max when the model passes an explicit `head_limit` (content lines).
const CONTENT_LINE_LIMIT: usize = 2_000;
/// Default when `head_limit` is omitted (content). Chosen near observed agent
@@ -170,13 +149,11 @@ const MAX_STDOUT_BYTES: usize = 5_000_000;
/// and discard the already-buffered matches via `grep_timeout_output`.
const EXACT_FIT_PROBE_TIMEOUT: Duration = Duration::from_millis(100);
/// Default grep wall-clock timeout (seconds) on non-WSL platforms.
const GREP_TIMEOUT_DEFAULT_SECS: u64 = 20;
/// Grep wall-clock timeout (seconds) under WSL, where filesystem reads are 3-5x slower.
/// Higher than the default because WSL filesystem reads are 3-5x slower.
const GREP_TIMEOUT_WSL_SECS: u64 = 60;
/// Grep's wall-clock timeout in whole seconds: 60s on WSL (slow filesystem), 20s elsewhere.
fn grep_timeout_secs(is_wsl: bool) -> u64 {
if is_wsl {
GREP_TIMEOUT_WSL_SECS
@@ -185,13 +162,10 @@ fn grep_timeout_secs(is_wsl: bool) -> u64 {
}
}
/// Grep's wall-clock timeout for the current platform.
fn grep_timeout() -> Duration {
Duration::from_secs(grep_timeout_secs(kigi_tty_utils::is_wsl()))
}
/// Resolve the effective line/entry budget for this call.
///
/// Always returns a finite limit so we can stop reading (and kill `rg`) once
/// enough output is in hand — even when the model omits `head_limit`.
fn resolve_effective_head_limit(input: &GrepSearchInput, output_mode: &OutputMode) -> usize {
@@ -204,9 +178,9 @@ fn resolve_effective_head_limit(input: &GrepSearchInput, output_mode: &OutputMod
/// Hard `head_limit` ceiling for a mode (what an explicit limit is clamped to).
///
/// Callers that paginate over the full underlying result themselves
/// must request this instead of `head_limit: None`, which
/// now resolves to the small omitted-`head_limit` default and kills `rg` early.
/// Callers that paginate over the full underlying result themselves must
/// request this rather than `head_limit: None`, which resolves to the small
/// omitted-`head_limit` default and kills `rg` early.
pub fn max_head_limit(output_mode: &OutputMode) -> usize {
match output_mode {
OutputMode::Content => CONTENT_LINE_LIMIT,
@@ -214,10 +188,9 @@ pub fn max_head_limit(output_mode: &OutputMode) -> usize {
}
}
/// grep's capabilities incl. its streaming spec (single source of truth).
/// grep streams the formatted card body (`PlainText` / `Append`), never raw
/// stdout; the `<workspace_result …>` wrapper and "Found N …" summary are a
/// terminal-only footer, so the stream is a faithful prefix of the card body.
/// grep streams the formatted card body, never raw stdout; the
/// `<workspace_result …>` wrapper and "Found N …" summary are terminal-only, so
/// the stream stays a faithful prefix of the card body.
static GREP_CAPABILITIES: LazyLock<kigi_tool_protocol::ToolCapabilities> =
LazyLock::new(|| kigi_tool_protocol::ToolCapabilities {
is_read_only: true,
@@ -229,10 +202,6 @@ static GREP_CAPABILITIES: LazyLock<kigi_tool_protocol::ToolCapabilities> =
..Default::default()
});
// ───────────────────────────────────────────────────────────────────────────
// Tool implementation
// ───────────────────────────────────────────────────────────────────────────
#[derive(Debug, Default)]
pub struct GrepTool;
@@ -275,38 +244,34 @@ impl kigi_tool_runtime::Tool for GrepTool {
}
fn capabilities(&self) -> kigi_tool_protocol::ToolCapabilities {
// Clone of `GREP_CAPABILITIES`; read at registration time only.
GREP_CAPABILITIES.clone()
}
/// Streaming entry point. Gate OFF (default): byte-for-byte the blocking
/// [`GrepTool::run`] contract. Gate ON: spawn ripgrep, project each match
/// line via [`BodyStreamer`] (same projection [`finalize_grep`] re-derives
/// in batch) and emit `grep_match_chunk` deltas — the stream is a faithful
/// prefix of the terminal card body. Gated by
/// `WorkspaceViewerContext::stream_tool_progress`.
/// Streaming entry point, gated by
/// `WorkspaceViewerContext::stream_tool_progress`. Gate off: defers to the
/// blocking [`GrepTool::run`]. Gate on: spawns ripgrep and projects each
/// match line via [`BodyStreamer`] — the same projection [`finalize_grep`]
/// re-derives in batch, so the deltas are a faithful prefix of the terminal
/// card body.
async fn execute(
&self,
ctx: kigi_tool_runtime::ToolCallContext,
input: GrepSearchInput,
) -> kigi_tool_runtime::ToolStream<GrepSearchOutput> {
// Absent extension or spec ⇒ gate off. `Some(spec)` iff the gate is
// on; the spec borrow is `'static` (LazyLock), so it moves straight
// into the stream below.
// Absent extension or spec ⇒ gate off. The spec borrow is `'static`
// (LazyLock), so it moves straight into the stream below.
let admitted_spec = ctx
.get::<kigi_tool_runtime::WorkspaceViewerContext>()
.zip(GREP_CAPABILITIES.streaming.as_ref())
.filter(|(vctx, _)| vctx.stream_tool_progress)
.map(|(_, spec)| spec);
// Fast path: gate off ⇒ run the blocking implementation and wrap its
// single result. Identical to the pre-streaming contract.
let Some(spec) = admitted_spec else {
return kigi_tool_runtime::terminal_only(self.run(ctx, input).await);
};
// `tool.grep` span matching `run`'s; a guard can't be held across
// the stream's await points, so the handle is scoped explicitly.
// A span guard can't be held across the stream's await points, so the
// handle is passed down and entered explicitly instead.
let span = tracing::info_span!(
"tool.grep",
timed_out = tracing::field::Empty,
@@ -351,11 +316,10 @@ impl kigi_tool_runtime::Tool for GrepTool {
let timeout = grep_timeout();
let io_result = tokio::time::timeout(timeout, async {
// Read stdout until EOF, byte cap, or one line past the budget.
// Reading `effective_head_limit + 1` lines lets us distinguish an
// exact-fit result (not truncated) from an overflowing one, so we
// never flag truncation when there are exactly `effective_head_limit`
// lines — matching `finalize_grep`'s `> limit` check.
// `+ 1`: reading one line past the budget distinguishes an
// exact-fit result (not truncated) from an overflowing one, so a
// result of exactly `effective_head_limit` lines is never flagged
// truncated — matching `finalize_grep`'s `> limit` check.
let (stdout_buf, stdout_truncated) = if let Some(stdout_pipe) = stdout_pipe {
read_rg_stdout_capped(stdout_pipe, config.effective_head_limit.saturating_add(1))
.await
@@ -368,13 +332,12 @@ impl kigi_tool_runtime::Tool for GrepTool {
// `rg` only observes that on its next match write; until then it holds
// stderr open, so `read_to_end` would block until `rg` exits or the
// outer timeout fires — the latter returns `grep_timeout_output` and
// drops the matches we already buffered (the same failure the
// discards the already-buffered matches (the same failure the
// exact-fit probe bound guards against, one step later).
if stdout_truncated {
let _ = child.start_kill();
}
// Read stderr (always small).
let mut stderr_buf = Vec::new();
if let Some(stderr_pipe) = stderr_pipe {
let _ = stderr_pipe
@@ -400,8 +363,8 @@ impl kigi_tool_runtime::Tool for GrepTool {
}
};
// `rg` was already killed inside the timeout block when `stdout_truncated`
// (before the stderr drain); just reap it here.
// When `stdout_truncated`, `rg` is already killed (inside the timeout
// block, before the stderr drain); this only reaps it.
let status = child.wait().await.ok();
let exit_code = if stdout_truncated {
0
@@ -429,8 +392,6 @@ impl kigi_tool_runtime::Tool for GrepTool {
}
}
/// Streaming grep pipeline: spawn ripgrep, project each match line via
/// `BodyStreamer`, and emit deltas before the terminal card.
fn grep_progress_stream(
ctx: kigi_tool_runtime::ToolCallContext,
input: GrepSearchInput,
@@ -447,8 +408,8 @@ fn grep_progress_stream(
} = match prepare_grep(&ctx, &input).await {
Ok(GrepStep::Ready(ready)) => ready,
Ok(GrepStep::Early(out)) => {
// Mirror `run`'s Early arm so path-not-found / spawn short-circuits
// still populate the `tool.grep` span in the streaming (prod) path.
// Mirrors `run`'s Early arm so path-not-found / spawn
// short-circuits still populate the `tool.grep` span here.
span.record("wall_ms", stream_started.elapsed().as_millis() as u64);
span.record("early_kill", false);
yield kigi_tool_runtime::ToolStreamItem::Terminal(Ok(out));
@@ -460,24 +421,22 @@ fn grep_progress_stream(
}
};
// Raw bytes for the authoritative terminal card.
span.record("effective_head_limit", config.effective_head_limit as u64);
// Raw bytes, kept for the authoritative terminal card.
let mut stdout_buf = Vec::with_capacity(MAX_STDOUT_BYTES.min(65_536));
let mut stdout_truncated = false;
// Incremental card-body formatter (deltas == terminal body).
let mut streamer = BodyStreamer::new(spec, &config);
let mut timed_out = false;
// Complete newlines accepted into `stdout_buf` (same budget as
// `read_rg_stdout_capped` / `finalize_grep`).
let mut complete_lines = 0usize;
// One deadline shared by stdout loop + stderr drain (same total
// budget as `run`).
// One deadline shared by the stdout loop and the stderr drain, so the
// total budget matches `run`'s.
let timeout = grep_timeout();
let deadline_at = tokio::time::Instant::now() + timeout;
if let Some(mut stdout_pipe) = stdout_pipe {
let mut tmp = [0u8; 8192];
// Deadline rides the `select!` (can't wrap a yielding block).
// The deadline rides the `select!` because `tokio::time::timeout`
// cannot wrap a block that yields.
let deadline = tokio::time::sleep_until(deadline_at);
tokio::pin!(deadline);
loop {
@@ -493,8 +452,6 @@ fn grep_progress_stream(
Ok(n) => n,
Err(_) => break,
};
// Mirror `run`'s hard byte + line caps when filling
// `stdout_buf`, then kill so rg stops walking the tree.
// `+ 1`: read one line past the budget so truncation is
// only flagged when there are genuinely MORE than
// `effective_head_limit` lines (matches `run` /
@@ -514,23 +471,21 @@ fn grep_progress_stream(
stdout_buf.extend_from_slice(&tmp[..accepted]);
}
// Project + emit each newly completed line BEFORE the
// exact-fit probe below: the probe reads into `tmp`,
// overwriting the just-accepted bytes, so feeding after
// it would stream corrupted data (the terminal card is
// rebuilt from `stdout_buf`, but streamed deltas must
// stay a faithful prefix of it).
// Must run BEFORE the exact-fit probe below: the probe
// reads into `tmp`, overwriting the just-accepted bytes,
// so feeding afterwards would stream corrupted data.
for p in streamer.feed(&tmp[..accepted]) {
yield kigi_tool_runtime::ToolStreamItem::Progress(p);
}
if hit_cap {
// Same short exact-fit probe as `read_rg_stdout_capped`.
// Use ONLY `EXACT_FIT_PROBE_TIMEOUT` — never the shared
// tool `deadline_at`. Clamping the probe to `deadline_at`
// and setting `timed_out` on expiry would force the
// timeout terminal branch (banner, exit -1) for a
// normal head-limit fill near the wall-clock edge.
// Same exact-fit probe as `read_rg_stdout_capped`,
// bounded ONLY by `EXACT_FIT_PROBE_TIMEOUT` — never
// by the shared `deadline_at`. Clamping it to
// `deadline_at` and setting `timed_out` on expiry
// would force the timeout terminal branch (exit -1)
// for an ordinary head-limit fill that happens to
// land near the wall-clock edge.
if accepted < n {
stdout_truncated = true;
} else {
@@ -543,17 +498,17 @@ fn grep_progress_stream(
Ok(Ok(0)) => stdout_truncated = false,
Ok(Ok(_)) => stdout_truncated = true,
Ok(Err(_)) => stdout_truncated = true,
// Probe budget only: head-limit truncation path
// (keep buffer, kill `rg` below). Never set
// `timed_out` here.
// Probe budget only — treat as head-limit
// truncation (keep the buffer, kill `rg`
// below); never set `timed_out` here.
Err(_elapsed) => stdout_truncated = true,
}
}
}
// Also stop once the formatted body has hit its own
// head/byte budget (may trip before raw line count when
// max_output_bytes is small).
// The formatted body has its own head/byte budget, which
// can trip before the raw line count when
// `max_output_bytes` is small.
if hit_cap || streamer.done {
if streamer.done {
stdout_truncated = true;
@@ -575,9 +530,9 @@ fn grep_progress_stream(
});
let _ = child.start_kill();
let _ = child.wait().await;
// Timeout: finalize what was read (marked truncated) plus an
// explicit notice, so the stream isn't contradicted; with
// nothing streamed, fall back to the timeout-only card.
// Finalize what was read (marked truncated) plus an explicit
// notice, so the terminal card does not contradict what was already
// streamed; with nothing streamed, the timeout-only card suffices.
if stdout_buf.is_empty() {
yield kigi_tool_runtime::ToolStreamItem::Terminal(Ok(grep_timeout_output(secs)));
} else {
@@ -600,7 +555,6 @@ fn grep_progress_stream(
}
span.record("timed_out", false);
// Flush the final non-terminated segment (see `BodyStreamer::finish`).
if let Some(p) = streamer.finish() {
yield kigi_tool_runtime::ToolStreamItem::Progress(p);
}
@@ -608,14 +562,13 @@ fn grep_progress_stream(
// Kill the child **before** draining stderr when we stopped early
// (byte/line/format cap); rg may still be walking the tree and only
// notices the closed stdout on its next write, so a stderr drain first
// would stall until the deadline (up to the full timeout) even though we
// already have a full budget.
// would stall until the deadline even though a full budget is in hand.
if stdout_truncated {
let _ = child.start_kill();
}
// stderr is small and never streamed; still bounded by the shared
// deadline as a backstop so a wedged child can't stall the stream.
// Bounded by the shared deadline as a backstop so a wedged child cannot
// stall the stream.
let mut stderr_buf = Vec::new();
if let Some(stderr_pipe) = stderr_pipe {
let _ = tokio::time::timeout_at(
@@ -651,20 +604,15 @@ fn grep_progress_stream(
})
}
// ───────────────────────────────────────────────────────────────────────────
// Execution helpers (shared by `run` and `execute`)
// ───────────────────────────────────────────────────────────────────────────
/// Formatting/projection knobs resolved once in [`prepare_grep`] and consumed by
/// both the streamed body ([`BodyStreamer`]) and the terminal card
/// ([`finalize_grep`]) so the two never drift.
/// Formatting knobs resolved once in [`prepare_grep`] and consumed by both the
/// streamed body ([`BodyStreamer`]) and the terminal card ([`finalize_grep`]),
/// so the two projections never drift.
struct GrepFormatConfig {
output_mode: OutputMode,
/// Line/entry budget: model `head_limit` clamped to the per-mode cap, or
/// the per-mode default when omitted. Always finite so we can kill `rg`
/// once enough output is collected.
/// Model `head_limit` clamped to the per-mode cap, or the per-mode default
/// when omitted. Always finite so `rg` can be killed once enough output is
/// collected.
effective_head_limit: usize,
/// Per-line truncation width (`trim_line`).
max_chars_per_line: usize,
/// Cumulative body byte cap.
max_output_bytes: usize,
@@ -672,7 +620,6 @@ struct GrepFormatConfig {
cwd_display: String,
}
/// A spawned ripgrep ready to be read, plus the resolved formatting config.
struct GrepReady {
child: Child,
stdout_pipe: Option<ChildStdout>,
@@ -680,16 +627,14 @@ struct GrepReady {
config: GrepFormatConfig,
}
/// Outcome of [`prepare_grep`]: either a spawned process to read, or a fully
/// formed early result (path-not-found / spawn failure) that needs no reading.
#[allow(clippy::large_enum_variant)]
enum GrepStep {
Ready(GrepReady),
/// A fully formed result (path-not-found / spawn failure) that needs no
/// reading.
Early(GrepSearchOutput),
}
/// Resolve resources, build the ripgrep command, and spawn it; `Early` for
/// pre-read short-circuits. Shared by `run` and `execute`.
async fn prepare_grep(
ctx: &kigi_tool_runtime::ToolCallContext,
input: &GrepSearchInput,
@@ -708,24 +653,20 @@ async fn prepare_grep(
)
};
// Resolve the model-provided path for the working directory.
let workdir = resolve_model_path(
&cwd,
display_cwd.as_deref(),
input.path.as_deref().unwrap_or(""),
);
// Use display_cwd for output paths so model sees stable paths.
// Output paths are rendered against `display_cwd` so the model sees stable
// paths regardless of the real cwd.
let display_base = display_cwd_or_cwd(&cwd, display_cwd.as_deref());
let cwd_display = display_base.display().to_string();
// Pre-check: if the search path doesn't exist, return enriched hints
// before rg runs. We intentionally pre-check with metadata() rather
// than parsing rg's stderr after the fact because rg lumps all errors
// under exit code 2 (path not found, invalid regex, bad glob, unknown
// file type, etc.). Distinguishing path-not-found would require
// matching on OS error strings in stderr, which is fragile. The
// pre-check avoids that and keeps the exit-code-2 handler below
// unchanged for all other rg error classes.
// Path-not-found is detected up front with `metadata()` rather than by
// parsing rg's stderr, because rg lumps every error under exit code 2 (path
// not found, invalid regex, bad glob, unknown file type, …); telling them
// apart would mean matching OS error strings, which is fragile.
if input.path.is_some()
&& let Err(e) = tokio::fs::metadata(&workdir).await
&& e.kind() == std::io::ErrorKind::NotFound
@@ -778,8 +719,8 @@ async fn prepare_grep(
// Managed Read-deny globs become ripgrep excludes so a search never reads
// a policy-forbidden path — whether reached by a recursive walk or by a
// `glob` arg that targets a denied file. Added AFTER the caller's `--glob`
// so the exclude wins (ripgrep applies the last matching glob). An
// `glob` arg that targets a denied file. These must follow the caller's
// `--glob` so the exclude wins: ripgrep applies the last matching glob. An
// explicitly-passed denied `path` is blocked earlier by the permission
// manager (ripgrep searches explicit paths even against excludes).
for deny in &deny_read_globs {
@@ -843,12 +784,10 @@ async fn prepare_grep(
}
};
// Take pipes so child remains accessible for cleanup on timeout.
// Taken out of `child` so `child` stays available for kill/reap on timeout.
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
// Resolve truncation settings from tool-specific Params (static config; no
// dependency on the rg output, so it is resolved up front).
let params = resources
.lock()
.await
@@ -881,8 +820,7 @@ async fn prepare_grep(
/// Longest prefix of `bytes` that ends on a UTF-8 character boundary.
///
/// Used when a hard *byte* budget would otherwise cut mid-code-unit; line-budget
/// stops already land on `\n` (ASCII), so they are always boundaries. Counting
/// lines by `b'\n'` is UTF-8-safe (newlines are never multi-byte).
/// stops already land on `\n` (single-byte ASCII), so they are always boundaries.
fn utf8_char_boundary_prefix_len(bytes: &[u8]) -> usize {
match std::str::from_utf8(bytes) {
Ok(_) => bytes.len(),
@@ -894,13 +832,12 @@ fn utf8_char_boundary_prefix_len(bytes: &[u8]) -> usize {
/// running byte/line budgets. Returns `(accepted_len, hit_cap)`.
///
/// Stops at the first of: remaining room under [`MAX_STDOUT_BYTES`], or the
/// newline that brings complete line count to `max_lines`. Used by both the
/// newline that brings complete line count to `max_lines`. Shared by the
/// blocking and streaming read loops so early-kill behavior cannot drift.
///
/// On a pure byte-cap stop (no line budget hit), the accepted slice is snapped
/// to a UTF-8 char boundary so we never append a partial multi-byte sequence
/// into `stdout_buf` (downstream uses `String::from_utf8_lossy`, but mid-char
/// cuts also break incremental `BodyStreamer` line assembly).
/// On a pure byte-cap stop, the accepted slice is snapped to a UTF-8 char
/// boundary so no partial multi-byte sequence lands in `stdout_buf`: a mid-char
/// cut would break incremental `BodyStreamer` line assembly.
fn accept_rg_stdout_chunk(
chunk: &[u8],
buf_len: usize,
@@ -921,17 +858,17 @@ fn accept_rg_stdout_chunk(
if b == b'\n' {
lines += 1;
if lines >= max_lines {
// Include the newline that filled the budget, then stop.
// `\n` is a single-byte ASCII boundary no UTF-8 snap needed.
// Include the `\n` that filled the budget; it is a single-byte
// ASCII boundary, so no UTF-8 snap is needed.
return (i + 1, true);
}
}
}
let hit_byte_cap = limited.len() < chunk.len();
if hit_byte_cap {
// Prefer a complete UTF-8 prefix over a mid-code-unit cut. If the entire
// limited slice is an incomplete sequence (shouldn't happen when the
// prior buffer always ends on a boundary), accept 0 and hit the cap.
// Prefer a complete UTF-8 prefix over a mid-code-unit cut. A fully
// incomplete slice (which shouldn't occur when the prior buffer always
// ends on a boundary) accepts 0 and still hits the cap.
let safe = utf8_char_boundary_prefix_len(limited);
return (safe, true);
}
@@ -940,13 +877,13 @@ fn accept_rg_stdout_chunk(
/// Read `rg` stdout until EOF or a hard stop (byte cap / effective head_limit
/// lines). Callers should kill the child when the returned truncated flag is
/// set so `rg` does not keep walking the tree.
/// When the line budget is filled exactly and the next read is EOF, `truncated`
/// is **false** (exact fit). If more bytes remain after the budget, true.
/// set so `rg` does not keep walking the tree. When the line budget is filled
/// exactly and the next read is EOF, `truncated` is **false** (exact fit);
/// otherwise true.
///
/// The post-budget "exact-fit" probe is **time-bounded** ([`EXACT_FIT_PROBE_TIMEOUT`]).
/// An unbounded `read` would hold the outer tool timeout and, on expiry, drop the
/// already-buffered matches in favor of a timeout error card.
/// An unbounded `read` would hold the outer tool timeout and, on expiry, discard
/// the already-buffered matches in favor of a timeout error card.
async fn read_rg_stdout_capped(mut stdout_pipe: ChildStdout, max_lines: usize) -> (Vec<u8>, bool) {
let mut buf = Vec::with_capacity(MAX_STDOUT_BYTES.min(65_536));
let mut complete_lines = 0usize;
@@ -966,7 +903,6 @@ async fn read_rg_stdout_capped(mut stdout_pipe: ChildStdout, max_lines: usize) -
if accepted < n {
truncated = true;
} else {
// Bounded probe: never wait for the full tool timeout here.
match tokio::time::timeout(
EXACT_FIT_PROBE_TIMEOUT,
stdout_pipe.read(&mut tmp),
@@ -976,9 +912,10 @@ async fn read_rg_stdout_capped(mut stdout_pipe: ChildStdout, max_lines: usize) -
Ok(Ok(0)) => truncated = false,
Ok(Ok(_)) => truncated = true,
Ok(Err(_)) => truncated = true,
// No more data arrived quickly assume overflow so the
// caller kills `rg` and keeps the buffer (do not escalate
// to the outer timeout path that drops matches).
// No more data arrived quickly: assume overflow so the
// caller kills `rg` and keeps the buffer, rather than
// escalating to the outer timeout path that discards
// matches.
Err(_elapsed) => truncated = true,
}
}
@@ -1008,8 +945,8 @@ fn grep_timeout_output(secs: u64) -> GrepSearchOutput {
}
}
/// Build the authoritative terminal card from the fully-read rg output.
/// Single source of truth; the streamed body is a faithful prefix of it.
/// Build the authoritative terminal card from the fully-read rg output. Single
/// source of truth; the streamed body is a faithful prefix of it.
fn finalize_grep(
stdout_buf: Vec<u8>,
stdout_truncated: bool,
@@ -1020,7 +957,6 @@ fn finalize_grep(
let stdout = String::from_utf8_lossy(&stdout_buf);
let stderr = String::from_utf8_lossy(&stderr_buf);
// Handle exit codes.
if (exit_code == 1 && stdout.is_empty())
|| (exit_code == 2 && stderr.contains("No files were searched"))
{
@@ -1134,18 +1070,18 @@ fn finalize_grep(
/// Incremental builder for grep's streamed card body: raw stdout in via
/// [`BodyStreamer::feed`], flushed at EOF via [`BodyStreamer::finish`]. Each
/// line is projected exactly as [`finalize_grep`] projects the terminal body,
/// so the concatenated deltas equal the card body (prefix mode). Line
/// splitting matches `str::lines()` exactly (incl. trailing-`\r` handling).
/// so the concatenated deltas equal the card body. Line splitting matches
/// `str::lines()` exactly, including trailing-`\r` handling.
struct BodyStreamer<'a> {
spec: &'a kigi_tool_protocol::StreamingSpec,
config: &'a GrepFormatConfig,
/// Accumulated card body. Equals the body `finalize_grep` produces.
/// Equals the body `finalize_grep` produces.
body: String,
/// Monotonic body bytes already surfaced as deltas.
last_total: u64,
/// Body lines emitted so far (drives the head-limit).
/// Drives the head-limit.
emitted_lines: usize,
/// Cumulative trimmed-line length (drives the byte-cap).
/// Cumulative trimmed-line length; drives the byte-cap.
cum_len: usize,
/// Set once the head-limit or byte-cap is hit (body complete).
done: bool,
@@ -1175,15 +1111,17 @@ impl<'a> BodyStreamer<'a> {
return deltas;
}
self.pending.extend_from_slice(bytes);
// Own the buffer to project lines from borrowed slices (no per-line
// alloc); the unconsumed tail is carried forward at the end.
// Owned so lines can be projected from borrowed slices without a
// per-line alloc; the unconsumed tail is carried forward at the end.
let buf = std::mem::take(&mut self.pending);
let mut start = 0;
while let Some(rel) = buf[start..].iter().position(|&b| b == b'\n') {
let nl = start + rel;
let mut end = nl; // exclusive; drops the '\n'
// Exclusive end, dropping the '\n' and, for a "\r\n", the '\r' too
// (matches `str::lines()`).
let mut end = nl;
if end > start && buf[end - 1] == b'\r' {
end -= 1; // drop the '\r' of a '\r\n' (matches `str::lines()`)
end -= 1;
}
if let Some(p) = self.push_line(&buf[start..end]) {
deltas.push(p);
@@ -1193,7 +1131,6 @@ impl<'a> BodyStreamer<'a> {
break;
}
}
// Carry the in-progress (post-last-newline) bytes to the next feed.
self.pending.extend_from_slice(&buf[start..]);
deltas
}
@@ -1209,22 +1146,20 @@ impl<'a> BodyStreamer<'a> {
}
/// Project one line into the body; returns its delta. Sets [`Self::done`]
/// at the head-limit or byte-cap.
/// at the head-limit or byte-cap, both matching `finalize_grep`.
fn push_line(&mut self, line: &[u8]) -> Option<kigi_tool_runtime::ToolProgress> {
// Head-limit (matches `finalize_grep`).
if self.emitted_lines >= self.config.effective_head_limit {
self.done = true;
return None;
}
let line_str = String::from_utf8_lossy(line);
let trimmed = trim_line(&line_str, self.config.max_chars_per_line);
// Byte-cap; shares `exceeds_cum_byte_cap` with the batch path.
if exceeds_cum_byte_cap(self.cum_len, trimmed.len(), self.config.max_output_bytes) {
self.done = true;
return None;
}
// Separator keyed off `emitted_lines` so a leading empty line still
// gets one.
// Keyed off `emitted_lines` so a leading empty line still gets a
// separator.
if self.emitted_lines > 0 {
self.body.push('\n');
}
@@ -1236,25 +1171,19 @@ impl<'a> BodyStreamer<'a> {
self.body.as_bytes(),
self.body.len() as u64,
&mut self.last_total,
// No upstream cumulative truncation; only the per-tick `gap`.
false,
)
}
}
// ───────────────────────────────────────────────────────────────────────────
// Parsing & formatting helpers (free functions)
// ───────────────────────────────────────────────────────────────────────────
fn trim_line(line: &str, max_chars_per_line: usize) -> String {
truncate_line(line, max_chars_per_line).into_owned()
}
/// Parse a ripgrep "numbered line" prefix: `123:content` or `45-context`.
///
/// `pub` so siblings can reuse the parser instead of
/// duplicating it -- avoids drift between the two namespaces' rg-output
/// reformatters.
/// `pub` so sibling namespaces' rg-output reformatters reuse this parser rather
/// than duplicating (and drifting from) it.
pub fn parse_numbered_line_prefix(line: &str) -> Option<(usize, char, &str)> {
let bytes = line.as_bytes();
let mut idx = 0usize;
@@ -1461,10 +1390,6 @@ pub fn format_count_output(
final_output_lines.join("\n")
}
// ───────────────────────────────────────────────────────────────────────────
// Tests
// ───────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
@@ -1896,13 +1821,11 @@ mod tests {
#[tokio::test]
async fn tool_uses_params_for_truncation() {
let tmp = TempDir::new().unwrap();
// Create a file with many matching lines
let content: String = (0..100).map(|i| format!("match_line_{}\n", i)).collect();
fs::write(tmp.path().join("big.txt"), &content).unwrap();
let mut resources = Resources::new();
resources.insert(Cwd(tmp.path().to_path_buf()));
// Set a very small output limit
resources.insert(Params(GrepParams {
max_output_bytes: Some(200),
max_chars_per_line: None,
@@ -2104,10 +2027,8 @@ mod tests {
assert!(stdout.contains("secret_value"));
}
// ─── Streaming (GrepTool::execute) tests ───
//
// `test_ctx` stamps `WorkspaceViewerContext { stream_tool_progress: true }`,
// so these exercise the streaming path.
// so the tests below exercise the streaming path.
/// Destructure a `grep_match_chunk` payload, asserting the canonical
/// `plain_text` / `append` envelope. Returns the `delta`.
@@ -2230,8 +2151,8 @@ mod tests {
/// Byte-cap must not cut mid multi-byte UTF-8 sequence (e.g. "é" = C3 A9).
#[test]
fn accept_rg_stdout_chunk_byte_cap_snaps_to_utf8_boundary() {
// One byte of room left, but next char is 2-byte UTF-8.
let chunk = "é\n".as_bytes(); // [0xC3, 0xA9, 0x0A]
// One byte of room left, but next char is 2-byte UTF-8 ("é" = C3 A9).
let chunk = "é\n".as_bytes();
assert_eq!(chunk.len(), 3);
let (n, hit) = accept_rg_stdout_chunk(chunk, MAX_STDOUT_BYTES - 1, 0, 100);
assert!(hit, "must hit byte cap");
@@ -35,11 +35,10 @@ fn resolve_bundled_rg() -> std::io::Result<PathBuf> {
Ok(p)
}
/// Get the path to the ripgrep executable.
/// Path to the ripgrep executable.
///
/// In release builds with bundling enabled, this extracts the bundled ripgrep
/// binary to ~/.kigi/vendor/ and returns that path.
/// Otherwise, assumes `rg` is in PATH.
/// With bundling enabled this extracts the embedded binary to `~/.kigi/vendor/`
/// on first call; otherwise it falls back to `rg` on PATH.
pub fn rg_path() -> PathBuf {
static RG_EXEC: OnceLock<PathBuf> = OnceLock::new();
RG_EXEC
@@ -50,14 +49,12 @@ pub fn rg_path() -> PathBuf {
}
#[cfg(not(bundle_rg))]
{
// RG_BIN_PATH: explicit override (tests / packaging can set this).
if let Ok(p) = std::env::var("RG_BIN_PATH") {
return PathBuf::from(p);
}
// Some hermetic test runners set RUNFILES_DIR and ship rg as a
// data dependency rather than on PATH. Scan for a directory
// entry containing "ripgrep_hermetic" and prefer arch-scoped
// paths when present.
// Hermetic test runners set RUNFILES_DIR and ship rg as a data
// dependency rather than on PATH, under a directory whose name
// contains "ripgrep_hermetic".
if let Ok(rf) = std::env::var("RUNFILES_DIR") {
let base = PathBuf::from(rf);
if let Ok(entries) = std::fs::read_dir(&base) {
@@ -1,7 +1,4 @@
//! `kill_task` tool — new architecture (`Tool` trait).
//!
//! Terminates a running background task. Reads the `Terminal` resource
//! from Resources to access the terminal backend.
//! `kill_task` tool: terminates a running background task or subagent.
pub mod terminal_command;
pub use terminal_command::KillTerminalCommandTool;
@@ -17,40 +14,24 @@ use crate::types::tool::ToolKind;
use crate::types::tool::ToolNamespace;
use kigi_tool_types::{KillTaskOutput, KillTaskResult, KillTaskToolInput};
// ───────────────────────────────────────────────────────────────────────────
// Tool implementation
// ───────────────────────────────────────────────────────────────────────────
#[derive(Debug, Default)]
pub struct KillTaskTool;
// ── Legacy message helpers ───────────────────────────────────────────────
//
// Historical fixture captured from an earlier (0.4.10) revision of this tool.
//
// In 0.4.10, kill_task returned:
// Err(ToolError::ProcessManagerError(format!("Task {} not found", input.task_id)))
//
// The meaningful customer-facing message content is the inner string.
// Subagent wording is out of scope — subagents didn't exist in 0.4.10.
/// Exact historical not-found message for `kill_task` in legacy-0.4.10.
/// Byte-for-byte fixture of the 0.4.10 message, which clients on the
/// `legacy-0.4.10` contract still match against. It carries no subagent
/// wording because subagents did not exist in that release.
fn render_legacy_kill_task_not_found(task_id: &str) -> String {
format!("Task {} not found", task_id)
}
/// Format a "not found" response for kill_task.
async fn not_found_response(
task_id: &str,
terminal: &std::sync::Arc<dyn crate::computer::types::TerminalBackend>,
is_legacy: bool,
) -> KillTaskOutput {
if is_legacy {
// Legacy: simple error without task ID enumeration.
// Subagent wording is out of scope — subagents didn't exist in 0.4.10.
return KillTaskOutput::TaskNotFound(render_legacy_kill_task_not_found(task_id));
}
// Current: include known task IDs for discoverability.
let known = terminal.list_tasks().await;
let msg = if known.is_empty() {
format!(
@@ -76,9 +57,8 @@ impl crate::types::tool_metadata::ToolMetadata for KillTaskTool {
}
fn description_template(&self) -> &str {
// Canonical wording lives in the shared builder; `versioned_definition`
// renders it context-aware from the finalized toolset. This static
// fallback mirrors the default kigi toolset on the current OS.
// Only a fallback for callers that skip `versioned_definition`; it
// assumes the default kigi toolset rather than the finalized one.
static DESC: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
kigi_tool_types::build_kill_task_description(&kigi_tool_types::KillTaskToolNaming {
monitor_tool: Some("monitor"),
@@ -126,11 +106,10 @@ impl crate::types::tool_metadata::ToolMetadata for KillTaskTool {
}
}
/// Resolve the model-facing `kill_task` description from the finalized toolset,
/// honoring an explicit config override. Wording lives in the shared
/// [`kigi_tool_types::build_kill_task_description`] builder so the CLI and
/// prod-chat can't drift; the monitor / subagent / bash clauses follow the
/// tools actually registered this turn, and the kill verb follows the host OS.
/// Wording lives in the shared [`kigi_tool_types::build_kill_task_description`]
/// builder so the CLI and prod-chat can't drift; the monitor / subagent / bash
/// clauses follow the tools actually registered this turn, so the description
/// never names a tool the model wasn't given.
fn kill_task_description(
renderer: &TemplateRenderer,
description_override: Option<&str>,
@@ -209,7 +188,7 @@ impl kigi_tool_runtime::Tool for KillTaskTool {
message: "Task had already completed".to_string(),
})),
KillOutcome::NotFound => {
// Try subagent cancel via backend
// Not a terminal task; it may still be a subagent.
let backend = {
resources
.lock()
@@ -246,10 +225,6 @@ impl kigi_tool_runtime::Tool for KillTaskTool {
}
}
// ───────────────────────────────────────────────────────────────────────────
// Tests
// ───────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
@@ -277,9 +252,7 @@ mod tests {
ctx
}
/// Minimal mock backend for testing kill_task.
struct MockTerminal {
/// Pre-configured outcome for `kill_task` calls.
outcome: KO,
}
@@ -331,14 +304,11 @@ mod tests {
fn tool_name_and_description() {
let tool = KillTaskTool;
assert_eq!(kigi_tool_runtime::Tool::id(&tool).as_str(), "kill_task");
// The static fallback is the shared builder's default kigi
// rendering (monitor + task + bash present) for the current OS.
// Fallback assumes the default toolset, so monitor + task + bash all appear.
let desc = crate::types::tool_metadata::ToolMetadata::description_template(&tool);
assert!(desc.contains("Terminate"));
assert!(desc.contains("subagent"));
// Must name "monitor" so the model connects stopping a monitor to this tool.
assert!(desc.contains("monitor"));
// The kill verb is OS-specific (SIGTERM on POSIX, Job Object on Windows).
if cfg!(not(unix)) {
assert!(desc.contains("Job Object"), "windows verb: {desc}");
} else {
@@ -523,7 +493,8 @@ mod tests {
},
)
.await
.unwrap(); // Should be Ok, not Err
// Not-found is a typed output, not an `Err`.
.unwrap();
match result {
KillTaskOutput::TaskNotFound(msg) => {
@@ -559,8 +530,6 @@ mod tests {
);
}
// ── MP-3: Legacy message parity fixture tests ────────────────────────
#[tokio::test]
async fn legacy_kill_task_not_found_exact_historical_message() {
let resources = resources_with_terminal(KO::NotFound);
@@ -578,7 +547,6 @@ mod tests {
match result {
KillTaskOutput::TaskNotFound(msg) => {
// Exact historical fixture — no trailing period.
assert_eq!(msg, "Task task-abc not found");
}
other => panic!("Expected TaskNotFound, got {:?}", other),
@@ -613,11 +581,8 @@ mod tests {
}
}
// ── Subagent cancel via backend tests ─────────────────────────────
/// Build resources with a terminal that returns `NotFound` and a
/// `SubagentBackendResource` backed by channels, returning the cancel
/// receiver so the test can simulate the coordinator.
/// Terminal returns `NotFound` and a channel-backed `SubagentBackendResource`
/// is installed; the returned receiver lets the test play the coordinator.
fn resources_with_backend_cancel() -> (
Resources,
tokio::sync::mpsc::UnboundedReceiver<SubagentEvent>,
@@ -1,23 +1,17 @@
//! `list_dir` tool — new architecture (`Tool` trait).
//! `list_dir` tool — directory listing.
//!
//! This is the new-architecture implementation of the directory listing tool.
//! It reads `Cwd` from `Resources` and `max_output_chars` from its own
//! `Params<ListDirParams>` instead of receiving them via `ToolContext`.
//! Seeds depth-1 children (capped at `MAX_SEED_ITEMS`) before the budgeted deep
//! walk so a fat early sibling cannot starve later top-level dirs
//! (`MAX_GLOBAL_ITEMS` applies only to depth ≥ 2), then BFS-expands dirs within
//! the char budget. When either the seed or the walk hits its item limit, the
//! agent-visible cutoff notice is emitted.
//!
//! Seeds depth-1 children (capped at `MAX_SEED_ITEMS`) before the budgeted deep walk
//! so a fat early sibling cannot starve later top-level dirs (`MAX_GLOBAL_ITEMS`
//! applies only to depth ≥ 2). Then BFS-expands dirs within the char budget
//! (`continue` on fat dirs). When either seed or walk hits its item limit, the
//! agent-visible cutoff notice is emitted (copy unchanged from `main`).
//! Partial-output case: when the walk truncates, a sibling surfaced by the seed
//! may be listed by name only while its descendants are absent, because the walk
//! cap was exhausted inside an earlier sibling.
//!
//! Partial-output case: when `walk_truncated` is true, a sibling surfaced by the
//! seed may be listed by name only while its descendants are absent (the walk cap
//! was exhausted inside an earlier sibling). The agent-visible notice copy is
//! intentionally left identical to `main`; this behavior is documented in the
//! CHANGELOG rather than via new model-facing wording.
//!
//! Under `legacy-0.4.10`, the old depth-threshold algorithm is used instead
//! (see `versions::legacy_0_4_10` module).
//! Under contract version `legacy-0.4.10` the depth-threshold algorithm in
//! `versions::legacy_0_4_10` runs instead.
mod versions;
use crate::types::output::{ListDirContent, ListDirOutput};
#[allow(unused_imports)]
@@ -42,20 +36,15 @@ pub struct ListDirInput {
#[serde(deny_unknown_fields)]
pub struct ListDirParams {
/// BFS expansion stops when this budget would be exceeded.
/// Defaults to `DEFAULT_MAX_OUTPUT_CHARS` (10,000) to match the Python
/// Defaults to `DEFAULT_MAX_OUTPUT_CHARS`.
pub max_output_chars: Option<usize>,
}
crate::register_resource!("kigi", "ListDir", ListDirParams);
/// Exact historical invalid-directory message for `list_dir` in legacy-0.4.10.
///
/// Historical fixture captured from an earlier (0.4.10) revision of this tool.
///
/// Historical 0.4.10 collapsed nonexistent paths, file paths, and other
/// invalid-directory failures into the same generic message.
/// Contract version `legacy-0.4.10` collapses nonexistent paths, file paths and
/// every other invalid-directory failure into this one generic message.
fn render_legacy_list_dir_error(path: &Path) -> String {
format!("Error: {} is not a valid directory", path.display())
}
/// Internal version discriminant for list_dir.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ListDirVersion {
Current,
@@ -72,9 +61,8 @@ impl ListDirVersion {
self == Self::Legacy0_4_10
}
}
/// Compute the path shown in the list_dir tool result header.
/// Special-cases `list_dir(".")`, `list_dir("")`, and `list_dir("./foo")` so the
/// output does not contain ugly "/./" components (e.g. `/workspace/./`).
/// result header does not contain "/./" components (e.g. `/workspace/./`).
fn compute_display_path(display_base: &std::path::Path, target: &str) -> std::path::PathBuf {
let t = target.trim().trim_start_matches("./");
if t.is_empty() || t == "." {
@@ -85,7 +73,6 @@ fn compute_display_path(display_base: &std::path::Path, target: &str) -> std::pa
}
#[derive(Debug, Default)]
pub struct ListDirTool;
/// Default character budget for directory listing output.
/// Matches the Python SWE tool's `lim_characters` default.
const DEFAULT_MAX_OUTPUT_CHARS: usize = 10_000;
/// Show top-N extension buckets in collapsed-directory summary lines.
@@ -109,8 +96,8 @@ fn root_truncation_notice(renderer: Option<&TemplateRenderer>) -> String {
const MAX_GLOBAL_ITEMS: usize = 100_000;
/// Cap on depth-1 seed entries so a pathological flat root (millions of direct
/// children) cannot fully materialize into `DirNode` before the char budget truncates.
/// Independent in role from `MAX_GLOBAL_ITEMS`, but pinned equal to it (see guard below) so
/// the cutoff notice's shared count stays correct whichever cap triggers truncation.
/// Independent in role from `MAX_GLOBAL_ITEMS`, but pinned equal to it (see the guard
/// below) so the cutoff notice's shared count stays correct whichever cap triggers.
const MAX_SEED_ITEMS: usize = 100_000;
const _: () = assert!(MAX_SEED_ITEMS == MAX_GLOBAL_ITEMS);
#[derive(Debug, Default)]
@@ -222,7 +209,6 @@ impl DirNode {
self.subtree.add_ext(&ext);
}
}
/// Sort files and subdirs case-insensitively, recursively.
fn sort_recursive(&mut self) {
self.files.sort_by_key(|a| a.to_ascii_lowercase());
self.subdirs.sort_by_key(|a| a.to_ascii_lowercase());
@@ -254,7 +240,6 @@ impl DirNode {
}
(self.depth + 1) * 2 + s.len() + 1
}
/// Render this node's children, recursing into expanded child nodes.
fn render_expanded(&self, top_k: usize) -> String {
let mut out = String::new();
for name in self.all_subitems_sorted() {
@@ -266,7 +251,7 @@ impl DirNode {
}
out
}
/// Render subtree: expanded nodes show children, collapsed show summary.
/// Expanded nodes render their children; collapsed nodes render a summary line.
fn render_subtree(&self, top_k: usize) -> String {
if self.is_expanded {
return self.render_expanded(top_k);
@@ -428,7 +413,6 @@ fn navigate_mut<'a>(root: &'a mut DirNode, path: &[String]) -> Option<&'a mut Di
}
Some(node)
}
/// Render as many root items as fit within budget, then append truncation notice.
fn render_truncated_root(root: &DirNode, max_chars: usize, top_k: usize, notice: &str) -> String {
let mut out = String::new();
let mut remaining = max_chars;
@@ -1,16 +1,11 @@
//! Legacy (0.4.10) depth-threshold directory rendering.
//!
//! Extracted from an earlier revision of the codebase. The current renderer uses
//! BFS character-budget expansion; this module preserves the old depth-based
//! summarization algorithm for `contract_version = "legacy-0.4.10"`.
//! The depth-based summarization algorithm, served for
//! `contract_version = "legacy-0.4.10"`.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
// ───────────────────────────────────────────────────────────────────────────
// Configuration
// ───────────────────────────────────────────────────────────────────────────
const ROOT_SUMMARIZATION_THRESHOLD: usize = 1500;
const SUBDIR_SUMMARIZATION_THRESHOLD: usize = 15;
const TOP_K_EXTENSIONS_TO_RENDER: usize = 3;
@@ -46,10 +41,6 @@ impl RenderConfig {
}
}
// ───────────────────────────────────────────────────────────────────────────
// Accumulator + helpers
// ───────────────────────────────────────────────────────────────────────────
#[derive(Debug, Default)]
struct DirAccum {
total_files: usize,
@@ -115,10 +106,6 @@ fn filename(path: &Path) -> String {
})
}
// ───────────────────────────────────────────────────────────────────────────
// Tree structures
// ───────────────────────────────────────────────────────────────────────────
#[derive(Debug)]
struct ChildEntry {
is_dir: bool,
@@ -164,10 +151,6 @@ fn get_or_init_dir<'a>(
})
}
// ───────────────────────────────────────────────────────────────────────────
// Collection + rendering
// ───────────────────────────────────────────────────────────────────────────
fn collect(root_path: &Path, walker: ignore::Walk, cfg: &RenderConfig) -> Collected {
let mut dirs: HashMap<PathBuf, DirectoryView> = HashMap::new();
for directory_entry in walker {
@@ -302,13 +285,7 @@ fn render_with_fallback(root: &Path, collected: &Collected, cfg: &RenderConfig)
}
}
// ───────────────────────────────────────────────────────────────────────────
// Public entry point
// ───────────────────────────────────────────────────────────────────────────
/// Render a directory listing using the legacy (0.4.10) depth-threshold algorithm.
///
/// Returns the body text (without the root path header line).
/// Returns the body text without the root path header line.
pub(crate) fn render_legacy(root: &Path, max_output_bytes: usize) -> String {
let cfg = RenderConfig {
max_output_bytes,
@@ -327,16 +304,11 @@ pub(crate) fn render_legacy(root: &Path, max_output_bytes: usize) -> String {
}
}
// ───────────────────────────────────────────────────────────────────────────
// Tests — fixture-based historical verification
// ───────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
/// Create a reference directory tree for fixture comparison.
fn create_fixture_tree(root: &std::path::Path) {
// src/
// main.rs
@@ -361,22 +333,15 @@ mod tests {
std::fs::write(root.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
}
/// The legacy depth-based renderer expands small directories fully,
/// showing individual files with indentation per depth level.
/// Archived-output fixture test: asserts exact string equality against
/// the known output of the earlier depth-threshold algorithm.
///
/// Fixture captured from `render_legacy()` on the reference tree defined
/// by `create_fixture_tree()`. If this test fails after a change to the
/// legacy renderer, the change has drifted from historical behavior.
/// Guards the legacy renderer against drift: a failure means output no
/// longer matches the archived depth-threshold behavior.
#[test]
fn legacy_renders_small_tree_exact_fixture() {
let tmp = TempDir::new().unwrap();
create_fixture_tree(tmp.path());
let body = render_legacy(tmp.path(), 40_000);
// Exact archived output from the depth-threshold algorithm.
// Root files listed alphabetically, src/ expanded (< 15 children),
// Root files alphabetical, src/ expanded (< 15 children),
// util/ summarized (depth >= 2), tests/ expanded.
let expected = " - Cargo.toml\n - README.md\n - src/\n - lib.rs\n - main.rs\n - util/\n [1 file in subtree: 1 *.rs]\n - tests/\n - test_main.rs";
@@ -387,8 +352,8 @@ mod tests {
);
}
/// Empty directories should produce empty output (no "no children found"
/// — that is added by the caller in mod.rs, not by the renderer).
/// The renderer emits nothing for an empty dir; the "no children found"
/// line is added by the caller in mod.rs, not here.
#[test]
fn legacy_empty_directory_returns_empty_string() {
let tmp = TempDir::new().unwrap();
@@ -399,9 +364,6 @@ mod tests {
);
}
/// The depth-based algorithm summarizes directories when child count
/// exceeds the threshold (15 for subdirs by default). Verify that a
/// large directory gets a summary line instead of full expansion.
#[test]
fn legacy_summarizes_large_subdirectory() {
let tmp = TempDir::new().unwrap();
@@ -413,7 +375,6 @@ mod tests {
}
let body = render_legacy(tmp.path(), 40_000);
// Should show a summary line with file count and extension breakdown.
assert!(
body.contains("files in subtree") || body.contains("file in subtree"),
"large dir should be summarized: {body}"
@@ -424,17 +385,12 @@ mod tests {
);
}
/// Verify structural equivalence: the depth-based renderer produces
/// lines with consistent 2-space indentation matching the historical
/// algorithm's output pattern.
#[test]
fn legacy_indentation_matches_historical_pattern() {
let tmp = TempDir::new().unwrap();
create_fixture_tree(tmp.path());
let body = render_legacy(tmp.path(), 40_000);
// Every line should start with some number of " " pairs followed by "- "
// or be a summary line (starts with spaces + "[").
for line in body.lines() {
let trimmed = line.trim_start();
let indent_chars = line.len() - trimmed.len();
@@ -1,7 +1,5 @@
//! Version-specific behavior modules for `list_dir`.
//!
//! - `legacy_0_4_10`: depth-threshold rendering + generic error messages
//! - Current behavior remains in `list_dir/mod.rs` (BFS budget rendering
//! + structured error variants).
//! Version-specific behavior modules for `list_dir`. Current behavior (BFS
//! budget rendering, structured error variants) lives in `list_dir/mod.rs`;
//! `legacy_0_4_10` holds depth-threshold rendering and generic error messages.
pub(crate) mod legacy_0_4_10;
@@ -1,7 +1,6 @@
//! `lsp` tool - code intelligence via language servers.
//!
//! Implementation is in `implementations::lsp`. This module provides the
//! `LspTool` (Tool trait impl) under the `Kigi` namespace.
//! The `Tool` trait wrapper; the backend dispatch lives in `implementations::lsp`.
use std::sync::Arc;
@@ -1,13 +1,5 @@
//! New-architecture tool implementations (NewTool trait).
//!
//! Each sub-module here contains a tool that implements `NewTool` instead
//! of the old `Tool` trait. During migration, old implementations live in
//! `implementations/<tool>/` and new implementations live in
//! `implementations/kigi/<tool>/`.
//!
//! The [`register_all()`] function is the single entry-point for wiring up
//! the standard toolset. It inserts shared resources (`Terminal`,
//! `AvailableSkills`, `BashParams`) and registers every built-in tool.
//! Tool implementations built on the `NewTool` trait; the sibling
//! `implementations/<tool>/` modules hold the `Tool`-trait counterparts.
pub mod ask_user_question;
pub mod bash;
#[path = "deploy_app_stub.rs"]
@@ -1,10 +1,9 @@
use super::types::{BATCH_TRUNCATION_LIMIT, BUFFER_CAP_BYTES, LINE_TRUNCATION_LIMIT};
use crate::util::floor_char_boundary;
/// Processes raw stdout chunks into complete lines.
///
/// Buffers partial lines, splits on `\n`, truncates individual lines at
/// `LINE_TRUNCATION_LIMIT` chars, and caps the internal buffer at `BUFFER_CAP_BYTES`.
/// Splits raw stdout chunks into complete lines, buffering partial lines
/// across chunks. Individual lines are truncated at `LINE_TRUNCATION_LIMIT`
/// chars and the buffer is capped at `BUFFER_CAP_BYTES`, keeping the tail.
#[derive(Default)]
pub struct LineProcessor {
buffer: Vec<u8>,
@@ -15,11 +14,9 @@ impl LineProcessor {
Self::default()
}
/// Push a raw stdout chunk. Returns any complete lines extracted.
pub fn push(&mut self, chunk: &[u8]) -> Vec<String> {
self.buffer.extend_from_slice(chunk);
// Cap buffer at BUFFER_CAP_BYTES (keep the tail).
if self.buffer.len() > BUFFER_CAP_BYTES {
let start = self.buffer.len() - BUFFER_CAP_BYTES;
self.buffer = self.buffer[start..].to_vec();
@@ -37,7 +34,6 @@ impl LineProcessor {
lines
}
/// Flush any remaining partial line from the buffer.
pub fn flush(&mut self) -> Option<String> {
if self.buffer.is_empty() {
return None;
@@ -60,7 +56,6 @@ fn truncate_line(line: &str) -> String {
}
}
/// Batch multiple lines into a single event string, truncating at `BATCH_TRUNCATION_LIMIT`.
pub fn batch_lines(lines: &[String]) -> String {
let joined = lines.join("\n");
if joined.len() > BATCH_TRUNCATION_LIMIT {
@@ -93,9 +88,6 @@ pub fn wrap_monitor_event(description: &str, event_text: &str, task_id: &str) ->
mod tests {
use super::*;
/// Quotes and newlines in the model-supplied description are neutralized
/// before embedding — they would otherwise break the attribute quoting
/// (`" task_id="` anchor) or the single-line opening tag (`>\n` anchor).
#[test]
fn wrap_sanitizes_description() {
let wrapped = wrap_monitor_event("watch \"prod\"\nlogs", "line", "t-1");
@@ -150,7 +142,6 @@ mod tests {
#[test]
fn buffer_cap_enforced() {
let mut proc = LineProcessor::new();
// Push more than BUFFER_CAP_BYTES without newlines
let big = vec![b'a'; BUFFER_CAP_BYTES + 1000];
proc.push(&big);
assert!(proc.buffer.len() <= BUFFER_CAP_BYTES);
@@ -187,17 +178,15 @@ mod tests {
#[test]
fn truncate_line_multibyte_no_panic() {
// 3-byte UTF-8 chars — truncation boundary may land mid-char
let line = "\u{4e16}\u{754c}".repeat(200); // CJK chars, 3 bytes each
// 3-byte UTF-8 chars — the truncation boundary may land mid-char
let line = "\u{4e16}\u{754c}".repeat(200);
let truncated = truncate_line(&line);
assert!(truncated.ends_with("...(truncated)"));
// Verify the result is valid UTF-8 (would panic if not)
let _ = truncated.as_bytes();
}
#[test]
fn truncate_line_emoji_no_panic() {
// 4-byte emoji chars
let line = "\u{1F600}".repeat(200);
let truncated = truncate_line(&line);
assert!(truncated.ends_with("...(truncated)"));
@@ -23,7 +23,7 @@ impl TokenBucket {
}
}
/// Try to consume one token. Returns true if a token was available.
/// Consumes one token, refilling first. Returns false when the bucket is empty.
pub fn try_consume(&mut self) -> bool {
let now = Instant::now();
let elapsed = now.duration_since(self.last_refill);
@@ -41,10 +41,9 @@ impl TokenBucket {
}
}
/// Tracks rate-limit suppression state and auto-kill logic.
///
/// Used alongside `TokenBucket` to detect sustained overload and generate
/// catch-up notices when the rate subsides.
/// Tracks suppression state next to a `TokenBucket`: detects sustained
/// overload, generates catch-up notices when the rate subsides, and
/// triggers auto-kill.
#[derive(Default)]
pub struct SuppressionTracker {
pub suppressed_count: u64,
@@ -118,7 +117,6 @@ impl SuppressionTracker {
self.suppression_start = Some(Instant::now());
}
// Check auto-kill threshold.
if let Some(start) = self.suppression_start {
let elapsed = start.elapsed();
if elapsed > Duration::from_millis(AUTO_KILL_THRESHOLD_MS) {
@@ -161,7 +159,6 @@ impl MonitorRateLimiter {
self
}
/// Process an event. Returns the rate limit decision.
pub fn process_event(&mut self, description: &str) -> RateLimitOutcome {
let available = self.bucket.try_consume();
self.suppression.process(available, description)
@@ -187,20 +184,19 @@ mod tests {
#[test]
fn bucket_refills_after_interval() {
let mut bucket = TokenBucket::new(10, 50); // 50ms for test speed
let mut bucket = TokenBucket::new(10, 50);
for _ in 0..10 {
bucket.try_consume();
}
assert!(!bucket.try_consume());
std::thread::sleep(Duration::from_millis(60));
assert!(bucket.try_consume()); // one token refilled
assert!(bucket.try_consume());
}
#[test]
fn bucket_does_not_exceed_capacity() {
let mut bucket = TokenBucket::new(3, 50);
std::thread::sleep(Duration::from_millis(200)); // enough for many refills
// Should be capped at 3
std::thread::sleep(Duration::from_millis(200));
assert!(bucket.try_consume());
assert!(bucket.try_consume());
assert!(bucket.try_consume());
@@ -218,13 +214,11 @@ mod tests {
#[test]
fn catch_up_notice_on_recovery() {
let mut tracker = SuppressionTracker::new();
// Suppress some events
tracker.process(false, "test");
tracker.process(false, "test");
tracker.process(false, "test");
assert_eq!(tracker.suppressed_count, 3);
// Now a token is available
let outcome = tracker.process(true, "test");
match outcome {
RateLimitOutcome::Allowed { catch_up_notice } => {
@@ -259,14 +253,12 @@ mod tests {
#[test]
fn combined_rate_limiter() {
let mut rl = MonitorRateLimiter::new(3, 2000);
// First 3 events pass
for _ in 0..3 {
assert!(matches!(
rl.process_event("test"),
RateLimitOutcome::Allowed { .. }
));
}
// 4th is suppressed
assert!(matches!(
rl.process_event("test"),
RateLimitOutcome::Suppressed
@@ -119,7 +119,8 @@ impl kigi_tool_runtime::Tool for MonitorTool {
"1".to_string(),
)]),
timeout: if resolved_timeout == 0 {
Duration::from_secs(86400 * 365) // long-running (until kill or session end)
// long-running (until kill or session end)
Duration::from_secs(86400 * 365)
} else {
Duration::from_millis(resolved_timeout)
},
@@ -160,8 +161,6 @@ impl kigi_tool_runtime::Tool for MonitorTool {
});
// Spawn the stdout processing pipeline.
// Reads the output file, processes lines through the rate limiter,
// and emits MonitorEvent notifications.
let pipeline_task_id = task_id.clone();
let pipeline_description = description.clone();
// Weak handle: the pipeline must not keep the session's terminal backend
@@ -188,7 +187,8 @@ impl kigi_tool_runtime::Tool for MonitorTool {
&pipeline_notif,
&pipeline_output_file,
pipeline_kill_name,
0, // fresh pipeline — read from start
// fresh pipeline — read from start
0,
)
.await;
});
@@ -263,7 +263,6 @@ pub(crate) async fn run_monitor_pipeline(
break;
};
// Check if the task is still running.
let snapshot = terminal.get_task(task_id).await;
let completed = snapshot.is_none() || snapshot.as_ref().is_some_and(|s| s.completed);
@@ -275,7 +274,6 @@ pub(crate) async fn run_monitor_pipeline(
}
let owner_session_id = snapshot_owner.or_else(|| last_owner.clone());
// Read new output from the file.
let new_bytes = read_new_bytes(output_file, &mut last_read_offset).await;
if !new_bytes.is_empty() {
let lines = line_processor.push(&new_bytes);
@@ -294,7 +292,6 @@ pub(crate) async fn run_monitor_pipeline(
}
if completed {
// Flush any remaining partial line.
if let Some(remaining) = line_processor.flush() {
process_event(
task_id,
@@ -349,7 +346,8 @@ async fn read_new_bytes(path: &std::path::Path, offset: &mut u64) -> Vec<u8> {
}
let to_read = (file_len - *offset) as usize;
let mut buf = vec![0u8; to_read.min(1024 * 1024)]; // cap single read at 1MB
// cap single read at 1MB
let mut buf = vec![0u8; to_read.min(1024 * 1024)];
let Ok(n) = file.read(&mut buf).await else {
return Vec::new();
};
@@ -471,7 +469,6 @@ mod tests {
.await;
});
// The monitor is running and visible.
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(
backend.get_task(&task_id).await.is_some(),
@@ -4,8 +4,8 @@ pub const LINE_TRUNCATION_LIMIT: usize = 500;
/// Max characters per batched event (multiple lines joined).
pub const BATCH_TRUNCATION_LIMIT: usize = 3_000;
/// Raw stdout buffer cap in bytes.
pub const BUFFER_CAP_BYTES: usize = 1_048_576; // 1 MB
/// Raw stdout buffer cap. 1 MB.
pub const BUFFER_CAP_BYTES: usize = 1_048_576;
/// Debounce window for batching concurrent stdout lines (ms).
pub const DEBOUNCE_MS: u64 = 200;
@@ -21,10 +21,10 @@ pub const AUTO_KILL_THRESHOLD_MS: u64 = 30_000;
/// Default monitor timeout (non-persistent). 10 hours to avoid short
/// unexpected cutoffs for monitors the model starts without an explicit deadline.
pub const DEFAULT_TIMEOUT_MS: u64 = 36_000_000; // 10 hours
pub const DEFAULT_TIMEOUT_MS: u64 = 36_000_000;
/// Maximum monitor timeout.
pub const MAX_TIMEOUT_MS: u64 = 36_000_000; // 10 hours
/// Maximum monitor timeout (10 hours).
pub const MAX_TIMEOUT_MS: u64 = 36_000_000;
/// Max result size for the tool_result response.
pub const MAX_RESULT_SIZE_CHARS: usize = 10_000;
@@ -83,7 +83,6 @@ pub enum MonitorError {
}
impl MonitorInput {
/// Validate input constraints.
pub fn validate(&self) -> Result<(), MonitorError> {
let persistent = self.persistent.unwrap_or(false);
if let Some(timeout) = self.timeout_ms
@@ -1,11 +1,5 @@
//! ReadFile — new-architecture implementation.
//!
//! Reuses the core logic (`extract_file_content_lines`, `bytes_to_metadata`,
//! constants) from the old `implementations::read_file` module.
//! State:
//! - Notifications emitted via `NotificationHandle` from Resources.
//!
//! Reminders are NOT implemented here (Phase 5).
//! ReadFile tool. Shares core helpers (`extract_file_content_lines`,
//! `bytes_to_metadata`, constants) with `implementations::read_file`.
use crate::implementations::read_file::{
handle_pdf, is_pdf_file, raw_text_to_file_content, run_document_extraction,
};
@@ -23,7 +17,6 @@ use crate::types::tool::{ToolKind, ToolNamespace};
use std::sync::LazyLock;
mod versions;
use crate::types::schema::KigiIntegerSchema;
/// Configuration for the ReadFile tool, stored as `Params<ReadFileParams>` in Resources.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReadFileParams {
@@ -91,15 +84,11 @@ async fn handle_pptx(
.await
}
/// Extract text from a PPTX file (zip + DrawingML text runs).
///
/// Returns line-numbered text via the shared `raw_text_to_file_content`
/// helper.
fn extract_pptx_text(file_bytes: Vec<u8>) -> Result<ReadFileOutput, String> {
let text = crate::implementations::read_file::pptx::extract_pptx_text_from_bytes(&file_bytes)
.map_err(|e| format!("Failed to extract text from PPTX: {e}"))?;
Ok(raw_text_to_file_content(text))
}
/// Description for default toolset (full/non-concise)
pub(crate) const DESCRIPTION_FULL: &str = r#"Read a file.
Usage:
@@ -180,7 +169,6 @@ pub struct ExtractedContent {
pub content: String,
/// Concise format: identical to content (kept for backward compatibility)
pub content_concise: String,
/// Raw unformatted content
pub raw_output: String,
/// Base64 images captured per-line before truncation. Plumbed through
/// `FileContent.extracted_images` and turned into multimodal
@@ -540,11 +528,6 @@ pub(crate) async fn run_read_file(
extracted_images,
}))
}
/// New-architecture `ReadFile` tool.
///
/// Params: `()` — no per-tool configuration.
///
/// Notifications: Emits `FileRead` via `NotificationHandle`.
#[derive(Default, Debug)]
pub struct ReadFileTool;
impl crate::types::tool_metadata::ToolMetadata for ReadFileTool {
@@ -666,7 +649,6 @@ mod tests {
use crate::types::tool_metadata::test_ctx;
use std::sync::Arc;
use tempfile::TempDir;
/// Set up Resources with real filesystem for tests.
fn test_resources(cwd: &std::path::Path) -> Resources {
let mut resources = Resources::new();
resources.insert(Cwd(cwd.to_path_buf()));
@@ -1014,10 +996,9 @@ mod tests {
assert_eq!(extracted.content_concise, "1→1\n2\n3\n");
assert_eq!(extracted.raw_output, "1\n2\r\n3\n");
}
/// Regression: a long single-line base64 URI used to be cut
/// mid-payload by the (since-removed) per-line clip and re-emitted as
/// a corrupt vision token. Pin that the full payload is captured
/// byte-equal.
/// Regression: a long single-line base64 URI must be captured
/// byte-equal, not clipped mid-payload and re-emitted as a corrupt
/// vision token.
#[test]
fn extract_captures_long_inline_base64_image_before_truncation() {
let payload = "A".repeat(50_000);
@@ -1624,8 +1605,8 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
}
}
/// Wrapper-level user-visible message: prefix matches the caller's
/// `"Could not embed image in conversation: ..."` and the legacy
/// "Image too large to embed..." is no longer used.
/// `"Could not embed image in conversation: ..."`, not the legacy
/// "Image too large to embed..." form.
#[test]
fn compress_oversized_garbage_user_message_is_non_legacy() {
let bytes = vec![0u8; MAX_IMAGE_PAYLOAD_BYTES + 4096];
@@ -2122,9 +2103,9 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
}
/// Regression for the "death spiral" incident: a single-line
/// ~49.5KB JSON payload must be readable in full with default config.
/// The old 2000-char per-line clip made such files unreadable by
/// construction (bash output and MCP results are byte-capped too), so the
/// model could never load a payload it needed to re-emit as tool input.
/// A per-line clip would make such files unreadable by construction
/// (bash output and MCP results are byte-capped too), so the model
/// could never load a payload it needed to re-emit as tool input.
#[tokio::test]
async fn single_line_payload_reads_in_full_by_default() {
let tmp = TempDir::new().unwrap();
@@ -1,26 +1,16 @@
//! Legacy (0.4.10) behavior for `read_file`.
//!
//! Centralizes all version-specific policy decisions for legacy-0.4.10:
//! - Generic error message for all filesystem failures (no structured variants)
//! - No gitignore enforcement (gitignored files are readable)
//!
//! The main `run_read_file()` flow calls these helpers to make version-specific
//! decisions. The execution path stays in `mod.rs`; the policy lives here.
//! Legacy (0.4.10) policy for `read_file`: a generic error message for every
//! filesystem failure (no structured variants) and no gitignore enforcement.
//! The execution path stays in `mod.rs`; only version-specific policy lives here.
use std::path::Path;
/// Exact historical read failure message for `read_file` in legacy-0.4.10.
///
/// Captured from the historical 0.4.10 implementation.
///
/// Historical 0.4.10 collapsed filesystem read failures (missing file,
/// directory path, permission denied, etc.) into the same generic message
/// without appending OS error detail.
/// Historical 0.4.10 collapsed all filesystem read failures (missing file,
/// directory path, permission denied) into this generic message without
/// appending OS error detail.
pub(crate) fn render_read_error(path: &Path) -> String {
format!("Failed to read file: {}", path.display())
}
/// Legacy 0.4.10 does not enforce gitignore — gitignored files are readable.
pub(crate) fn allows_gitignored_reads() -> bool {
true
}
@@ -1,8 +1,4 @@
//! Version-specific behavior modules for `read_file`.
//!
//! - `legacy_0_4_10`: generic error messages, no gitignore enforcement,
//! legacy marker for reminder suppression.
//! - Current behavior remains in `read_file/mod.rs` (structured error
//! variants, gitignore enforcement, confusable reminders).
//! Version-specific behavior modules for `read_file`. Legacy policy lives in
//! `legacy_0_4_10`; current behavior stays in `read_file/mod.rs`.
pub(crate) mod legacy_0_4_10;
@@ -13,9 +13,8 @@ use super::types::{ScheduledTask, SchedulerCommand, SchedulerError, SchedulerSta
const MAX_SCHEDULED_TASKS: usize = 50;
/// Build a `ScheduledTaskCreated` payload from a task. Shared between the
/// live `SchedulerCommand::Create` path and the post-restore re-announce so
/// the wire format stays in lockstep.
/// Shared between the live `SchedulerCommand::Create` path and the post-restore
/// re-announce so the wire format stays in lockstep.
fn task_created_payload(task: &ScheduledTask) -> ScheduledTaskCreated {
ScheduledTaskCreated {
task_id: task.id.clone(),
@@ -450,14 +449,12 @@ mod tests {
.unwrap();
reply_rx.await.unwrap().unwrap();
// Drain ScheduledTaskCreated.
let notif = tokio::time::timeout(Duration::from_secs(2), notif_rx.recv())
.await
.expect("created")
.expect("channel open");
assert!(matches!(notif, ToolNotification::ScheduledTaskCreated(_)));
// First fire.
let notif = tokio::time::timeout(Duration::from_secs(2), notif_rx.recv())
.await
.expect("first fire")
@@ -600,7 +597,6 @@ mod tests {
}
assert!(notif_rx.try_recv().is_err());
// All fired missed one-shots are pruned from state.
let res = shared.lock().await;
let remaining = res
.get::<State<SchedulerState>>()
@@ -643,7 +639,6 @@ mod tests {
cancel_token.cancel();
handle.await.expect("actor should complete");
// Collect all ScheduledTaskRemoved notifications.
let mut removed_ids = Vec::new();
while let Ok(notif) = notif_rx.try_recv() {
if let ToolNotification::ScheduledTaskRemoved(r) = notif {
@@ -13,15 +13,12 @@ pub use kigi_tools_api::slash_commands::{
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct SchedulerCreateInput {
/// Interval string: "5m", "2h", "1d", etc.
#[schemars(description = "Interval between executions, e.g. \"5m\", \"2h\", \"1d\"")]
pub interval: String,
/// The prompt to run on each fire.
#[schemars(description = "The prompt text to execute on each scheduled fire")]
pub prompt: String,
/// Whether the task recurs. Default true.
#[serde(
default = "default_true",
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
@@ -31,7 +28,6 @@ pub struct SchedulerCreateInput {
)]
pub recurring: bool,
/// Whether the task persists across sessions. Default false (session-only).
#[serde(
default,
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool"
@@ -39,9 +35,8 @@ pub struct SchedulerCreateInput {
#[schemars(description = "Whether the task persists across sessions. Default: false")]
pub durable: Option<bool>,
/// Whether to fire immediately on creation. Default false (wait for the
/// first interval — a "scheduled" task should not run on creation unless
/// explicitly asked to).
/// Default false: a "scheduled" task should not run on creation unless
/// explicitly asked to.
#[serde(
default,
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
@@ -10,7 +10,6 @@ pub const SCHEDULER_DELETE_TOOL_NAME: &str = "scheduler_delete";
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct SchedulerDeleteInput {
/// The scheduled task ID to cancel.
#[schemars(description = "The task ID to cancel (from scheduler_create output)")]
pub id: String,
}
@@ -11,7 +11,6 @@ pub enum SchedulerError {
TaskLimitReached(usize),
}
/// A single scheduled recurring or one-shot task.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduledTask {
@@ -1,18 +1,8 @@
//! SearchReplace tool implementation.
//!
//! This tool performs exact string replacements in files with support for:
//! - Exact string replacement (find/replace)
//! - New file creation (when `old_string` is empty)
//! - Replace all mode (`replace_all: true`)
//! - Read-before-edit validation (non-concise mode)
//! - External modification detection
//! String-edit and confusable-normalized matching helpers for the
//! SearchReplace tool.
use crate::types::output::SearchReplaceEditDetail;
// ============================================================================
// Shared string-edit helpers
// ============================================================================
/// Render a snippet of the file with line numbers around the edit.
pub(crate) fn render_snippet(
new_text: &str,
@@ -60,7 +50,6 @@ pub(crate) struct LineRange {
pub end_line: usize,
}
/// Compute the line range of the inserted text in the text
pub(crate) fn compute_line_range(text: &str, start_pos: usize, inserted_text: &str) -> LineRange {
let start_line = text[..start_pos].matches('\n').count();
let lines_in_inserted = inserted_text.split_inclusive('\n').count().max(1);
@@ -93,7 +82,6 @@ pub(crate) fn replace_using_positions(
(new_text, new_positions)
}
/// Build edit details for each replacement.
pub(crate) fn build_edit_details(
new_text: &str,
old_string: &str,
@@ -107,7 +95,6 @@ pub(crate) fn build_edit_details(
render_snippet(new_text, new_string, start_pos, context_lines);
let line_range_new = compute_line_range(new_text, start_pos, new_string);
// Extract the leading text on the line before the match starts.
// This is the text between the last '\n' before start_pos and start_pos itself.
let line_start = new_text[..start_pos]
.rfind('\n')
.map(|i| i + 1)
@@ -127,10 +114,6 @@ pub(crate) fn build_edit_details(
details
}
// ============================================================================
// Normalized (confusable-aware) matching helpers
// ============================================================================
/// A single match found via confusable-normalized comparison, expressed in
/// the original text's byte coordinates.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -184,8 +167,6 @@ pub(crate) fn find_normalized_match_positions(text: &str, pattern: &str) -> Norm
return NormalizedMatchResult::NoMatch;
}
// Collect all non-overlapping matches in normalized space and validate
// each candidate via roundtrip check.
let mut validated = Vec::new();
let mut had_rejected_candidates = false;
@@ -371,8 +352,6 @@ mod tests {
assert_eq!(new_positions, vec![0, 10]);
}
// ── Normalized matching helpers ─────────────────────────────────────
fn unwrap_matches(result: NormalizedMatchResult) -> Vec<NormalizedMatch> {
match result {
NormalizedMatchResult::Matches(m) => m,
@@ -444,8 +423,6 @@ mod tests {
);
}
// ── Partial-expansion rejection ─────────────────────────────────────
#[test]
fn partial_expansion_dash_inside_em_dash_rejected() {
let text = "\u{2014}";
@@ -493,8 +470,6 @@ mod tests {
assert_eq!(matches.len(), 1);
}
// ── Replace with new return type ────────────────────────────────────
#[test]
fn replace_normalized_matches_basic() {
let text = "say \u{201C}hello\u{201D} world";
@@ -1,4 +1,4 @@
//! SearchReplace (Edit) tool — new architecture (`Tool` trait).
//! SearchReplace (Edit) tool.
//!
//! Replaces an exact string in a file, with support for:
//! - New file creation (when `old_string` is empty)
@@ -33,7 +33,6 @@ use helpers::{
replace_normalized_matches, replace_using_positions,
};
pub(crate) const CONTEXT_LINES: usize = 3;
/// Internal version discriminant for search_replace.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SearchReplaceVersion {
Current,
@@ -61,7 +60,6 @@ pub(crate) const DESCRIPTION_FULL: &str = r#"Replace an exact string in a file.
- Read the file with `${{ tools.by_kind.read }}` before editing it.
- `${{ tools.by_kind.read }}` prefixes each line with "LINE_NUMBER→". That prefix is not part of the file: match only what comes after the , with its exact indentation.
- `${{ params.edit.old_string }}` must match exactly one place in the file. If it appears more than once, add surrounding lines to make it unique, or set `${{ params.edit.replace_all }}` to change every occurrence (handy for renaming an identifier)."#;
/// Input for the search_replace tool.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct SearchReplaceInput {
#[schemars(
@@ -87,8 +85,6 @@ fn default_true() -> bool {
true
}
/// Configuration for the search_replace tool, stored as `Params<SearchReplaceParams>` in Resources.
///
/// Replaces the old `SearchReplaceOptions` that was stored via `tool_options_as()`.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SearchReplaceParams {
@@ -96,8 +92,9 @@ pub struct SearchReplaceParams {
/// `deny_unknown_fields`. Still gates the config-time Read-tool requirement (`requires_expr`).
#[serde(default)]
pub skip_read_before_edit: bool,
/// Empty old string DOES not override the file unless its empty, by default we allow
/// empty old string to override the file content completely``
/// When true, an empty `old_string` may only create a new file or overwrite an
/// empty one, never clobber an existing non-empty file. Default false: an empty
/// `old_string` overwrites the file completely.
#[serde(default)]
pub empty_old_string_does_not_override: bool,
/// When true, enable normalized-fallback matching for Unicode confusable
@@ -117,9 +114,6 @@ pub struct SearchReplaceParams {
pub include_user_edit_hint: bool,
}
register_resource!("kigi", "SearchReplace", SearchReplaceParams);
/// SearchReplace tool — new architecture.
///
/// Replaces an exact string in a file.
#[derive(Debug, Default)]
pub struct SearchReplaceTool;
/// Core search-replace logic shared by `SearchReplaceTool` and `SearchReplaceConciseTool`.
@@ -250,10 +244,6 @@ pub(crate) async fn run_search_replace(
/// Maximum length for a single path component (file or directory name).
/// POSIX `NAME_MAX` is 255 on both macOS and Linux.
const NAME_MAX: usize = 255;
/// Validate that no path component exceeds `NAME_MAX`.
///
/// Returns `Some(SearchReplaceOutput::FilenameTooLong(..))` if any component is
/// too long, `None` if the path is valid.
fn validate_path_length(file_path: &str) -> Option<SearchReplaceOutput> {
for component in std::path::Path::new(file_path).components() {
if let std::path::Component::Normal(name) = component {
@@ -269,7 +259,6 @@ fn validate_path_length(file_path: &str) -> Option<SearchReplaceOutput> {
}
None
}
/// Handle new file creation when `old_string` is empty.
async fn handle_new_file_creation(
input: &SearchReplaceInput,
resources: SharedResources,
@@ -497,7 +486,6 @@ fn build_confusable_hint(
line_summary, read_qualifier, old_string_param, terminal_fallback
))
}
/// Handle replacement in existing file.
async fn handle_replacement(
input: &SearchReplaceInput,
resources: SharedResources,
@@ -833,7 +821,6 @@ mod tests {
use crate::{computer::local::LocalFs, types::resources::Resources};
use std::sync::Arc;
use tempfile::TempDir;
/// Set up Resources with real filesystem for tests.
fn test_resources(cwd: &std::path::Path) -> Resources {
let mut resources = Resources::new();
resources.insert(Cwd(cwd.to_path_buf()));
@@ -908,7 +895,6 @@ mod tests {
"harness skip_read_before_edit config must validate against SearchReplaceParams",
);
}
/// Consecutive edits to the same file succeed without any prior read.
#[tokio::test]
async fn consecutive_edits_succeed_without_prior_read() {
let tmp = TempDir::new().unwrap();
@@ -1773,7 +1759,6 @@ gamma delta";
"should match on longest token, got: {hint}"
);
}
/// Integration: NoMatchesFound message includes the nearest-match hint.
#[tokio::test]
async fn no_matches_message_includes_hint() {
let tmp = TempDir::new().unwrap();
@@ -2001,7 +1986,6 @@ neutTest_set);
hint
);
}
/// Integration: NoMatchesFound message includes confusable hint for smart quotes.
#[tokio::test]
async fn no_matches_includes_confusable_hint_for_smart_quotes() {
let tmp = TempDir::new().unwrap();
@@ -2034,7 +2018,6 @@ neutTest_set);
other => panic!("Expected NoMatchesFound, got {:?}", other),
}
}
/// Integration: NoMatchesFound message has NO confusable hint for plain ASCII miss.
#[tokio::test]
async fn no_matches_no_confusable_hint_for_ascii_file() {
let tmp = TempDir::new().unwrap();
@@ -2061,8 +2044,6 @@ neutTest_set);
other => panic!("Expected NoMatchesFound, got {:?}", other),
}
}
/// Integration: confusables in file but unrelated to the missed old_string
/// should NOT produce false guidance.
#[tokio::test]
async fn no_matches_no_false_confusable_guidance() {
let tmp = TempDir::new().unwrap();
@@ -2098,7 +2079,6 @@ neutTest_set);
include_user_edit_hint: false,
}
}
/// Exact match still works and returns unicode_normalized=false.
#[tokio::test]
async fn fallback_exact_match_still_preferred() {
let tmp = TempDir::new().unwrap();
@@ -2122,7 +2102,6 @@ neutTest_set);
other => panic!("Expected EditsApplied, got {:?}", other),
}
}
/// Smart quotes fallback succeeds with unicode_normalized=true.
#[tokio::test]
async fn fallback_smart_quotes() {
let tmp = TempDir::new().unwrap();
@@ -2147,7 +2126,6 @@ neutTest_set);
other => panic!("Expected EditsApplied, got {:?}", other),
}
}
/// Em-dash fallback succeeds.
#[tokio::test]
async fn fallback_em_dash() {
let tmp = TempDir::new().unwrap();
@@ -2168,7 +2146,6 @@ neutTest_set);
other => panic!("Expected EditsApplied, got {:?}", other),
}
}
/// NBSP fallback succeeds.
#[tokio::test]
async fn fallback_nbsp() {
let tmp = TempDir::new().unwrap();
@@ -2189,7 +2166,6 @@ neutTest_set);
other => panic!("Expected EditsApplied, got {:?}", other),
}
}
/// Ellipsis fallback succeeds.
#[tokio::test]
async fn fallback_ellipsis() {
let tmp = TempDir::new().unwrap();
@@ -2210,7 +2186,6 @@ neutTest_set);
other => panic!("Expected EditsApplied, got {:?}", other),
}
}
/// Multi-match + replace_all=false returns MultipleMatchesFound.
#[tokio::test]
async fn fallback_multi_match_without_replace_all() {
let tmp = TempDir::new().unwrap();
@@ -2237,7 +2212,6 @@ neutTest_set);
other => panic!("Expected MultipleMatchesFound, got {:?}", other),
}
}
/// Multi-match + replace_all=true replaces all occurrences.
#[tokio::test]
async fn fallback_multi_match_with_replace_all() {
let tmp = TempDir::new().unwrap();
@@ -2263,7 +2237,6 @@ neutTest_set);
other => panic!("Expected EditsApplied, got {:?}", other),
}
}
/// Fallback disabled by default — smart quotes produce NoMatchesFound.
#[tokio::test]
async fn fallback_disabled_by_default() {
let tmp = TempDir::new().unwrap();
@@ -2286,7 +2259,6 @@ neutTest_set);
result
);
}
/// Exact match exists → exact path wins even when confusables present elsewhere.
#[tokio::test]
async fn fallback_exact_match_wins_over_normalized() {
let tmp = TempDir::new().unwrap();
@@ -2312,7 +2284,6 @@ neutTest_set);
other => panic!("Expected EditsApplied, got {:?}", other),
}
}
/// Replacement preserves valid UTF-8 and only mutates matched region.
#[tokio::test]
async fn fallback_preserves_surrounding_content() {
let tmp = TempDir::new().unwrap();
@@ -2374,7 +2345,6 @@ neutTest_set);
other => panic!("Expected EditsApplied, got {:?}", other),
}
}
/// Single-line match within a CRLF file works and preserves CRLF.
#[tokio::test]
async fn crlf_single_line_match_preserves_line_endings() {
let tmp = TempDir::new().unwrap();
@@ -2398,7 +2368,6 @@ neutTest_set);
other => panic!("Expected EditsApplied, got {:?}", other),
}
}
/// LF-only files are unaffected by CRLF normalization logic.
#[tokio::test]
async fn lf_only_file_unaffected_by_crlf_logic() {
let tmp = TempDir::new().unwrap();
@@ -2422,7 +2391,6 @@ neutTest_set);
other => panic!("Expected EditsApplied, got {:?}", other),
}
}
/// Replace-all mode works correctly with CRLF files.
#[tokio::test]
async fn crlf_replace_all() {
let tmp = TempDir::new().unwrap();
@@ -70,8 +70,6 @@ async fn build_render_context(
})
}
/// Downgrade structured error variants to generic `InvalidInput` for legacy,
/// restoring exact historical 0.4.10 wording.
pub(crate) async fn downgrade_structured_errors(
output: SearchReplaceOutput,
resources: &SharedResources,
@@ -1,8 +1,6 @@
//! Version-specific behavior modules for `search_replace`.
//! Version-specific behavior for `search_replace`.
//!
//! - `legacy_0_4_10`: error downgrade logic that restores exact historical
//! 0.4.10 wording by collapsing structured error variants to `InvalidInput`.
//! - Current behavior remains in `search_replace/mod.rs` (structured errors,
//! gitignore enforcement, confusable diagnostics).
//! `legacy_0_4_10` restores exact historical 0.4.10 error wording; current
//! behavior lives in `search_replace/mod.rs`.
pub(crate) mod legacy_0_4_10;
@@ -11,9 +11,9 @@ use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use anyhow::Context;
const IMAGE_MAX_BYTES: u64 = 1024 * 1024 * 1024; // 1 GB
const VIDEO_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024; // 2 GB
const DEFAULT_MAX_BYTES: u64 = 1024 * 1024 * 1024; // 1 GB
const IMAGE_MAX_BYTES: u64 = 1024 * 1024 * 1024;
const VIDEO_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024;
const DEFAULT_MAX_BYTES: u64 = 1024 * 1024 * 1024;
fn budget_for(dir_name: &str) -> u64 {
match dir_name {
@@ -137,20 +137,17 @@ async fn scan_dir_stats(dir: &Path) -> Result<(u32, u64), std::io::Error> {
let name = entry.file_name();
let name_str = name.to_string_lossy();
// Remove orphan temp files from interrupted writes
if name_str.starts_with(".tmp") {
let _ = tokio::fs::remove_file(entry.path()).await;
continue;
}
// Track the highest numbered file (any extension)
if let Some(stem) = name_str.split_once('.').map(|(s, _)| s)
&& let Ok(n) = stem.parse::<u32>()
{
max = max.max(n);
}
// Sum bytes for budget init
if let Ok(meta) = entry.metadata().await {
total_bytes += meta.len();
}
@@ -225,7 +222,7 @@ mod tests {
.unwrap();
let (max, bytes) = scan_dir_stats(tmp.path()).await.unwrap();
assert_eq!(max, 3);
assert_eq!(bytes, 4 + 6 + 2); // aaaa + bbbbbb + cc
assert_eq!(bytes, 4 + 6 + 2);
}
#[tokio::test]
@@ -23,11 +23,9 @@ use super::types::{
use crate::register_resource;
use kigi_tool_runtime::ToolError;
/// Abstraction over the mechanism used to spawn, query, and cancel subagents.
///
/// Injected into `Resources` as [`SubagentBackendResource`] so that
/// `TaskTool`, `TaskOutputTool`, and `KillTaskTool` can operate
/// identically regardless of the underlying transport.
/// Injected into `Resources` as [`SubagentBackendResource`] so `TaskTool`,
/// `TaskOutputTool`, and `KillTaskTool` dispatch identically regardless of
/// the underlying transport.
#[async_trait::async_trait]
pub trait SubagentBackend: Send + Sync + 'static {
/// Spawn a subagent and await its result.
@@ -48,7 +46,6 @@ pub trait SubagentBackend: Send + Sync + 'static {
timeout_ms: Option<u64>,
) -> Option<SubagentSnapshot>;
/// Request cancellation of a subagent by ID.
async fn cancel(&self, id: &str) -> SubagentCancelOutcome;
/// Validate a subagent type synchronously before spawning.
@@ -554,8 +551,6 @@ mod tests {
assert!(snap.is_none());
}
// ── validate_type ────────────────────────────────────────────────
#[tokio::test]
async fn channel_backend_validate_type_round_trips_outcome() {
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
@@ -676,8 +671,6 @@ mod tests {
holder.abort();
}
// ── describe_subagent_type ───────────────────────────────────────
#[tokio::test]
async fn channel_backend_describe_round_trips_summary() {
use super::super::types::{SubagentDescribeOutcome, SubagentTypeSummary};
@@ -30,17 +30,9 @@ use kigi_tool_types::{SubagentCompletedOutput, SubagentIsolationMode, TaskToolIn
/// the first subagent is depth 1. Subagents cannot spawn further subagents.
pub const MAX_SUBAGENT_DEPTH: u32 = 1;
// ───────────────────────────────────────────────────────────────────────────
// Tool implementation
// ───────────────────────────────────────────────────────────────────────────
#[derive(Debug, Default)]
pub struct TaskTool;
// ───────────────────────────────────────────────────────────────────────────
// Tests
// ───────────────────────────────────────────────────────────────────────────
impl crate::types::tool_metadata::ToolMetadata for TaskTool {
fn kind(&self) -> ToolKind {
ToolKind::Task
@@ -117,7 +109,6 @@ impl kigi_tool_runtime::Tool for TaskTool {
use crate::types::tool_metadata::shared_resources;
let resources = shared_resources(&ctx)?;
// 1. Depth check
let (depth, backend, model_validator, parent_session_id, parent_prompt_id) = {
let res = resources.lock().await;
@@ -201,7 +192,6 @@ impl kigi_tool_runtime::Tool for TaskTool {
but not both.",
));
}
// Non-existent path alongside worktree — clear it so worktree wins.
tracing::debug!(
cwd = %cwd.as_deref().unwrap_or(""),
"clearing non-existent cwd path because isolation=worktree is set"
@@ -211,7 +201,6 @@ impl kigi_tool_runtime::Tool for TaskTool {
cwd
};
// Validate that cwd points to an existing directory (skip when resuming).
if let Some(ref cwd_path) = cwd
&& resume_from.is_none()
{
@@ -226,8 +215,8 @@ impl kigi_tool_runtime::Tool for TaskTool {
}
}
// 2. Eager validation — catch unknown / disabled / not-allowed
// types before the fire-and-forget background spawn.
// Eager validation — catch unknown / disabled / not-allowed
// types before the fire-and-forget background spawn.
match backend
.backend()
.validate_type(&input.subagent_type, &parent_session_id)
@@ -284,7 +273,6 @@ impl kigi_tool_runtime::Tool for TaskTool {
}
}
// 3. Build the subagent request
let id = input
.task_id
.clone()
@@ -321,7 +309,7 @@ impl kigi_tool_runtime::Tool for TaskTool {
result_tx,
};
// 4. Background mode: fire-and-forget via backend.spawn().
// Background mode: fire-and-forget via backend.spawn().
// Coordinator stores the result for TaskOutputTool polling.
// Both transport errors and coordinator rejections are logged so
// late failures (worktree creation, etc.) remain visible.
@@ -368,10 +356,9 @@ impl kigi_tool_runtime::Tool for TaskTool {
));
}
// 5. Blocking mode (default): spawn via backend and await result
let result = backend.backend().spawn(request).await?;
// 5b. The await budget expired and the coordinator auto-backgrounded the
// The await budget expired and the coordinator auto-backgrounded the
// still-running child — return a task_id to poll, like the background
// branch above (the result arrives via auto-wake or a later poll).
if result.backgrounded {
@@ -397,7 +384,6 @@ impl kigi_tool_runtime::Tool for TaskTool {
));
}
// 6. Return result
if result.success {
let resume_from_hint = result.subagent_id.clone();
let persona_hint: Option<String> = None;
@@ -498,7 +484,7 @@ mod tests {
let (backend, _rx) = make_backend();
let mut resources = Resources::new();
resources.insert(backend);
resources.insert(SubagentDepthCounter(MAX_SUBAGENT_DEPTH)); // at limit
resources.insert(SubagentDepthCounter(MAX_SUBAGENT_DEPTH));
resources.insert(SessionIdResource("test-session".to_string()));
resources.insert(CurrentPromptIdResource("prompt-123".to_string()));
@@ -531,7 +517,7 @@ mod tests {
let (backend, _rx) = make_backend();
let mut resources = Resources::new();
resources.insert(backend);
resources.insert(SubagentDepthCounter(1)); // first-level subagent
resources.insert(SubagentDepthCounter(1));
resources.insert(SessionIdResource("child-session".to_string()));
resources.insert(CurrentPromptIdResource("prompt-456".to_string()));
@@ -604,7 +590,6 @@ mod tests {
let tool = TaskTool;
let shared = resources.into_shared();
// Spawn a task that will handle the request
let handle = tokio::spawn(async move {
let request = unwrap_spawn(rx.recv().await.unwrap());
assert_eq!(request.subagent_type, "explore");
@@ -663,7 +648,7 @@ mod tests {
let (backend, mut rx) = make_backend();
let mut resources = Resources::new();
resources.insert(backend);
resources.insert(SubagentDepthCounter(0)); // top-level session
resources.insert(SubagentDepthCounter(0));
resources.insert(SessionIdResource("parent-session".to_string()));
resources.insert(CurrentPromptIdResource("prompt-123".to_string()));
@@ -719,7 +704,6 @@ mod tests {
let tool = TaskTool;
let shared = resources.into_shared();
// Spawn a task that drops the result_tx without sending
let handle = tokio::spawn(async move {
let request = unwrap_spawn(rx.recv().await.unwrap());
drop(request.result_tx);
@@ -771,7 +755,7 @@ mod tests {
let result = kigi_tool_runtime::Tool::run(
&TaskTool,
test_ctx(resources.into_shared()),
task_input("general-purpose", false), // blocking mode
task_input("general-purpose", false),
)
.await
.expect("auto-backgrounded blocking spawn returns Ok");
@@ -1135,8 +1119,6 @@ mod tests {
assert!(capture_rx.try_recv().is_err(), "must fire exactly once");
}
// ── Runtime overrides serde tests ─────────────────
#[test]
fn runtime_overrides_parse() {
let input: TaskToolInput = serde_json::from_str(
@@ -1274,8 +1256,6 @@ mod tests {
}
}
// -- Isolation mode tests --
#[test]
fn isolation_defaults_to_none() {
let input: TaskToolInput =
@@ -1337,8 +1317,6 @@ mod tests {
}
}
// -- Capability mode enforcement tests --
fn tc(id: &str, kind: crate::types::tool::ToolKind) -> crate::registry::types::ToolConfig {
let mut c = crate::registry::types::ToolConfig::from_id(id);
c.kind = Some(kind);
@@ -1444,8 +1422,6 @@ mod tests {
);
}
// ── resume_from tests ────────────────────────────────────────────
#[test]
fn task_tool_input_has_no_fork_context_field() {
let _: TaskToolInput =
@@ -1682,8 +1658,6 @@ mod tests {
}
}
// ── cwd tests ────────────────────────────────────────────────────
#[test]
fn cwd_defaults_to_none() {
let input: TaskToolInput =
@@ -2270,8 +2244,6 @@ mod tests {
}
}
// ── model override tests ─────────────────────────────────────
#[tokio::test]
async fn model_threads_to_runtime_overrides() {
let (backend, mut rx) = make_backend();
@@ -24,8 +24,6 @@ use tokio::sync::{mpsc, oneshot};
use crate::register_resource;
// Request / Response
/// Request emitted by TaskTool, received by MvpAgent coordinator.
#[derive(Educe)]
#[educe(Debug)]
@@ -41,14 +39,13 @@ pub struct SubagentRequest {
/// Used to cancel only the subagents spawned by the currently-cancelled turn,
/// without affecting background subagents from earlier turns.
pub parent_prompt_id: Option<String>,
/// Resume from a previously completed subagent's conversation.
/// Resume from a completed subagent's conversation.
/// Inherits raw transcript, tool state, and model. System prompt is
/// freshly rendered.
pub resume_from: Option<String>,
/// Explicit working directory for the child session.
/// Validated at spawn time in `handle_subagent_request()`.
pub cwd: Option<String>,
/// Runtime overrides for the child agent.
pub runtime_overrides: SubagentRuntimeOverrides,
/// Whether this subagent was launched with `run_in_background: true`.
///
@@ -63,15 +60,10 @@ pub struct SubagentRequest {
/// Harness-only: seed child with normalized parent conversation, then append
/// `prompt`. Not on TaskToolInput. Successful `resume_from` takes precedence.
pub fork_context: bool,
/// Oneshot channel for the coordinator to send back the result.
#[educe(Debug(ignore))]
pub result_tx: oneshot::Sender<SubagentResult>,
}
/// Per-spawn dynamic runtime overrides for a subagent.
///
/// Optional values inherit from the parent or role default. Explicit values take
/// precedence over role defaults.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ModelOverrideProvenance {
/// Internal harness, role, persona, or config resolution.
@@ -81,9 +73,12 @@ pub enum ModelOverrideProvenance {
Tool,
}
/// Per-spawn dynamic runtime overrides for a subagent.
///
/// Optional values inherit from the parent or role default. Explicit values take
/// precedence over role defaults.
#[derive(Debug, Clone, Default)]
pub struct SubagentRuntimeOverrides {
/// Override the model (e.g. "test-model").
pub model: Option<String>,
/// Whether `model` came from a model-facing Task call or internal harness logic.
pub model_override_provenance: ModelOverrideProvenance,
@@ -107,7 +102,6 @@ pub struct SubagentRuntimeOverrides {
pub harness_agent_type: Option<String>,
}
/// Re-export of [`kigi_tool_types::is_not_sentinel`] for existing call sites.
pub use kigi_tool_types::is_not_sentinel;
/// Sanitize a model-emitted `cwd` argument for the `task` tool.
@@ -147,7 +141,6 @@ pub trait SubagentCapabilityModeExt {
/// `ToolConfig::from_id()`) are preserved unconditionally.
fn filter_tool_config(self, config: &mut crate::registry::types::ToolServerConfig);
/// Return the set of `ToolKind`s allowed under this capability mode.
fn allowed_tool_kinds(self) -> &'static [crate::types::tool::ToolKind];
}
@@ -199,7 +192,6 @@ impl SubagentCapabilityModeExt for SubagentCapabilityMode {
prune_orphaned_background_task_tools(config);
}
/// Return the set of `ToolKind`s allowed under this capability mode.
fn allowed_tool_kinds(self) -> &'static [crate::types::tool::ToolKind] {
use crate::types::tool::ToolKind;
match self {
@@ -304,7 +296,6 @@ pub struct SubagentResult {
/// bump rather than a full copy. Subagent outputs can be arbitrarily
/// large (entire transcript), so this matters at scale.
pub output: Arc<str>,
/// Error message if the subagent failed.
pub error: Option<String>,
/// True if the subagent was cancelled (by user or model).
/// Distinct from failure — cancellation is intentional.
@@ -358,19 +349,15 @@ impl SubagentResult {
}
}
// Query protocol
/// Query sent by TaskOutputTool, received by MvpAgent coordinator.
#[derive(Educe)]
#[educe(Debug)]
pub struct SubagentQueryRequest {
/// The subagent ID to look up.
pub subagent_id: String,
/// If true, coordinator waits for completion (up to timeout) before responding.
pub block: bool,
/// Max wait time in ms when blocking. Default 30s.
pub timeout_ms: Option<u64>,
/// Oneshot for the coordinator to send back the snapshot.
#[educe(Debug(ignore))]
pub respond_to: oneshot::Sender<Option<SubagentSnapshot>>,
}
@@ -387,7 +374,6 @@ pub struct SubagentSnapshot {
pub started_at_epoch_ms: u64,
/// Elapsed wall-clock time in milliseconds.
pub duration_ms: u64,
/// Persona used by this subagent, if any.
pub persona: Option<String>,
}
@@ -439,8 +425,6 @@ impl SubagentSnapshotStatus {
}
}
// Cancel protocol
#[derive(Debug, Clone)]
pub enum SubagentCancelTarget {
SubagentId(String),
@@ -539,8 +523,6 @@ pub struct SubagentMarkUsageNotAppliedRequest {
pub respond_to: oneshot::Sender<()>,
}
// Validate-type protocol
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum SubagentValidateTypeOutcome {
@@ -566,8 +548,6 @@ pub struct SubagentValidateTypeRequest {
pub respond_to: oneshot::Sender<SubagentValidateTypeOutcome>,
}
// Describe-type protocol
/// Outcome of a `describe_subagent_type` round-trip.
///
/// Mirrors [`SubagentValidateTypeOutcome`] but, on success, additionally
@@ -655,8 +635,6 @@ pub enum SubagentEvent {
DescribeType(SubagentDescribeRequest),
}
// Resource types
/// Unified sender for all subagent coordinator events.
///
/// Cloned into each session's `ToolContext` / `ToolBridge Resources` so
@@ -669,8 +647,6 @@ pub struct SubagentEventSender(#[educe(Debug(ignore))] pub mpsc::UnboundedSender
register_resource!("kigi", "SubagentEventSender", SubagentEventSender);
// Mid-turn monitor event buffer
/// A monitor event notification to be surfaced as a `<system-reminder>` mid-turn.
#[derive(Debug, Clone)]
pub struct MonitorEventNotification {
@@ -714,8 +690,6 @@ pub fn drain_owned(
buffer.drain_matching(|e| e.owned_by_session(my_owner))
}
// Active subagent listing (compaction)
/// Lightweight summary of a running subagent.
///
/// This is the single shared definition of this type. The coordinator in
@@ -893,7 +867,6 @@ mod tests {
use super::SubagentCapabilityModeExt;
use super::is_valid_resume_id;
/// Create a `ToolConfig` with the given id and kind set.
fn tc(id: &str, kind: ToolKind) -> ToolConfig {
let mut c = ToolConfig::from_id(id);
c.kind = Some(kind);
@@ -180,7 +180,6 @@ impl TaskOutputTool {
return Ok(format_subagent_snapshot(&snapshot));
}
// Neither found
{
let msg = if is_legacy {
render_legacy_task_output_not_found(task_id)
@@ -348,11 +347,6 @@ pub(crate) async fn resolve_tasks(
pending_subagent_ids,
}
}
//
// Uses `TerminalBackend::wait_for_completion` for bash tasks (event-driven via
// the underlying `Notify`) and `SubagentQueryRequest { block: true }` for
// subagents (blocks in the coordinator until the child session finishes).
// No 200ms polling loop — wakeups happen on actual state transitions.
/// Aborts all wrapped helper-wait tasks when dropped.
///
@@ -482,15 +476,6 @@ pub(crate) async fn wait_all_event_driven(
}
}
//
// Historical fixture captured from the 0.4.10 implementation.
//
// In 0.4.10, get_task_output returned:
// Err(ToolError::ProcessManagerError(format!("Task {} not found", input.task_id)))
//
// The meaningful customer-facing message content is the inner string.
// Subagent wording is out of scope — subagents didn't exist in 0.4.10.
/// Exact historical not-found message for `get_task_output` in legacy-0.4.10.
fn render_legacy_task_output_not_found(task_id: &str) -> String {
format!("Task {} not found", task_id)
@@ -939,16 +924,12 @@ mod tests {
// unbounded blocking wait wedged the turn for hours).
#[test]
fn capped_wait_timeout_clamps_and_defaults() {
// Omitted -> default 30s.
assert_eq!(capped_wait_timeout(None), DEFAULT_WAIT_TIMEOUT);
// Small value -> unchanged.
assert_eq!(
capped_wait_timeout(Some(5_000)),
Duration::from_millis(5_000)
);
// Huge value (10h) -> clamped to the cap.
assert_eq!(capped_wait_timeout(Some(36_000_000)), MAX_WAIT_BLOCK);
// Exactly at the cap (10m) -> unchanged.
assert_eq!(capped_wait_timeout(Some(600_000)), MAX_WAIT_BLOCK);
}
@@ -959,9 +940,6 @@ mod tests {
kigi_tool_runtime::Tool::id(&tool).as_str(),
"get_task_output"
);
// The static fallback is the shared builder's default kigi
// rendering (monitor + task + bash + read present): concrete names, no
// leftover template markers.
let desc = ToolMetadata::description_template(&tool);
assert!(desc.contains("Get output and status from a background task"));
// Must name "monitor" so the model connects polling a monitor to this tool.
@@ -1165,7 +1143,7 @@ mod tests {
},
)
.await
.unwrap(); // Should be Ok, not Err
.unwrap();
match result {
TaskOutputOutput::TaskNotFound(msg) => {
@@ -1205,7 +1183,7 @@ mod tests {
}
async fn get_task(&self, _task_id: &str) -> Option<TaskSnapshot> {
None // requested task not found
None
}
async fn wait_for_completion(
@@ -1242,7 +1220,7 @@ mod tests {
},
)
.await
.unwrap(); // Should be Ok, not Err
.unwrap();
match result {
TaskOutputOutput::TaskNotFound(msg) => {
@@ -1257,7 +1235,6 @@ mod tests {
#[tokio::test]
async fn get_task_not_found_block_mode_lists_known_tasks() {
// Verify that blocking mode also provides helpful errors.
struct MockTerminalBlockNotFound;
#[async_trait::async_trait]
@@ -1289,7 +1266,7 @@ mod tests {
_task_id: &str,
_timeout: Option<Duration>,
) -> Option<TaskSnapshot> {
None // task not found even when blocking
None
}
async fn list_tasks(&self) -> Vec<TaskSnapshot> {
@@ -1354,10 +1331,10 @@ mod tests {
#[tokio::test]
async fn uses_tool_name_mapping_for_truncation_hint() {
let mut snapshot = make_snapshot("task-4", true, Some(0));
snapshot.output = "x".repeat(500_000); // large output triggers truncation
// large output exceeds the default cap and triggers truncation
snapshot.output = "x".repeat(500_000);
let mut resources = resources_with_terminal(Some(snapshot));
// Set a custom model-facing name for the Read tool
resources.insert(TemplateRenderer::new(
[(ToolKind::Read, "Read".to_string())].into(),
Default::default(),
@@ -1430,7 +1407,6 @@ mod tests {
.unwrap();
assert!(with_timeout.waits());
// Legacy block is ignored; wait is driven only by timeout_ms.
let legacy_block_false: TaskOutputToolInput = serde_json::from_value(serde_json::json!({
"task_ids": ["t"],
"block": false,
@@ -1470,11 +1446,10 @@ mod tests {
#[tokio::test]
async fn respects_truncation_config() {
let mut snapshot = make_snapshot("task-6", true, Some(0));
snapshot.output = "x".repeat(10_000); // 10KB
snapshot.output = "x".repeat(10_000);
let mut resources = resources_with_terminal(Some(snapshot));
// Set a custom truncation config with 5KB limit
let mut trunc = crate::types::context::TruncationConfig::default();
trunc
.per_tool_max_output_bytes
@@ -1505,16 +1480,6 @@ mod tests {
}
}
// ── Legacy message parity fixture tests ────────────────────────
//
// These tests verify exact historical wording for legacy-0.4.10.
// Fixture source: the historical 0.4.10 task_output implementation.
//
// Historical 0.4.10 message (inner string from ToolError::ProcessManagerError):
// "Task {task_id} not found"
//
// Subagent wording is out of scope — subagents didn't exist in 0.4.10.
#[tokio::test]
async fn legacy_get_task_not_found_exact_historical_message() {
let resources = resources_with_terminal(None);
@@ -1547,8 +1512,6 @@ mod tests {
#[tokio::test]
async fn current_get_task_not_found_includes_discoverability() {
// Current (non-legacy) path must still include known task IDs
// or "No background tasks" text for discoverability.
let resources = resources_with_terminal(None);
let tool = TaskOutputTool;
@@ -1574,8 +1537,6 @@ mod tests {
}
}
// ── Subagent running snapshot formatting ─────────────────────────────
#[test]
fn format_initializing_subagent_reports_status() {
let snap = SubagentSnapshot {
@@ -1644,7 +1605,6 @@ mod tests {
assert_eq!(r.status, "running");
assert!(r.exit_code.is_none());
assert!(r.ended.is_none());
// Progress line
assert!(
r.output.contains("turn 3"),
"should contain turn count: {}",
@@ -1665,13 +1625,11 @@ mod tests {
"should contain context pct: {}",
r.output
);
// Tools used
assert!(
r.output.contains("bash, read_file, grep"),
"should contain tools list: {}",
r.output
);
// Errors
assert!(
r.output.contains("Errors: 0"),
"should contain error count: {}",
@@ -1790,8 +1748,6 @@ mod tests {
}
}
// ── Subagent backend query fallback tests ────────────────────────────
/// Build resources with a terminal that returns None (task not found)
/// and a SubagentBackendResource backed by the unified event channel.
fn resources_with_backend_query() -> (
@@ -1814,7 +1770,6 @@ mod tests {
(resources, rx)
}
/// Extract a `SubagentQueryRequest` from a `SubagentEvent`, panicking on wrong variant.
fn unwrap_query(
event: crate::implementations::kigi::task::types::SubagentEvent,
) -> crate::implementations::kigi::task::types::SubagentQueryRequest {
@@ -1,9 +1,5 @@
//! TodoWrite — new-architecture implementation.
//!
//! Reuses the core logic (`validate_no_duplicate_ids`, `apply_replace`,
//! `apply_merge`, `summarize_todo_state`) from the old `implementations::todo`
//! module. State is stored as `State<TodoState>` in Resources instead of
//! `ToolState.todo_state`.
//! `TodoWrite` tool: the model-maintained task list, persisted as
//! `State<TodoState>` in Resources.
use std::fmt::Write;
@@ -72,7 +68,6 @@ pub(crate) fn apply_replace(
pub(crate) fn apply_merge(state: &mut TodoState, updates: &[TodoUpdate]) -> Result<(), TodoError> {
for u in updates {
if state.update(&u.id, u.content.as_deref(), u.status) {
// Existing item partial update succeeded, content was optional.
continue;
}
let content = if u.has_no_content() {
@@ -252,7 +247,7 @@ pub struct TodoWriteInput {
pub todos: Vec<TodoUpdate>,
}
/// New-architecture `TodoWrite` tool.
/// `TodoWrite` tool.
///
/// State: `State<TodoState>` — persisted across calls via Resources serde.
/// Params: `()` — no per-tool configuration.
@@ -368,8 +363,6 @@ mod tests {
use crate::types::resources::Resources;
use crate::types::tool_metadata::test_ctx;
// -- Helpers --
fn make_update(id: &str, content: Option<&str>, status: Option<TodoStatus>) -> TodoUpdate {
TodoUpdate {
id: id.to_owned(),
@@ -386,8 +379,6 @@ mod tests {
}
}
// -- Tests --
#[test]
fn name_and_description() {
use crate::types::tool_metadata::ToolMetadata;
@@ -419,7 +410,6 @@ mod tests {
assert!(output.summary_for_prompt.contains("Task A"));
assert!(output.summary_for_prompt.contains("Task B"));
// State persists in Resources
let res = shared.lock().await;
let state = res.get::<State<TodoState>>().unwrap();
assert_eq!(state.0.todo_items().count(), 2);
@@ -431,7 +421,6 @@ mod tests {
let resources = Resources::new();
let shared = resources.into_shared();
// Seed initial state
let input1 = TodoWriteInput {
merge: false,
todos: vec![make_update(
@@ -444,7 +433,6 @@ mod tests {
.await
.unwrap();
// Replace with new
let input2 = TodoWriteInput {
merge: false,
todos: vec![make_update(
@@ -469,7 +457,6 @@ mod tests {
let resources = Resources::new();
let shared = resources.into_shared();
// Create initial items
let input1 = TodoWriteInput {
merge: false,
todos: vec![
@@ -481,7 +468,6 @@ mod tests {
.await
.unwrap();
// Merge: mark item 1 completed (no content), add item 3
let input2 = TodoWriteInput {
merge: true,
todos: vec![
@@ -496,7 +482,6 @@ mod tests {
);
assert_eq!(output.todos.len(), 3);
// Item 1 content preserved, status updated
let item1 = output
.todos
.iter()
@@ -510,7 +495,6 @@ mod tests {
let tool = TodoWriteTool;
let resources = Resources::new();
// Merge into empty state — should not error
let input = TodoWriteInput {
merge: true,
todos: vec![make_update("explore", None, Some(TodoStatus::Completed))],
@@ -521,7 +505,6 @@ mod tests {
.unwrap(),
);
assert_eq!(output.todos.len(), 1);
// Id used as fallback content
assert_eq!(output.todos[0].content, "explore");
assert_eq!(output.todos[0].status, TodoStatus::Completed);
}
@@ -580,7 +563,6 @@ mod tests {
.unwrap(),
);
// state field should match what's in Resources
assert!(!output.state.is_empty());
assert_eq!(output.state.todo_items().count(), 1);
}
@@ -591,7 +573,6 @@ mod tests {
let mut resources = Resources::new();
resources.register_state::<TodoState>();
// Create some state
let input = TodoWriteInput {
merge: false,
todos: vec![
@@ -604,7 +585,6 @@ mod tests {
.await
.unwrap();
// Serialize
let res = shared.lock().await;
let snapshot = res.serialize();
let state_map = snapshot.get("state").unwrap();
@@ -613,7 +593,6 @@ mod tests {
"TodoState should serialize under 'kigi.Todo'"
);
// Deserialize into fresh Resources
let mut resources2 = Resources::new();
resources2.register_state::<TodoState>();
let data: std::collections::HashMap<
@@ -622,7 +601,6 @@ mod tests {
> = serde_json::from_value(snapshot).unwrap();
resources2.load_from(data);
// Verify state was restored
let restored = resources2.get::<State<TodoState>>().unwrap();
assert_eq!(restored.0.todo_items().count(), 2);
let items: Vec<_> = restored.0.todo_items().collect();
@@ -656,8 +634,6 @@ mod tests {
.unwrap_or_else(|| panic!("item {id} not found in state"))
}
// ── replace (merge=false) ────────────────────────────────────────
#[test]
fn replace_without_content_falls_back_to_id() {
let mut state = TodoState::default();
@@ -669,7 +645,7 @@ mod tests {
apply_replace(&mut state, &updates).unwrap();
let item = get_item(&state, "build_project");
assert_eq!(item.content, "build_project"); // id used as fallback
assert_eq!(item.content, "build_project");
assert_eq!(item.status, TodoStatus::Pending);
}
@@ -709,13 +685,10 @@ mod tests {
)];
apply_replace(&mut state, &updates).unwrap();
// Old item is gone.
assert!(!state.todo_items_with_ids().any(|(id, _)| *id == "old"));
assert_eq!(get_item(&state, "new").content, "New task");
}
// ── merge (merge=true) ───────────────────────────────────────────
#[test]
fn merge_existing_item_status_only() {
// The core use-case: mark in_progress → completed without sending content.
@@ -725,7 +698,7 @@ mod tests {
let item = get_item(&state, "1");
assert_eq!(item.status, TodoStatus::Completed);
assert_eq!(item.content, "Build the project"); // unchanged
assert_eq!(item.content, "Build the project");
}
#[test]
@@ -767,7 +740,7 @@ mod tests {
apply_merge(&mut state, &updates).unwrap();
let item = get_item(&state, "explore_codebase");
assert_eq!(item.content, "explore_codebase"); // id used as fallback
assert_eq!(item.content, "explore_codebase");
assert_eq!(item.status, TodoStatus::Completed);
}
@@ -799,24 +772,20 @@ mod tests {
fn merge_mixed_existing_and_new() {
let mut state = seed_state(&[("exist", "Existing task", TodoStatus::InProgress)]);
let updates = vec![
// Update existing — content omitted, just flip status.
make_update("exist", None, Some(TodoStatus::Completed)),
// Brand-new item — content required.
make_update("fresh", Some("New task"), Some(TodoStatus::Pending)),
];
apply_merge(&mut state, &updates).unwrap();
let existing = get_item(&state, "exist");
assert_eq!(existing.status, TodoStatus::Completed);
assert_eq!(existing.content, "Existing task"); // preserved
assert_eq!(existing.content, "Existing task");
let fresh = get_item(&state, "fresh");
assert_eq!(fresh.content, "New task");
assert_eq!(fresh.status, TodoStatus::Pending);
}
// ── duplicate id validation ──────────────────────────────────────
#[test]
fn duplicate_ids_rejected_unit() {
let updates = vec![
@@ -836,8 +805,6 @@ mod tests {
validate_no_duplicate_ids(&updates).unwrap();
}
// ── regression: missing merge=true auto-upgrade ────────────────────
#[tokio::test]
async fn missing_merge_flag_auto_upgrades_when_status_only() {
// Regression: status-only update without merge=true must not wipe content.
@@ -845,7 +812,6 @@ mod tests {
let resources = Resources::new();
let shared = resources.into_shared();
// Create todos with content
let input1 = TodoWriteInput {
merge: false,
todos: vec![
@@ -858,9 +824,8 @@ mod tests {
.await
.unwrap();
// Status-only update without merge=true
let input2 = TodoWriteInput {
merge: false, // model forgot merge: true
merge: false,
todos: vec![
make_update("1", None, Some(TodoStatus::Completed)),
make_update("2", None, Some(TodoStatus::Completed)),
@@ -873,7 +838,6 @@ mod tests {
.unwrap(),
);
// Content must be preserved, not replaced with id fallback.
assert_eq!(output.todos.len(), 3);
assert_eq!(output.todos[0].content, "Explore codebase");
assert_eq!(output.todos[0].status, TodoStatus::Completed);
@@ -883,8 +847,6 @@ mod tests {
assert_eq!(output.todos[2].status, TodoStatus::InProgress);
}
// ── regression: merge with null content should never error ────────
#[test]
fn merge_after_replace_status_update_with_null_content() {
// Reproduces the exact scenario from the bug report:
@@ -892,7 +854,6 @@ mod tests {
// 2. Merge updates 2 items with content=null, status changed
let mut state = TodoState::default();
// Step 1: replace (merge=false)
let initial = vec![
make_update(
"explore_codebase",
@@ -912,14 +873,12 @@ mod tests {
];
apply_replace(&mut state, &initial).unwrap();
// Step 2: merge (merge=true) — content=null, just status changes
let updates = vec![
make_update("explore_codebase", None, Some(TodoStatus::Completed)),
make_update("analyze_and_propose", None, Some(TodoStatus::InProgress)),
];
apply_merge(&mut state, &updates).unwrap();
// Statuses flipped, content preserved from step 1.
assert_eq!(
get_item(&state, "explore_codebase").status,
TodoStatus::Completed
@@ -936,15 +895,12 @@ mod tests {
get_item(&state, "analyze_and_propose").content,
"Analyze current SQLite min version"
);
// Third item unchanged.
assert_eq!(
get_item(&state, "implementation").status,
TodoStatus::Pending
);
}
// ── regression: empty-string content must not wipe existing content ──
#[test]
fn merge_existing_item_empty_string_content_preserves_original() {
// Model sends content: "" instead of omitting it. Must not wipe.
@@ -954,7 +910,7 @@ mod tests {
let item = get_item(&state, "1");
assert_eq!(item.status, TodoStatus::Completed);
assert_eq!(item.content, "Build the project"); // unchanged
assert_eq!(item.content, "Build the project");
}
#[test]
@@ -10,10 +10,6 @@ use crate::types::tool::{ToolKind, ToolNamespace};
pub use kigi_tools_api::slash_commands::UPDATE_GOAL_TOOL_NAME;
// ---------------------------------------------------------------------------
// Input schema
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct UpdateGoalInput {
#[serde(
@@ -38,9 +34,7 @@ pub struct UpdateGoalInput {
pub blocked_reason: Option<String>,
}
// ---------------------------------------------------------------------------
// Channel types — inserted into Resources, read by SessionActor
// ---------------------------------------------------------------------------
/// Outcome of an `update_goal` call as delivered by the session actor.
#[derive(Debug)]
@@ -120,7 +114,7 @@ pub enum RejectReason {
/// `harness_no_ack` "dropped the response channel" error).
HarnessDisabled,
/// Reserved for strict-mode eviction surfacing; not currently
/// constructed (the new design acks evicted entries as
/// constructed (the design acks evicted entries as
/// `DeferredToTurnEnd` at their own defer time).
PendingQueueEvicted,
/// The goal auto-paused mid-drain (cap, stall/no_progress, or
@@ -178,10 +172,6 @@ impl std::fmt::Debug for GoalUpdateHandle {
}
}
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct UpdateGoalOutput {
pub success: bool,
@@ -190,10 +180,6 @@ pub struct UpdateGoalOutput {
impl kigi_tool_runtime::ToolOutput for UpdateGoalOutput {}
// ---------------------------------------------------------------------------
// Tool implementation
// ---------------------------------------------------------------------------
#[derive(Debug, Default)]
pub struct UpdateGoalTool;
@@ -17,7 +17,6 @@ pub(crate) struct FetchCache {
max_entries: usize,
}
/// Simple cache that holds N completed fetch requests on a TTL.
impl FetchCache {
pub(crate) fn new(ttl: Duration, max_entries: usize) -> Self {
Self {
@@ -43,7 +42,6 @@ impl FetchCache {
return;
}
if self.entries.len() >= self.max_entries {
// Evict oldest entry.
let oldest_key = self
.entries
.iter()
@@ -19,7 +19,6 @@ use scraper::{Html, Selector};
const DEFAULT_DOWNLOAD_DIR: &str = "downloads";
/// Shared HTTP client and cache for web fetching.
#[derive(Clone)]
pub struct WebFetchClient {
http: HttpClient,
@@ -57,7 +56,6 @@ impl WebFetchClient {
);
Ok(Self {
// Reqwest client can fail to build.
http: HttpClient::new(params)?,
cache: Arc::new(parking_lot::RwLock::new(FetchCache::new(
params.cache_ttl_secs(),
@@ -149,7 +147,6 @@ impl WebFetchClient {
let url_str = url.to_string();
// Check cache.
{
let cache = self.cache.read();
if let Some(cached) = cache.get(&url_str) {
@@ -185,10 +182,8 @@ impl WebFetchClient {
}
}
// SSRF check.
ssrf::check_ssrf(&url).await?;
// Make request and build output.
let http = self.http.get_or_rebuild()?;
let result = match fetch_url(&http, &url, self.params.max_content_length()).await {
Ok(result) => result,
@@ -233,7 +228,6 @@ impl WebFetchClient {
return Ok(output);
}
// Image: validate magic bytes, save to disk.
if is_image(&content_type) {
if !validate_media_magic_bytes(&content_type, &body) {
return Err(WebFetchError::ContentTypeMismatch {
@@ -255,7 +249,6 @@ impl WebFetchClient {
return Ok(output);
}
// Video: validate magic bytes, save to disk.
if is_video(&content_type) {
if !validate_media_magic_bytes(&content_type, &body) {
return Err(WebFetchError::ContentTypeMismatch {
@@ -310,7 +303,6 @@ impl WebFetchClient {
output_location: None,
});
// Insert into cache.
{
let mut cache = self.cache.write();
cache.insert_text(url_str, output.clone(), was_truncated);
@@ -363,10 +355,6 @@ impl WebFetchClient {
}
}
// ───────────────────────────────────────────────────────────────────────────
// URL Validation
// ───────────────────────────────────────────────────────────────────────────
/// Validates URL scheme, length, credentials, and hostname labels.
fn validate_url(raw: &str) -> Result<Url, WebFetchError> {
if raw.len() > MAX_URL_LENGTH {
@@ -375,7 +363,7 @@ fn validate_url(raw: &str) -> Result<Url, WebFetchError> {
});
}
let parsed = Url::parse(raw)?; // uses #[from] url::ParseError
let parsed = Url::parse(raw)?;
match parsed.scheme() {
"http" | "https" => {}
@@ -401,17 +389,12 @@ fn validate_url(raw: &str) -> Result<Url, WebFetchError> {
Ok(parsed)
}
/// Upgrade `http://` to `https://`.
fn upgrade_to_https(url: &mut Url) {
if url.scheme() == "http" {
let _ = url.set_scheme("https");
}
}
// ───────────────────────────────────────────────────────────────────────────
// HTTP Fetching
// ───────────────────────────────────────────────────────────────────────────
enum FetchResult {
Content {
body: Vec<u8>,
@@ -434,7 +417,6 @@ async fn fetch_url(
let mut current_url = url.clone();
let mut hops = 0;
// Loop to follow redirects under the same host.
loop {
let resp = client
.get(current_url.as_str())
@@ -455,7 +437,6 @@ async fn fetch_url(
return Err(WebFetchError::TooManyRedirects { max: MAX_REDIRECTS });
}
// Follow same host; break on cross-host.
if let Some(location) = resp.headers().get("location") {
let location_str = location.to_str().unwrap_or("");
let next_url = current_url
@@ -507,10 +488,6 @@ fn is_same_host(a: &Url, b: &Url) -> bool {
strip_www(host_a) == strip_www(host_b)
}
// ───────────────────────────────────────────────────────────────────────────
// Content Processing
// ───────────────────────────────────────────────────────────────────────────
fn require_media_session_folder(session_folder: Option<&Path>) -> Result<&Path, WebFetchError> {
session_folder.ok_or_else(|| {
WebFetchError::IoError(std::io::Error::new(
@@ -567,11 +544,10 @@ fn validate_media_magic_bytes(content_type: &str, body: &[u8]) -> bool {
"image/webp" => body.len() >= 12 && &body[..4] == b"RIFF" && &body[8..12] == b"WEBP",
"video/mp4" => body.len() >= 8 && &body[4..8] == b"ftyp",
"video/webm" => body.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]),
_ => true, // unknown subtypes: allow (fail-open for niche formats)
_ => true,
}
}
/// Map a media Content-Type to the correct file extension.
fn media_extension(content_type: &str) -> &'static str {
let mime = content_type
.split(';')
@@ -634,8 +610,6 @@ fn is_binary_content_type(content_type: &str) -> bool {
)
}
/// Save fetched PDF bytes to the session download directory and return a
/// `WebFetchOutput` pointing the model at the saved file.
async fn save_pdf(
writer: &SessionFileWriter,
session_folder: &Path,
@@ -677,8 +651,6 @@ async fn save_pdf(
}))
}
/// Save fetched image bytes to the session images directory and return a
/// `WebFetchOutput` pointing the model at the saved file.
async fn save_image(
writer: &SessionFileWriter,
session_folder: &Path,
@@ -722,8 +694,6 @@ async fn save_image(
}))
}
/// Save fetched video bytes to the session videos directory and return a
/// `WebFetchOutput` pointing the model at the saved file.
async fn save_video(
writer: &SessionFileWriter,
session_folder: &Path,
@@ -869,7 +839,6 @@ fn strip_base64_data_uris(content: String) -> String {
let mime = if mime.is_empty() { "unknown" } else { mime };
if parts.any(|p| p.eq_ignore_ascii_case("base64")) {
// Consume valid base64 characters after the comma.
let payload_start = comma + 1;
let payload_len = s[payload_start..]
.bytes()
@@ -900,10 +869,6 @@ fn strip_base64_data_uris(content: String) -> String {
result
}
// ───────────────────────────────────────────────────────────────────────────
// Tests
// ───────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
@@ -1050,8 +1015,6 @@ mod tests {
assert_eq!(tokio::fs::read_to_string(artifact).await.unwrap(), expected);
}
// ── URL validation ──────────────────────────────────────────────────
#[test]
fn validate_url_accepts_valid() {
assert!(validate_url("https://docs.rs/reqwest/latest").is_ok());
@@ -1105,8 +1068,6 @@ mod tests {
assert_eq!(url.scheme(), "https");
}
// ── Same-host redirect check ────────────────────────────────────────
#[test]
fn same_host_exact_match() {
let a = Url::parse("https://example.com/a").unwrap();
@@ -1129,8 +1090,6 @@ mod tests {
assert!(!is_same_host(&a, &d));
}
// ── Content type detection ──────────────────────────────────────────
#[test]
fn is_html_detects_html_types() {
assert!(is_html("text/html"));
@@ -1146,8 +1105,6 @@ mod tests {
assert!(!is_html("application/pdf"));
}
// ── PDF content type detection ────────────────────────────────────
#[test]
fn is_pdf_detects_pdf_types() {
assert!(is_pdf("application/pdf"));
@@ -1162,8 +1119,6 @@ mod tests {
assert!(!is_pdf("application/octet-stream"));
}
// ── Binary content type detection ────────────────────────────────
#[test]
fn binary_detects_images_and_media() {
assert!(is_binary_content_type("image/png"));
@@ -1200,8 +1155,6 @@ mod tests {
assert!(!is_binary_content_type("application/graphql"));
}
// ── HTML to markdown conversion ─────────────────────────────────────
#[test]
fn html_to_markdown_basic() {
let md = html_to_markdown(&test_converter(), "<h1>Hello</h1><p>World</p>");
@@ -1340,8 +1293,6 @@ mod tests {
assert!(md.contains("Beta"));
}
// ── Base64 data URI stripping ─────────────────────────────────────
/// Golden test: verify exact output format for the most common case.
#[test]
fn strip_base64_output_format() {
@@ -1400,8 +1351,6 @@ mod tests {
);
}
// ── Regex equivalence ──────────────────────────────────────────────
/// Reference implementation: the original regex-based stripper.
fn strip_base64_data_uris_regex(content: &str) -> String {
let re = regex::Regex::new(r"data:([^;,\s]{1,80});base64,[A-Za-z0-9+/=]+")
@@ -1463,8 +1412,6 @@ mod tests {
}
}
// ── Proxy configs ─────────────────────────────────────
#[test]
fn proxy_endpoint_round_trips_through_config() {
let json = r#"{"proxy_endpoint": "https://proxy.corp.example.com", "allowed_domains": ["example.com"]}"#;
@@ -1474,7 +1421,6 @@ mod tests {
Some("https://proxy.corp.example.com")
);
// Client builds successfully with the proxy endpoint set.
let client = WebFetchClient::new(&params, None);
assert!(client.is_ok());
}
@@ -1486,8 +1432,6 @@ mod tests {
assert!(params.proxy_endpoint.is_none());
}
// ── HTML cleaning (scraper-based) ─────────────────────────────────
#[test]
fn clean_html_removes_boilerplate_tags() {
let html = r#"<body><nav>Menu</nav><header>Header</header><main>Content</main><footer>Footer</footer></body>"#;
@@ -1516,8 +1460,6 @@ mod tests {
assert!(!cleaned.contains("Menu"));
}
// ── Image content type detection ──────────────────────────────────
#[test]
fn is_image_detects_image_types() {
assert!(is_image("image/png"));
@@ -1543,8 +1485,6 @@ mod tests {
assert!(!is_image("application/octet-stream"));
}
// ── Video content type detection ──────────────────────────────────
#[test]
fn is_video_detects_video_types() {
assert!(is_video("video/mp4"));
@@ -1562,8 +1502,6 @@ mod tests {
assert!(!is_video("audio/mpeg"));
}
// ── Magic byte validation ─────────────────────────────────────────
#[test]
fn magic_bytes_valid_png() {
let png = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
@@ -1625,8 +1563,6 @@ mod tests {
assert!(validate_media_magic_bytes("video/x-custom", b"anything"));
}
// ── Media extension mapping ───────────────────────────────────────
#[test]
fn media_extension_maps_known_types() {
assert_eq!(media_extension("image/png"), "png");
@@ -52,7 +52,6 @@ pub struct WebFetchParams {
register_resource!("kigi", "WebFetch", WebFetchParams);
// Keep defaults here so call-sites don't have to manage unwrapping.
// Vars are still public following other conventions though.
impl WebFetchParams {
pub fn cache_ttl_secs(&self) -> Duration {
Duration::from_secs(self.cache_ttl_secs.unwrap_or(15 * 60))
@@ -10,10 +10,6 @@ use url::Url;
use crate::types::output::WebFetchOutput;
// ───────────────────────────────────────────────────────────────────────────
// Domain normalization
// ───────────────────────────────────────────────────────────────────────────
/// Canonical form for domain comparison: trim whitespace, strip trailing
/// slashes and dots, remove `www.` prefix, and lowercase.
pub fn normalize_domain(raw: &str) -> String {
@@ -22,10 +18,6 @@ pub fn normalize_domain(raw: &str) -> String {
s.to_lowercase()
}
// ───────────────────────────────────────────────────────────────────────────
// Precomputed host entry
// ───────────────────────────────────────────────────────────────────────────
/// What a single host is allowed to serve.
#[derive(Debug, Clone)]
enum HostEntry {
@@ -36,10 +28,6 @@ enum HostEntry {
PathPrefixes(Vec<String>),
}
// ───────────────────────────────────────────────────────────────────────────
// DomainMatcher
// ───────────────────────────────────────────────────────────────────────────
/// Precomputed domain allowlist. Built once from the raw allowlist entries,
/// provides O(1) host lookup + small linear scan over path prefixes.
#[derive(Debug, Clone)]
@@ -153,10 +141,6 @@ pub fn domain_from_url(raw_url: &str) -> Option<String> {
.and_then(|u| u.host_str().map(normalize_domain))
}
// ───────────────────────────────────────────────────────────────────────────
// Tests
// ───────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
@@ -165,8 +149,6 @@ mod tests {
Url::parse(s).unwrap()
}
// ── normalize_domain ─────────────────────────────────────────────────
#[test]
fn normalize_strips_www_and_trailing_dot() {
assert_eq!(normalize_domain("www.Example.COM."), "example.com");
@@ -177,8 +159,6 @@ mod tests {
assert_eq!(normalize_domain(" docs.rs "), "docs.rs");
}
// ── Host-only entries ────────────────────────────────────────────────
#[test]
fn allows_listed_domain() {
let m = DomainMatcher::new(&["docs.rs".into(), "Example.Com".into()]);
@@ -219,8 +199,6 @@ mod tests {
assert!(m.check(&url("https://react.dev./learn")).is_none());
}
// ── Path-scoped entries ──────────────────────────────────────────────
#[test]
fn path_scoped_allows_matching_path() {
let m = DomainMatcher::new(&["vercel.com/docs".into()]);
@@ -287,8 +265,6 @@ mod tests {
assert!(m.check(&url("https://example.com/anything")).is_none());
}
// ── Multiple path prefixes per host ──────────────────────────────────
#[test]
fn multiple_path_prefixes_per_host() {
let m = DomainMatcher::new(&["github.com/org-a".into(), "github.com/org-b".into()]);
@@ -314,8 +290,6 @@ mod tests {
assert!(m.check(&url("https://github.com/anything")).is_none());
}
// ── Model URL variants ───────────────────────────────────────────────
#[test]
fn model_url_variants() {
let m = DomainMatcher::new(&[
@@ -354,8 +328,6 @@ mod tests {
assert!(m.check(&url("https://93.184.216.34/page")).is_some());
}
// ── domain_from_url ─────────────────────────────────────────────────
#[test]
fn domain_from_url_extracts_and_normalizes() {
assert_eq!(
@@ -1,4 +1,4 @@
/// Structured errors for the `web_fetch` tool.
//! Structured errors for the `web_fetch` tool.
use std::net::IpAddr;
#[derive(Debug, thiserror::Error)]
@@ -98,7 +98,7 @@ mod tests {
fn github_host_detection() {
assert!(is_github_host("github.com"));
assert!(is_github_host("api.github.com"));
assert!(is_github_host("github.ghe.example.com")); // synthetic GHE-style
assert!(is_github_host("github.ghe.example.com"));
assert!(!is_github_host("ghe.example.com"));
assert!(!is_github_host("internal-wiki.corp.example.com"));
assert!(!is_github_host("gitlab.example.com"));
@@ -109,9 +109,7 @@ mod tests {
// Exercises the same `which` lookup `gh_available` uses, with a
// controlled search dir so it doesn't depend on the test host's PATH.
let dir = tempfile::tempdir().unwrap();
// No gh in this dir yet.
assert!(which::which_in("gh", Some(dir.path()), dir.path()).is_err());
// Create an executable `gh`.
let gh = dir.path().join("gh");
std::fs::write(&gh, b"#!/bin/sh\nexit 0\n").unwrap();
#[cfg(unix)]
@@ -100,7 +100,6 @@ mod tests {
let second = client.get_or_rebuild().unwrap();
let second_ptr = Arc::as_ptr(&second);
// After invalidation, we should get a different client instance.
assert_ne!(first_ptr, second_ptr);
}
@@ -110,7 +109,6 @@ mod tests {
proxy_endpoint: Some("https://proxy.corp.example.com".into()),
..Default::default()
};
// Should succeed — reqwest accepts the proxy URL.
let client = HttpClient::new(&params);
assert!(client.is_ok());
}
@@ -1,4 +1,4 @@
//! `web_fetch` tool — client-side URL fetching with improved HTML-to-markdown
//! `web_fetch` tool — client-side URL fetching with HTML-to-markdown
//! conversion and SSRF protection.
//!
//! Fetches a URL via `reqwest`, converts HTML to markdown via `htmd` (with
@@ -21,10 +21,6 @@ pub use config::WebFetchParams;
pub use domain::{DomainMatcher, domain_from_url};
pub use error::WebFetchError;
// ───────────────────────────────────────────────────────────────────────────
// Config enum (feature flag gating)
// ───────────────────────────────────────────────────────────────────────────
/// Configuration for the `web_fetch` tool.
///
/// When `Enabled`, the tool is registered and a `WebFetchClient` is injected
@@ -34,13 +30,11 @@ pub enum WebFetchConfig {
#[default]
Disabled,
Enabled {
/// Runtime parameters (allowed_domains, proxy_endpoint, timeouts, etc.)
params: WebFetchParams,
},
}
impl WebFetchConfig {
/// Returns `true` when the config is the `Enabled` variant.
pub fn is_enabled(&self) -> bool {
matches!(self, Self::Enabled { .. })
}
@@ -51,21 +45,12 @@ use crate::types::requirements::{Expr, ToolRequirement};
use crate::types::resources::SessionFolder;
use crate::types::tool::{ToolKind, ToolNamespace};
// ───────────────────────────────────────────────────────────────────────────
// Input
// ───────────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct WebFetchInput {
/// The URL to fetch content from.
#[schemars(description = "The URL to fetch content from.")]
pub url: String,
}
// ───────────────────────────────────────────────────────────────────────────
// Tool
// ───────────────────────────────────────────────────────────────────────────
#[derive(Debug, Default)]
pub struct WebFetchTool;
@@ -46,7 +46,6 @@ pub(crate) fn is_blocked_ip(ip: &IpAddr) -> bool {
if octets[0] == 100 && (64..=127).contains(&octets[1]) {
return true;
}
// 0.0.0.0 — unspecified address.
if v4.is_unspecified() {
return true;
}
@@ -57,7 +56,6 @@ pub(crate) fn is_blocked_ip(ip: &IpAddr) -> bool {
if v6.is_loopback() {
return false;
}
// :: — unspecified.
if v6.is_unspecified() {
return true;
}
@@ -88,7 +86,6 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
host: String::new(),
})?;
// If the host is already a literal IP, check it directly.
if let Ok(ip) = host.parse::<IpAddr>() {
if is_blocked_ip(&ip) {
return Err(WebFetchError::SsrfBlocked {
@@ -99,7 +96,6 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
return Ok(());
}
// DNS resolution.
let port = url.port_or_known_default().unwrap_or(443);
let addr_str = format!("{host}:{port}");
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host(&addr_str)
@@ -129,8 +125,6 @@ pub(crate) async fn check_ssrf(url: &Url) -> Result<(), WebFetchError> {
mod tests {
use super::*;
// ── IPv4 blocking ───────────────────────────────────────────────────
#[test]
fn blocks_rfc1918_10x() {
assert!(is_blocked_ip(&"10.0.0.1".parse().unwrap()));
@@ -185,8 +179,6 @@ mod tests {
assert!(!is_blocked_ip(&"142.250.80.46".parse().unwrap()));
}
// ── IPv6 ────────────────────────────────────────────────────────────
#[test]
fn blocks_ipv6_link_local() {
assert!(is_blocked_ip(&"fe80::1".parse().unwrap()));
@@ -211,8 +203,6 @@ mod tests {
assert!(!is_blocked_ip(&"::ffff:8.8.8.8".parse::<IpAddr>().unwrap()));
}
// ── check_ssrf integration ──────────────────────────────────────────
#[tokio::test]
async fn ssrf_blocks_ip_literal_private() {
let url = Url::parse("https://10.0.0.1/secret").unwrap();
@@ -1,4 +1,4 @@
//! `web_search` tool — new architecture (`Tool` trait).
//! `web_search` tool.
//!
//! Calls the Kimi search service (PRD F5; kimi-cli `tools/web/search.py`
//! parity). Reads the pre-constructed `WebSearchClient` from Resources
@@ -10,10 +10,6 @@ use crate::types::output::WebSearchOutput;
use crate::types::requirements::{Expr, ToolRequirement};
use crate::types::tool::{ToolKind, ToolNamespace};
// ───────────────────────────────────────────────────────────────────────────
// Input
// ───────────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct WebSearchInput {
#[schemars(description = "The query text to search for.")]
@@ -37,10 +33,6 @@ pub struct WebSearchInput {
const DEFAULT_LIMIT: u8 = 5;
const MAX_LIMIT: u8 = 20;
// ───────────────────────────────────────────────────────────────────────────
// Tool implementation
// ───────────────────────────────────────────────────────────────────────────
#[derive(Debug, Default)]
pub struct WebSearchTool;
@@ -29,11 +29,9 @@ fn annotations(bash: &BashOutput) -> String {
s
}
/// CONCISE foreground format: `Exit code: N [annotations]\n\nCommand output:\n\n```...```\n\nCommand completed.\n...`
///
/// When the process was killed by the harness or a kernel signal
/// (see [`KillReason`]), the header reads
/// `Exit code: killed (reason)` instead of `Exit code: -1 [signal=…]`.
/// (see [`KillReason`]), the header reads `Exit code: killed (reason)`
/// instead of `Exit code: -1 [signal=…]`.
fn format_concise_foreground_prompt(bash: &BashOutput) -> String {
let raw = String::from_utf8_lossy(&bash.output);
let output_str = strip_str(&raw).to_string();
@@ -57,7 +55,6 @@ fn format_concise_foreground_prompt(bash: &BashOutput) -> String {
)
}
/// CONCISE backgrounded format: same as DEFAULT backgrounded (code-fenced partial output).
fn format_concise_background_prompt(bash: &BashOutput) -> String {
let raw = String::from_utf8_lossy(&bash.output);
let output_str = strip_str(&raw).to_string();
@@ -77,7 +74,7 @@ fn format_concise_background_prompt(bash: &BashOutput) -> String {
/// Concise variant of `BashTool`.
///
/// Delegates to `BashTool::run()`, then overwrites `output_for_prompt` with
/// the concise format. The `concise` concept lives entirely in this file.
/// the concise format.
#[derive(Debug, Default)]
pub struct BashConciseTool;
@@ -262,14 +259,11 @@ mod tests {
// output_for_prompt, then BashConciseTool overwrites with concise.
// to_prompt_format() is a passthrough — it must NOT add another header.
let mut bash = make_bash(0, "hello world\n");
// Pre-bake DEFAULT (what BashTool::run() does)
bash.output_for_prompt = crate::implementations::kigi::bash::format_default_prompt(&bash);
assert!(bash.output_for_prompt.starts_with("exit: 0"));
// Concise post-processing (what BashConciseTool::run() does)
bash.output_for_prompt = format_concise_foreground_prompt(&bash);
// to_prompt_format() is a passthrough
let prompt = crate::types::output::ToolOutput::Bash(bash).to_prompt_format();
assert!(
prompt.starts_with("Exit code: 0"),
@@ -77,13 +77,14 @@ impl kigi_tool_runtime::Tool for ReadFileConciseTool {
use crate::types::tool_metadata::shared_resources;
let resources = shared_resources(&ctx)?;
// KigiConcise is not version-managed — always pass None.
let cwd_override = ctx
.extensions
.get::<kigi_tool_runtime::Cwd>()
.map(|c| c.0.clone());
// `None`: the concise tool does not stream, so it needs no
// text-path streamability signal (see `run_read_file`).
// The two `None`s are contract_version (KigiConcise is not
// version-managed) and streamable_out (the concise tool does not
// stream, so it needs no text-path streamability signal); see
// `run_read_file`.
let result = run_read_file(input, cwd_override, None, resources, None).await?;
match result {
@@ -2,7 +2,6 @@
use crate::implementations::kigi::search_replace::{SearchReplaceInput, run_search_replace};
/// Concise description — no read-before-edit enforcement, simplified formatting guidance.
const DESCRIPTION_CONCISE: &str = r#"Replace an exact string in a file.
- Do not include the "LINE_NUMBER→" prefixes from file reads in ${{ params.edit.old_string }} or ${{ params.edit.new_string }}; keep the exact indentation.
@@ -1,7 +1,4 @@
//! Anchor convenience helpers and re-exports.
//!
//! This module re-exports the core types from [`super::scheme`] and provides
//! helper functions for common anchor operations.
pub use super::scheme::{
Anchor, AnchorScheme, CheckpointChain, ChunkFingerprint, ContentOnly, DEFAULT_SEARCH_RADIUS,
@@ -9,10 +6,6 @@ pub use super::scheme::{
};
/// Split file content into lines suitable for anchor generation.
///
/// Strips trailing newlines from each line (matching the convention used by
/// `AnchorScheme::generate_anchors`). The returned `Vec<&str>` has one entry
/// per logical line.
pub fn split_lines(content: &str) -> Vec<&str> {
if content.is_empty() {
return vec![""];
@@ -31,18 +24,12 @@ pub fn split_lines(content: &str) -> Vec<&str> {
}
/// Generate anchors for file content using the given scheme.
///
/// Convenience wrapper: splits `content` into lines and calls
/// `scheme.generate_anchors()`.
pub fn generate_for_content(scheme: &dyn AnchorScheme, content: &str) -> Vec<Anchor> {
let lines = split_lines(content);
scheme.generate_anchors(&lines)
}
/// Validate a parsed anchor against file content.
///
/// Convenience wrapper: splits `content` into lines and calls
/// `scheme.validate()`.
pub fn validate_against_content(
scheme: &dyn AnchorScheme,
anchor: &ParsedAnchor,
@@ -53,9 +40,6 @@ pub fn validate_against_content(
}
/// Search for a shifted anchor in file content.
///
/// Convenience wrapper: splits `content` into lines and calls
/// `scheme.find_shifted()`.
pub fn find_shifted_in_content(
scheme: &dyn AnchorScheme,
anchor: &ParsedAnchor,
@@ -95,7 +79,8 @@ mod tests {
let content = "line one\nline two\nline three\n";
let scheme = ContentOnly::new();
let anchors = generate_for_content(&scheme, content);
assert_eq!(anchors.len(), 4); // 3 content lines + trailing empty
// 3 content lines + trailing empty
assert_eq!(anchors.len(), 4);
assert_eq!(anchors[0].line, 1);
assert_eq!(anchors[3].line, 4);
}
@@ -144,7 +129,7 @@ mod tests {
// Insert a line at the top → "b" shifts from line 2 to line 3.
let modified = "new\na\nb\nc\n";
let parsed = ParsedAnchor {
line: anchors[1].line, // originally line 2 ("b")
line: anchors[1].line,
local: anchors[1].local.clone(),
context: None,
};
@@ -29,16 +29,16 @@ use super::scheme::{
/// Configuration for the benchmark harness.
#[derive(Debug, Clone)]
pub struct BenchmarkConfig {
/// Hash lengths to test (default: [2, 3]).
/// Hash lengths to test.
pub hash_lengths: Vec<usize>,
/// Chunk sizes to test for Candidate B (default: [8, 16, 32]).
/// Chunk sizes to test for Candidate B.
pub chunk_sizes: Vec<usize>,
/// Checkpoint intervals to test for Candidate C (default: [16, 32, 64]).
/// Checkpoint intervals to test for Candidate C.
pub checkpoint_intervals: Vec<usize>,
/// Search radius for shifted-anchor recovery (default: 15).
/// Search radius for shifted-anchor recovery.
pub search_radius: usize,
}
@@ -90,7 +90,7 @@ pub struct SchemeMetrics {
/// Shifted-anchor recovery: not found.
pub recovery_not_found: usize,
/// Collision count: distinct lines that produced the same anchor in the
/// Distinct lines that produced the same anchor in the
/// same file (local hash only).
pub collision_count: usize,
@@ -270,7 +270,6 @@ pub fn run_benchmark(corpus: &[(&str, &str)], config: &BenchmarkConfig) -> Bench
.map(|(_, content)| split_lines(content).len())
.sum();
// Build scheme configurations to test.
let schemes = build_scheme_configs(config);
for (label, scheme) in &schemes {
@@ -354,7 +353,7 @@ fn standard_mutations(line_count: usize) -> Vec<(&'static str, Mutation)> {
}
/// Estimate the read-amplification cost for a single validation under the
/// given scheme, using the scheme's own `validation_window_lines()` method.
/// given scheme.
fn estimate_read_amp_lines(scheme: &dyn AnchorScheme, line_count: usize, line_idx: usize) -> usize {
scheme.validation_window_lines(line_idx, line_count)
}
@@ -371,7 +370,7 @@ fn run_phase1_for_file(
let line_count = original_lines.len();
metrics.total_lines += line_count;
// --- Collision measurement ---
// Collision measurement
let anchors = scheme.generate_anchors(&original_lines);
let mut seen = std::collections::HashSet::new();
for a in &anchors {
@@ -384,7 +383,7 @@ fn run_phase1_for_file(
}
}
// --- Mutation scenarios ---
// Mutation scenarios
let mutations = standard_mutations(line_count);
for (_mutation_name, mutation) in &mutations {
@@ -392,7 +391,6 @@ fn run_phase1_for_file(
let mutation_result = apply_mutation(&mut mutated_lines, mutation);
let mutated_refs: Vec<&str> = mutated_lines.iter().map(|s| s.as_str()).collect();
// For each original anchor, validate against the mutated file.
for (orig_idx, anchor) in anchors.iter().enumerate() {
let parsed = ParsedAnchor {
line: anchor.line,
@@ -432,7 +430,8 @@ fn run_phase1_for_file(
// line was shifted (not modified or deleted).
if !reported_valid && let LineOutcome::Shifted { new_idx } = outcome {
metrics.recovery_attempts += 1;
let expected_line = new_idx + 1; // 1-based
// 1-based
let expected_line = new_idx + 1;
match scheme.find_shifted(&parsed, &mutated_refs, config.search_radius) {
ShiftResult::Found { new_line } => {
@@ -521,13 +520,11 @@ fn run_phase2_for_file(
let traces = standard_traces(line_count);
for trace in &traces {
// Start with the original file and its anchors.
let mut current_lines: Vec<String> = original_lines.iter().map(|s| s.to_string()).collect();
let mut current_anchors = scheme.generate_anchors(&original_lines);
let mut needs_refresh = false;
for step in trace {
// If the previous step required a re-read, regenerate anchors now.
if needs_refresh {
let refs: Vec<&str> = current_lines.iter().map(|s| s.as_str()).collect();
current_anchors = scheme.generate_anchors(&refs);
@@ -539,27 +536,22 @@ fn run_phase2_for_file(
continue;
}
// Snapshot the anchor we want to probe.
let probe_anchor = ParsedAnchor {
line: current_anchors[probe_idx].line,
local: current_anchors[probe_idx].local.clone(),
context: current_anchors[probe_idx].context.clone(),
};
// Apply the mutation.
apply_mutation(&mut current_lines, &step.mutation);
// Validate the probed anchor against the mutated file.
let refs: Vec<&str> = current_lines.iter().map(|s| s.as_str()).collect();
let result = scheme.validate(&probe_anchor, &refs);
metrics.trace_steps += 1;
if result == ValidationResult::Valid {
metrics.trace_anchors_survived += 1;
// Keep using existing anchors — no refresh needed.
} else {
metrics.trace_reread_required += 1;
// Mark for refresh at the start of the next step.
needs_refresh = true;
}
}
@@ -650,7 +642,6 @@ struct Config3 {
let config = BenchmarkConfig::default();
let report = run_benchmark(&corpus, &config);
// Expected: hash_lengths.len() * (1 + chunk_sizes.len() + checkpoint_intervals.len())
let expected = config.hash_lengths.len()
* (1 + config.chunk_sizes.len() + config.checkpoint_intervals.len());
assert_eq!(report.schemes.len(), expected);
@@ -696,7 +687,7 @@ struct Config3 {
search_radius: DEFAULT_SEARCH_RADIUS,
};
let report = run_benchmark(&corpus, &config);
assert_eq!(report.schemes.len(), 2); // A + B
assert_eq!(report.schemes.len(), 2);
let b = &report.schemes[1];
assert!(
b.false_stale > 0,
@@ -716,7 +707,7 @@ struct Config3 {
search_radius: DEFAULT_SEARCH_RADIUS,
};
let report = run_benchmark(&corpus, &config);
assert_eq!(report.schemes.len(), 2); // A + B
assert_eq!(report.schemes.len(), 2);
let a = &report.schemes[0];
let b = &report.schemes[1];
@@ -864,8 +855,6 @@ struct Config3 {
#[test]
fn recovery_correctness_tracked() {
// Verify that recovery_correct + recovery_wrong + recovery_ambiguous
// + recovery_not_found == recovery_attempts.
let corpus = test_corpus();
let config = BenchmarkConfig {
hash_lengths: vec![3],
@@ -91,7 +91,6 @@ impl Default for HashlineSchemeParams {
}
impl HashlineSchemeParams {
/// Validate the parameters. Returns an error message if invalid.
pub fn validate(&self) -> Result<(), String> {
match self.scheme.as_str() {
"chunk" | "content_only" => {}
@@ -111,8 +110,6 @@ impl HashlineSchemeParams {
}
/// Generate example anchor strings for use in tool descriptions.
/// Returns (single_anchor, line_with_anchor) based on the configured scheme.
/// Returns `(anchor, read_line1, read_line2, grep_match, grep_context)`.
pub fn example_anchors(&self) -> ExampleAnchors {
let len = self.hash_len.clamp(1, 4);
let hash = &"abcd"[..len];
@@ -169,7 +166,7 @@ impl HashlineSchemeParams {
crate::types::definition::ToolDefinition::function(client_name, Some(description), schema)
}
/// Validate and build the anchor scheme. Returns an error if params are invalid.
/// Validate and build the anchor scheme.
pub fn build_scheme(&self) -> Result<Box<dyn AnchorScheme>, String> {
self.validate()?;
Ok(match self.scheme.as_str() {
@@ -320,22 +317,26 @@ mod tests {
fn render_does_not_panic_on_invalid_hash_len() {
let params = HashlineSchemeParams {
scheme: "chunk".to_owned(),
hash_len: 100, // invalid but clamped
// invalid but clamped
hash_len: 100,
chunk_size: 8,
};
let rendered = params.render_description("{example_anchor}");
assert_eq!(rendered, "22:abcd:rstu"); // clamped to 4
// clamped to 4
assert_eq!(rendered, "22:abcd:rstu");
}
#[test]
fn render_does_not_panic_on_zero_hash_len() {
let params = HashlineSchemeParams {
scheme: "chunk".to_owned(),
hash_len: 0, // invalid but clamped
// invalid but clamped
hash_len: 0,
chunk_size: 8,
};
let rendered = params.render_description("{example_anchor}");
assert_eq!(rendered, "22:a:r"); // clamped to 1
// clamped to 1
assert_eq!(rendered, "22:a:r");
}
#[test]
@@ -70,10 +70,9 @@ fn detect_anchor_prefix_in_content(content: &str) -> Option<usize> {
fn anchor_content_error(op_label: &str, content: &str, line_num: usize) -> HashlineEditError {
let offending_line = content.lines().nth(line_num - 1).unwrap_or("").to_owned();
// Build a small context snippet (up to 3 lines around the offending line).
let lines: Vec<&str> = content.lines().collect();
let ctx_start = line_num.saturating_sub(1).saturating_sub(1); // 1 line before (0-based)
let ctx_end = (line_num + 1).min(lines.len()); // 1 line after
let ctx_start = line_num.saturating_sub(1).saturating_sub(1);
let ctx_end = (line_num + 1).min(lines.len());
let context: String = (ctx_start..ctx_end)
.map(|i| {
let marker = if i + 1 == line_num { ">>>" } else { " " };
@@ -122,7 +121,6 @@ struct ResolvedOp {
/// Result of `apply_edits`: the output to return to the caller, plus the new
/// file content on success (to be written to disk by the tool layer).
pub(crate) struct ApplyResult {
/// The structured output (success or error).
pub output: HashlineEditOutput,
/// The new file content string. `Some` only when `output` is
/// `EditsApplied`; `None` on error.
@@ -283,7 +281,6 @@ pub(crate) fn apply_edits(
let new_content = result_lines.join("\n");
let total_new_lines = split_lines(&new_content).len();
// Sort edit regions top-down and merge nearby ones.
edit_regions.sort_by_key(|r| r.0);
let snippet = build_snippet(&new_content, &edit_regions, total_new_lines, scheme);
let snippet_start_line = edit_regions
@@ -336,7 +333,7 @@ fn build_snippet(
.unwrap()
.min(total_new_lines);
// If the span is small enough, emit one contiguous snippet (original behavior).
// Span small enough: emit one contiguous snippet.
if global_end - global_start <= MAX_CONTIGUOUS_SNIPPET {
let (snippet, _raw) = format_hashline_content(
new_content,
@@ -361,7 +358,6 @@ fn build_snippet(
merged.push((ctx_start, ctx_end));
}
// Build per-region snippets separated by gap markers.
let mut parts: Vec<String> = Vec::new();
let mut prev_end: usize = 0;
@@ -420,16 +416,16 @@ fn resolve_op(
ambiguous_candidates: vec![],
});
}
e + 1 // exclusive end
e + 1
}
None => start + 1, // single line
None => start + 1,
};
if let Some(line_num) = detect_anchor_prefix_in_content(content) {
return Err(anchor_content_error("replace", content, line_num));
}
let new_lines: Vec<String> = if content.is_empty() {
vec![] // delete
vec![]
} else {
content.lines().map(|l| l.to_owned()).collect()
};
@@ -464,7 +460,8 @@ fn resolve_op(
return Err(anchor_content_error("insert_after", content, line_num));
}
let new_lines: Vec<String> = if content.is_empty() {
vec![String::new()] // blank line
// Empty content inserts a blank line.
vec![String::new()]
} else {
content.lines().map(|l| l.to_owned()).collect()
};
@@ -472,7 +469,7 @@ fn resolve_op(
Ok(ResolvedOp {
original_idx,
start: insert_at,
end: insert_at, // insertion: start == end
end: insert_at,
new_lines,
})
}
@@ -575,7 +572,7 @@ fn validate_anchor(
let result = scheme.validate(&parsed, lines);
match result {
ValidationResult::Valid => Ok(parsed.line - 1), // 0-based
ValidationResult::Valid => Ok(parsed.line - 1),
ValidationResult::OutOfRange => Err(HashlineEditError {
error: HashlineEditErrorKind::AnchorNotFound,
@@ -693,7 +690,7 @@ fn check_overlaps(ops: &[ResolvedOp]) -> Option<HashlineEditError> {
// strictly inside a replacement span (start <= insert_at < end).
for op in ops {
if op.start != op.end {
continue; // not an insertion
continue;
}
let insert_at = op.start;
for &(rs, re, r_idx) in &ranges {
@@ -796,7 +793,7 @@ mod tests {
fn point_replace() {
let anchors = anchors_for(SAMPLE);
let ops = vec![HashlineOp::Replace {
anchor: anchors[1].clone(), // " let x = 1;"
anchor: anchors[1].clone(),
end_anchor: None,
content: " let x = 999;".to_owned(),
}];
@@ -817,13 +814,12 @@ mod tests {
let ops = vec![HashlineOp::Replace {
anchor: anchors[1].clone(),
end_anchor: None,
content: String::new(), // delete
content: String::new(),
}];
match apply_edits(SAMPLE, &ops, &test_path(), &*test_scheme()).output {
HashlineEditOutput::EditsApplied(result) => {
assert_eq!(result.applied, 1);
// Deleted line should not appear in snippet.
assert!(!result.snippet.contains("let x = 1"));
}
HashlineEditOutput::Error(e) => panic!("Expected success, got error: {}", e.message),
@@ -834,8 +830,8 @@ mod tests {
fn range_replace() {
let anchors = anchors_for(SAMPLE);
let ops = vec![HashlineOp::Replace {
anchor: anchors[1].clone(), // " let x = 1;"
end_anchor: Some(anchors[2].clone()), // " let y = 2;"
anchor: anchors[1].clone(),
end_anchor: Some(anchors[2].clone()),
content: " let z = 42;".to_owned(),
}];
@@ -915,15 +911,14 @@ mod tests {
#[test]
fn batch_ordering_bottom_up() {
let anchors = anchors_for(SAMPLE);
// Two non-overlapping replacements at lines 2 and 4.
let ops = vec![
HashlineOp::Replace {
anchor: anchors[1].clone(), // line 2
anchor: anchors[1].clone(),
end_anchor: None,
content: " let x = 100;".to_owned(),
},
HashlineOp::Replace {
anchor: anchors[3].clone(), // line 4
anchor: anchors[3].clone(),
end_anchor: None,
content: " println!(\"changed\");".to_owned(),
},
@@ -944,7 +939,7 @@ mod tests {
// Build a file large enough that edits at opposite ends exceed MAX_CONTIGUOUS_SNIPPET.
let line_count = 200;
let mut file_lines: Vec<String> = (0..line_count).map(|i| format!("line_{i}")).collect();
file_lines.push(String::new()); // trailing newline
file_lines.push(String::new());
let content = file_lines.join("\n");
let anchors = anchors_for(&content);
@@ -995,12 +990,12 @@ mod tests {
let anchors = anchors_for(SAMPLE);
let ops = vec![
HashlineOp::Replace {
anchor: anchors[1].clone(), // line 2
anchor: anchors[1].clone(),
end_anchor: None,
content: " let x = 99;".to_owned(),
},
HashlineOp::Replace {
anchor: anchors[3].clone(), // line 4
anchor: anchors[3].clone(),
end_anchor: None,
content: " println!(\"hi\");".to_owned(),
},
@@ -1143,11 +1138,11 @@ mod tests {
let ops = vec![
HashlineOp::Replace {
anchor: anchors[1].clone(),
end_anchor: Some(anchors[3].clone()), // lines 2-4
end_anchor: Some(anchors[3].clone()),
content: "a".to_owned(),
},
HashlineOp::Replace {
anchor: anchors[2].clone(), // line 3 — overlaps
anchor: anchors[2].clone(),
end_anchor: None,
content: "b".to_owned(),
},
@@ -1209,11 +1204,11 @@ mod tests {
let ops = vec![
HashlineOp::Replace {
anchor: anchors[1].clone(),
end_anchor: Some(anchors[3].clone()), // lines 2-4
end_anchor: Some(anchors[3].clone()),
content: "a".to_owned(),
},
HashlineOp::Replace {
anchor: anchors[2].clone(), // line 3 — overlaps
anchor: anchors[2].clone(),
end_anchor: None,
content: "b".to_owned(),
},
@@ -1231,8 +1226,8 @@ mod tests {
fn end_before_start_error() {
let anchors = anchors_for(SAMPLE);
let ops = vec![HashlineOp::Replace {
anchor: anchors[3].clone(), // line 4
end_anchor: Some(anchors[1].clone()), // line 2 — before start
anchor: anchors[3].clone(),
end_anchor: Some(anchors[1].clone()),
content: "x".to_owned(),
}];
@@ -1272,7 +1267,6 @@ mod tests {
match apply_edits(SAMPLE, &ops, &test_path(), &*test_scheme()).output {
HashlineEditOutput::EditsApplied(result) => {
// Snippet should have the hashline format with anchors.
assert!(result.snippet.contains('→'));
assert!(result.snippet.contains(':'));
}
@@ -1294,7 +1288,6 @@ mod tests {
// "line3" should appear right after "line2" in the snippet,
// without an intervening blank line.
assert!(result.snippet.contains("line3"));
// Count content lines in snippet (excluding "lines not shown").
let content_lines: Vec<&str> = result
.snippet
.lines()
@@ -1341,18 +1334,17 @@ mod tests {
let anchors = anchors_for(SAMPLE);
let ops = vec![
HashlineOp::InsertAfter {
anchor: anchors[1].clone(), // after line 2
anchor: anchors[1].clone(),
content: " // first".to_owned(),
},
HashlineOp::InsertAfter {
anchor: anchors[1].clone(), // same anchor
anchor: anchors[1].clone(),
content: " // second".to_owned(),
},
];
match apply_edits(SAMPLE, &ops, &test_path(), &*test_scheme()).output {
HashlineEditOutput::EditsApplied(result) => {
// "first" should appear before "second" in the output.
let first_pos = result.snippet.find("// first");
let second_pos = result.snippet.find("// second");
assert!(
@@ -1374,11 +1366,11 @@ mod tests {
let ops = vec![
HashlineOp::Replace {
anchor: anchors[1].clone(),
end_anchor: Some(anchors[3].clone()), // lines 2-4
end_anchor: Some(anchors[3].clone()),
content: "replaced".to_owned(),
},
HashlineOp::InsertAfter {
anchor: anchors[2].clone(), // line 3 — inside replaced span
anchor: anchors[2].clone(),
content: "inserted".to_owned(),
},
];
@@ -1399,11 +1391,11 @@ mod tests {
let ops = vec![
HashlineOp::Replace {
anchor: anchors[1].clone(),
end_anchor: Some(anchors[3].clone()), // 0-based [1..4)
end_anchor: Some(anchors[3].clone()),
content: "replaced".to_owned(),
},
HashlineOp::InsertAfter {
anchor: "0:".to_owned(), // inserts at idx 0, before range
anchor: "0:".to_owned(),
content: "header".to_owned(),
},
];
@@ -1424,11 +1416,11 @@ mod tests {
let ops = vec![
HashlineOp::Replace {
anchor: anchors[1].clone(),
end_anchor: Some(anchors[3].clone()), // 0-based [1..4)
end_anchor: Some(anchors[3].clone()),
content: "replaced".to_owned(),
},
HashlineOp::InsertAfter {
anchor: anchors[0].clone(), // after line 1 → insert_at=1 = range.start
anchor: anchors[0].clone(),
content: "at_range_start".to_owned(),
},
];
@@ -1450,11 +1442,11 @@ mod tests {
let ops = vec![
HashlineOp::Replace {
anchor: anchors[1].clone(),
end_anchor: Some(anchors[2].clone()), // lines 2-3
end_anchor: Some(anchors[2].clone()),
content: "replaced".to_owned(),
},
HashlineOp::InsertAfter {
anchor: anchors[2].clone(), // insert after line 3 — at idx 3, which is exclusive end
anchor: anchors[2].clone(),
content: "after_range".to_owned(),
},
];
@@ -1470,7 +1462,7 @@ mod tests {
}
}
// -- Stateless range policy tests ----------------------------------------
// Stateless range policy tests.
#[test]
fn large_range_produces_warning() {
@@ -1527,7 +1519,7 @@ mod tests {
let anchors = anchors_for(SAMPLE);
let ops = vec![HashlineOp::Replace {
anchor: anchors[1].clone(),
end_anchor: Some(anchors[2].clone()), // 2-line range
end_anchor: Some(anchors[2].clone()),
content: "replaced".to_owned(),
}];
@@ -1539,12 +1531,12 @@ mod tests {
}
}
// -- Recovery tests -----------------------------------------------------
// Recovery tests.
#[test]
fn shifted_recovery_after_insert_above() {
let anchors = anchors_for(SAMPLE);
let anchor_line2 = anchors[1].clone(); // " let x = 1;"
let anchor_line2 = anchors[1].clone();
// Insert 2 lines at the top → line 2 shifts to line 4.
let mut shifted_lines: Vec<&str> = vec!["// new1", "// new2"];
@@ -1587,7 +1579,7 @@ mod tests {
#[test]
fn shifted_recovery_after_delete_above() {
let anchors = anchors_for(SAMPLE);
let anchor_line4 = anchors[3].clone(); // " println!(...)"
let anchor_line4 = anchors[3].clone();
// Delete line 1 → line 4 shifts to line 3.
let mut lines: Vec<&str> = SAMPLE.lines().collect();
@@ -1655,7 +1647,7 @@ mod tests {
let content = lines.join("\n");
let anchors = anchors_for(&content);
let anchor_line5 = anchors[4].clone(); // one of the repeated lines
let anchor_line5 = anchors[4].clone();
// Insert a line at top → all repeated lines shift.
let mut shifted = vec!["// inserted".to_owned()];
@@ -1770,7 +1762,7 @@ mod tests {
// Get the FULL anchor (with chunk context) for line 5.
let full_anchors = anchors_for(&original);
let full_anchor = full_anchors[4].clone(); // line 5, has :local:context
let full_anchor = full_anchors[4].clone();
// Insert exactly 8 new lines at the top.
// Line 5 → position 13. Chunk at [8,16) in the shifted file =
@@ -1800,7 +1792,7 @@ mod tests {
"Recovery must find shifted line with full chunk anchor. Error: {}",
err.message
);
assert_eq!(err.shifted_to.unwrap(), 13); // line 5 + 8 = line 13
assert_eq!(err.shifted_to.unwrap(), 13);
let fresh = err.shifted_anchor.expect("shifted_anchor must be present");
assert!(err.message.contains("Retry"));
@@ -1864,18 +1856,15 @@ mod tests {
let result = apply_edits(content, &ops, &test_path(), &*test_scheme());
let new = result.new_content.expect("should succeed");
// A blank line should appear between line1 and line2.
assert!(
new.contains("line1\n\nline2"),
"empty content should insert a blank line, got: {new}"
);
// The detail should reflect the blank line insertion.
assert_eq!(result.edit_details.len(), 1);
assert_eq!(result.edit_details[0].old_text, "");
assert_eq!(result.edit_details[0].new_text, "");
// The snippet should include the blank line with a fresh anchor.
match result.output {
HashlineEditOutput::EditsApplied(applied) => {
assert!(
@@ -1908,8 +1897,8 @@ mod tests {
let anchors = anchors_for(content);
let ops = vec![HashlineOp::Replace {
anchor: anchors[1].clone(), // line2
end_anchor: Some(anchors[3].clone()), // line4
anchor: anchors[1].clone(),
end_anchor: Some(anchors[3].clone()),
content: "replaced_range".to_owned(),
}];
@@ -1925,11 +1914,11 @@ mod tests {
let anchors = anchors_for(SAMPLE);
let ops = vec![
HashlineOp::InsertAfter {
anchor: anchors[0].clone(), // after "fn main() {"
anchor: anchors[0].clone(),
content: " // comment".to_owned(),
},
HashlineOp::Replace {
anchor: anchors[3].clone(), // println line
anchor: anchors[3].clone(),
end_anchor: None,
content: " println!(\"changed\");".to_owned(),
},
@@ -1937,10 +1926,8 @@ mod tests {
let result = apply_edits(SAMPLE, &ops, &test_path(), &*test_scheme());
assert_eq!(result.edit_details.len(), 2);
// Insert: no old content
assert_eq!(result.edit_details[0].old_text, "");
assert_eq!(result.edit_details[0].new_text, " // comment");
// Replace: old content is the println line
assert_eq!(
result.edit_details[1].old_text,
" println!(\"{x} {y}\");"
@@ -1951,7 +1938,7 @@ mod tests {
);
// New line should account for the insertion shift
assert_eq!(result.edit_details[1].old_line, 4);
assert_eq!(result.edit_details[1].new_line, 5); // shifted by 1
assert_eq!(result.edit_details[1].new_line, 5);
}
#[test]
@@ -1999,7 +1986,6 @@ mod tests {
let result = apply_edits(&content, &ops, &test_path(), &*test_scheme());
assert_eq!(result.edit_details.len(), 2);
// Each detail should contain only the affected content
assert_eq!(result.edit_details[0].old_text, "");
assert_eq!(result.edit_details[0].new_text, "INSERTED");
assert_eq!(result.edit_details[1].old_text, "line_194");
@@ -2123,7 +2109,7 @@ mod tests {
result.is_ok(),
"unique hash suffix should recover: {suffix}"
);
assert_eq!(result.unwrap(), 2); // 0-based line index
assert_eq!(result.unwrap(), 2);
}
#[test]
@@ -67,12 +67,10 @@ Follow-up edits:
- Never fabricate or modify anchors only use exact anchors as returned by
previous read, grep, or edit calls."#;
/// `hashline_edit` tool — edits files using anchor references.
#[derive(Debug, Default)]
pub struct HashlineEditTool;
impl HashlineEditTool {
/// Build a `FileNotFound` output with enriched path hints (if enabled).
async fn file_not_found(
display_path: &std::path::Path,
joined_path: &std::path::Path,
@@ -118,9 +116,8 @@ fn to_search_replace(
.into_iter()
.map(|d| {
let ctx_count = 3;
let old_idx = d.old_line.saturating_sub(1); // 0-based
let old_idx = d.old_line.saturating_sub(1);
// Lines before the edit in the old file.
let before_start = old_idx.saturating_sub(ctx_count);
let context_before = if before_start < old_idx {
let mut cb = old_lines[before_start..old_idx].join("\n");
@@ -130,7 +127,6 @@ fn to_search_replace(
String::new()
};
// Lines after the edit in the old file.
let old_text_line_count = if d.old_text.is_empty() {
0
} else {
@@ -321,8 +317,6 @@ impl kigi_tool_runtime::Tool for HashlineEditTool {
let path = match crate::util::fs::try_canonicalize(&joined_path).await {
Ok(p) => p,
Err(_) => {
// Try unicode-confusable resolution before giving up.
// Used in search_replace.
let resolved = crate::util::try_resolve_unicode_filename(&joined_path).await;
if let Some(m) = resolved {
m.resolved_path
@@ -374,7 +368,6 @@ impl kigi_tool_runtime::Tool for HashlineEditTool {
}
};
// Read current file content.
let file_bytes = match fs.read_file(&path).await {
Ok(b) => b,
Err(e) => {
@@ -500,7 +493,6 @@ mod tests {
}
}
/// Integration: same-anchor insertions preserve request order on disk.
#[tokio::test]
async fn disk_preserves_same_anchor_insert_order() {
let tmp = TempDir::new().unwrap();
@@ -515,11 +507,11 @@ mod tests {
file_path: "test.txt".to_string(),
edits: vec![
HashlineOp::InsertAfter {
anchor: anchors[0].clone(), // after line 1
anchor: anchors[0].clone(),
content: "first_insert".to_owned(),
},
HashlineOp::InsertAfter {
anchor: anchors[0].clone(), // same anchor
anchor: anchors[0].clone(),
content: "second_insert".to_owned(),
},
],
@@ -548,7 +540,6 @@ mod tests {
);
}
/// Integration: EOF append on file with trailing newline.
#[tokio::test]
async fn disk_eof_append_no_extra_blank() {
let tmp = TempDir::new().unwrap();
@@ -583,7 +574,6 @@ mod tests {
);
}
/// The erased tool must produce ToolOutput::SearchReplace, not a custom variant.
#[tokio::test]
async fn output_is_tool_output_search_replace() {
use crate::types::output::ToolOutput;
@@ -610,7 +600,6 @@ mod tests {
.await
.unwrap();
// Convert to ToolOutput — must be the SearchReplace variant.
let tool_output: ToolOutput = result.into();
assert!(
matches!(tool_output, ToolOutput::SearchReplace(_)),
@@ -618,8 +607,6 @@ mod tests {
);
}
// -- Diff detail tests (multi-edit compactness) -------------------------
fn test_scheme() -> Box<dyn crate::implementations::kigi_hashline::scheme::AnchorScheme> {
crate::implementations::kigi_hashline::config::HashlineSchemeParams::default()
.build_scheme()
@@ -637,12 +624,10 @@ mod tests {
let anchors = anchors_for(&content);
let ops = vec![
// Insert near the top — changes line count.
HashlineOp::InsertAfter {
anchor: anchors[4].clone(),
content: "INSERTED_LINE".to_owned(),
},
// Replace near the bottom.
HashlineOp::Replace {
anchor: anchors[90].clone(),
end_anchor: None,
@@ -664,7 +649,6 @@ mod tests {
match sr {
crate::types::output::SearchReplaceOutput::EditsApplied(applied) => {
// Should have exactly 2 details (one per edit).
assert_eq!(
applied.edits.details.len(),
2,
@@ -672,15 +656,12 @@ mod tests {
applied.edits.details.len()
);
// Detail 0: insertion (empty old, "INSERTED_LINE" new)
assert_eq!(applied.edits.details[0].old_string, "");
assert_eq!(applied.edits.details[0].new_string, "INSERTED_LINE");
// Detail 1: replacement
assert_eq!(applied.edits.details[1].old_string, "line_90");
assert_eq!(applied.edits.details[1].new_string, "REPLACED_LINE");
// Total detail size should be very small — NOT the entire file.
let total_detail_bytes: usize = applied
.edits
.details
@@ -731,8 +712,6 @@ mod tests {
}
}
/// Scattered edits across a large file must produce compact per-edit details,
/// not a diff that spans the entire file.
#[test]
fn scattered_edits_details_total_size_bounded() {
let line_count = 500;
@@ -754,7 +733,7 @@ mod tests {
HashlineOp::Replace {
anchor: anchors[498].clone(),
end_anchor: None,
content: String::new(), // delete
content: String::new(),
},
];
@@ -774,21 +753,17 @@ mod tests {
crate::types::output::SearchReplaceOutput::EditsApplied(applied) => {
assert_eq!(applied.edits.details.len(), 3);
// Total detail content should be small compared to the 500-line file.
let total_detail_bytes: usize = applied
.edits
.details
.iter()
.map(|d| d.old_string.len() + d.new_string.len())
.sum();
// With old code, this would be thousands of bytes due to positional diff.
// With new code, it's just the affected lines.
assert!(
total_detail_bytes < 200,
"Details should be compact for scattered edits, got {total_detail_bytes} bytes"
);
// Verify each detail has the correct content.
assert_eq!(applied.edits.details[0].old_string, "");
assert_eq!(applied.edits.details[0].new_string, "TOP_INSERT");
assert_eq!(applied.edits.details[1].old_string, "line_250");
@@ -806,12 +781,10 @@ mod tests {
let anchors = anchors_for(content);
let ops = vec![
// Insert after line 1 — adds a line, shifting everything below by 1.
HashlineOp::InsertAfter {
anchor: anchors[0].clone(),
content: "inserted".to_owned(),
},
// Replace line 4 — in the new file, this is at line 5 due to the insertion.
HashlineOp::Replace {
anchor: anchors[3].clone(),
end_anchor: None,
@@ -835,17 +808,15 @@ mod tests {
crate::types::output::SearchReplaceOutput::EditsApplied(applied) => {
assert_eq!(applied.edits.details.len(), 2);
// First edit: insert after line 1
let d0 = &applied.edits.details[0];
assert_eq!(d0.old_string, ""); // insertion has no old content
assert_eq!(d0.old_string, "");
assert_eq!(d0.new_string, "inserted");
// Second edit: replace line 4
let d1 = &applied.edits.details[1];
assert_eq!(d1.old_line, 4); // line 4 in old file
assert_eq!(d1.old_line, 4);
assert_eq!(d1.old_string, "line4");
assert_eq!(d1.new_string, "replaced");
assert_eq!(d1.new_line, 5); // shifted to line 5 in new file
assert_eq!(d1.new_line, 5);
}
_ => panic!("Expected EditsApplied"),
}
@@ -872,7 +843,6 @@ mod tests {
match sr {
crate::types::output::SearchReplaceOutput::EditsApplied(applied) => {
// Write op now produces a single whole-file detail for TUI diffing.
assert_eq!(
applied.edits.details.len(),
1,
@@ -887,8 +857,6 @@ mod tests {
}
}
// -- Context lines tests (TUI rendering) ---------------------------------
const RENDER_SAMPLE: &str = "fn main() {\n let x = 1;\n let y = 2;\n let z = x + y;\n println!(\"sum = {z}\");\n if z > 2 {\n println!(\"big\");\n }\n let w = z * 2;\n println!(\"double = {w}\");\n}\n";
fn apply_and_convert(
@@ -914,7 +882,6 @@ mod tests {
#[test]
fn context_lines_for_single_replace() {
let anchors = anchors_for(RENDER_SAMPLE);
// Replace line 5: println!("sum = {z}");
let applied = apply_and_convert(
RENDER_SAMPLE,
vec![HashlineOp::Replace {
@@ -928,7 +895,6 @@ mod tests {
assert_eq!(d.old_string, " println!(\"sum = {z}\");");
assert_eq!(d.new_string, " println!(\"total = {z}\");");
// 3 context lines before (lines 2-4).
assert!(
d.context_before.contains("let y = 2;"),
"context_before should have line 3: {}",
@@ -940,7 +906,6 @@ mod tests {
d.context_before
);
// 3 context lines after (lines 6-8).
assert!(
d.context_after.contains("if z > 2"),
"context_after should have line 6: {}",
@@ -956,7 +921,6 @@ mod tests {
#[test]
fn context_lines_for_insert_after() {
let anchors = anchors_for(RENDER_SAMPLE);
// Insert after line 3: let y = 2;
let applied = apply_and_convert(
RENDER_SAMPLE,
vec![HashlineOp::InsertAfter {
@@ -969,7 +933,6 @@ mod tests {
assert_eq!(d.old_string, "");
assert_eq!(d.new_string, " let a = 99;");
// Context before should include lines leading up to insertion point.
assert!(
d.context_before.contains("let x = 1;"),
"context_before: {}",
@@ -981,7 +944,6 @@ mod tests {
d.context_before
);
// Context after should include lines after insertion point.
assert!(
d.context_after.contains("let z = x + y;"),
"context_after: {}",
@@ -992,7 +954,6 @@ mod tests {
#[test]
fn context_lines_for_delete() {
let anchors = anchors_for(RENDER_SAMPLE);
// Delete line 5: println!("sum = {z}");
let applied = apply_and_convert(
RENDER_SAMPLE,
vec![HashlineOp::Replace {
@@ -1006,7 +967,6 @@ mod tests {
assert_eq!(d.old_string, " println!(\"sum = {z}\");");
assert_eq!(d.new_string, "");
// Context before and after should still be populated.
assert!(
!d.context_before.is_empty(),
"delete should have context_before"
@@ -1025,7 +985,6 @@ mod tests {
#[test]
fn context_lines_for_multi_range_edit() {
let anchors = anchors_for(RENDER_SAMPLE);
// Replace line 2 (let x = 1) AND line 10 (println!("double = {w}"))
let applied = apply_and_convert(
RENDER_SAMPLE,
vec![
@@ -1044,7 +1003,6 @@ mod tests {
assert_eq!(applied.edits.details.len(), 2);
// First edit (line 2): context_before has only line 1 (fn main).
let d0 = &applied.edits.details[0];
assert_eq!(d0.old_string, " let x = 1;");
assert!(
@@ -1058,7 +1016,6 @@ mod tests {
d0.context_after
);
// Second edit (line 10): context_before has lines 7-9, context_after has line 11.
let d1 = &applied.edits.details[1];
assert_eq!(d1.old_string, " println!(\"double = {w}\");");
assert!(
@@ -1075,11 +1032,9 @@ mod tests {
#[test]
fn context_at_file_boundaries() {
// Edit the very first and very last lines — context should not panic.
let content = "first\nsecond\nthird\n";
let anchors = anchors_for(content);
// Replace first line.
let applied = apply_and_convert(
content,
vec![HashlineOp::Replace {
@@ -1099,7 +1054,6 @@ mod tests {
d.context_after
);
// Replace last content line.
let applied = apply_and_convert(
content,
vec![HashlineOp::Replace {
@@ -1,11 +1,5 @@
//! Stateless range-size policy for hashline edit safety.
//!
//! Classifies edit ranges by size and produces tiered warnings:
//! - Small (≤5 lines): no warning
//! - Medium (620 lines): caution
//! - Large (>20 lines): stronger caution
//!
//! No session state — purely a function of the requested range.
//! Stateless range-size policy for hashline edit safety: classify an edit
//! range by line count and emit a tiered caution warning.
const SMALL_MAX: usize = 5;
const MEDIUM_MAX: usize = 20;
@@ -29,7 +23,6 @@ impl RangeSize {
}
}
/// Evaluate a range edit and return a warning for medium or large ranges.
pub fn range_warning(start: usize, end: usize) -> Option<String> {
let count = end.saturating_sub(start);
match RangeSize::classify(count) {
@@ -7,7 +7,6 @@ use serde::{Deserialize, Serialize};
/// Input for the `hashline_edit` tool.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
pub struct HashlineEditInput {
/// Path to the file to edit.
#[schemars(description = "The path of the file to edit.")]
pub file_path: String,
@@ -20,10 +20,8 @@ use super::anchor::split_lines;
use super::config::HashlineSchemeParams;
use super::scheme::{Anchor, AnchorScheme};
/// Default timeout for anchor injection (seconds).
const DEFAULT_ANCHOR_TIMEOUT_SECS: u64 = 60;
/// Get cached anchors or generate and cache them for a single invocation.
async fn get_or_generate<'a>(
cache: &'a mut HashMap<PathBuf, Vec<Anchor>>,
path: &Path,
@@ -152,7 +150,6 @@ Usage:
- Only use 'type' or 'glob' when certain of the file type
- Results are capped; truncated results show "at least" counts"#;
/// `hashline_grep` — searches with anchor-annotated results.
#[derive(Debug, Default)]
pub struct HashlineGrepTool;
@@ -237,7 +234,6 @@ impl kigi_tool_runtime::Tool for HashlineGrepTool {
let output_mode = input.output_mode.clone().unwrap_or(OutputMode::Content);
// Delegate to standard GrepTool for ripgrep execution.
let grep = GrepTool;
let cwd = crate::types::tool_metadata::resolve_cwd(&ctx, &resources).await?;
let call_id = kigi_tool_protocol::ToolCallId::new_v7();
@@ -253,7 +249,6 @@ impl kigi_tool_runtime::Tool for HashlineGrepTool {
)
})?;
// Inject anchors only for content mode.
if matches!(output_mode, OutputMode::Content) && result.exit_code == 0 {
let cwd = crate::types::tool_metadata::resolve_cwd(&ctx, &resources).await?;
let (fs, scheme) = {
@@ -369,7 +364,6 @@ mod tests {
let fs = Arc::new(LocalFs);
// Simulate ripgrep output for "let" search.
let rg_output = format!(
"<workspace_result workspace_path=\"{}\">\n\
Found 2 matching lines\n\
@@ -450,14 +444,12 @@ mod tests {
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
let output = String::from_utf8_lossy(&result);
// Context line should use '-' separator after the anchor.
let line_2 = output.lines().find(|l| l.starts_with('2')).unwrap();
assert!(
line_2.contains('-'),
"context line should keep '-': {line_2}"
);
// Match line should use ':' separator after the anchor.
let line_3 = output.lines().find(|l| l.starts_with('3')).unwrap();
// Count colons: line:local:context:content = 3 colons with ':'
let colon_count = line_3.matches(':').count();
@@ -476,7 +468,6 @@ mod tests {
let tmp = TempDir::new().unwrap();
let fs = Arc::new(LocalFs);
// File doesn't exist — anchors can't be generated.
let rg_output = format!(
"<workspace_result workspace_path=\"{}\">\n\
Found 1 matching lines\n\
@@ -490,7 +481,6 @@ mod tests {
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
let output = String::from_utf8_lossy(&result);
// Should fall through without anchors — original line preserved.
assert!(output.contains("5:some content"));
}
@@ -552,7 +542,6 @@ mod tests {
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
let output = String::from_utf8_lossy(&result);
// No numbered lines → no anchor injection. Output should be unchanged.
assert!(output.contains("src/main.rs"));
assert!(output.contains("src/lib.rs"));
assert!(
@@ -570,7 +559,6 @@ mod tests {
let tmp = TempDir::new().unwrap();
let fs = Arc::new(LocalFs);
// count output: "file:N" format.
let rg_output = format!(
"<workspace_result workspace_path=\"{}\">\n\
src/main.rs:5\n\
@@ -619,7 +607,6 @@ mod tests {
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
let output = String::from_utf8_lossy(&result);
// All 4 lines should be anchored (same file, same cache entry).
let anchored_count = output
.lines()
.filter(|l| l.starts_with(|c: char| c.is_ascii_digit()))
@@ -725,7 +712,6 @@ mod tests {
std::fs::write(tmp.path().join("big.rs"), "match\n".repeat(100)).unwrap();
let fs = Arc::new(LocalFs);
// Simulate truncated output with "... [N lines truncated]" marker.
let mut rg_lines = String::new();
for i in 1..=10 {
rg_lines.push_str(&format!("{i}:match\n"));
@@ -744,11 +730,8 @@ mod tests {
let result = inject_anchors(rg_output.as_bytes(), tmp.path(), &*fs, &*scheme).await;
let output = String::from_utf8_lossy(&result);
// Truncation marker should be preserved.
assert!(output.contains("... [at least 90 lines truncated] ..."));
// The wrapper should be intact.
assert!(output.contains("</workspace_result>"));
// Visible lines should be anchored.
let anchored = output
.lines()
.filter(|l| l.starts_with(|c: char| c.is_ascii_digit()))
@@ -86,7 +86,6 @@ pub fn apply_mutation(lines: &mut Vec<String>, mutation: &Mutation) -> MutationR
lines.insert(idx + i, line.clone());
}
// Lines before idx: unchanged. Lines at idx and above: shifted.
let outcomes = (0..orig_len)
.map(|i| {
if i < idx {
@@ -210,8 +209,6 @@ pub fn apply_mutation(lines: &mut Vec<String>, mutation: &Mutation) -> MutationR
}
}
/// Generate an "insert lines above" mutation: insert `count` boilerplate
/// lines before `before_idx`.
pub fn gen_insert_above(before_idx: usize, count: usize) -> Mutation {
let lines: Vec<String> = (0..count)
.map(|i| format!("// inserted line {i}"))
@@ -219,12 +216,10 @@ pub fn gen_insert_above(before_idx: usize, count: usize) -> Mutation {
Mutation::InsertLines { before_idx, lines }
}
/// Generate a "delete lines" mutation.
pub fn gen_delete(start_idx: usize, count: usize) -> Mutation {
Mutation::DeleteLines { start_idx, count }
}
/// Generate a local token edit on a single line.
pub fn gen_token_edit(line_idx: usize, new_content: &str) -> Mutation {
Mutation::EditLine {
line_idx,
@@ -232,7 +227,6 @@ pub fn gen_token_edit(line_idx: usize, new_content: &str) -> Mutation {
}
}
/// Generate a formatter-style re-indentation.
pub fn gen_reindent(line_idx: usize, new_indent: &str) -> Mutation {
Mutation::ReindentLine {
line_idx,
@@ -240,7 +234,6 @@ pub fn gen_reindent(line_idx: usize, new_indent: &str) -> Mutation {
}
}
/// Generate a range rewrite replacing `start_idx..end_idx` with new content.
pub fn gen_range_rewrite(start_idx: usize, end_idx: usize, new_lines: &[&str]) -> Mutation {
Mutation::RangeRewrite {
start_idx,
@@ -249,7 +242,6 @@ pub fn gen_range_rewrite(start_idx: usize, end_idx: usize, new_lines: &[&str]) -
}
}
/// Generate a boilerplate-insertion mutation: insert repeated identical lines.
pub fn gen_boilerplate_insert(before_idx: usize, line: &str, count: usize) -> Mutation {
Mutation::InsertLines {
before_idx,
@@ -281,7 +273,6 @@ mod tests {
assert!(lines[2].contains("inserted line 1"));
assert_eq!(lines[3], " let x = 1;");
assert_eq!(result.line_delta, 2);
// Line 0 unchanged, lines 1-4 shifted by +2.
assert_eq!(result.outcomes[0], LineOutcome::Unchanged);
assert_eq!(result.outcomes[1], LineOutcome::Shifted { new_idx: 3 });
assert_eq!(result.outcomes[4], LineOutcome::Shifted { new_idx: 6 });
@@ -290,12 +281,12 @@ mod tests {
#[test]
fn insert_at_end() {
let mut lines = sample_lines();
let m = gen_insert_above(100, 1); // past end → clamped
// past end → clamped
let m = gen_insert_above(100, 1);
let result = apply_mutation(&mut lines, &m);
assert_eq!(lines.len(), 6);
assert!(lines[5].contains("inserted line 0"));
assert_eq!(result.line_delta, 1);
// All original lines unchanged (insert was at end).
assert!(result.outcomes.iter().all(|o| *o == LineOutcome::Unchanged));
}
@@ -317,9 +308,10 @@ mod tests {
#[test]
fn delete_past_end_clamped() {
let mut lines = sample_lines();
let m = gen_delete(3, 100); // tries to delete 100 from idx 3
let m = gen_delete(3, 100);
let result = apply_mutation(&mut lines, &m);
assert_eq!(lines.len(), 3); // only deleted 2 (indices 3,4)
// only deleted 2 (indices 3,4)
assert_eq!(lines.len(), 3);
assert_eq!(result.line_delta, -2);
assert_eq!(result.outcomes[3], LineOutcome::Deleted);
assert_eq!(result.outcomes[4], LineOutcome::Deleted);
@@ -339,7 +331,8 @@ mod tests {
#[test]
fn reindent_line() {
let mut lines = sample_lines();
let m = gen_reindent(1, " "); // double indent
// double indent
let m = gen_reindent(1, " ");
let result = apply_mutation(&mut lines, &m);
assert_eq!(lines[1], " let x = 1;");
assert_eq!(result.line_delta, 0);
@@ -351,7 +344,8 @@ mod tests {
let mut lines = sample_lines();
let m = gen_range_rewrite(1, 3, &[" let z = 42;"]);
let result = apply_mutation(&mut lines, &m);
assert_eq!(lines.len(), 4); // 5 - 2 removed + 1 added
// 5 - 2 removed + 1 added
assert_eq!(lines.len(), 4);
assert_eq!(lines[1], " let z = 42;");
assert_eq!(lines[2], " println!(\"{x} {y}\");");
assert_eq!(result.line_delta, -1);
@@ -371,7 +365,6 @@ mod tests {
assert_eq!(lines[2], "// boilerplate");
assert_eq!(lines[3], "fn main() {");
assert_eq!(result.line_delta, 3);
// All original lines shifted by +3.
assert_eq!(result.outcomes[0], LineOutcome::Shifted { new_idx: 3 });
assert_eq!(result.outcomes[4], LineOutcome::Shifted { new_idx: 7 });
}
@@ -385,7 +378,8 @@ mod tests {
&[" let a = 1;", " let b = 2;", " let c = 3;"],
);
let result = apply_mutation(&mut lines, &m);
assert_eq!(lines.len(), 7); // 5 - 1 removed + 3 added
// 5 - 1 removed + 3 added
assert_eq!(lines.len(), 7);
assert_eq!(result.line_delta, 2);
assert_eq!(result.outcomes[1], LineOutcome::Deleted);
assert_eq!(result.outcomes[2], LineOutcome::Shifted { new_idx: 4 });
@@ -43,7 +43,7 @@ pub(crate) fn format_hashline_content(
let mut first_line: Option<usize> = None;
for (i, line) in all_lines.iter().enumerate().skip(skip).take(take) {
let line_num = i + 1; // 1-based
let line_num = i + 1;
if first_line.is_none() {
first_line = Some(line_num);
@@ -52,14 +52,11 @@ pub(crate) fn format_hashline_content(
raw_output.push('\n');
}
// Build the anchor suffix: "local" or "local:context" (without line number,
// since we format the line number separately with right-alignment).
let anchor_suffix = match &anchors[i].context {
Some(ctx) => format!("{}:{ctx}", anchors[i].local),
None => anchors[i].local.clone(),
};
// Format: "LINE:LOCAL:CONTEXT→CONTENT" (or "LINE:LOCAL→CONTENT" for A)
_ = write!(&mut output, "{line_num}:{anchor_suffix}→{line}").ok();
raw_output.push_str(line);
}
@@ -228,16 +225,14 @@ impl kigi_tool_runtime::Tool for HashlineReadTool {
format_hashline_content(&full_content, fc.offset, effective_limit, &*scheme);
fc.content = hashline_content;
fc.content_concise = None; // hashline has only one format
// hashline has only one format
fc.content_concise = None;
// Drop tool-layer captures: `hashline_content` keeps the
// original URIs intact, so session-layer extraction will
// catch them — clearing here avoids double-injection.
fc.extracted_images.clear();
// raw_output, offset, limit, tracking remain as set by
// run_read_file — windowed semantics preserved.
Ok(ReadFileOutput::FileContent(fc))
}
// Non-text results (images, errors) pass through unchanged.
other => Ok(other),
}
}
@@ -263,17 +258,12 @@ mod tests {
resources
}
// -----------------------------------------------------------------------
// format_hashline_content unit tests
// -----------------------------------------------------------------------
#[test]
fn format_basic_file() {
let content = "line one\nline two\nline three\n";
let scheme = HashlineSchemeParams::default().build_scheme().unwrap();
let (output, _raw) = format_hashline_content(content, None, None, &*scheme);
// Each line should have the pattern: ANCHOR→CONTENT
for line in output.lines() {
assert!(line.contains(':'), "missing anchor separator: {line}");
assert!(line.contains('→'), "missing content separator: {line}");
@@ -286,8 +276,6 @@ mod tests {
let scheme = HashlineSchemeParams::default().build_scheme().unwrap();
let (output, _raw) = format_hashline_content(content, None, None, &*scheme);
// chunk scheme produces LINE:LOCAL:CONTEXT→CONTENT
// Check that the first content line has two colons (line:local:context)
let first_content_line = output.lines().next().unwrap();
let before_arrow = first_content_line.split('→').next().unwrap();
let colon_count = before_arrow.matches(':').count();
@@ -303,7 +291,6 @@ mod tests {
let scheme = HashlineSchemeParams::default().build_scheme().unwrap();
let (output, _raw) = format_hashline_content(content, Some(2), Some(2), &*scheme);
// Should contain lines starting with "2:" and "3:"
let content_lines: Vec<&str> = output.lines().collect();
assert_eq!(content_lines.len(), 2);
assert!(content_lines[0].starts_with("2:"));
@@ -316,7 +303,6 @@ mod tests {
let scheme = HashlineSchemeParams::default().build_scheme().unwrap();
let (output, _raw) = format_hashline_content(content, None, None, &*scheme);
// Should produce a single anchored empty line.
assert!(output.contains("1:"), "should contain line 1");
assert!(output.contains('→'), "should contain arrow separator");
}
@@ -345,10 +331,6 @@ mod tests {
assert_eq!(a, b);
}
// -----------------------------------------------------------------------
// HashlineReadTool integration tests
// -----------------------------------------------------------------------
#[test]
fn tool_metadata() {
use crate::types::tool_metadata::ToolMetadata;
@@ -399,11 +381,9 @@ mod tests {
match result {
ReadFileOutput::FileContent(fc) => {
// Hashline format: ANCHOR→CONTENT
assert!(fc.content.contains('→'));
assert!(fc.content.contains("fn main()"));
// Should have chunk-style anchors (two colons before →)
let first_line = fc.content.lines().next().unwrap();
let before_arrow = first_line.split('→').next().unwrap();
assert!(
@@ -411,7 +391,6 @@ mod tests {
"expected chunk anchors, got: {before_arrow}"
);
// concise should be None for hashline
assert!(fc.content_concise.is_none());
}
other => panic!("Expected FileContent, got {:?}", other),
@@ -568,7 +547,6 @@ mod tests {
ReadFileOutput::FileContent(fc) => {
let content_lines: Vec<&str> = fc.content.lines().collect();
// Exactly 2 content lines should be rendered.
assert_eq!(
content_lines.len(),
2,
@@ -577,7 +555,6 @@ mod tests {
content_lines
);
// Line numbers should be 2 and 3 (original file positions).
assert!(
content_lines[0].starts_with("2:"),
"first line should start with '2:', got: {}",
@@ -589,13 +566,11 @@ mod tests {
content_lines[1]
);
// Content should be the original lines "beta" and "gamma".
let after_arrow_0 = content_lines[0].split('→').nth(1).unwrap();
let after_arrow_1 = content_lines[1].split('→').nth(1).unwrap();
assert_eq!(after_arrow_0, "beta", "line 2 content mismatch");
assert_eq!(after_arrow_1, "gamma", "line 3 content mismatch");
// The stored offset/limit should reflect the original request.
assert_eq!(fc.offset, Some(2));
assert_eq!(fc.limit, Some(2));
}
@@ -629,8 +604,6 @@ mod tests {
match result {
ReadFileOutput::FileContent(fc) => {
// raw_output should contain only the windowed content (beta, gamma),
// not the full file.
assert!(
fc.raw_output.contains("beta"),
"raw_output should contain 'beta'"
@@ -656,8 +629,6 @@ mod tests {
#[tokio::test]
async fn small_window_into_large_file_succeeds() {
let tmp = TempDir::new().unwrap();
// Create a file with many lines (more than would fit in token budget
// if read fully, but a small window should be fine).
let mut content = String::new();
for i in 0..2000 {
content.push_str(&format!("// line {i}: some padding content here\n"));
@@ -678,7 +649,6 @@ mod tests {
.await
.unwrap();
// Should succeed with FileContent, not FileTooLarge.
match result {
ReadFileOutput::FileContent(fc) => {
let content_lines: Vec<&str> = fc.content.lines().collect();
@@ -715,7 +685,6 @@ mod tests {
match result {
ReadFileOutput::FileContent(fc) => {
// With offset=100 on a 4-line file, no content lines should be rendered.
let content_lines: Vec<&str> = fc.content.lines().collect();
assert!(
content_lines.is_empty(),
@@ -792,7 +761,6 @@ mod tests {
}
}
/// Explicit limit exceeding MAX_LINES_READ gets capped.
#[tokio::test]
async fn explicit_large_limit_capped_to_max_lines() {
let tmp = TempDir::new().unwrap();
@@ -21,9 +21,6 @@ use std::fmt;
use crate::util::hash::{self, DEFAULT_HASH_LEN};
/// Trait for pluggable anchor generation and validation schemes.
///
/// Implementations generate anchors for file lines and validate anchors
/// against current file content.
pub trait AnchorScheme: fmt::Debug + Send + Sync {
/// Machine-readable name for this scheme (e.g. `"content_only_v1"`).
fn name(&self) -> &str;
@@ -31,16 +28,11 @@ pub trait AnchorScheme: fmt::Debug + Send + Sync {
/// Number of lowercase letters in the local line hash component.
fn hash_len(&self) -> usize;
/// Generate anchors for all lines in a file.
///
/// `lines` is a slice of the file's lines (without trailing newlines).
/// Returns one `Anchor` per line, in order.
/// `lines` are the file's lines without trailing newlines. Returns one
/// `Anchor` per line, in order.
fn generate_anchors(&self, lines: &[&str]) -> Vec<Anchor>;
/// Validate a parsed anchor against current file content.
///
/// `anchor` is the anchor to validate. `lines` is the current file
/// content split by line. Returns the validation result.
fn validate(&self, anchor: &ParsedAnchor, lines: &[&str]) -> ValidationResult;
/// Estimated number of lines read to validate a single anchor at
@@ -54,10 +46,6 @@ pub trait AnchorScheme: fmt::Debug + Send + Sync {
/// Search for a shifted anchor within a bounded window around the
/// original line number.
///
/// Returns `ShiftResult::Found` if exactly one nearby line validates
/// under this scheme, `ShiftResult::Ambiguous` if multiple candidates
/// match, and `ShiftResult::NotFound` if none match.
fn find_shifted(
&self,
anchor: &ParsedAnchor,
@@ -78,8 +66,6 @@ pub struct Anchor {
}
impl Anchor {
/// Render this anchor as a string suitable for output.
///
/// Format: `"LINE:LOCAL"` or `"LINE:LOCAL:CONTEXT"`.
pub fn render(&self) -> String {
match &self.context {
@@ -107,8 +93,6 @@ pub struct ParsedAnchor {
}
impl ParsedAnchor {
/// Parse an anchor string into its components.
///
/// Accepted formats:
/// - `"22:abc"` → line=22, local="abc", context=None
/// - `"22:abc:rst"` → line=22, local="abc", context=Some("rst")
@@ -129,13 +113,11 @@ impl ParsedAnchor {
return None;
}
// Validate local hash: must be all lowercase ASCII letters.
if !local.bytes().all(|b| b.is_ascii_lowercase()) {
return None;
}
let context = parts.next().map(|s| s.to_owned());
// Validate context hash if present: must be non-empty lowercase ASCII letters.
if let Some(ref ctx) = context
&& (ctx.is_empty() || !ctx.bytes().all(|b| b.is_ascii_lowercase()))
{
@@ -149,7 +131,6 @@ impl ParsedAnchor {
})
}
/// Render back to string form.
pub fn render(&self) -> String {
match &self.context {
Some(ctx) => format!("{}:{}:{}", self.line, self.local, ctx),
@@ -321,11 +302,9 @@ impl ChunkFingerprint {
let chunk_start = (line_idx / self.chunk_size) * self.chunk_size;
let chunk_end = (chunk_start + self.chunk_size).min(lines.len());
// Hash all normalized lines in the chunk together.
let mut combined: u32 = hash::fnv1a_32(b"chunk");
for line in &lines[chunk_start..chunk_end] {
let lh = hash::line_hash(line);
// Mix each line hash into the combined hash.
combined ^= lh;
combined = combined.wrapping_mul(16_777_619);
}
@@ -390,7 +369,6 @@ impl AnchorScheme for ChunkFingerprint {
return ValidationResult::OutOfRange;
}
// Validate local line hash.
let expected_local = hash::encode_hash(hash::line_hash(lines[idx]), self.hash_len);
if anchor.local != expected_local {
return ValidationResult::Stale;
@@ -528,7 +506,6 @@ impl AnchorScheme for CheckpointChain {
return ValidationResult::OutOfRange;
}
// Validate local line hash.
let expected_local = hash::encode_hash(hash::line_hash(lines[idx]), self.hash_len);
if anchor.local != expected_local {
return ValidationResult::Stale;
@@ -587,7 +564,6 @@ fn find_shifted_generic(
continue;
}
// Cheap check: does the local line hash match?
let local = hash::encode_hash(hash::line_hash(lines[idx]), hash_len);
if local != anchor.local {
continue;
@@ -623,9 +599,7 @@ fn find_shifted_generic(
mod tests {
use super::*;
// -----------------------------------------------------------------------
// Test fixture
// -----------------------------------------------------------------------
fn sample_lines() -> Vec<&'static str> {
vec![
@@ -637,9 +611,7 @@ mod tests {
]
}
// -----------------------------------------------------------------------
// ParsedAnchor tests
// -----------------------------------------------------------------------
#[test]
fn parse_anchor_two_parts() {
@@ -671,16 +643,19 @@ mod tests {
assert!(ParsedAnchor::parse("abc").is_none());
assert!(ParsedAnchor::parse(":abc").is_none());
assert!(ParsedAnchor::parse("22:").is_none());
assert!(ParsedAnchor::parse("0:abc").is_none()); // line 0 invalid
assert!(ParsedAnchor::parse("22:ABC").is_none()); // uppercase
assert!(ParsedAnchor::parse("22:abc:").is_none()); // empty context
assert!(ParsedAnchor::parse("22:abc:XYZ").is_none()); // uppercase context
assert!(ParsedAnchor::parse("abc:def").is_none()); // non-numeric line
// line 0 invalid
assert!(ParsedAnchor::parse("0:abc").is_none());
// uppercase
assert!(ParsedAnchor::parse("22:ABC").is_none());
// empty context
assert!(ParsedAnchor::parse("22:abc:").is_none());
// uppercase context
assert!(ParsedAnchor::parse("22:abc:XYZ").is_none());
// non-numeric line
assert!(ParsedAnchor::parse("abc:def").is_none());
}
// -----------------------------------------------------------------------
// Anchor::render tests
// -----------------------------------------------------------------------
#[test]
fn anchor_render_without_context() {
@@ -703,9 +678,7 @@ mod tests {
assert_eq!(a.render(), "22:abc:rst");
}
// -----------------------------------------------------------------------
// Candidate A — ContentOnly
// -----------------------------------------------------------------------
#[test]
fn content_only_generates_correct_count() {
@@ -814,9 +787,7 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// Candidate B — ChunkFingerprint
// -----------------------------------------------------------------------
#[test]
fn chunk_generates_context() {
@@ -830,7 +801,8 @@ mod tests {
#[test]
fn chunk_same_chunk_same_context() {
let lines = sample_lines(); // 5 lines, all in chunk 0 (size 16)
// 5 lines, all in chunk 0 (size 16)
let lines = sample_lines();
let scheme = ChunkFingerprint::new();
let anchors = scheme.generate_anchors(&lines);
let ctx0 = anchors[0].context.as_ref().unwrap();
@@ -899,9 +871,7 @@ mod tests {
assert_eq!(a, b);
}
// -----------------------------------------------------------------------
// Candidate C — CheckpointChain
// -----------------------------------------------------------------------
#[test]
fn checkpoint_generates_context() {
@@ -957,9 +927,7 @@ mod tests {
assert_eq!(a, b);
}
// -----------------------------------------------------------------------
// Shared: find_shifted recovery tests
// -----------------------------------------------------------------------
#[test]
fn find_shifted_after_insert_above() {
@@ -968,13 +936,13 @@ mod tests {
let scheme = ContentOnly::new();
let anchors = scheme.generate_anchors(&lines);
// Insert a new line at position 0 → all lines shift down by 1.
let mut shifted = vec!["// new line"];
shifted.extend_from_slice(&lines);
// Anchor for original line 3 ("export function App() {") is now at line 4.
let parsed = ParsedAnchor {
line: anchors[2].line, // line 3
// line 3
line: anchors[2].line,
local: anchors[2].local.clone(),
context: None,
};
@@ -1012,7 +980,8 @@ mod tests {
let anchors = scheme.generate_anchors(&lines);
let parsed = ParsedAnchor {
line: 5,
local: anchors[0].local.clone(), // same hash for all lines
// same hash for all lines
local: anchors[0].local.clone(),
context: None,
};
@@ -1024,9 +993,7 @@ mod tests {
}
}
// -----------------------------------------------------------------------
// Finding 1: B/C reject missing context
// -----------------------------------------------------------------------
#[test]
fn chunk_rejects_anchor_without_context() {
@@ -1038,7 +1005,8 @@ mod tests {
let truncated = ParsedAnchor {
line: anchors[0].line,
local: anchors[0].local.clone(),
context: None, // intentionally missing
// intentionally missing
context: None,
};
assert_eq!(scheme.validate(&truncated, &lines), ValidationResult::Stale);
}
@@ -1052,14 +1020,13 @@ mod tests {
let truncated = ParsedAnchor {
line: anchors[0].line,
local: anchors[0].local.clone(),
context: None, // intentionally missing
// intentionally missing
context: None,
};
assert_eq!(scheme.validate(&truncated, &lines), ValidationResult::Stale);
}
// -----------------------------------------------------------------------
// Finding 4: B/C shifted recovery after insertion/deletion
// -----------------------------------------------------------------------
#[test]
fn chunk_find_shifted_after_insert_above() {
@@ -1075,7 +1042,8 @@ mod tests {
// Anchor for original line 3 with context — shifted recovery should
// find it at line 4 (same local + recomputed context at new position).
let parsed = ParsedAnchor {
line: anchors[2].line, // line 3
// line 3
line: anchors[2].line,
local: anchors[2].local.clone(),
context: anchors[2].context.clone(),
};
@@ -1118,9 +1086,7 @@ mod tests {
}
}
// -----------------------------------------------------------------------
// Finding 4: B/C ambiguity in repetitive files
// -----------------------------------------------------------------------
#[test]
fn chunk_ambiguity_with_repeated_lines() {
@@ -1167,9 +1133,7 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// Finding 3: Invalid constructor parameters
// -----------------------------------------------------------------------
#[test]
fn custom_hash_len_2() {
@@ -1217,9 +1181,7 @@ mod tests {
CheckpointChain::with_params(3, 0);
}
// -----------------------------------------------------------------------
// Scheme names
// -----------------------------------------------------------------------
#[test]
fn scheme_names() {
@@ -49,7 +49,7 @@ impl std::fmt::Debug for LspClient {
}
}
// ── Startup helpers (called by LspClient::start) ────────────────────────
// Startup helpers, called by LspClient::start.
type LspMainLoopAndServer = (LspMainLoop, async_lsp::ServerSocket);
@@ -126,7 +126,8 @@ fn build_initialize_params(config: &LspServerConfig, workspace_root: &Path) -> I
}]
});
#[allow(deprecated)] // root_uri still needed for older servers
// root_uri still needed for older servers
#[allow(deprecated)]
InitializeParams {
root_uri: Url::from_file_path(effective_root).ok(),
workspace_folders,
@@ -172,8 +173,6 @@ fn abort_transport(handle: &tokio::task::JoinHandle<()>, child: &mut Option<std:
}
}
// ── LspClient ───────────────────────────────────────────────────────────
impl LspClient {
pub async fn start(
server_name: String,
@@ -33,7 +33,6 @@ pub fn load_servers_with_plugins_sourced(
let user_path = crate::util::kigi_home::kigi_home().join("lsp.json");
let project_path = cwd.join(".kigi").join("lsp.json");
// User-level servers
let mut servers: BTreeMap<String, (LspServerConfig, ConfigSource)> = load_file(&user_path)
.into_iter()
.map(|(name, cfg)| {
@@ -49,7 +48,6 @@ pub fn load_servers_with_plugins_sourced(
})
.collect();
// Project-level overrides
for (name, cfg) in load_file(&project_path) {
servers.insert(
name,
@@ -62,7 +60,6 @@ pub fn load_servers_with_plugins_sourced(
);
}
// Plugin file-based configs
for (i, lsp_path) in plugin_lsp_paths.iter().enumerate() {
let pname = plugin_names.get(i).copied().unwrap_or("unknown");
for (name, cfg) in load_file(lsp_path) {
@@ -78,7 +75,6 @@ pub fn load_servers_with_plugins_sourced(
}
}
// Plugin inline configs
for (i, inline) in plugin_inline_lsp.iter().enumerate() {
let pname = inline_plugin_names.get(i).copied().unwrap_or("unknown");
match serde_json::from_value::<BTreeMap<String, LspServerConfig>>((*inline).clone()) {
@@ -24,8 +24,6 @@ use super::format::{
use super::manager::LspManager;
use super::{LspError, file_uri, text_document_position};
// ── Public adapter ──────────────────────────────────────────────────────
#[derive(Debug, Clone)]
enum StartupState {
NotStarted,
@@ -199,7 +197,6 @@ impl super::LspBackend for LspBackendAdapter {
}
}
};
// Lock dropped — dispatch on cloned socket(s).
dispatch_on_sockets(input, sockets).await
}
@@ -255,7 +252,6 @@ impl super::LspBackend for LspBackendAdapter {
let notified = notify.notified();
let _ = tokio::time::timeout(std::time::Duration::from_millis(1000), notified).await;
// Collect diagnostics from all clients for the requested paths.
let mgr = self.lsp_manager.lock().await;
let mut results = Vec::new();
for path in paths {
@@ -303,8 +299,6 @@ impl super::LspBackend for LspBackendAdapter {
}
}
// ── Internal types ──────────────────────────────────────────────────────
enum DispatchSockets {
One(async_lsp::ServerSocket),
All(Vec<async_lsp::ServerSocket>),
@@ -348,9 +342,7 @@ where
/// Distinguishes validation errors (missing params) from LSP protocol errors.
enum DispatchError {
/// Missing or invalid input parameters.
Validation(String),
/// LSP request failed or timed out.
Lsp(LspError),
}
@@ -366,8 +358,6 @@ impl From<LspError> for DispatchError {
}
}
// ── Router ──────────────────────────────────────────────────────────────
async fn dispatch_on_sockets(input: &LspToolInput, sockets: DispatchSockets) -> LspToolResult {
use super::LspOperation;
@@ -394,8 +384,6 @@ async fn dispatch_on_sockets(input: &LspToolInput, sockets: DispatchSockets) ->
}
}
// ── Per-operation helpers ───────────────────────────────────────────────
/// Handles both GoToDefinition and GoToImplementation (same params/response shape).
async fn dispatch_goto(
input: &LspToolInput,
@@ -528,8 +516,6 @@ async fn dispatch_workspace_symbols(
}
}
// ── Response converters ─────────────────────────────────────────────────
fn parse_goto_response(response: Option<GotoDefinitionResponse>) -> Vec<Location> {
match response {
Some(GotoDefinitionResponse::Scalar(l)) => vec![l],
@@ -17,7 +17,8 @@ pub fn flatten_document_symbols(
out: &mut Vec<SymbolInformation>,
) {
for sym in symbols {
#[allow(deprecated)] // container_name is deprecated but still the LSP way
// container_name is deprecated but still the LSP way
#[allow(deprecated)]
out.push(SymbolInformation {
name: sym.name.clone(),
kind: sym.kind,
@@ -254,7 +254,6 @@ impl LspManager {
}
}
/// Pure data collection — reads from clients and pending state without mutation.
fn collect_pending_diagnostics(&self) -> CollectedDiagnostics {
let mut result = CollectedDiagnostics::default();
@@ -274,7 +273,8 @@ impl LspManager {
continue;
};
server_had_diagnostics = true;
let display_path = uri.strip_prefix("file://").unwrap_or(uri); // Unix-only
// Unix-only
let display_path = uri.strip_prefix("file://").unwrap_or(uri);
let mut has_header = false;
for d in diags {
@@ -308,10 +308,8 @@ impl LspManager {
}
/// Auto-open file if needed, return cloned socket for lock-free dispatch.
/// Single resolve — no double lookup.
pub async fn socket_for_file(&mut self, path: &Path) -> Option<async_lsp::ServerSocket> {
let (server_name, lang_id) = super::config::resolve_server(&self.servers, path)?;
// Auto-open if not yet tracked.
let needs_open = self
.clients
.get(&server_name)
@@ -17,8 +17,6 @@ pub use types::{
LspOperation, LspToolInput, LspToolResult,
};
// ── Shared types used across submodules ─────────────────────────────────
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
@@ -10,7 +10,6 @@ use super::config::LspServerConfig;
use super::manager::LspManager;
use super::{DiagnosticsNotify, file_uri};
/// Waits for the current lifecycle to exit.
async fn wait_for_crashed_lifecycle(
lsp_manager: &Arc<tokio::sync::Mutex<LspManager>>,
server_name: &str,
@@ -27,7 +26,6 @@ async fn wait_for_crashed_lifecycle(
}
}
/// Replays tracked documents and returns their URIs.
fn replay_tracked_documents(
restarted_client: &mut LspClient,
tracked_docs: &[(String, String)],
@@ -46,7 +44,6 @@ fn replay_tracked_documents(
.collect()
}
/// Removes the crashed client if it is still current and returns restart state.
async fn take_crashed_client_if_current(
lsp_manager: &Arc<tokio::sync::Mutex<LspManager>>,
server_name: &str,
@@ -82,7 +79,6 @@ async fn take_crashed_client_if_current(
))
}
/// Removes the crashed client if it is still current.
async fn discard_crashed_client_if_current(
lsp_manager: &Arc<tokio::sync::Mutex<LspManager>>,
server_name: &str,
@@ -99,7 +95,6 @@ async fn discard_crashed_client_if_current(
true
}
/// Installs a restarted client unless shutdown has begun.
async fn install_restarted_client(
lsp_manager: &Arc<tokio::sync::Mutex<LspManager>>,
server_name: &str,
@@ -395,7 +395,6 @@ async fn poll_diagnostics(client: &LspClient, path: &Path, expected: usize) -> V
client.get_diagnostics(path)
}
/// Creates a single-server LspManager with the mock TS server, already initialized.
async fn single_server_manager(script_path: &Path, workspace: &tempfile::TempDir) -> LspManager {
let mut servers = BTreeMap::new();
servers.insert("mock-ts".to_string(), mock_server_config(script_path));
@@ -481,9 +480,9 @@ async fn e2e_lsp_manager_full_lifecycle() {
let mut mgr = single_server_manager(&script_path, &workspace).await;
assert!(mgr.is_initialized());
mgr.ensure_initialized().await; // idempotent
// idempotent
mgr.ensure_initialized().await;
// Fire-and-forget notification.
let test_file = workspace.path().join("app.ts");
let content = "let y = 2;\n";
std::fs::write(&test_file, content).unwrap();
@@ -528,7 +527,6 @@ async fn e2e_did_change_updates_diagnostics() {
let test_file = workspace.path().join("test.ts");
std::fs::write(&test_file, "const x = 1;\n").unwrap();
// First open.
client.notify_file_change(&test_file, "const x = 1;\n", "typescript");
let diags = poll_diagnostics(&client, &test_file, 2).await;
assert_eq!(diags.len(), 2);
@@ -609,7 +607,6 @@ async fn e2e_multi_server_routing() {
// Bad server skipped, 2 good ones remain.
assert_eq!(mgr.clients.len(), 2);
// .ts and .py route to their respective servers.
let ts_file = workspace.path().join("app.ts");
let ts_content = "let x = 1;\n";
std::fs::write(&ts_file, ts_content).unwrap();
@@ -620,7 +617,6 @@ async fn e2e_multi_server_routing() {
std::fs::write(&py_file, py_content).unwrap();
mgr.notify_file_changed(&py_file, py_content);
// .go has no configured server — doesn't add to pending.
let go_file = workspace.path().join("main.go");
std::fs::write(&go_file, "package main\n").unwrap();
let pending_before = mgr.pending_count();
@@ -649,7 +645,8 @@ async fn e2e_multi_server_routing() {
summary.text
);
assert_eq!(summary.file_count, 2);
assert_eq!(summary.diagnostic_count, 4); // 2 per file (error + warning)
// 2 per file (error + warning)
assert_eq!(summary.diagnostic_count, 4);
mgr.lock().await.shutdown().await;
}
@@ -758,13 +755,11 @@ async fn e2e_session_diagnostics_injection_flow() {
let workspace = tempfile::tempdir().unwrap();
let mut mgr = single_server_manager(&script_path, &workspace).await;
// Step 1: tool edits file -> fire-and-forget notify (returns immediately).
let edited_file = workspace.path().join("component.ts");
let content = "const x: number = 'wrong_type';\n";
std::fs::write(&edited_file, content).unwrap();
mgr.notify_file_changed(&edited_file, content);
// Step 2: (simulated) other tools run... time passes... LSP server responds.
wait_for_server(&mgr, &edited_file, 2000).await;
let mgr = tokio::sync::Mutex::new(mgr);
@@ -786,12 +781,10 @@ async fn e2e_session_diagnostics_injection_flow() {
assert_eq!(summary.file_count, 1);
assert_eq!(summary.diagnostic_count, 2);
// Step 4: the injected user message the model sees.
let injected = format!("<system-reminder>\n{}\n</system-reminder>", summary.text);
assert!(injected.contains("mock error: undeclared variable"));
assert!(injected.contains("mock warning: unused import"));
// Step 5: .py has no server — notify is a no-op.
{
let mut mgr = mgr.lock().await;
let py_file = workspace.path().join("script.py");
@@ -817,7 +810,6 @@ async fn e2e_session_tool_dispatch_flow() {
std::fs::write(&ts_file, content).unwrap();
mgr.notify_file_changed(&ts_file, content);
// goToDefinition
let result = mgr
.dispatch_tool_typed(&LspToolInput {
operation: LspOperation::GoToDefinition,
@@ -834,7 +826,6 @@ async fn e2e_session_tool_dispatch_flow() {
result.text
);
// findReferences
let result = mgr
.dispatch_tool_typed(&LspToolInput {
operation: LspOperation::FindReferences,
@@ -848,7 +839,6 @@ async fn e2e_session_tool_dispatch_flow() {
assert!(result.text.contains(":6:1"), "line 6: {}", result.text);
assert!(result.text.contains(":16:4"), "line 16: {}", result.text);
// missing server -> error
let rs_file = workspace.path().join("lib.rs");
std::fs::write(&rs_file, "fn main() {}\n").unwrap();
let result = mgr
@@ -867,7 +857,6 @@ async fn e2e_session_tool_dispatch_flow() {
result.text
);
// missing file_path for position-based operation -> error
let result = mgr
.dispatch_tool_typed(&LspToolInput {
operation: LspOperation::GoToDefinition,
@@ -883,18 +872,15 @@ async fn e2e_session_tool_dispatch_flow() {
mgr.shutdown().await;
}
/// Verifies the tools_enabled gating logic.
#[tokio::test(flavor = "current_thread")]
async fn e2e_tools_enabled_gating() {
let (_dir, script_path) = write_mock_server();
let workspace = tempfile::tempdir().unwrap();
// tools_enabled=false (default) — tools should NOT be advertised.
let mut mgr = single_server_manager(&script_path, &workspace).await;
assert!(!mgr.tools_enabled(), "tools disabled by default");
mgr.shutdown().await;
// tools_enabled=true — Arc<dyn LspBackend> would be injected into ToolBridge Resources.
let mut servers = BTreeMap::new();
servers.insert("mock-ts".to_string(), mock_server_config(&script_path));
let mut mgr = LspManager {
@@ -3,7 +3,6 @@ use std::collections::BTreeMap;
use super::config::LspServerConfig;
use super::manager::DiagnosticsSummary;
/// LSP configuration passed from shell. Same pattern as `WebSearchConfig`.
#[derive(Debug, Clone, Default)]
pub enum LspConfig {
#[default]
@@ -25,9 +24,6 @@ pub struct LspToolResult {
pub is_error: bool,
}
/// Trait object interface for LSP operations.
///
/// Implemented by `LspBackendAdapter` which wraps `LspManager`.
#[async_trait::async_trait]
pub trait LspBackend: Send + Sync + 'static {
fn ensure_started_background(&self);
@@ -50,7 +46,6 @@ pub trait LspBackend: Send + Sync + 'static {
async fn read_diagnostics(&self, paths: &[std::path::PathBuf]) -> Vec<FileDiagnosticEntry>;
}
/// A single diagnostic entry returned by `LspBackend::read_diagnostics`.
#[derive(Debug, Clone)]
pub struct DiagnosticEntry {
pub severity: DiagnosticSeverityLevel,
@@ -1,4 +1,4 @@
//! `memory_get` tool — new architecture (`Tool` trait).
//! `memory_get` tool.
use std::sync::Arc;
@@ -7,17 +7,15 @@ use crate::types::memory_backend::MemoryBackend;
use crate::types::output::ToolOutput;
use crate::types::tool::{ToolKind, ToolNamespace};
/// Format content with line numbers: `{line_num}→{line}`.
///
/// Extracted as a free function so it can be unit-tested independently of
/// the async tool infrastructure. `first_line_num` is the 1-based number
/// for the first line of `content` (accounts for `from` offset).
/// Format content with line numbers: `{line_num}→{line}`. `first_line_num`
/// is the 1-based number of the first line of `content` (accounts for the
/// `from` offset).
///
/// Uses `split('\n')` rather than `lines()` so that content ending with a
/// newline (`"a\n"`) emits a trailing blank numbered line, matching the
/// behavior of the standard `read_file` tool. `lines()` would silently drop
/// that trailing element, causing off-by-one line references for files
/// (virtually all Markdown memory files) that end with a newline.
/// standard `read_file` tool. `lines()` would silently drop that trailing
/// element, causing off-by-one line references for files (virtually all
/// Markdown memory files) that end with a newline.
pub(crate) fn format_with_line_numbers(content: &str, first_line_num: usize) -> String {
if content.is_empty() {
return String::new();
@@ -124,51 +122,42 @@ impl kigi_tool_runtime::Tool for MemoryGetImpl {
mod tests {
use super::*;
/// format_with_line_numbers produces 1-based unpadded output.
#[test]
fn test_format_basic_line_numbers() {
let out = format_with_line_numbers("alpha\nbeta\ngamma", 1);
assert_eq!(out, "1→alpha\n2→beta\n3→gamma");
}
/// The `from` offset shifts the first line number so numbers reflect the
/// actual position in the source file, not the slice position.
#[test]
fn test_format_offset_adjusts_line_numbers() {
// Simulates memory_get called with from=4 (0-based) first displayed
// line should be labelled "5" (1-based).
// from=4 (0-based) makes the first displayed line number 5 (1-based).
let out = format_with_line_numbers("line five\nline six", 5);
assert!(out.starts_with("5→line five"), "got: {out}");
assert!(out.ends_with("6→line six"), "got: {out}");
}
/// Empty content produces empty output (no panic).
#[test]
fn test_format_empty_content() {
let out = format_with_line_numbers("", 1);
assert!(out.is_empty(), "empty input must produce empty output");
}
/// Single-line content produces one numbered line.
#[test]
fn test_format_single_line() {
let out = format_with_line_numbers("only line", 1);
assert_eq!(out, "1→only line");
}
/// Wide line numbers (>= 7 digits) are not truncated.
#[test]
fn test_format_large_line_numbers() {
let out = format_with_line_numbers("x", 1_000_000);
assert!(out.starts_with("1000000→"), "got: {out}");
}
/// Content ending with `\n` emits a trailing blank numbered line.
///
/// Regression test for the `lines()` vs `split('\n')` difference.
/// Virtually all Markdown memory files end with a trailing newline, so
/// without this fix `memory_get` line numbers are off-by-one relative to
/// `read_file` for any file that ends with a newline.
/// Regression test for the `lines()` vs `split('\n')` difference:
/// virtually all Markdown memory files end with a trailing newline, so
/// with `lines()` `memory_get` line numbers would be off-by-one relative
/// to `read_file`.
#[test]
fn test_format_trailing_newline_emits_blank_line() {
let out = format_with_line_numbers("alpha\n", 1);
@@ -178,14 +167,12 @@ mod tests {
);
}
/// Two trailing newlines produce two extra blank lines.
#[test]
fn test_format_double_trailing_newline() {
let out = format_with_line_numbers("a\n\n", 1);
assert_eq!(out, "1→a\n2→\n3→");
}
/// Content without a trailing newline does NOT produce a spurious blank line.
#[test]
fn test_format_no_trailing_newline_no_blank_line() {
let out = format_with_line_numbers("alpha", 1);
@@ -10,13 +10,11 @@ pub mod types;
pub use get_tool::MemoryGetImpl;
pub use search_tool::MemorySearchImpl;
/// Registered name of the `memory_search` tool.
///
/// Single source of truth shared between the tool definition and any
/// gating callers (e.g. shell-side slash-command availability checks).
/// Registered name of the `memory_search` tool. Single source of truth
/// shared between the tool definition and gating callers (e.g. shell-side
/// slash-command availability checks).
pub const MEMORY_SEARCH_TOOL_NAME: &str = "memory_search";
/// Registered name of the `memory_get` tool.
pub const MEMORY_GET_TOOL_NAME: &str = "memory_get";
#[cfg(test)]
@@ -1,4 +1,4 @@
//! `memory_search` tool — new architecture (`Tool` trait).
//! `memory_search` tool.
use std::sync::Arc;

Some files were not shown because too many files have changed in this diff Show More