From cf4bed1b1689fd45f0a80250517b39f92de85554 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 27 Dec 2025 02:14:27 +0000 Subject: [PATCH 01/64] Added: Initial core tools, infrastructure, and error handling --- src/Cargo.lock | 265 +++++++++++++- src/rig-coding-tools/Cargo.toml | 17 +- src/rig-coding-tools/README.MD | 12 +- src/rig-coding-tools/src/error.rs | 93 +++++ src/rig-coding-tools/src/lib.rs | 18 + src/rig-coding-tools/src/output.rs | 83 +++++ src/rig-coding-tools/src/tools/bash.rs | 346 ++++++++++++++++++ src/rig-coding-tools/src/tools/edit.rs | 255 +++++++++++++ src/rig-coding-tools/src/tools/glob.rs | 241 +++++++++++++ src/rig-coding-tools/src/tools/grep.rs | 353 ++++++++++++++++++ src/rig-coding-tools/src/tools/mod.rs | 20 ++ src/rig-coding-tools/src/tools/read.rs | 253 +++++++++++++ src/rig-coding-tools/src/tools/task.rs | 293 +++++++++++++++ src/rig-coding-tools/src/tools/todo.rs | 322 +++++++++++++++++ src/rig-coding-tools/src/tools/webfetch.rs | 400 +++++++++++++++++++++ src/rig-coding-tools/src/tools/write.rs | 179 +++++++++ src/rig-coding-tools/src/util.rs | 122 +++++++ 17 files changed, 3263 insertions(+), 9 deletions(-) create mode 100644 src/rig-coding-tools/src/error.rs create mode 100644 src/rig-coding-tools/src/output.rs create mode 100644 src/rig-coding-tools/src/tools/bash.rs create mode 100644 src/rig-coding-tools/src/tools/edit.rs create mode 100644 src/rig-coding-tools/src/tools/glob.rs create mode 100644 src/rig-coding-tools/src/tools/grep.rs create mode 100644 src/rig-coding-tools/src/tools/mod.rs create mode 100644 src/rig-coding-tools/src/tools/read.rs create mode 100644 src/rig-coding-tools/src/tools/task.rs create mode 100644 src/rig-coding-tools/src/tools/todo.rs create mode 100644 src/rig-coding-tools/src/tools/webfetch.rs create mode 100644 src/rig-coding-tools/src/tools/write.rs create mode 100644 src/rig-coding-tools/src/util.rs diff --git a/src/Cargo.lock b/src/Cargo.lock index 63edc89d..365a43c0 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -14,6 +23,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -36,6 +55,17 @@ dependencies = [ "syn", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -60,6 +90,16 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -110,6 +150,49 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "displaydoc" version = "0.2.5" @@ -318,6 +401,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "h2" version = "0.4.12" @@ -343,6 +439,12 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "http" version = "1.4.0" @@ -382,6 +484,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -396,6 +504,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -549,6 +658,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -591,12 +716,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" @@ -682,6 +819,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -837,7 +984,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -913,6 +1060,35 @@ dependencies = [ "syn", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "reqwest" version = "0.12.28" @@ -963,9 +1139,19 @@ name = "rig-coding-tools" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", + "glob", + "ignore", + "regex", "reqwest", "rig-core", + "schemars", + "serde", + "serde_json", + "tempfile", + "thiserror", "tokio", + "wiremock", ] [[package]] @@ -1019,6 +1205,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.35" @@ -1066,6 +1265,15 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schemars" version = "1.2.0" @@ -1265,6 +1473,19 @@ dependencies = [ "libc", ] +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.17" @@ -1491,6 +1712,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1615,6 +1846,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1806,6 +2046,29 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/src/rig-coding-tools/Cargo.toml b/src/rig-coding-tools/Cargo.toml index 401a3ab6..82c21b8f 100644 --- a/src/rig-coding-tools/Cargo.toml +++ b/src/rig-coding-tools/Cargo.toml @@ -9,11 +9,20 @@ include = ["src/**/*"] readme = "README.MD" [dependencies] +async-trait = "0.1" rig-core = { version = "0.27", default-features = false, features = ["reqwest-rustls"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +schemars = "1.2.0" +thiserror = "2.0" +tokio = { version = "1", features = ["fs", "process", "time", "io-util"] } +glob = "0.3" +ignore = "0.4" +regex = "1.11" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } [dev-dependencies] -tokio = { version = "1", features = ["full"] } -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +tokio = { version = "1", features = ["full", "test-util"] } +tempfile = "3" +wiremock = "0.6" anyhow = "1.0" - - diff --git a/src/rig-coding-tools/README.MD b/src/rig-coding-tools/README.MD index 51402cbd..ca025f5b 100644 --- a/src/rig-coding-tools/README.MD +++ b/src/rig-coding-tools/README.MD @@ -40,14 +40,18 @@ rig-coding-tools = "0.1.0" ### Basic Example ```rust -// TODO: Add your basic example here +use rig_coding_tools::{ToolError, ToolResult}; + +// Tool implementations will be added in future releases +fn example() -> ToolResult<()> { + // Tools will follow the rig-core Tool trait pattern + Ok(()) +} ``` ### Advanced Example -```rust -// TODO: Add a more advanced example here -``` +Examples will be added as tools are implemented. ## License diff --git a/src/rig-coding-tools/src/error.rs b/src/rig-coding-tools/src/error.rs new file mode 100644 index 00000000..04bfd6f5 --- /dev/null +++ b/src/rig-coding-tools/src/error.rs @@ -0,0 +1,93 @@ +//! Common error types for rig-coding-tools. + +use thiserror::Error; + +/// Unified error type for all tool operations. +#[derive(Debug, Error)] +pub enum ToolError { + /// File I/O operation failed. + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + /// Path validation failed (not absolute, doesn't exist, etc.). + #[error("invalid path: {0}")] + InvalidPath(String), + + /// Requested offset/limit exceeds file bounds. + #[error("out of bounds: {0}")] + OutOfBounds(String), + + /// Glob/regex pattern is invalid. + #[error("invalid pattern: {0}")] + InvalidPattern(String), + + /// HTTP request failed. + #[error("HTTP error: {0}")] + Http(String), + + /// Command execution failed. + #[error("execution error: {0}")] + Execution(String), + + /// Timeout exceeded. + #[error("timeout: {0}")] + Timeout(String), + + /// Validation failed. + #[error("validation error: {0}")] + Validation(String), + + /// JSON serialization/deserialization failed. + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + /// Regex compilation or matching failed. + #[error("regex error: {0}")] + Regex(#[from] regex::Error), +} + +/// Result type alias for tool operations. +pub type ToolResult = Result; + +impl From for ToolError { + fn from(e: glob::PatternError) -> Self { + ToolError::InvalidPattern(e.to_string()) + } +} + +impl From for ToolError { + fn from(e: glob::GlobError) -> Self { + ToolError::Io(e.into_error()) + } +} + +impl From for ToolError { + fn from(e: reqwest::Error) -> Self { + ToolError::Http(e.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_error_displays_io_error() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let err: ToolError = io_err.into(); + assert!(err.to_string().contains("I/O error")); + } + + #[test] + fn tool_error_displays_invalid_path() { + let err = ToolError::InvalidPath("not absolute".into()); + assert!(err.to_string().contains("invalid path")); + } + + #[test] + fn tool_error_from_glob_pattern_error() { + let glob_err = glob::Pattern::new("[invalid").unwrap_err(); + let err: ToolError = glob_err.into(); + assert!(matches!(err, ToolError::InvalidPattern(_))); + } +} diff --git a/src/rig-coding-tools/src/lib.rs b/src/rig-coding-tools/src/lib.rs index abc68bed..1d682924 100644 --- a/src/rig-coding-tools/src/lib.rs +++ b/src/rig-coding-tools/src/lib.rs @@ -1 +1,19 @@ #![doc = include_str!(concat!("../", env!("CARGO_PKG_README")))] +#![warn(missing_docs)] + +pub mod error; +pub mod output; +pub mod tools; +pub mod util; + +// Re-export primary types at crate root +pub use error::{ToolError, ToolResult}; +pub use output::ToolOutput; +pub use tools::bash::BashTool; +pub use tools::edit::{EditArgs, EditError, EditTool}; +pub use tools::grep::GrepTool; +pub use tools::read::{ReadArgs, ReadTool}; +pub use tools::task::{MockTaskExecutor, TaskArgs, TaskExecutor, TaskResult, TaskTool}; +pub use tools::todo::{Todo, TodoPriority, TodoReadTool, TodoState, TodoStatus, TodoWriteTool}; +pub use tools::webfetch::WebFetchTool; +pub use tools::write::{WriteTool, WriteToolArgs}; diff --git a/src/rig-coding-tools/src/output.rs b/src/rig-coding-tools/src/output.rs new file mode 100644 index 00000000..a1c3c067 --- /dev/null +++ b/src/rig-coding-tools/src/output.rs @@ -0,0 +1,83 @@ +//! Common output types for tool responses. + +use serde::Serialize; + +/// Wrapper for tool output with truncation metadata. +#[derive(Debug, Clone, Serialize)] +pub struct ToolOutput { + /// The main content returned by the tool. + pub content: String, + /// Whether the output was truncated due to size limits. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub truncated: bool, +} + +impl ToolOutput { + /// Creates a new output with the given content. + #[inline] + pub fn new(content: impl Into) -> Self { + Self { + content: content.into(), + truncated: false, + } + } + + /// Creates a truncated output. + #[inline] + pub fn truncated(content: impl Into) -> Self { + Self { + content: content.into(), + truncated: true, + } + } +} + +impl From for ToolOutput { + fn from(content: String) -> Self { + Self::new(content) + } +} + +impl From<&str> for ToolOutput { + fn from(content: &str) -> Self { + Self::new(content) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_output_new_creates_non_truncated() { + let output = ToolOutput::new("content"); + assert_eq!(output.content, "content"); + assert!(!output.truncated); + } + + #[test] + fn tool_output_truncated_marks_truncated() { + let output = ToolOutput::truncated("partial"); + assert!(output.truncated); + } + + #[test] + fn tool_output_from_string() { + let output: ToolOutput = "hello".into(); + assert_eq!(output.content, "hello"); + } + + #[test] + fn tool_output_serializes_without_truncated_when_false() { + let output = ToolOutput::new("content"); + let json = serde_json::to_string(&output).unwrap(); + assert!(!json.contains("truncated")); + } + + #[test] + fn tool_output_serializes_with_truncated_when_true() { + let output = ToolOutput::truncated("content"); + let json = serde_json::to_string(&output).unwrap(); + assert!(json.contains("truncated")); + } +} diff --git a/src/rig-coding-tools/src/tools/bash.rs b/src/rig-coding-tools/src/tools/bash.rs new file mode 100644 index 00000000..99b87fd4 --- /dev/null +++ b/src/rig-coding-tools/src/tools/bash.rs @@ -0,0 +1,346 @@ +//! Shell command execution tool. +//! +//! Provides cross-platform shell command execution with timeout support +//! for rig-based LLM agents. + +use crate::error::{ToolError, ToolResult}; +use crate::output::ToolOutput; +use crate::util::truncate_text; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; +use tokio::process::Command; + +/// Maximum output size in bytes before truncation (100KB). +const MAX_OUTPUT_BYTES: usize = 100 * 1024; + +/// Default command timeout in milliseconds. +const DEFAULT_TIMEOUT_MS: u64 = 30_000; + +/// Arguments for executing a shell command. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct BashArgs { + /// The shell command to execute. + pub command: String, + /// Working directory for command execution. + #[serde(default)] + pub workdir: Option, + /// Command timeout in milliseconds (default: 30000). + #[serde(default = "default_timeout")] + pub timeout_ms: u64, +} + +fn default_timeout() -> u64 { + DEFAULT_TIMEOUT_MS +} + +/// Result of shell command execution. +#[derive(Debug, Clone, Serialize)] +pub struct BashOutput { + /// Exit code from the command (None if killed by timeout). + pub exit_code: Option, + /// Standard output from the command. + pub stdout: String, + /// Standard error output from the command. + pub stderr: String, +} + +/// Shell command execution tool. +/// +/// Executes commands using the system shell (bash on Unix, cmd on Windows) +/// and captures stdout, stderr, and exit code. +/// +/// # Example +/// +/// ```rust,ignore +/// use rig_coding_tools::tools::bash::BashTool; +/// use rig::tool::Tool; +/// +/// let tool = BashTool; +/// let result = tool.call(BashArgs { +/// command: "echo hello".into(), +/// workdir: None, +/// timeout_ms: 5000, +/// }).await?; +/// ``` +#[derive(Debug, Clone, Default)] +pub struct BashTool; + +impl BashTool { + /// Creates a new [`BashTool`] instance. + pub fn new() -> Self { + Self + } + + /// Builds a [`Command`] for the given shell command string. + fn build_command(command: &str, workdir: Option<&Path>) -> Command { + let mut cmd = if cfg!(target_os = "windows") { + let mut c = Command::new("cmd"); + c.args(["/C", command]); + c + } else { + let mut c = Command::new("bash"); + c.args(["-c", command]); + c + }; + + if let Some(dir) = workdir { + cmd.current_dir(dir); + } + + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + cmd + } + + /// Executes the command and returns structured output. + async fn execute(args: &BashArgs) -> ToolResult { + let workdir = args.workdir.as_ref().map(Path::new); + + // Validate workdir exists if specified + if let Some(dir) = workdir { + if !dir.is_dir() { + return Err(ToolError::InvalidPath(format!( + "working directory does not exist: {}", + dir.display() + ))); + } + } + + let mut cmd = Self::build_command(&args.command, workdir); + let timeout = Duration::from_millis(args.timeout_ms); + + let result = tokio::time::timeout(timeout, cmd.output()).await; + + match result { + Ok(Ok(output)) => { + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + + Ok(BashOutput { + exit_code: output.status.code(), + stdout, + stderr, + }) + } + Ok(Err(e)) => Err(ToolError::Execution(e.to_string())), + Err(_) => Err(ToolError::Timeout(format!( + "command timed out after {}ms", + args.timeout_ms + ))), + } + } + + /// Formats output for display, handling truncation. + fn format_output(output: BashOutput) -> ToolOutput { + let (stdout, stdout_truncated) = truncate_text(&output.stdout, MAX_OUTPUT_BYTES); + let (stderr, stderr_truncated) = truncate_text(&output.stderr, MAX_OUTPUT_BYTES); + let truncated = stdout_truncated || stderr_truncated; + + let exit_display = output + .exit_code + .map(|c| c.to_string()) + .unwrap_or_else(|| "killed".to_string()); + + let content = format!( + "Exit code: {}\nstdout:\n{}\nstderr:\n{}", + exit_display, stdout, stderr + ); + + if truncated { + ToolOutput::truncated(content) + } else { + ToolOutput::new(content) + } + } +} + +impl Tool for BashTool { + const NAME: &'static str = "bash"; + + type Error = ToolError; + type Args = BashArgs; + type Output = ToolOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Execute a shell command and return its output.".to_string(), + parameters: serde_json::to_value(schema_for!(BashArgs)) + .expect("BashArgs schema generation should not fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let output = Self::execute(&args).await?; + Ok(Self::format_output(output)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn echo_hello_returns_output() { + let tool = BashTool::new(); + let result = tool + .call(BashArgs { + command: "echo hello".into(), + workdir: None, + timeout_ms: 5000, + }) + .await + .unwrap(); + + assert!(result.content.contains("Exit code: 0")); + assert!(result.content.contains("hello")); + } + + #[tokio::test] + async fn respects_working_directory() { + let temp = TempDir::new().unwrap(); + let tool = BashTool::new(); + + let cmd = if cfg!(target_os = "windows") { + "cd" + } else { + "pwd" + }; + + let result = tool + .call(BashArgs { + command: cmd.into(), + workdir: Some(temp.path().to_string_lossy().into_owned()), + timeout_ms: 5000, + }) + .await + .unwrap(); + + assert!(result.content.contains("Exit code: 0")); + // Output should contain the temp directory path + let temp_path = temp.path().to_string_lossy(); + assert!( + result.content.contains(temp_path.as_ref()), + "Expected path {} in output: {}", + temp_path, + result.content + ); + } + + #[tokio::test] + async fn timeout_kills_long_running_command() { + let tool = BashTool::new(); + + let cmd = if cfg!(target_os = "windows") { + "ping -n 10 127.0.0.1" + } else { + "sleep 10" + }; + + let result = tool + .call(BashArgs { + command: cmd.into(), + workdir: None, + timeout_ms: 100, + }) + .await; + + assert!(matches!(result, Err(ToolError::Timeout(_)))); + } + + #[tokio::test] + async fn captures_exit_code() { + let tool = BashTool::new(); + + let cmd = if cfg!(target_os = "windows") { + "exit /b 42" + } else { + "exit 42" + }; + + let result = tool + .call(BashArgs { + command: cmd.into(), + workdir: None, + timeout_ms: 5000, + }) + .await + .unwrap(); + + assert!(result.content.contains("Exit code: 42")); + } + + #[tokio::test] + async fn captures_stderr() { + let tool = BashTool::new(); + + let cmd = if cfg!(target_os = "windows") { + "echo error message 1>&2" + } else { + "echo 'error message' >&2" + }; + + let result = tool + .call(BashArgs { + command: cmd.into(), + workdir: None, + timeout_ms: 5000, + }) + .await + .unwrap(); + + assert!(result.content.contains("stderr:")); + assert!(result.content.contains("error message")); + } + + #[tokio::test] + async fn invalid_workdir_returns_error() { + let tool = BashTool::new(); + + let result = tool + .call(BashArgs { + command: "echo hello".into(), + workdir: Some("/nonexistent/path/that/does/not/exist".into()), + timeout_ms: 5000, + }) + .await; + + assert!(matches!(result, Err(ToolError::InvalidPath(_)))); + } + + #[tokio::test] + async fn command_not_found_returns_error_output() { + let tool = BashTool::new(); + + let result = tool + .call(BashArgs { + command: "this_command_definitely_does_not_exist_12345".into(), + workdir: None, + timeout_ms: 5000, + }) + .await + .unwrap(); + + // Command should complete but with non-zero exit + assert!(!result.content.contains("Exit code: 0")); + } + + #[test] + fn bash_args_deserializes_with_defaults() { + let json = r#"{"command": "echo test"}"#; + let args: BashArgs = serde_json::from_str(json).unwrap(); + + assert_eq!(args.command, "echo test"); + assert!(args.workdir.is_none()); + assert_eq!(args.timeout_ms, DEFAULT_TIMEOUT_MS); + } +} diff --git a/src/rig-coding-tools/src/tools/edit.rs b/src/rig-coding-tools/src/tools/edit.rs new file mode 100644 index 00000000..7960332c --- /dev/null +++ b/src/rig-coding-tools/src/tools/edit.rs @@ -0,0 +1,255 @@ +//! Edit tool for exact string replacements in files. + +use crate::error::ToolError; +use crate::util::validate_absolute_path; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use thiserror::Error; + +/// Tool arguments for file editing. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct EditArgs { + /// Absolute path to the file to modify. + pub file_path: String, + /// Exact text to find and replace. + pub old_string: String, + /// Replacement text. + pub new_string: String, + /// Replace all occurrences (default false). + #[serde(default)] + pub replace_all: bool, +} + +/// Errors specific to edit operations. +#[derive(Debug, Error)] +pub enum EditError { + /// I/O or path validation failed. + #[error(transparent)] + Tool(#[from] ToolError), + /// old_string was empty. + #[error("old_string must not be empty")] + EmptyOldString, + /// old_string and new_string are identical. + #[error("old_string and new_string must be different")] + IdenticalStrings, + /// old_string not found in file. + #[error("old_string not found in file content")] + NotFound, + /// Multiple matches found when replace_all=false. + #[error("oldString found {0} times and requires more code context to uniquely identify the intended match")] + AmbiguousMatch(usize), +} + +/// Tool for making exact string replacements in files. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EditTool; + +impl EditTool { + /// Creates a new [`EditTool`] instance. + #[inline] + pub fn new() -> Self { + Self + } + + /// Performs the edit operation. + async fn execute(args: EditArgs) -> Result { + // Validate arguments + if args.old_string.is_empty() { + return Err(EditError::EmptyOldString); + } + if args.old_string == args.new_string { + return Err(EditError::IdenticalStrings); + } + + let path = Path::new(&args.file_path); + validate_absolute_path(path)?; + + // Read file content + let content = tokio::fs::read_to_string(path) + .await + .map_err(ToolError::from)?; + + // Count occurrences + let count = content.matches(&args.old_string).count(); + + if count == 0 { + return Err(EditError::NotFound); + } + + if !args.replace_all && count > 1 { + return Err(EditError::AmbiguousMatch(count)); + } + + // Perform replacement + let new_content = if args.replace_all { + content.replace(&args.old_string, &args.new_string) + } else { + content.replacen(&args.old_string, &args.new_string, 1) + }; + + // Write back + tokio::fs::write(path, &new_content) + .await + .map_err(ToolError::from)?; + + Ok(format!("Successfully replaced {} occurrence(s)", count)) + } +} + +impl Tool for EditTool { + const NAME: &'static str = "edit"; + + type Error = EditError; + type Args = EditArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Makes exact string replacements in files. Use replace_all=true to replace all occurrences.".to_string(), + parameters: serde_json::to_value(schema_for!(EditArgs)) + .expect("EditArgs schema generation should not fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + Self::execute(args).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + async fn create_temp_file(content: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(content.as_bytes()).unwrap(); + file.flush().unwrap(); + file + } + + #[tokio::test] + async fn single_replacement_succeeds() { + let file = create_temp_file("hello world").await; + let args = EditArgs { + file_path: file.path().to_string_lossy().to_string(), + old_string: "world".to_string(), + new_string: "rust".to_string(), + replace_all: false, + }; + let result = EditTool::execute(args).await.unwrap(); + assert!(result.contains("1 occurrence")); + let content = tokio::fs::read_to_string(file.path()).await.unwrap(); + assert_eq!(content, "hello rust"); + } + + #[tokio::test] + async fn replace_all_succeeds() { + let file = create_temp_file("foo bar foo baz foo").await; + let args = EditArgs { + file_path: file.path().to_string_lossy().to_string(), + old_string: "foo".to_string(), + new_string: "qux".to_string(), + replace_all: true, + }; + let result = EditTool::execute(args).await.unwrap(); + assert!(result.contains("3 occurrence")); + let content = tokio::fs::read_to_string(file.path()).await.unwrap(); + assert_eq!(content, "qux bar qux baz qux"); + } + + #[tokio::test] + async fn no_match_returns_error() { + let file = create_temp_file("hello world").await; + let args = EditArgs { + file_path: file.path().to_string_lossy().to_string(), + old_string: "missing".to_string(), + new_string: "replacement".to_string(), + replace_all: false, + }; + let err = EditTool::execute(args).await.unwrap_err(); + assert!(matches!(err, EditError::NotFound)); + } + + #[tokio::test] + async fn ambiguous_match_returns_error() { + let file = create_temp_file("foo bar foo").await; + let args = EditArgs { + file_path: file.path().to_string_lossy().to_string(), + old_string: "foo".to_string(), + new_string: "baz".to_string(), + replace_all: false, + }; + let err = EditTool::execute(args).await.unwrap_err(); + assert!(matches!(err, EditError::AmbiguousMatch(2))); + } + + #[tokio::test] + async fn empty_old_string_returns_error() { + let file = create_temp_file("content").await; + let args = EditArgs { + file_path: file.path().to_string_lossy().to_string(), + old_string: "".to_string(), + new_string: "replacement".to_string(), + replace_all: false, + }; + let err = EditTool::execute(args).await.unwrap_err(); + assert!(matches!(err, EditError::EmptyOldString)); + } + + #[tokio::test] + async fn identical_strings_returns_error() { + let file = create_temp_file("content").await; + let args = EditArgs { + file_path: file.path().to_string_lossy().to_string(), + old_string: "same".to_string(), + new_string: "same".to_string(), + replace_all: false, + }; + let err = EditTool::execute(args).await.unwrap_err(); + assert!(matches!(err, EditError::IdenticalStrings)); + } + + #[tokio::test] + async fn relative_path_returns_error() { + let args = EditArgs { + file_path: "relative/path.txt".to_string(), + old_string: "old".to_string(), + new_string: "new".to_string(), + replace_all: false, + }; + let err = EditTool::execute(args).await.unwrap_err(); + assert!(matches!(err, EditError::Tool(ToolError::InvalidPath(_)))); + } + + #[tokio::test] + async fn file_not_found_returns_error() { + let args = EditArgs { + file_path: "/nonexistent/path/file.txt".to_string(), + old_string: "old".to_string(), + new_string: "new".to_string(), + replace_all: false, + }; + let err = EditTool::execute(args).await.unwrap_err(); + assert!(matches!(err, EditError::Tool(ToolError::Io(_)))); + } + + #[tokio::test] + async fn preserves_whitespace_exactly() { + let file = create_temp_file(" indented\n\tmore\n").await; + let args = EditArgs { + file_path: file.path().to_string_lossy().to_string(), + old_string: "indented".to_string(), + new_string: "REPLACED".to_string(), + replace_all: false, + }; + EditTool::execute(args).await.unwrap(); + let content = tokio::fs::read_to_string(file.path()).await.unwrap(); + assert_eq!(content, " REPLACED\n\tmore\n"); + } +} diff --git a/src/rig-coding-tools/src/tools/glob.rs b/src/rig-coding-tools/src/tools/glob.rs new file mode 100644 index 00000000..d18af542 --- /dev/null +++ b/src/rig-coding-tools/src/tools/glob.rs @@ -0,0 +1,241 @@ +//! Glob pattern file finding tool. +//! +//! Finds files matching glob patterns like `**/*.rs` while respecting `.gitignore`. + +use crate::error::{ToolError, ToolResult}; +use crate::util::validate_absolute_path; +use ignore::WalkBuilder; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::time::SystemTime; + +/// Maximum number of file matches to return. +const MAX_RESULTS: usize = 1000; + +/// Arguments for the glob tool. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GlobArgs { + /// Glob pattern to match files against (e.g., "**/*.rs", "src/**/*.ts"). + pub pattern: String, + /// Absolute directory path to search in. + pub path: String, +} + +/// Output from the glob tool. +#[derive(Debug, Serialize)] +pub struct GlobOutput { + /// Matched file paths relative to search directory, sorted by mtime (newest first). + pub files: Vec, + /// Whether results were truncated due to limit. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub truncated: bool, +} + +/// Tool for finding files matching glob patterns. +/// +/// Walks directory trees matching files against glob patterns while respecting +/// `.gitignore` files. Results are sorted by modification time (newest first). +#[derive(Debug, Default, Clone, Copy)] +pub struct GlobTool; + +impl Tool for GlobTool { + const NAME: &'static str = "glob"; + + type Error = ToolError; + type Args = GlobArgs; + type Output = GlobOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Find files matching a glob pattern. Respects .gitignore and \ + returns paths sorted by modification time (newest first)." + .to_string(), + parameters: serde_json::to_value(schemars::schema_for!(GlobArgs)) + .expect("schema serialization should not fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + glob_files(&args.pattern, &args.path) + } +} + +/// Finds files matching a glob pattern in the given directory. +fn glob_files(pattern: &str, search_path: &str) -> ToolResult { + let path = Path::new(search_path); + validate_absolute_path(path)?; + + if !path.is_dir() { + return Err(ToolError::InvalidPath(format!( + "path is not a directory: {}", + path.display() + ))); + } + + // Compile the glob pattern for matching + let compiled_pattern = + ::glob::Pattern::new(pattern).map_err(|e| ToolError::InvalidPattern(e.to_string()))?; + + // Collect files with modification times + let mut files_with_mtime: Vec<(String, SystemTime)> = Vec::new(); + + let walker = WalkBuilder::new(path) + .hidden(false) // Include hidden files + .git_ignore(true) // Respect .gitignore + .git_global(true) // Respect global gitignore + .git_exclude(true) // Respect .git/info/exclude + .build(); + + for entry_result in walker { + let entry = match entry_result { + Ok(e) => e, + Err(_) => continue, // Skip permission errors + }; + + // Skip directories + if let Some(ft) = entry.file_type() { + if ft.is_dir() { + continue; + } + } else { + continue; + } + + // Get relative path + let rel_path = match entry.path().strip_prefix(path) { + Ok(p) => p.to_string_lossy().into_owned(), + Err(_) => continue, + }; + + // Skip empty paths (root directory itself) + if rel_path.is_empty() { + continue; + } + + // Check if relative path matches the pattern + if !compiled_pattern.matches(&rel_path) { + continue; + } + + // Get modification time + let mtime = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .unwrap_or(SystemTime::UNIX_EPOCH); + + files_with_mtime.push((rel_path, mtime)); + } + + // Sort by modification time (newest first) + files_with_mtime.sort_by(|a, b| b.1.cmp(&a.1)); + + // Check if truncation is needed + let truncated = files_with_mtime.len() > MAX_RESULTS; + + // Extract paths, truncating if needed + let files: Vec = files_with_mtime + .into_iter() + .take(MAX_RESULTS) + .map(|(path, _)| path) + .collect(); + + Ok(GlobOutput { files, truncated }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::{self, File}; + use std::io::Write; + use std::thread; + use std::time::Duration; + use tempfile::TempDir; + + fn create_test_tree() -> TempDir { + let dir = TempDir::new().unwrap(); + let base = dir.path(); + + // Create .git directory so ignore crate recognizes this as a git repo + fs::create_dir_all(base.join(".git")).unwrap(); + + // Create directory structure + fs::create_dir_all(base.join("src")).unwrap(); + fs::create_dir_all(base.join("tests")).unwrap(); + fs::create_dir_all(base.join("target/debug")).unwrap(); + + // Create files with slight delays for mtime ordering + File::create(base.join("src/lib.rs")).unwrap(); + thread::sleep(Duration::from_millis(10)); + File::create(base.join("src/main.rs")).unwrap(); + thread::sleep(Duration::from_millis(10)); + File::create(base.join("tests/test.rs")).unwrap(); + File::create(base.join("Cargo.toml")).unwrap(); + File::create(base.join("target/debug/binary")).unwrap(); + + // Create .gitignore + let mut gitignore = File::create(base.join(".gitignore")).unwrap(); + writeln!(gitignore, "target/").unwrap(); + + dir + } + + #[test] + fn glob_matches_simple_pattern() { + let dir = create_test_tree(); + let result = glob_files("*.toml", dir.path().to_str().unwrap()).unwrap(); + assert_eq!(result.files, vec!["Cargo.toml"]); + assert!(!result.truncated); + } + + #[test] + fn glob_matches_recursive_pattern() { + let dir = create_test_tree(); + let result = glob_files("**/*.rs", dir.path().to_str().unwrap()).unwrap(); + assert_eq!(result.files.len(), 3); + assert!(result.files.iter().any(|f| f.ends_with("lib.rs"))); + assert!(result.files.iter().any(|f| f.ends_with("main.rs"))); + assert!(result.files.iter().any(|f| f.ends_with("test.rs"))); + } + + #[test] + fn glob_respects_gitignore() { + let dir = create_test_tree(); + let result = glob_files("**/*", dir.path().to_str().unwrap()).unwrap(); + // target/ should be excluded + assert!(!result.files.iter().any(|f| f.contains("target"))); + } + + #[test] + fn glob_sorts_by_mtime_newest_first() { + let dir = create_test_tree(); + let result = glob_files("src/*.rs", dir.path().to_str().unwrap()).unwrap(); + assert_eq!(result.files.len(), 2); + // main.rs was created after lib.rs, so should be first + assert!(result.files[0].ends_with("main.rs")); + assert!(result.files[1].ends_with("lib.rs")); + } + + #[test] + fn glob_rejects_relative_path() { + let result = glob_files("*.rs", "relative/path"); + assert!(matches!(result, Err(ToolError::InvalidPath(_)))); + } + + #[test] + fn glob_rejects_nonexistent_directory() { + let result = glob_files("*.rs", "/nonexistent/path/that/does/not/exist"); + assert!(result.is_err()); + } + + #[test] + fn glob_handles_invalid_pattern() { + let dir = TempDir::new().unwrap(); + let result = glob_files("[invalid", dir.path().to_str().unwrap()); + assert!(matches!(result, Err(ToolError::InvalidPattern(_)))); + } +} diff --git a/src/rig-coding-tools/src/tools/grep.rs b/src/rig-coding-tools/src/tools/grep.rs new file mode 100644 index 00000000..9d29ac4d --- /dev/null +++ b/src/rig-coding-tools/src/tools/grep.rs @@ -0,0 +1,353 @@ +//! Grep tool for searching file contents using regex patterns. + +use crate::error::{ToolError, ToolResult}; +use crate::util::validate_absolute_path; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::time::Duration; +use tokio::process::Command; +use tokio::time::timeout; + +const DEFAULT_LIMIT: usize = 100; +const MAX_LIMIT: usize = 2000; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); + +fn default_limit() -> Option { + Some(DEFAULT_LIMIT) +} + +/// Arguments for the grep tool. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GrepArgs { + /// Regex pattern to search for in file contents. + pub pattern: String, + /// Absolute directory path to search in. + pub path: String, + /// Optional file glob filter (e.g., "*.rs", "*.{ts,tsx}"). + #[serde(default)] + pub include: Option, + /// Maximum number of files to return. + #[serde(default = "default_limit")] + pub limit: Option, +} + +/// Output from the grep tool. +#[derive(Debug, Serialize)] +pub struct GrepOutput { + /// List of file paths containing matches. + pub files: Vec, + /// Whether results were truncated due to limit. + pub truncated: bool, +} + +/// Tool for searching file contents using regex patterns. +/// +/// Finds files containing content matching a regex pattern within a directory. +/// Results are sorted by modification time (most recent first). +/// Binary files are automatically skipped. +pub struct GrepTool; + +impl Tool for GrepTool { + const NAME: &'static str = "grep"; + + type Error = ToolError; + type Args = GrepArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Search file contents using regex patterns. Returns file paths containing matches, sorted by modification time.".to_string(), + parameters: serde_json::json!({ + "type": "object", + "required": ["pattern", "path"], + "properties": { + "pattern": { + "type": "string", + "description": "Regex pattern to search for in file contents" + }, + "path": { + "type": "string", + "description": "Absolute directory path to search in" + }, + "include": { + "type": "string", + "description": "File glob filter (e.g., \"*.rs\", \"*.{ts,tsx}\")" + }, + "limit": { + "type": "integer", + "description": "Maximum files to return (default: 100, max: 2000)" + } + } + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let path = Path::new(&args.path); + validate_absolute_path(path)?; + + let pattern = args.pattern.trim(); + if pattern.is_empty() { + return Err(ToolError::InvalidPattern( + "pattern must not be empty".into(), + )); + } + + // Validate regex compiles + regex::Regex::new(pattern)?; + + let limit = args.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + if limit == 0 { + return Err(ToolError::InvalidPattern( + "limit must be greater than zero".into(), + )); + } + + let include = args.include.as_deref().and_then(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }); + + let result = run_rg_search(pattern, include, path, limit).await?; + + if result.files.is_empty() { + Ok("No matches found.".to_string()) + } else { + let mut output = result.files.join("\n"); + if result.truncated { + output.push_str(&format!("\n\n(Results truncated at {} files)", limit)); + } + Ok(output) + } + } +} + +/// Execute ripgrep to find files matching the pattern. +async fn run_rg_search( + pattern: &str, + include: Option<&str>, + search_path: &Path, + limit: usize, +) -> ToolResult { + let mut command = Command::new("rg"); + command + .arg("--files-with-matches") + .arg("--sortr=modified") + .arg("--regexp") + .arg(pattern) + .arg("--no-messages"); + + if let Some(glob) = include { + command.arg("--glob").arg(glob); + } + + command.arg("--").arg(search_path); + + let output = timeout(COMMAND_TIMEOUT, command.output()) + .await + .map_err(|_| ToolError::Timeout("rg timed out after 30 seconds".into()))? + .map_err(|e| { + ToolError::Execution(format!( + "failed to launch rg: {e}. Ensure ripgrep is installed and on PATH." + )) + })?; + + match output.status.code() { + Some(0) => { + let (files, truncated) = parse_results(&output.stdout, limit); + Ok(GrepOutput { files, truncated }) + } + Some(1) => Ok(GrepOutput { + files: Vec::new(), + truncated: false, + }), + _ => { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(ToolError::Execution(format!("rg failed: {stderr}"))) + } + } +} + +/// Parse ripgrep output into file paths, respecting the limit. +fn parse_results(stdout: &[u8], limit: usize) -> (Vec, bool) { + let mut results = Vec::new(); + let mut truncated = false; + + for line in stdout.split(|&b| b == b'\n') { + if line.is_empty() { + continue; + } + if let Ok(text) = std::str::from_utf8(line) { + if text.is_empty() { + continue; + } + if results.len() >= limit { + truncated = true; + break; + } + results.push(text.to_string()); + } + } + + (results, truncated) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command as StdCommand; + use tempfile::tempdir; + + fn rg_available() -> bool { + StdCommand::new("rg") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + #[test] + fn parse_results_handles_basic_output() { + let stdout = b"/tmp/a.rs\n/tmp/b.rs\n"; + let (files, truncated) = parse_results(stdout, 10); + assert_eq!(files, vec!["/tmp/a.rs", "/tmp/b.rs"]); + assert!(!truncated); + } + + #[test] + fn parse_results_truncates_at_limit() { + let stdout = b"/tmp/a.rs\n/tmp/b.rs\n/tmp/c.rs\n"; + let (files, truncated) = parse_results(stdout, 2); + assert_eq!(files.len(), 2); + assert!(truncated); + } + + #[test] + fn parse_results_handles_empty_lines() { + let stdout = b"/tmp/a.rs\n\n/tmp/b.rs\n"; + let (files, _) = parse_results(stdout, 10); + assert_eq!(files, vec!["/tmp/a.rs", "/tmp/b.rs"]); + } + + #[tokio::test] + async fn grep_tool_validates_absolute_path() { + let tool = GrepTool; + let args = GrepArgs { + pattern: "test".into(), + path: "relative/path".into(), + include: None, + limit: None, + }; + let result = tool.call(args).await; + assert!(matches!(result, Err(ToolError::InvalidPath(_)))); + } + + #[tokio::test] + async fn grep_tool_validates_empty_pattern() { + let tool = GrepTool; + let args = GrepArgs { + pattern: " ".into(), + path: "/tmp".into(), + include: None, + limit: None, + }; + let result = tool.call(args).await; + assert!(matches!(result, Err(ToolError::InvalidPattern(_)))); + } + + #[tokio::test] + async fn grep_tool_validates_invalid_regex() { + let tool = GrepTool; + let args = GrepArgs { + pattern: "[invalid".into(), + path: "/tmp".into(), + include: None, + limit: None, + }; + let result = tool.call(args).await; + assert!(matches!(result, Err(ToolError::Regex(_)))); + } + + #[tokio::test] + async fn run_rg_search_finds_matches() { + if !rg_available() { + return; + } + let temp = tempdir().unwrap(); + let dir = temp.path(); + std::fs::write(dir.join("match.txt"), "hello world").unwrap(); + std::fs::write(dir.join("other.txt"), "goodbye").unwrap(); + + let result = run_rg_search("hello", None, dir, 10).await.unwrap(); + assert_eq!(result.files.len(), 1); + assert!(result.files[0].ends_with("match.txt")); + } + + #[tokio::test] + async fn run_rg_search_respects_glob_filter() { + if !rg_available() { + return; + } + let temp = tempdir().unwrap(); + let dir = temp.path(); + std::fs::write(dir.join("match.rs"), "hello world").unwrap(); + std::fs::write(dir.join("match.txt"), "hello world").unwrap(); + + let result = run_rg_search("hello", Some("*.rs"), dir, 10).await.unwrap(); + assert_eq!(result.files.len(), 1); + assert!(result.files[0].ends_with(".rs")); + } + + #[tokio::test] + async fn run_rg_search_respects_limit() { + if !rg_available() { + return; + } + let temp = tempdir().unwrap(); + let dir = temp.path(); + std::fs::write(dir.join("a.txt"), "pattern").unwrap(); + std::fs::write(dir.join("b.txt"), "pattern").unwrap(); + std::fs::write(dir.join("c.txt"), "pattern").unwrap(); + + let result = run_rg_search("pattern", None, dir, 2).await.unwrap(); + assert_eq!(result.files.len(), 2); + assert!(result.truncated); + } + + #[tokio::test] + async fn run_rg_search_returns_empty_on_no_match() { + if !rg_available() { + return; + } + let temp = tempdir().unwrap(); + let dir = temp.path(); + std::fs::write(dir.join("file.txt"), "content").unwrap(); + + let result = run_rg_search("nonexistent", None, dir, 10).await.unwrap(); + assert!(result.files.is_empty()); + assert!(!result.truncated); + } + + #[tokio::test] + async fn run_rg_search_supports_regex() { + if !rg_available() { + return; + } + let temp = tempdir().unwrap(); + let dir = temp.path(); + std::fs::write(dir.join("match.txt"), "foo123bar").unwrap(); + std::fs::write(dir.join("nomatch.txt"), "foobar").unwrap(); + + let result = run_rg_search(r"foo\d+bar", None, dir, 10).await.unwrap(); + assert_eq!(result.files.len(), 1); + assert!(result.files[0].ends_with("match.txt")); + } +} diff --git a/src/rig-coding-tools/src/tools/mod.rs b/src/rig-coding-tools/src/tools/mod.rs new file mode 100644 index 00000000..0e153413 --- /dev/null +++ b/src/rig-coding-tools/src/tools/mod.rs @@ -0,0 +1,20 @@ +//! Tool implementations for rig-based LLM agents. +//! +//! Each submodule implements a specific tool following the `rig_core::tool::Tool` trait. + +// Tool submodules will be added here as they are implemented: +pub mod bash; +pub mod edit; +pub mod glob; +pub mod grep; +pub mod read; +pub mod task; +pub mod todo; +pub mod webfetch; +pub mod write; +// pub mod skill; + +// Re-exports +pub use bash::BashTool; +pub use todo::{Todo, TodoPriority, TodoReadTool, TodoState, TodoStatus, TodoWriteTool}; +pub use webfetch::WebFetchTool; diff --git a/src/rig-coding-tools/src/tools/read.rs b/src/rig-coding-tools/src/tools/read.rs new file mode 100644 index 00000000..d386d312 --- /dev/null +++ b/src/rig-coding-tools/src/tools/read.rs @@ -0,0 +1,253 @@ +//! Read file tool for reading file contents with line numbers. + +use crate::error::{ToolError, ToolResult}; +use crate::output::ToolOutput; +use crate::util::{truncate_line, validate_absolute_path}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; +use std::path::Path; +use tokio::fs::File; +use tokio::io::{AsyncBufReadExt, BufReader}; + +const MAX_LINE_LENGTH: usize = 2000; +const DEFAULT_OFFSET: usize = 1; +const DEFAULT_LIMIT: usize = 2000; + +fn default_offset() -> usize { + DEFAULT_OFFSET +} + +fn default_limit() -> usize { + DEFAULT_LIMIT +} + +/// Arguments for the read file tool. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct ReadArgs { + /// Absolute path to the file to read. + pub file_path: String, + /// 1-indexed line number to start reading from (default: 1). + #[serde(default = "default_offset")] + pub offset: usize, + /// Maximum number of lines to return (default: 2000). + #[serde(default = "default_limit")] + pub limit: usize, +} + +/// Tool for reading file contents with line numbers. +/// +/// Reads files with configurable offset and limit, returning content +/// with line numbers prefixed in the format `L{number}: {content}`. +#[derive(Debug, Clone, Default)] +pub struct ReadTool; + +impl ReadTool { + /// Creates a new read tool instance. + #[inline] + pub fn new() -> Self { + Self + } +} + +impl Tool for ReadTool { + const NAME: &'static str = "read"; + + type Error = ToolError; + type Args = ReadArgs; + type Output = ToolOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Read file contents with line numbers. Returns lines prefixed with L{number}: format.".to_string(), + parameters: serde_json::to_value(schema_for!(ReadArgs)) + .expect("schema serialization should never fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + read_file(&args.file_path, args.offset, args.limit).await + } +} + +/// Reads a file and returns formatted content with line numbers. +async fn read_file(file_path: &str, offset: usize, limit: usize) -> ToolResult { + // Validate arguments + if offset == 0 { + return Err(ToolError::OutOfBounds( + "offset must be >= 1 (1-indexed)".into(), + )); + } + if limit == 0 { + return Err(ToolError::OutOfBounds("limit must be >= 1".into())); + } + + let path = Path::new(file_path); + validate_absolute_path(path)?; + + let file = File::open(path).await?; + let mut reader = BufReader::new(file); + let mut buffer = Vec::new(); + let mut collected = Vec::new(); + let mut line_number = 0usize; + + loop { + buffer.clear(); + let bytes_read = reader.read_until(b'\n', &mut buffer).await?; + + if bytes_read == 0 { + break; + } + + // Strip trailing newline characters + if buffer.last() == Some(&b'\n') { + buffer.pop(); + if buffer.last() == Some(&b'\r') { + buffer.pop(); + } + } + + line_number += 1; + + // Skip lines before offset + if line_number < offset { + continue; + } + + // Stop if we've collected enough lines + if collected.len() >= limit { + break; + } + + // Convert to string with lossy UTF-8 handling + let content = String::from_utf8_lossy(&buffer); + + // Truncate long lines + let (truncated_content, _) = truncate_line(&content, MAX_LINE_LENGTH); + + collected.push(format!("L{}: {}", line_number, truncated_content)); + } + + // Check if offset exceeded file length + if line_number < offset { + return Err(ToolError::OutOfBounds(format!( + "offset {} exceeds file length of {} lines", + offset, line_number + ))); + } + + Ok(ToolOutput::new(collected.join("\n"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + async fn read_temp_file(content: &[u8], offset: usize, limit: usize) -> ToolResult { + let mut temp = NamedTempFile::new().unwrap(); + temp.write_all(content).unwrap(); + read_file(temp.path().to_str().unwrap(), offset, limit).await + } + + #[tokio::test] + async fn reads_basic_file() { + let result = read_temp_file(b"hello\nworld\n", 1, 2000).await.unwrap(); + assert_eq!(result.content, "L1: hello\nL2: world"); + } + + #[tokio::test] + async fn reads_with_offset() { + let result = read_temp_file(b"one\ntwo\nthree\n", 2, 2000).await.unwrap(); + assert_eq!(result.content, "L2: two\nL3: three"); + } + + #[tokio::test] + async fn reads_with_limit() { + let result = read_temp_file(b"one\ntwo\nthree\n", 1, 2).await.unwrap(); + assert_eq!(result.content, "L1: one\nL2: two"); + } + + #[tokio::test] + async fn reads_with_offset_and_limit() { + let result = read_temp_file(b"one\ntwo\nthree\nfour\n", 2, 2) + .await + .unwrap(); + assert_eq!(result.content, "L2: two\nL3: three"); + } + + #[tokio::test] + async fn handles_crlf_line_endings() { + let result = read_temp_file(b"line1\r\nline2\r\n", 1, 2000) + .await + .unwrap(); + assert_eq!(result.content, "L1: line1\nL2: line2"); + } + + #[tokio::test] + async fn handles_non_utf8_content() { + let result = read_temp_file(b"\xff\xfe\nplain\n", 1, 2000).await.unwrap(); + assert!(result.content.contains("L1:")); + assert!(result.content.contains('\u{FFFD}')); // replacement char + assert!(result.content.contains("L2: plain")); + } + + #[tokio::test] + async fn truncates_long_lines() { + let long_line = "x".repeat(MAX_LINE_LENGTH + 100); + let content = format!("{}\n", long_line); + let result = read_temp_file(content.as_bytes(), 1, 1).await.unwrap(); + let expected = format!("L1: {}", "x".repeat(MAX_LINE_LENGTH)); + assert_eq!(result.content, expected); + } + + #[tokio::test] + async fn errors_on_offset_zero() { + let err = read_temp_file(b"test\n", 0, 10).await.unwrap_err(); + assert!(matches!(err, ToolError::OutOfBounds(_))); + } + + #[tokio::test] + async fn errors_on_limit_zero() { + let err = read_temp_file(b"test\n", 1, 0).await.unwrap_err(); + assert!(matches!(err, ToolError::OutOfBounds(_))); + } + + #[tokio::test] + async fn errors_on_offset_exceeds_file() { + let err = read_temp_file(b"one\ntwo\n", 10, 100).await.unwrap_err(); + assert!(matches!(err, ToolError::OutOfBounds(_))); + } + + #[tokio::test] + async fn errors_on_relative_path() { + let err = read_file("relative/path.txt", 1, 100).await.unwrap_err(); + assert!(matches!(err, ToolError::InvalidPath(_))); + } + + #[tokio::test] + async fn errors_on_nonexistent_file() { + let err = read_file("/nonexistent/file.txt", 1, 100) + .await + .unwrap_err(); + assert!(matches!(err, ToolError::Io(_))); + } + + #[tokio::test] + async fn handles_empty_file() { + let result = read_temp_file(b"", 1, 100).await; + // Empty file with offset 1 should error + assert!(matches!(result, Err(ToolError::OutOfBounds(_)))); + } + + #[tokio::test] + async fn handles_file_without_trailing_newline() { + let result = read_temp_file(b"no trailing newline", 1, 100) + .await + .unwrap(); + assert_eq!(result.content, "L1: no trailing newline"); + } +} diff --git a/src/rig-coding-tools/src/tools/task.rs b/src/rig-coding-tools/src/tools/task.rs new file mode 100644 index 00000000..a57b9c87 --- /dev/null +++ b/src/rig-coding-tools/src/tools/task.rs @@ -0,0 +1,293 @@ +//! Task tool for launching autonomous sub-agents. +//! +//! Provides [`TaskTool`] for spawning sub-agents to handle complex tasks. +//! Includes [`MockTaskExecutor`] for testing without LLM dependencies. + +use crate::error::ToolResult; +use async_trait::async_trait; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, RwLock}; + +/// Input arguments for the task tool. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct TaskArgs { + /// Short 3-5 word task description. + pub description: String, + /// Detailed instructions for the sub-agent. + pub prompt: String, + /// Type of agent to use (e.g., "general", "coder"). + pub subagent_type: String, + /// Existing session to continue. + #[serde(default)] + pub session_id: Option, +} + +/// Result from task execution. +#[derive(Debug, Clone, Serialize)] +pub struct TaskResult { + /// The task description. + pub description: String, + /// The agent type used. + pub subagent_type: String, + /// Session ID (new or continued). + pub session_id: String, + /// Result message from the agent. + pub result: String, +} + +impl TaskResult { + /// Formats the result for tool output. + pub fn format(&self) -> String { + format!( + "Task: {}\nAgent: {}\nSession: {}\nStatus: completed\n\nResult: {}", + self.description, self.subagent_type, self.session_id, self.result + ) + } +} + +/// Trait for executing tasks. +/// +/// Implement this trait to provide custom task execution logic, +/// such as invoking a real LLM agent. +#[async_trait] +pub trait TaskExecutor: Send + Sync { + /// Execute a task with the given arguments. + async fn execute(&self, args: &TaskArgs) -> ToolResult; +} + +/// Mock task executor for testing. +/// +/// Returns predefined responses without requiring LLM authentication. +#[derive(Debug, Default)] +pub struct MockTaskExecutor { + responses: RwLock>, + session_counter: AtomicU64, +} + +impl MockTaskExecutor { + /// Creates a new mock executor. + pub fn new() -> Self { + Self::default() + } + + /// Sets a custom response for a specific description. + pub fn set_response(&self, description: impl Into, response: impl Into) { + self.responses + .write() + .expect("lock poisoned") + .insert(description.into(), response.into()); + } + + fn next_session_id(&self) -> String { + let id = self.session_counter.fetch_add(1, Ordering::Relaxed); + format!("mock-session-{id}") + } +} + +#[async_trait] +impl TaskExecutor for MockTaskExecutor { + async fn execute(&self, args: &TaskArgs) -> ToolResult { + let session_id = args + .session_id + .clone() + .unwrap_or_else(|| self.next_session_id()); + + let result = self + .responses + .read() + .expect("lock poisoned") + .get(&args.description) + .cloned() + .unwrap_or_else(|| { + format!( + "Task '{}' completed successfully by {} agent.", + args.description, args.subagent_type + ) + }); + + Ok(TaskResult { + description: args.description.clone(), + subagent_type: args.subagent_type.clone(), + session_id, + result, + }) + } +} + +/// Tool for launching autonomous sub-agents. +/// +/// Uses a [`TaskExecutor`] to handle task execution. For testing, +/// use [`TaskTool::with_mock`] to create a tool with [`MockTaskExecutor`]. +pub struct TaskTool { + executor: Arc, +} + +impl TaskTool { + /// Creates a new task tool with mock executor for testing. + pub fn with_mock() -> Self { + Self { + executor: Arc::new(MockTaskExecutor::new()), + } + } + + /// Returns a reference to the mock executor for setting responses. + pub fn mock_executor(&self) -> &MockTaskExecutor { + &self.executor + } +} + +impl TaskTool { + /// Creates a new task tool with the given executor. + pub fn new(executor: Arc) -> Self { + Self { executor } + } +} + +impl Tool for TaskTool { + const NAME: &'static str = "task"; + + type Error = crate::error::ToolError; + type Args = TaskArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Launch a sub-agent to handle complex, multi-step tasks autonomously." + .to_string(), + parameters: serde_json::to_value(schema_for!(TaskArgs)) + .expect("schema generation should not fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let result = self.executor.execute(&args).await?; + Ok(result.format()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn mock_executor_returns_default_response() { + let executor = MockTaskExecutor::new(); + let args = TaskArgs { + description: "test task".into(), + prompt: "do something".into(), + subagent_type: "general".into(), + session_id: None, + }; + + let result = executor.execute(&args).await.unwrap(); + + assert_eq!(result.description, "test task"); + assert_eq!(result.subagent_type, "general"); + assert!(result.session_id.starts_with("mock-session-")); + assert!(result.result.contains("test task")); + assert!(result.result.contains("general agent")); + } + + #[tokio::test] + async fn mock_executor_uses_custom_response() { + let executor = MockTaskExecutor::new(); + executor.set_response("custom task", "Custom result!"); + + let args = TaskArgs { + description: "custom task".into(), + prompt: "details".into(), + subagent_type: "coder".into(), + session_id: None, + }; + + let result = executor.execute(&args).await.unwrap(); + assert_eq!(result.result, "Custom result!"); + } + + #[tokio::test] + async fn mock_executor_continues_session() { + let executor = MockTaskExecutor::new(); + let args = TaskArgs { + description: "task".into(), + prompt: "prompt".into(), + subagent_type: "general".into(), + session_id: Some("existing-session".into()), + }; + + let result = executor.execute(&args).await.unwrap(); + assert_eq!(result.session_id, "existing-session"); + } + + #[tokio::test] + async fn task_tool_calls_executor() { + let tool = TaskTool::with_mock(); + let args = TaskArgs { + description: "analyze code".into(), + prompt: "review the main function".into(), + subagent_type: "coder".into(), + session_id: None, + }; + + let output = tool.call(args).await.unwrap(); + + assert!(output.contains("Task: analyze code")); + assert!(output.contains("Agent: coder")); + assert!(output.contains("Status: completed")); + } + + #[tokio::test] + async fn task_tool_with_custom_mock_response() { + let tool = TaskTool::with_mock(); + tool.mock_executor() + .set_response("special task", "Special output!"); + + let args = TaskArgs { + description: "special task".into(), + prompt: "do special things".into(), + subagent_type: "general".into(), + session_id: None, + }; + + let output = tool.call(args).await.unwrap(); + assert!(output.contains("Special output!")); + } + + #[tokio::test] + async fn task_tool_definition_has_correct_schema() { + let tool = TaskTool::with_mock(); + let def = tool.definition("".into()).await; + + assert_eq!(def.name, "task"); + assert!(def.description.contains("sub-agent")); + + let params = def.parameters.as_object().unwrap(); + let props = params.get("properties").unwrap().as_object().unwrap(); + assert!(props.contains_key("description")); + assert!(props.contains_key("prompt")); + assert!(props.contains_key("subagent_type")); + assert!(props.contains_key("session_id")); + } + + #[tokio::test] + async fn session_ids_increment() { + let executor = MockTaskExecutor::new(); + let args = TaskArgs { + description: "task".into(), + prompt: "prompt".into(), + subagent_type: "general".into(), + session_id: None, + }; + + let r1 = executor.execute(&args).await.unwrap(); + let r2 = executor.execute(&args).await.unwrap(); + + assert_eq!(r1.session_id, "mock-session-0"); + assert_eq!(r2.session_id, "mock-session-1"); + } +} diff --git a/src/rig-coding-tools/src/tools/todo.rs b/src/rig-coding-tools/src/tools/todo.rs new file mode 100644 index 00000000..6c826642 --- /dev/null +++ b/src/rig-coding-tools/src/tools/todo.rs @@ -0,0 +1,322 @@ +//! Todo list management tools for task tracking. +//! +//! Provides [`TodoWriteTool`] and [`TodoReadTool`] for managing a session-scoped +//! task list that LLM agents can use to track multi-step work. + +use crate::error::ToolError; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::{Deserialize, Serialize}; +use std::fmt::Write; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Task status with display icons. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum TodoStatus { + /// Not yet started. + Pending, + /// Currently being worked on. + InProgress, + /// Successfully finished. + Completed, + /// Abandoned or no longer relevant. + Cancelled, +} + +impl TodoStatus { + /// Returns the status indicator icon. + #[inline] + pub const fn icon(self) -> &'static str { + match self { + Self::Pending => "[ ]", + Self::InProgress => "[>]", + Self::Completed => "[x]", + Self::Cancelled => "[-]", + } + } +} + +/// Task priority level. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum TodoPriority { + /// Urgent, should be addressed first. + High, + /// Normal priority. + Medium, + /// Can be deferred. + Low, +} + +/// A single task item. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct Todo { + /// Unique identifier for the task. + pub id: String, + /// Task description. + pub content: String, + /// Current status. + pub status: TodoStatus, + /// Priority level. + pub priority: TodoPriority, +} + +/// Thread-safe shared state for todo list. +#[derive(Debug, Clone, Default)] +pub struct TodoState { + todos: Arc>>, +} + +impl TodoState { + /// Creates a new empty todo state. + #[inline] + pub fn new() -> Self { + Self::default() + } +} + +// ============================================================================ +// TodoWriteTool +// ============================================================================ + +/// Arguments for [`TodoWriteTool`]. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct TodoWriteArgs { + /// The complete updated todo list. + pub todos: Vec, +} + +/// Tool for writing/replacing the todo list. +#[derive(Debug, Clone)] +pub struct TodoWriteTool { + state: TodoState, +} + +impl TodoWriteTool { + /// Creates a new write tool with the given shared state. + #[inline] + pub fn new(state: TodoState) -> Self { + Self { state } + } +} + +impl Tool for TodoWriteTool { + const NAME: &'static str = "todowrite"; + + type Error = ToolError; + type Args = TodoWriteArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + let schema = schema_for!(TodoWriteArgs); + ToolDefinition { + name: Self::NAME.to_string(), + description: "Replace the entire todo list with a new list of tasks.".to_string(), + parameters: serde_json::to_value(schema).unwrap_or_default(), + } + } + + async fn call(&self, args: Self::Args) -> Result { + // Validate all todos have non-empty id and content + for todo in &args.todos { + if todo.id.trim().is_empty() { + return Err(ToolError::Validation("todo id cannot be empty".into())); + } + if todo.content.trim().is_empty() { + return Err(ToolError::Validation("todo content cannot be empty".into())); + } + } + + let count = args.todos.len(); + *self.state.todos.write().await = args.todos; + Ok(format!("Updated todo list with {count} task(s).")) + } +} + +// ============================================================================ +// TodoReadTool +// ============================================================================ + +/// Arguments for [`TodoReadTool`] (empty). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct TodoReadArgs {} + +/// Tool for reading the current todo list. +#[derive(Debug, Clone)] +pub struct TodoReadTool { + state: TodoState, +} + +impl TodoReadTool { + /// Creates a new read tool with the given shared state. + #[inline] + pub fn new(state: TodoState) -> Self { + Self { state } + } +} + +impl Tool for TodoReadTool { + const NAME: &'static str = "todoread"; + + type Error = ToolError; + type Args = TodoReadArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + let schema = schema_for!(TodoReadArgs); + ToolDefinition { + name: Self::NAME.to_string(), + description: "Read the current todo list.".to_string(), + parameters: serde_json::to_value(schema).unwrap_or_default(), + } + } + + async fn call(&self, _args: Self::Args) -> Result { + let todos = self.state.todos.read().await; + + if todos.is_empty() { + return Ok("No tasks.".to_string()); + } + + let mut output = format!("Tasks ({} total):\n", todos.len()); + for todo in todos.iter() { + let _ = writeln!( + output, + "{} ({:?}) {}: {}", + todo.status.icon(), + todo.priority, + todo.id, + todo.content + ); + } + + // Remove trailing newline + output.truncate(output.trim_end().len()); + Ok(output) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_todo(id: &str, status: TodoStatus) -> Todo { + Todo { + id: id.to_string(), + content: format!("Task {id}"), + status, + priority: TodoPriority::Medium, + } + } + + #[tokio::test] + async fn write_and_read_todos() { + let state = TodoState::new(); + let write_tool = TodoWriteTool::new(state.clone()); + let read_tool = TodoReadTool::new(state); + + let todos = vec![ + make_todo("1", TodoStatus::Completed), + make_todo("2", TodoStatus::InProgress), + make_todo("3", TodoStatus::Pending), + ]; + + let result = write_tool.call(TodoWriteArgs { todos }).await.unwrap(); + assert!(result.contains("3 task(s)")); + + let output = read_tool.call(TodoReadArgs {}).await.unwrap(); + assert!(output.contains("[x]")); // completed + assert!(output.contains("[>]")); // in_progress + assert!(output.contains("[ ]")); // pending + } + + #[tokio::test] + async fn read_empty_list() { + let state = TodoState::new(); + let read_tool = TodoReadTool::new(state); + let output = read_tool.call(TodoReadArgs {}).await.unwrap(); + assert_eq!(output, "No tasks."); + } + + #[tokio::test] + async fn write_replaces_existing() { + let state = TodoState::new(); + let write_tool = TodoWriteTool::new(state.clone()); + let read_tool = TodoReadTool::new(state); + + // First write + write_tool + .call(TodoWriteArgs { + todos: vec![make_todo("a", TodoStatus::Pending)], + }) + .await + .unwrap(); + + // Second write replaces + write_tool + .call(TodoWriteArgs { + todos: vec![make_todo("b", TodoStatus::Completed)], + }) + .await + .unwrap(); + + let output = read_tool.call(TodoReadArgs {}).await.unwrap(); + assert!(!output.contains("Task a")); // Check that todo "a" is not present + assert!(output.contains("Task b")); // Check that todo "b" is present + } + + #[tokio::test] + async fn write_validates_empty_id() { + let state = TodoState::new(); + let write_tool = TodoWriteTool::new(state); + let todo = Todo { + id: "".to_string(), + content: "Task".to_string(), + status: TodoStatus::Pending, + priority: TodoPriority::Low, + }; + let result = write_tool.call(TodoWriteArgs { todos: vec![todo] }).await; + assert!(matches!(result, Err(ToolError::Validation(_)))); + } + + #[tokio::test] + async fn write_validates_empty_content() { + let state = TodoState::new(); + let write_tool = TodoWriteTool::new(state); + let todo = Todo { + id: "1".to_string(), + content: " ".to_string(), + status: TodoStatus::Pending, + priority: TodoPriority::Low, + }; + let result = write_tool.call(TodoWriteArgs { todos: vec![todo] }).await; + assert!(matches!(result, Err(ToolError::Validation(_)))); + } + + #[test] + fn status_icons_are_correct() { + assert_eq!(TodoStatus::Pending.icon(), "[ ]"); + assert_eq!(TodoStatus::InProgress.icon(), "[>]"); + assert_eq!(TodoStatus::Completed.icon(), "[x]"); + assert_eq!(TodoStatus::Cancelled.icon(), "[-]"); + } + + #[test] + fn status_serde_roundtrip() { + let json = serde_json::to_string(&TodoStatus::InProgress).unwrap(); + assert_eq!(json, "\"in_progress\""); + let parsed: TodoStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, TodoStatus::InProgress); + } + + #[test] + fn priority_serde_roundtrip() { + let json = serde_json::to_string(&TodoPriority::High).unwrap(); + assert_eq!(json, "\"high\""); + let parsed: TodoPriority = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, TodoPriority::High); + } +} diff --git a/src/rig-coding-tools/src/tools/webfetch.rs b/src/rig-coding-tools/src/tools/webfetch.rs new file mode 100644 index 00000000..8c590b91 --- /dev/null +++ b/src/rig-coding-tools/src/tools/webfetch.rs @@ -0,0 +1,400 @@ +//! Web content fetching tool. +//! +//! Fetches URLs and returns content in a text-friendly format. + +use crate::error::ToolError; +use crate::util::truncate_text; +use reqwest::redirect::Policy; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// Maximum response size to accept (1MB). +const MAX_RESPONSE_SIZE: usize = 1_024 * 1_024; +/// Content truncation threshold (100KB). +const CONTENT_TRUNCATE_SIZE: usize = 100 * 1_024; +/// Default request timeout in milliseconds. +const DEFAULT_TIMEOUT_MS: u64 = 30_000; +/// Maximum redirects to follow. +const MAX_REDIRECTS: usize = 10; + +fn default_timeout_ms() -> u64 { + DEFAULT_TIMEOUT_MS +} + +/// Arguments for [`WebFetchTool`]. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct WebFetchArgs { + /// URL to fetch content from. + pub url: String, + /// Request timeout in milliseconds. + #[serde(default = "default_timeout_ms")] + #[schemars(default = "default_timeout_ms")] + pub timeout_ms: u64, +} + +/// Tool for fetching web content from URLs. +/// +/// Supports HTML (with tag stripping), JSON (formatted), and plain text. +#[derive(Clone)] +pub struct WebFetchTool { + client: reqwest::Client, +} + +impl Default for WebFetchTool { + fn default() -> Self { + Self::new() + } +} + +impl WebFetchTool { + /// Creates a new [`WebFetchTool`] with default settings. + pub fn new() -> Self { + let client = reqwest::Client::builder() + .redirect(Policy::limited(MAX_REDIRECTS)) + .build() + .expect("failed to build HTTP client"); + Self { client } + } + + /// Creates a [`WebFetchTool`] with a custom client. + pub fn with_client(client: reqwest::Client) -> Self { + Self { client } + } +} + +impl Tool for WebFetchTool { + const NAME: &'static str = "webfetch"; + + type Error = ToolError; + type Args = WebFetchArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + let schema = schemars::schema_for!(WebFetchArgs); + ToolDefinition { + name: Self::NAME.to_string(), + description: "Fetches content from a URL and returns it as text.".to_string(), + parameters: serde_json::to_value(schema).unwrap_or_default(), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let timeout = Duration::from_millis(args.timeout_ms); + + let response = self + .client + .get(&args.url) + .timeout(timeout) + .send() + .await + .map_err(|e| categorize_reqwest_error(e, &args.url))?; + + let status = response.status(); + if !status.is_success() { + return Err(ToolError::Http(format!("HTTP {} for {}", status, args.url))); + } + + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("text/plain") + .to_string(); + + let content_length = response.content_length(); + + // Check Content-Length if available + if let Some(len) = content_length { + if len as usize > MAX_RESPONSE_SIZE { + return Err(ToolError::Http(format!( + "Response too large: {} bytes (max {})", + len, MAX_RESPONSE_SIZE + ))); + } + } + + // Read response body with size limit + let bytes = read_limited_body(response, MAX_RESPONSE_SIZE).await?; + let raw_content = String::from_utf8_lossy(&bytes); + + // Process based on content type + let processed = if content_type.contains("text/html") { + strip_html_tags(&raw_content) + } else if content_type.contains("application/json") { + format_json(&raw_content) + } else { + raw_content.into_owned() + }; + + // Truncate if needed + let (content, truncated) = truncate_text(&processed, CONTENT_TRUNCATE_SIZE); + + // Format output + let mut output = format!( + "URL: {}\nContent-Type: {}\nLength: {} bytes\n\n{}", + args.url, + content_type, + bytes.len(), + content + ); + + if truncated { + output.push_str("\n\n[Content truncated]"); + } + + Ok(output) + } +} + +/// Reads response body up to a size limit. +async fn read_limited_body( + response: reqwest::Response, + max_size: usize, +) -> Result, ToolError> { + let bytes = response.bytes().await?; + if bytes.len() > max_size { + return Err(ToolError::Http(format!( + "Response too large: {} bytes (max {})", + bytes.len(), + max_size + ))); + } + Ok(bytes.to_vec()) +} + +/// Categorizes reqwest errors into appropriate ToolError variants. +fn categorize_reqwest_error(e: reqwest::Error, url: &str) -> ToolError { + if e.is_timeout() { + ToolError::Timeout(format!("Request timed out for {}", url)) + } else if e.is_connect() { + ToolError::Http(format!("Connection failed for {}: {}", url, e)) + } else if e.is_redirect() { + ToolError::Http(format!("Too many redirects for {}", url)) + } else { + ToolError::Http(e.to_string()) + } +} + +/// Strips HTML tags to extract text content. +fn strip_html_tags(html: &str) -> String { + // Remove script and style elements entirely + let re_script = regex::Regex::new(r"(?is)]*>.*?").unwrap(); + let re_style = regex::Regex::new(r"(?is)]*>.*?").unwrap(); + let text = re_script.replace_all(html, " "); + let text = re_style.replace_all(&text, " "); + + // Remove all other tags, replacing with space to preserve word boundaries + let re_tags = regex::Regex::new(r"<[^>]+>").unwrap(); + let text = re_tags.replace_all(&text, " "); + + // Decode common HTML entities + let text = text + .replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'"); + + // Collapse whitespace + let re_whitespace = regex::Regex::new(r"\s+").unwrap(); + re_whitespace.replace_all(&text, " ").trim().to_string() +} + +/// Formats JSON content for readability. +fn format_json(json_str: &str) -> String { + match serde_json::from_str::(json_str) { + Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| json_str.to_string()), + Err(_) => json_str.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + async fn setup_mock_server() -> MockServer { + MockServer::start().await + } + + #[tokio::test] + async fn fetches_plain_text() { + let server = setup_mock_server().await; + Mock::given(method("GET")) + .and(path("/text")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes("Hello, world!") + .insert_header("content-type", "text/plain; charset=utf-8"), + ) + .mount(&server) + .await; + + let tool = WebFetchTool::new(); + let result = tool + .call(WebFetchArgs { + url: format!("{}/text", server.uri()), + timeout_ms: 5000, + }) + .await + .unwrap(); + + assert!(result.contains("Hello, world!")); + assert!(result.contains("Content-Type: text/plain")); + } + + #[tokio::test] + async fn fetches_and_strips_html() { + let server = setup_mock_server().await; + let html = r#"Test +

Hello

World

"#; + Mock::given(method("GET")) + .and(path("/html")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(html) + .insert_header("content-type", "text/html; charset=utf-8"), + ) + .mount(&server) + .await; + + let tool = WebFetchTool::new(); + let result = tool + .call(WebFetchArgs { + url: format!("{}/html", server.uri()), + timeout_ms: 5000, + }) + .await + .unwrap(); + + assert!(result.contains("Hello")); + assert!(result.contains("World")); + assert!(!result.contains("

")); + assert!(!result.contains("

")); + assert!(result.contains("Content-Type: text/html")); + } + + #[tokio::test] + async fn fetches_and_formats_json() { + let server = setup_mock_server().await; + Mock::given(method("GET")) + .and(path("/json")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"key":"value","number":42})), + ) + .mount(&server) + .await; + + let tool = WebFetchTool::new(); + let result = tool + .call(WebFetchArgs { + url: format!("{}/json", server.uri()), + timeout_ms: 5000, + }) + .await + .unwrap(); + + assert!(result.contains("\"key\"")); + assert!(result.contains("\"value\"")); + assert!(result.contains("\"number\"")); + assert!(result.contains("42")); + assert!(result.contains("Content-Type: application/json")); + } + + #[tokio::test] + async fn handles_http_error_status() { + let server = setup_mock_server().await; + Mock::given(method("GET")) + .and(path("/notfound")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let tool = WebFetchTool::new(); + let result = tool + .call(WebFetchArgs { + url: format!("{}/notfound", server.uri()), + timeout_ms: 5000, + }) + .await; + + assert!(matches!(result, Err(ToolError::Http(_)))); + let err = result.unwrap_err(); + assert!(err.to_string().contains("404")); + } + + #[tokio::test] + async fn handles_timeout() { + let server = setup_mock_server().await; + Mock::given(method("GET")) + .and(path("/slow")) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(5))) + .mount(&server) + .await; + + let tool = WebFetchTool::new(); + let result = tool + .call(WebFetchArgs { + url: format!("{}/slow", server.uri()), + timeout_ms: 100, // Very short timeout + }) + .await; + + assert!(matches!(result, Err(ToolError::Timeout(_)))); + } + + #[tokio::test] + async fn handles_connection_refused() { + let tool = WebFetchTool::new(); + let result = tool + .call(WebFetchArgs { + url: "http://127.0.0.1:1".to_string(), // Invalid port + timeout_ms: 1000, + }) + .await; + + assert!(matches!(result, Err(ToolError::Http(_)))); + } + + #[test] + fn strip_html_tags_removes_all_tags() { + let html = "

Title

Content

"; + let result = strip_html_tags(html); + assert_eq!(result, "Title Content"); + } + + #[test] + fn strip_html_tags_removes_scripts() { + let html = "

Before

After

"; + let result = strip_html_tags(html); + assert_eq!(result, "Before After"); + } + + #[test] + fn strip_html_tags_decodes_entities() { + let html = "

Tom & Jerry <3

"; + let result = strip_html_tags(html); + assert_eq!(result, "Tom & Jerry <3"); + } + + #[test] + fn format_json_prettifies_valid_json() { + let json = r#"{"a":1,"b":2}"#; + let result = format_json(json); + assert!(result.contains("\"a\": 1")); + assert!(result.contains("\"b\": 2")); + } + + #[test] + fn format_json_returns_original_on_invalid() { + let invalid = "not json"; + let result = format_json(invalid); + assert_eq!(result, "not json"); + } +} diff --git a/src/rig-coding-tools/src/tools/write.rs b/src/rig-coding-tools/src/tools/write.rs new file mode 100644 index 00000000..16da95a2 --- /dev/null +++ b/src/rig-coding-tools/src/tools/write.rs @@ -0,0 +1,179 @@ +//! Write tool for creating or overwriting files. + +use crate::error::ToolError; +use crate::util::validate_absolute_path; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; +use std::path::Path; + +/// Tool for writing content to files on the filesystem. +/// +/// Creates parent directories if they don't exist and overwrites +/// existing files. +#[derive(Debug, Clone, Default)] +pub struct WriteTool; + +/// Arguments for the write tool. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct WriteToolArgs { + /// Absolute path for the file to write. + pub file_path: String, + /// Content to write to the file. + pub content: String, +} + +impl WriteTool { + /// Creates a new [`WriteTool`] instance. + #[inline] + pub fn new() -> Self { + Self + } +} + +impl Tool for WriteTool { + const NAME: &'static str = "write"; + + type Error = ToolError; + type Args = WriteToolArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Write content to a file, creating parent directories if needed. \ + Overwrites existing files." + .to_string(), + parameters: serde_json::to_value(schema_for!(WriteToolArgs)) + .expect("schema generation should not fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let path = Path::new(&args.file_path); + validate_absolute_path(path)?; + + // Create parent directories if they don't exist + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + tokio::fs::create_dir_all(parent).await?; + } + } + + // Write content to file + let bytes = args.content.as_bytes(); + tokio::fs::write(path, bytes).await?; + + Ok(format!( + "Successfully wrote {} bytes to {}", + bytes.len(), + path.display() + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn write_creates_new_file() { + let temp = TempDir::new().unwrap(); + let file_path = temp.path().join("new_file.txt"); + let tool = WriteTool::new(); + + let result = tool + .call(WriteToolArgs { + file_path: file_path.to_string_lossy().to_string(), + content: "hello world".to_string(), + }) + .await + .unwrap(); + + assert!(result.contains("11 bytes")); + assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "hello world"); + } + + #[tokio::test] + async fn write_overwrites_existing_file() { + let temp = TempDir::new().unwrap(); + let file_path = temp.path().join("existing.txt"); + std::fs::write(&file_path, "old content").unwrap(); + + let tool = WriteTool::new(); + tool.call(WriteToolArgs { + file_path: file_path.to_string_lossy().to_string(), + content: "new content".to_string(), + }) + .await + .unwrap(); + + assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "new content"); + } + + #[tokio::test] + async fn write_creates_parent_directories() { + let temp = TempDir::new().unwrap(); + let file_path = temp.path().join("a/b/c/deep.txt"); + let tool = WriteTool::new(); + + tool.call(WriteToolArgs { + file_path: file_path.to_string_lossy().to_string(), + content: "nested".to_string(), + }) + .await + .unwrap(); + + assert!(file_path.exists()); + assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "nested"); + } + + #[tokio::test] + async fn write_empty_content_creates_empty_file() { + let temp = TempDir::new().unwrap(); + let file_path = temp.path().join("empty.txt"); + let tool = WriteTool::new(); + + let result = tool + .call(WriteToolArgs { + file_path: file_path.to_string_lossy().to_string(), + content: String::new(), + }) + .await + .unwrap(); + + assert!(result.contains("0 bytes")); + assert!(file_path.exists()); + assert_eq!(std::fs::read_to_string(&file_path).unwrap(), ""); + } + + #[tokio::test] + async fn write_rejects_relative_path() { + let tool = WriteTool::new(); + + let result = tool + .call(WriteToolArgs { + file_path: "relative/path.txt".to_string(), + content: "content".to_string(), + }) + .await; + + assert!(matches!(result, Err(ToolError::InvalidPath(_)))); + } + + #[tokio::test] + async fn definition_returns_valid_schema() { + let tool = WriteTool::new(); + let def = tool.definition(String::new()).await; + + assert_eq!(def.name, "write"); + assert!(def.description.contains("Write content")); + + let params = def.parameters.as_object().unwrap(); + let props = params["properties"].as_object().unwrap(); + assert!(props.contains_key("file_path")); + assert!(props.contains_key("content")); + } +} diff --git a/src/rig-coding-tools/src/util.rs b/src/rig-coding-tools/src/util.rs new file mode 100644 index 00000000..75e80f40 --- /dev/null +++ b/src/rig-coding-tools/src/util.rs @@ -0,0 +1,122 @@ +//! Shared utilities for tool implementations. + +use crate::error::{ToolError, ToolResult}; +use std::path::Path; + +/// Validates that a path is absolute. +/// +/// Returns the path as-is if valid, or an error describing the issue. +pub fn validate_absolute_path(path: &Path) -> ToolResult<&Path> { + if !path.is_absolute() { + return Err(ToolError::InvalidPath(format!( + "path must be absolute: {}", + path.display() + ))); + } + Ok(path) +} + +/// Formats a line with its line number for output. +/// +/// Uses the format: `{spaces}{line_number}\t{content}` where spaces +/// pad the line number to align with the widest number in the range. +#[inline] +pub fn format_numbered_line(line_number: usize, content: &str, max_line_number: usize) -> String { + let width = max_line_number.checked_ilog10().unwrap_or(0) as usize + 1; + format!("{:>width$}\t{}", line_number, content) +} + +/// Truncates text to a maximum byte length, appending a truncation notice. +/// +/// Returns `(truncated_text, was_truncated)`. +pub fn truncate_text(text: &str, max_bytes: usize) -> (&str, bool) { + if text.len() <= max_bytes { + return (text, false); + } + + // Find a valid UTF-8 boundary before max_bytes + let mut end = max_bytes; + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + + (&text[..end], true) +} + +/// Truncates a single line to a maximum character count. +pub fn truncate_line(line: &str, max_chars: usize) -> (&str, bool) { + if line.chars().count() <= max_chars { + return (line, false); + } + + // Find byte position at max_chars character boundary + let byte_pos = line + .char_indices() + .nth(max_chars) + .map(|(i, _)| i) + .unwrap_or(line.len()); + + (&line[..byte_pos], true) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn validate_absolute_path_accepts_absolute() { + let path = PathBuf::from("/home/user/file.txt"); + assert!(validate_absolute_path(&path).is_ok()); + } + + #[test] + fn validate_absolute_path_rejects_relative() { + let path = PathBuf::from("relative/path.txt"); + let err = validate_absolute_path(&path).unwrap_err(); + assert!(matches!(err, ToolError::InvalidPath(_))); + } + + #[test] + fn format_numbered_line_pads_correctly() { + assert_eq!(format_numbered_line(1, "hello", 9), "1\thello"); + assert_eq!(format_numbered_line(1, "hello", 10), " 1\thello"); + assert_eq!(format_numbered_line(1, "hello", 100), " 1\thello"); + } + + #[test] + fn truncate_text_preserves_short_text() { + let (text, truncated) = truncate_text("hello", 10); + assert_eq!(text, "hello"); + assert!(!truncated); + } + + #[test] + fn truncate_text_truncates_long_text() { + let (text, truncated) = truncate_text("hello world", 5); + assert_eq!(text, "hello"); + assert!(truncated); + } + + #[test] + fn truncate_text_respects_utf8_boundaries() { + // "héllo" has é which is 2 bytes + let (text, truncated) = truncate_text("héllo", 2); + assert_eq!(text, "h"); + assert!(truncated); + } + + #[test] + fn truncate_line_preserves_short_line() { + let (line, truncated) = truncate_line("hello", 10); + assert_eq!(line, "hello"); + assert!(!truncated); + } + + #[test] + fn truncate_line_truncates_by_char_count() { + let (line, truncated) = truncate_line("héllo", 3); + assert_eq!(line, "hél"); + assert!(truncated); + } +} From c1ed25f9d89b77eef753796b6b5efbfe2399a839 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 27 Dec 2025 04:35:46 +0000 Subject: [PATCH 02/64] Optimize truncate_line with byte-length fast path --- src/rig-coding-tools/src/util.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/rig-coding-tools/src/util.rs b/src/rig-coding-tools/src/util.rs index 75e80f40..356d983f 100644 --- a/src/rig-coding-tools/src/util.rs +++ b/src/rig-coding-tools/src/util.rs @@ -45,16 +45,17 @@ pub fn truncate_text(text: &str, max_bytes: usize) -> (&str, bool) { /// Truncates a single line to a maximum character count. pub fn truncate_line(line: &str, max_chars: usize) -> (&str, bool) { - if line.chars().count() <= max_chars { + // Fast path: UTF-8 guarantees byte_count >= char_count, + // so if byte length fits, no truncation needed. + if line.len() <= max_chars { return (line, false); } // Find byte position at max_chars character boundary - let byte_pos = line - .char_indices() - .nth(max_chars) - .map(|(i, _)| i) - .unwrap_or(line.len()); + let Some((byte_pos, _)) = line.char_indices().nth(max_chars) else { + // Fewer than max_chars characters exist + return (line, false); + }; (&line[..byte_pos], true) } From fcbbb646a30e1440ade7ef4bb632b0419ae46971 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 27 Dec 2025 04:52:53 +0000 Subject: [PATCH 03/64] Add const generic to ReadTool for optional line number prefixes ReadTool allows compile-time selection of output format. When false, returns raw content without L{n}: prefixes. Branch in hot loop is eliminated at compile time for zero runtime cost. --- src/rig-coding-tools/src/tools/mod.rs | 1 + src/rig-coding-tools/src/tools/read.rs | 141 ++++++++++++++++++++----- 2 files changed, 113 insertions(+), 29 deletions(-) diff --git a/src/rig-coding-tools/src/tools/mod.rs b/src/rig-coding-tools/src/tools/mod.rs index 0e153413..0d483bc4 100644 --- a/src/rig-coding-tools/src/tools/mod.rs +++ b/src/rig-coding-tools/src/tools/mod.rs @@ -16,5 +16,6 @@ pub mod write; // Re-exports pub use bash::BashTool; +pub use read::ReadTool; pub use todo::{Todo, TodoPriority, TodoReadTool, TodoState, TodoStatus, TodoWriteTool}; pub use webfetch::WebFetchTool; diff --git a/src/rig-coding-tools/src/tools/read.rs b/src/rig-coding-tools/src/tools/read.rs index d386d312..eac87131 100644 --- a/src/rig-coding-tools/src/tools/read.rs +++ b/src/rig-coding-tools/src/tools/read.rs @@ -1,4 +1,4 @@ -//! Read file tool for reading file contents with line numbers. +//! Read file tool for reading file contents with optional line numbers. use crate::error::{ToolError, ToolResult}; use crate::output::ToolOutput; @@ -36,14 +36,28 @@ pub struct ReadArgs { pub limit: usize, } -/// Tool for reading file contents with line numbers. +/// Tool for reading file contents with optional line numbers. /// -/// Reads files with configurable offset and limit, returning content -/// with line numbers prefixed in the format `L{number}: {content}`. +/// The const generic `LINE_NUMBERS` controls whether lines are prefixed +/// with `L{number}: `. When `true` (default), output includes line numbers +/// for easier editing. When `false`, raw content is returned. +/// +/// # Examples +/// +/// ``` +/// use rig_coding_tools::tools::ReadTool; +/// +/// // With line numbers (explicit type needed for inference) +/// let tool: ReadTool = ReadTool::new(); +/// // or: ReadTool::::new() +/// +/// // Without line numbers +/// let raw_tool = ReadTool::::new(); +/// ``` #[derive(Debug, Clone, Default)] -pub struct ReadTool; +pub struct ReadTool; -impl ReadTool { +impl ReadTool { /// Creates a new read tool instance. #[inline] pub fn new() -> Self { @@ -51,7 +65,7 @@ impl ReadTool { } } -impl Tool for ReadTool { +impl Tool for ReadTool { const NAME: &'static str = "read"; type Error = ToolError; @@ -59,21 +73,33 @@ impl Tool for ReadTool { type Output = ToolOutput; async fn definition(&self, _prompt: String) -> ToolDefinition { + let description = if LINE_NUMBERS { + "Read file contents with line numbers. Returns lines prefixed with L{number}: format." + } else { + "Read file contents. Returns raw file content without line number prefixes." + }; ToolDefinition { name: Self::NAME.to_string(), - description: "Read file contents with line numbers. Returns lines prefixed with L{number}: format.".to_string(), + description: description.to_string(), parameters: serde_json::to_value(schema_for!(ReadArgs)) .expect("schema serialization should never fail"), } } async fn call(&self, args: Self::Args) -> Result { - read_file(&args.file_path, args.offset, args.limit).await + read_file::(&args.file_path, args.offset, args.limit).await } } -/// Reads a file and returns formatted content with line numbers. -async fn read_file(file_path: &str, offset: usize, limit: usize) -> ToolResult { +/// Reads a file and returns formatted content, optionally with line numbers. +/// +/// When `LINE_NUMBERS` is `true`, each line is prefixed with `L{number}: `. +/// When `false`, raw content is returned without prefixes. +async fn read_file( + file_path: &str, + offset: usize, + limit: usize, +) -> ToolResult { // Validate arguments if offset == 0 { return Err(ToolError::OutOfBounds( @@ -90,7 +116,7 @@ async fn read_file(file_path: &str, offset: usize, limit: usize) -> ToolResult ToolResult ToolResult { + async fn read_temp_file( + content: &[u8], + offset: usize, + limit: usize, + ) -> ToolResult { let mut temp = NamedTempFile::new().unwrap(); temp.write_all(content).unwrap(); - read_file(temp.path().to_str().unwrap(), offset, limit).await + read_file::(temp.path().to_str().unwrap(), offset, limit).await } #[tokio::test] async fn reads_basic_file() { - let result = read_temp_file(b"hello\nworld\n", 1, 2000).await.unwrap(); + let result = read_temp_file::(b"hello\nworld\n", 1, 2000) + .await + .unwrap(); assert_eq!(result.content, "L1: hello\nL2: world"); } + #[tokio::test] + async fn reads_basic_file_no_line_numbers() { + let result = read_temp_file::(b"hello\nworld\n", 1, 2000) + .await + .unwrap(); + assert_eq!(result.content, "hello\nworld"); + } + #[tokio::test] async fn reads_with_offset() { - let result = read_temp_file(b"one\ntwo\nthree\n", 2, 2000).await.unwrap(); + let result = read_temp_file::(b"one\ntwo\nthree\n", 2, 2000) + .await + .unwrap(); assert_eq!(result.content, "L2: two\nL3: three"); } + #[tokio::test] + async fn reads_with_offset_no_line_numbers() { + let result = read_temp_file::(b"one\ntwo\nthree\n", 2, 2000) + .await + .unwrap(); + assert_eq!(result.content, "two\nthree"); + } + #[tokio::test] async fn reads_with_limit() { - let result = read_temp_file(b"one\ntwo\nthree\n", 1, 2).await.unwrap(); + let result = read_temp_file::(b"one\ntwo\nthree\n", 1, 2) + .await + .unwrap(); assert_eq!(result.content, "L1: one\nL2: two"); } #[tokio::test] async fn reads_with_offset_and_limit() { - let result = read_temp_file(b"one\ntwo\nthree\nfour\n", 2, 2) + let result = read_temp_file::(b"one\ntwo\nthree\nfour\n", 2, 2) .await .unwrap(); assert_eq!(result.content, "L2: two\nL3: three"); @@ -181,7 +238,7 @@ mod tests { #[tokio::test] async fn handles_crlf_line_endings() { - let result = read_temp_file(b"line1\r\nline2\r\n", 1, 2000) + let result = read_temp_file::(b"line1\r\nline2\r\n", 1, 2000) .await .unwrap(); assert_eq!(result.content, "L1: line1\nL2: line2"); @@ -189,7 +246,9 @@ mod tests { #[tokio::test] async fn handles_non_utf8_content() { - let result = read_temp_file(b"\xff\xfe\nplain\n", 1, 2000).await.unwrap(); + let result = read_temp_file::(b"\xff\xfe\nplain\n", 1, 2000) + .await + .unwrap(); assert!(result.content.contains("L1:")); assert!(result.content.contains('\u{FFFD}')); // replacement char assert!(result.content.contains("L2: plain")); @@ -199,38 +258,54 @@ mod tests { async fn truncates_long_lines() { let long_line = "x".repeat(MAX_LINE_LENGTH + 100); let content = format!("{}\n", long_line); - let result = read_temp_file(content.as_bytes(), 1, 1).await.unwrap(); + let result = read_temp_file::(content.as_bytes(), 1, 1) + .await + .unwrap(); let expected = format!("L1: {}", "x".repeat(MAX_LINE_LENGTH)); assert_eq!(result.content, expected); } + #[tokio::test] + async fn truncates_long_lines_no_line_numbers() { + let long_line = "x".repeat(MAX_LINE_LENGTH + 100); + let content = format!("{}\n", long_line); + let result = read_temp_file::(content.as_bytes(), 1, 1) + .await + .unwrap(); + assert_eq!(result.content, "x".repeat(MAX_LINE_LENGTH)); + } + #[tokio::test] async fn errors_on_offset_zero() { - let err = read_temp_file(b"test\n", 0, 10).await.unwrap_err(); + let err = read_temp_file::(b"test\n", 0, 10).await.unwrap_err(); assert!(matches!(err, ToolError::OutOfBounds(_))); } #[tokio::test] async fn errors_on_limit_zero() { - let err = read_temp_file(b"test\n", 1, 0).await.unwrap_err(); + let err = read_temp_file::(b"test\n", 1, 0).await.unwrap_err(); assert!(matches!(err, ToolError::OutOfBounds(_))); } #[tokio::test] async fn errors_on_offset_exceeds_file() { - let err = read_temp_file(b"one\ntwo\n", 10, 100).await.unwrap_err(); + let err = read_temp_file::(b"one\ntwo\n", 10, 100) + .await + .unwrap_err(); assert!(matches!(err, ToolError::OutOfBounds(_))); } #[tokio::test] async fn errors_on_relative_path() { - let err = read_file("relative/path.txt", 1, 100).await.unwrap_err(); + let err = read_file::("relative/path.txt", 1, 100) + .await + .unwrap_err(); assert!(matches!(err, ToolError::InvalidPath(_))); } #[tokio::test] async fn errors_on_nonexistent_file() { - let err = read_file("/nonexistent/file.txt", 1, 100) + let err = read_file::("/nonexistent/file.txt", 1, 100) .await .unwrap_err(); assert!(matches!(err, ToolError::Io(_))); @@ -238,16 +313,24 @@ mod tests { #[tokio::test] async fn handles_empty_file() { - let result = read_temp_file(b"", 1, 100).await; + let result = read_temp_file::(b"", 1, 100).await; // Empty file with offset 1 should error assert!(matches!(result, Err(ToolError::OutOfBounds(_)))); } #[tokio::test] async fn handles_file_without_trailing_newline() { - let result = read_temp_file(b"no trailing newline", 1, 100) + let result = read_temp_file::(b"no trailing newline", 1, 100) .await .unwrap(); assert_eq!(result.content, "L1: no trailing newline"); } + + #[tokio::test] + async fn handles_file_without_trailing_newline_no_line_numbers() { + let result = read_temp_file::(b"no trailing newline", 1, 100) + .await + .unwrap(); + assert_eq!(result.content, "no trailing newline"); + } } From e717dcad5562e271f92296fdd09a4158e77570d6 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 27 Dec 2025 18:06:46 +0000 Subject: [PATCH 04/64] Improve: Optimize the read tool perf a little. --- src/rig-coding-tools/src/tools/read.rs | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/rig-coding-tools/src/tools/read.rs b/src/rig-coding-tools/src/tools/read.rs index eac87131..e8f2959c 100644 --- a/src/rig-coding-tools/src/tools/read.rs +++ b/src/rig-coding-tools/src/tools/read.rs @@ -7,6 +7,7 @@ use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; use serde::Deserialize; +use std::fmt::Write; use std::path::Path; use tokio::fs::File; use tokio::io::{AsyncBufReadExt, BufReader}; @@ -116,8 +117,12 @@ async fn read_file( let file = File::open(path).await?; let mut reader = BufReader::new(file); let mut buffer = Vec::new(); - let mut collected = Vec::with_capacity(limit.min(256)); + + // Pre-allocate output: estimate ~100 chars/line, capped to avoid over-allocation + let estimated_capacity = limit.min(256) * 100; + let mut output = String::with_capacity(estimated_capacity); let mut line_number = 0usize; + let mut lines_output = 0usize; loop { buffer.clear(); @@ -142,8 +147,8 @@ async fn read_file( continue; } - // Stop if we've collected enough lines - if collected.len() >= limit { + // Stop if we've output enough lines + if lines_output >= limit { break; } @@ -153,12 +158,20 @@ async fn read_file( // Truncate long lines let (truncated_content, _) = truncate_line(&content, MAX_LINE_LENGTH); + // Add newline separator for subsequent lines + if lines_output > 0 { + output.push('\n'); + } + // Branch eliminated at compile time due to const generic if LINE_NUMBERS { - collected.push(format!("L{}: {}", line_number, truncated_content)); + // write! to String is infallible + let _ = write!(&mut output, "L{}: {}", line_number, truncated_content); } else { - collected.push(truncated_content.to_owned()); + output.push_str(truncated_content); } + + lines_output += 1; } // Check if offset exceeded file length @@ -169,7 +182,7 @@ async fn read_file( ))); } - Ok(ToolOutput::new(collected.join("\n"))) + Ok(ToolOutput::new(output)) } #[cfg(test)] From 88badba03694d63a459b6fa4dfac0ae4c424604b Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 27 Dec 2025 18:09:05 +0000 Subject: [PATCH 05/64] Refactor: Move glob::Pattern import to module level --- src/rig-coding-tools/src/tools/glob.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rig-coding-tools/src/tools/glob.rs b/src/rig-coding-tools/src/tools/glob.rs index d18af542..e94e2abf 100644 --- a/src/rig-coding-tools/src/tools/glob.rs +++ b/src/rig-coding-tools/src/tools/glob.rs @@ -4,6 +4,7 @@ use crate::error::{ToolError, ToolResult}; use crate::util::validate_absolute_path; +use glob::Pattern; use ignore::WalkBuilder; use rig::completion::ToolDefinition; use rig::tool::Tool; @@ -78,7 +79,7 @@ fn glob_files(pattern: &str, search_path: &str) -> ToolResult { // Compile the glob pattern for matching let compiled_pattern = - ::glob::Pattern::new(pattern).map_err(|e| ToolError::InvalidPattern(e.to_string()))?; + Pattern::new(pattern).map_err(|e| ToolError::InvalidPattern(e.to_string()))?; // Collect files with modification times let mut files_with_mtime: Vec<(String, SystemTime)> = Vec::new(); From 61194811237ebadc63efb4a7498b50f4d92b2f73 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 27 Dec 2025 18:37:15 +0000 Subject: [PATCH 06/64] Refactor: Replace ripgrep binary with grep crate library Use the grep crate as a library instead of spawning the rg binary, eliminating the external dependency requirement. This also: - Uses schemars::schema_for! for consistent schema generation - Reuses Searcher instance across files for better performance - Enables binary detection to skip binary files - Removes rg_available() guards from tests --- src/Cargo.lock | 108 ++++++++ src/rig-coding-tools/Cargo.toml | 1 + src/rig-coding-tools/src/tools/grep.rs | 329 ++++++++++++++----------- 3 files changed, 295 insertions(+), 143 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index 365a43c0..73896e19 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -97,6 +97,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -219,6 +220,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -414,6 +424,85 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "grep" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "309217bc53e2c691c314389c7fa91f9cd1a998cda19e25544ea47d94103880c3" +dependencies = [ + "grep-cli", + "grep-matcher", + "grep-printer", + "grep-regex", + "grep-searcher", +] + +[[package]] +name = "grep-cli" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf32d263c5d5cc2a23ce587097f5ddafdb188492ba2e6fb638eaccdc22453631" +dependencies = [ + "bstr", + "globset", + "libc", + "log", + "termcolor", + "winapi-util", +] + +[[package]] +name = "grep-matcher" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d7b71093325ab22d780b40d7df3066ae4aebb518ba719d38c697a8228a8023" +dependencies = [ + "memchr", +] + +[[package]] +name = "grep-printer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd76035e87871f51c1ee5b793e32122b3ccf9c692662d9622ef1686ff5321acb" +dependencies = [ + "bstr", + "grep-matcher", + "grep-searcher", + "log", + "serde", + "serde_json", + "termcolor", +] + +[[package]] +name = "grep-regex" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef" +dependencies = [ + "bstr", + "grep-matcher", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "grep-searcher" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac63295322dc48ebb20a25348147905d816318888e64f531bfc2a2bc0577dc34" +dependencies = [ + "bstr", + "encoding_rs", + "encoding_rs_io", + "grep-matcher", + "log", + "memchr", + "memmap2", +] + [[package]] name = "h2" version = "0.4.12" @@ -767,6 +856,15 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + [[package]] name = "mime" version = "0.3.17" @@ -1141,6 +1239,7 @@ dependencies = [ "anyhow", "async-trait", "glob", + "grep", "ignore", "regex", "reqwest", @@ -1486,6 +1585,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "2.0.17" diff --git a/src/rig-coding-tools/Cargo.toml b/src/rig-coding-tools/Cargo.toml index 82c21b8f..9af2d832 100644 --- a/src/rig-coding-tools/Cargo.toml +++ b/src/rig-coding-tools/Cargo.toml @@ -19,6 +19,7 @@ tokio = { version = "1", features = ["fs", "process", "time", "io-util"] } glob = "0.3" ignore = "0.4" regex = "1.11" +grep = "0.4" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } [dev-dependencies] diff --git a/src/rig-coding-tools/src/tools/grep.rs b/src/rig-coding-tools/src/tools/grep.rs index 9d29ac4d..ecbb6e6d 100644 --- a/src/rig-coding-tools/src/tools/grep.rs +++ b/src/rig-coding-tools/src/tools/grep.rs @@ -2,18 +2,21 @@ use crate::error::{ToolError, ToolResult}; use crate::util::validate_absolute_path; +use glob::Pattern; +use grep::matcher::Matcher; +use grep::regex::RegexMatcher; +use grep::searcher::sinks::UTF8; +use grep::searcher::{BinaryDetection, Searcher, SearcherBuilder}; +use ignore::WalkBuilder; use rig::completion::ToolDefinition; use rig::tool::Tool; -use schemars::JsonSchema; +use schemars::{schema_for, JsonSchema}; use serde::{Deserialize, Serialize}; use std::path::Path; -use std::time::Duration; -use tokio::process::Command; -use tokio::time::timeout; +use std::time::SystemTime; const DEFAULT_LIMIT: usize = 100; const MAX_LIMIT: usize = 2000; -const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); fn default_limit() -> Option { Some(DEFAULT_LIMIT) @@ -60,29 +63,11 @@ impl Tool for GrepTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { name: Self::NAME.to_string(), - description: "Search file contents using regex patterns. Returns file paths containing matches, sorted by modification time.".to_string(), - parameters: serde_json::json!({ - "type": "object", - "required": ["pattern", "path"], - "properties": { - "pattern": { - "type": "string", - "description": "Regex pattern to search for in file contents" - }, - "path": { - "type": "string", - "description": "Absolute directory path to search in" - }, - "include": { - "type": "string", - "description": "File glob filter (e.g., \"*.rs\", \"*.{ts,tsx}\")" - }, - "limit": { - "type": "integer", - "description": "Maximum files to return (default: 100, max: 2000)" - } - } - }), + description: "Search file contents using regex patterns. Returns file paths \ + containing matches, sorted by modification time." + .to_string(), + parameters: serde_json::to_value(schema_for!(GrepArgs)) + .expect("schema serialization should not fail"), } } @@ -97,9 +82,6 @@ impl Tool for GrepTool { )); } - // Validate regex compiles - regex::Regex::new(pattern)?; - let limit = args.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); if limit == 0 { return Err(ToolError::InvalidPattern( @@ -116,7 +98,7 @@ impl Tool for GrepTool { } }); - let result = run_rg_search(pattern, include, path, limit).await?; + let result = run_grep_search(pattern, include, path, limit)?; if result.files.is_empty() { Ok("No matches found.".to_string()) @@ -130,111 +112,141 @@ impl Tool for GrepTool { } } -/// Execute ripgrep to find files matching the pattern. -async fn run_rg_search( +/// Execute grep search using the grep crate library. +fn run_grep_search( pattern: &str, include: Option<&str>, search_path: &Path, limit: usize, ) -> ToolResult { - let mut command = Command::new("rg"); - command - .arg("--files-with-matches") - .arg("--sortr=modified") - .arg("--regexp") - .arg(pattern) - .arg("--no-messages"); - - if let Some(glob) = include { - command.arg("--glob").arg(glob); - } - - command.arg("--").arg(search_path); - - let output = timeout(COMMAND_TIMEOUT, command.output()) - .await - .map_err(|_| ToolError::Timeout("rg timed out after 30 seconds".into()))? - .map_err(|e| { - ToolError::Execution(format!( - "failed to launch rg: {e}. Ensure ripgrep is installed and on PATH." - )) - })?; - - match output.status.code() { - Some(0) => { - let (files, truncated) = parse_results(&output.stdout, limit); - Ok(GrepOutput { files, truncated }) - } - Some(1) => Ok(GrepOutput { - files: Vec::new(), - truncated: false, - }), - _ => { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(ToolError::Execution(format!("rg failed: {stderr}"))) - } - } -} + // Compile the regex matcher for content searching + let matcher = + RegexMatcher::new(pattern).map_err(|e| ToolError::InvalidPattern(e.to_string()))?; + + // Compile glob pattern if provided + let glob_pattern = include + .map(|g| Pattern::new(g).map_err(|e| ToolError::InvalidPattern(e.to_string()))) + .transpose()?; + + // Build searcher once, reuse for all files (as recommended by grep-searcher docs) + let mut searcher = SearcherBuilder::new() + .binary_detection(BinaryDetection::quit(0)) + .build(); + + // Collect files with modification times + let mut files_with_mtime: Vec<(String, SystemTime)> = Vec::new(); + + let walker = WalkBuilder::new(search_path) + .hidden(false) + .git_ignore(true) + .git_global(true) + .git_exclude(true) + .build(); + + for entry_result in walker { + let entry = match entry_result { + Ok(e) => e, + Err(_) => continue, + }; -/// Parse ripgrep output into file paths, respecting the limit. -fn parse_results(stdout: &[u8], limit: usize) -> (Vec, bool) { - let mut results = Vec::new(); - let mut truncated = false; + // Skip directories + let file_type = match entry.file_type() { + Some(ft) if ft.is_file() => ft, + _ => continue, + }; - for line in stdout.split(|&b| b == b'\n') { - if line.is_empty() { + // Skip symlinks + if file_type.is_symlink() { continue; } - if let Ok(text) = std::str::from_utf8(line) { - if text.is_empty() { + + let entry_path = entry.path(); + + // Apply glob filter if provided + if let Some(ref glob) = glob_pattern { + let file_name = match entry_path.file_name().and_then(|n| n.to_str()) { + Some(name) => name, + None => continue, + }; + if !glob.matches(file_name) { continue; } - if results.len() >= limit { - truncated = true; - break; - } - results.push(text.to_string()); } + + // Check if file contains a match + if !file_has_match(&matcher, &mut searcher, entry_path) { + continue; + } + + // Get modification time + let mtime = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .unwrap_or(SystemTime::UNIX_EPOCH); + + let path_str = entry_path.to_string_lossy().into_owned(); + files_with_mtime.push((path_str, mtime)); } - (results, truncated) + // Sort by modification time (newest first) + files_with_mtime.sort_by(|a, b| b.1.cmp(&a.1)); + + // Check if truncation is needed + let truncated = files_with_mtime.len() > limit; + + // Extract paths, truncating if needed + let files: Vec = files_with_mtime + .into_iter() + .take(limit) + .map(|(path, _)| path) + .collect(); + + Ok(GrepOutput { files, truncated }) +} + +/// Check if a file contains at least one match for the pattern. +fn file_has_match(matcher: &RegexMatcher, searcher: &mut Searcher, path: &Path) -> bool { + let mut found = false; + + // Use grep searcher to check for matches + let result = searcher.search_path( + matcher, + path, + UTF8(|_line_num, line| { + // Check if this line actually contains a match + if matcher.find(line.as_bytes()).ok().flatten().is_some() { + found = true; + // Return false to stop searching after first match + Ok(false) + } else { + Ok(true) + } + }), + ); + + // If search succeeded and we found a match, return true + // If search failed (e.g., binary file), return false + result.is_ok() && found } #[cfg(test)] mod tests { use super::*; - use std::process::Command as StdCommand; use tempfile::tempdir; - fn rg_available() -> bool { - StdCommand::new("rg") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - } - #[test] - fn parse_results_handles_basic_output() { - let stdout = b"/tmp/a.rs\n/tmp/b.rs\n"; - let (files, truncated) = parse_results(stdout, 10); - assert_eq!(files, vec!["/tmp/a.rs", "/tmp/b.rs"]); - assert!(!truncated); + fn grep_validates_empty_pattern() { + let result = run_grep_search("", None, Path::new("/tmp"), 10); + // Empty pattern after trim should be caught before this function + // but RegexMatcher will accept empty pattern, so this tests the flow + assert!(result.is_ok() || matches!(result, Err(ToolError::InvalidPattern(_)))); } #[test] - fn parse_results_truncates_at_limit() { - let stdout = b"/tmp/a.rs\n/tmp/b.rs\n/tmp/c.rs\n"; - let (files, truncated) = parse_results(stdout, 2); - assert_eq!(files.len(), 2); - assert!(truncated); - } - - #[test] - fn parse_results_handles_empty_lines() { - let stdout = b"/tmp/a.rs\n\n/tmp/b.rs\n"; - let (files, _) = parse_results(stdout, 10); - assert_eq!(files, vec!["/tmp/a.rs", "/tmp/b.rs"]); + fn grep_validates_invalid_regex() { + let result = run_grep_search("[invalid", None, Path::new("/tmp"), 10); + assert!(matches!(result, Err(ToolError::InvalidPattern(_)))); } #[tokio::test] @@ -273,81 +285,112 @@ mod tests { limit: None, }; let result = tool.call(args).await; - assert!(matches!(result, Err(ToolError::Regex(_)))); + assert!(matches!(result, Err(ToolError::InvalidPattern(_)))); } - #[tokio::test] - async fn run_rg_search_finds_matches() { - if !rg_available() { - return; - } + #[test] + fn run_grep_search_finds_matches() { let temp = tempdir().unwrap(); let dir = temp.path(); std::fs::write(dir.join("match.txt"), "hello world").unwrap(); std::fs::write(dir.join("other.txt"), "goodbye").unwrap(); - let result = run_rg_search("hello", None, dir, 10).await.unwrap(); + let result = run_grep_search("hello", None, dir, 10).unwrap(); assert_eq!(result.files.len(), 1); assert!(result.files[0].ends_with("match.txt")); } - #[tokio::test] - async fn run_rg_search_respects_glob_filter() { - if !rg_available() { - return; - } + #[test] + fn run_grep_search_respects_glob_filter() { let temp = tempdir().unwrap(); let dir = temp.path(); std::fs::write(dir.join("match.rs"), "hello world").unwrap(); std::fs::write(dir.join("match.txt"), "hello world").unwrap(); - let result = run_rg_search("hello", Some("*.rs"), dir, 10).await.unwrap(); + let result = run_grep_search("hello", Some("*.rs"), dir, 10).unwrap(); assert_eq!(result.files.len(), 1); assert!(result.files[0].ends_with(".rs")); } - #[tokio::test] - async fn run_rg_search_respects_limit() { - if !rg_available() { - return; - } + #[test] + fn run_grep_search_respects_limit() { let temp = tempdir().unwrap(); let dir = temp.path(); std::fs::write(dir.join("a.txt"), "pattern").unwrap(); std::fs::write(dir.join("b.txt"), "pattern").unwrap(); std::fs::write(dir.join("c.txt"), "pattern").unwrap(); - let result = run_rg_search("pattern", None, dir, 2).await.unwrap(); + let result = run_grep_search("pattern", None, dir, 2).unwrap(); assert_eq!(result.files.len(), 2); assert!(result.truncated); } - #[tokio::test] - async fn run_rg_search_returns_empty_on_no_match() { - if !rg_available() { - return; - } + #[test] + fn run_grep_search_returns_empty_on_no_match() { let temp = tempdir().unwrap(); let dir = temp.path(); std::fs::write(dir.join("file.txt"), "content").unwrap(); - let result = run_rg_search("nonexistent", None, dir, 10).await.unwrap(); + let result = run_grep_search("nonexistent", None, dir, 10).unwrap(); assert!(result.files.is_empty()); assert!(!result.truncated); } - #[tokio::test] - async fn run_rg_search_supports_regex() { - if !rg_available() { - return; - } + #[test] + fn run_grep_search_supports_regex() { let temp = tempdir().unwrap(); let dir = temp.path(); std::fs::write(dir.join("match.txt"), "foo123bar").unwrap(); std::fs::write(dir.join("nomatch.txt"), "foobar").unwrap(); - let result = run_rg_search(r"foo\d+bar", None, dir, 10).await.unwrap(); + let result = run_grep_search(r"foo\d+bar", None, dir, 10).unwrap(); assert_eq!(result.files.len(), 1); assert!(result.files[0].ends_with("match.txt")); } + + #[test] + fn run_grep_search_skips_binary_files() { + let temp = tempdir().unwrap(); + let dir = temp.path(); + + // Create a text file with a match + std::fs::write(dir.join("text.txt"), "hello world").unwrap(); + + // Create a binary file with null bytes before the match text + // Binary detection triggers when null bytes are encountered + let mut binary_content = vec![0u8; 10]; // Null bytes first + binary_content.extend_from_slice(b"hello world"); + std::fs::write(dir.join("binary.bin"), &binary_content).unwrap(); + + let result = run_grep_search("hello", None, dir, 10).unwrap(); + // Should only find the text file, not the binary + assert_eq!(result.files.len(), 1); + assert!(result.files[0].ends_with("text.txt")); + } + + #[test] + fn file_has_match_returns_true_for_matching_file() { + let temp = tempdir().unwrap(); + let file_path = temp.path().join("test.txt"); + std::fs::write(&file_path, "hello world").unwrap(); + + let matcher = RegexMatcher::new("hello").unwrap(); + let mut searcher = SearcherBuilder::new() + .binary_detection(BinaryDetection::quit(0)) + .build(); + assert!(file_has_match(&matcher, &mut searcher, &file_path)); + } + + #[test] + fn file_has_match_returns_false_for_non_matching_file() { + let temp = tempdir().unwrap(); + let file_path = temp.path().join("test.txt"); + std::fs::write(&file_path, "goodbye world").unwrap(); + + let matcher = RegexMatcher::new("hello").unwrap(); + let mut searcher = SearcherBuilder::new() + .binary_detection(BinaryDetection::quit(0)) + .build(); + assert!(!file_has_match(&matcher, &mut searcher, &file_path)); + } } From 02bcbea338e87d4071040a62430a9d79e91a2620 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 27 Dec 2025 19:47:50 +0000 Subject: [PATCH 07/64] Enhance GrepTool with line numbers, content, and performance optimizations - Return line numbers and matched content instead of just file paths - Add LINE_NUMBERS const generic for optional L{n}: prefix (like ReadTool) - Use hierarchical GrepFileMatches/GrepLineMatch structure for efficient grouping - Optimize with single Vec: collect, sort, truncate in place - Pre-allocate output string (~8KiB) and files vec (~4KiB) - Remove redundant matcher.find() check (searcher already filters) - Change limit semantics from files to matches - Export new types: GrepArgs, GrepFileMatches, GrepLineMatch, GrepOutput --- src/rig-coding-tools/src/lib.rs | 2 +- src/rig-coding-tools/src/tools/grep.rs | 288 +++++++++++++++++++------ src/rig-coding-tools/src/tools/mod.rs | 1 + 3 files changed, 224 insertions(+), 67 deletions(-) diff --git a/src/rig-coding-tools/src/lib.rs b/src/rig-coding-tools/src/lib.rs index 1d682924..37bea69d 100644 --- a/src/rig-coding-tools/src/lib.rs +++ b/src/rig-coding-tools/src/lib.rs @@ -11,7 +11,7 @@ pub use error::{ToolError, ToolResult}; pub use output::ToolOutput; pub use tools::bash::BashTool; pub use tools::edit::{EditArgs, EditError, EditTool}; -pub use tools::grep::GrepTool; +pub use tools::grep::{GrepArgs, GrepFileMatches, GrepLineMatch, GrepOutput, GrepTool}; pub use tools::read::{ReadArgs, ReadTool}; pub use tools::task::{MockTaskExecutor, TaskArgs, TaskExecutor, TaskResult, TaskTool}; pub use tools::todo::{Todo, TodoPriority, TodoReadTool, TodoState, TodoStatus, TodoWriteTool}; diff --git a/src/rig-coding-tools/src/tools/grep.rs b/src/rig-coding-tools/src/tools/grep.rs index ecbb6e6d..3b24f90a 100644 --- a/src/rig-coding-tools/src/tools/grep.rs +++ b/src/rig-coding-tools/src/tools/grep.rs @@ -1,9 +1,9 @@ //! Grep tool for searching file contents using regex patterns. use crate::error::{ToolError, ToolResult}; -use crate::util::validate_absolute_path; +use crate::output::ToolOutput; +use crate::util::{truncate_line, validate_absolute_path}; use glob::Pattern; -use grep::matcher::Matcher; use grep::regex::RegexMatcher; use grep::searcher::sinks::UTF8; use grep::searcher::{BinaryDetection, Searcher, SearcherBuilder}; @@ -12,11 +12,13 @@ use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; use serde::{Deserialize, Serialize}; +use std::fmt::Write; use std::path::Path; use std::time::SystemTime; const DEFAULT_LIMIT: usize = 100; const MAX_LIMIT: usize = 2000; +const MAX_LINE_LENGTH: usize = 2000; fn default_limit() -> Option { Some(DEFAULT_LIMIT) @@ -32,16 +34,39 @@ pub struct GrepArgs { /// Optional file glob filter (e.g., "*.rs", "*.{ts,tsx}"). #[serde(default)] pub include: Option, - /// Maximum number of files to return. + /// Maximum number of matches to return. #[serde(default = "default_limit")] pub limit: Option, } +/// A single line match within a file. +#[derive(Debug, Clone, Serialize)] +pub struct GrepLineMatch { + /// 1-indexed line number. + pub line_num: u64, + /// Content of the matched line. + pub line_text: String, +} + +/// All matches within a single file. +#[derive(Debug, Clone, Serialize)] +pub struct GrepFileMatches { + /// File path. + pub path: String, + /// Matches in this file, in line order. + pub matches: Vec, + /// Modification time (used for sorting, not serialized). + #[serde(skip)] + mtime: SystemTime, +} + /// Output from the grep tool. #[derive(Debug, Serialize)] pub struct GrepOutput { - /// List of file paths containing matches. - pub files: Vec, + /// Files with matches, sorted by modification time (newest first). + pub files: Vec, + /// Total match count across all files. + pub match_count: usize, /// Whether results were truncated due to limit. pub truncated: bool, } @@ -51,21 +76,52 @@ pub struct GrepOutput { /// Finds files containing content matching a regex pattern within a directory. /// Results are sorted by modification time (most recent first). /// Binary files are automatically skipped. -pub struct GrepTool; +/// +/// The const generic `LINE_NUMBERS` controls whether lines are prefixed +/// with `L{number}: `. When `true` (default), output includes line numbers +/// for easier navigation. When `false`, only file paths and content are shown. +/// +/// # Examples +/// +/// ``` +/// use rig_coding_tools::GrepTool; +/// +/// // With line numbers (default) +/// let tool: GrepTool = GrepTool::new(); +/// // or: GrepTool::::new() +/// +/// // Without line numbers +/// let raw_tool = GrepTool::::new(); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct GrepTool; + +impl GrepTool { + /// Creates a new grep tool instance. + #[inline] + pub fn new() -> Self { + Self + } +} -impl Tool for GrepTool { +impl Tool for GrepTool { const NAME: &'static str = "grep"; type Error = ToolError; type Args = GrepArgs; - type Output = String; + type Output = ToolOutput; async fn definition(&self, _prompt: String) -> ToolDefinition { + let description = if LINE_NUMBERS { + "Search file contents using regex patterns. Returns matches with file paths, \ + line numbers, and content, sorted by file modification time." + } else { + "Search file contents using regex patterns. Returns matches with file paths \ + and content, sorted by file modification time." + }; ToolDefinition { name: Self::NAME.to_string(), - description: "Search file contents using regex patterns. Returns file paths \ - containing matches, sorted by modification time." - .to_string(), + description: description.to_string(), parameters: serde_json::to_value(schema_for!(GrepArgs)) .expect("schema serialization should not fail"), } @@ -101,14 +157,35 @@ impl Tool for GrepTool { let result = run_grep_search(pattern, include, path, limit)?; if result.files.is_empty() { - Ok("No matches found.".to_string()) - } else { - let mut output = result.files.join("\n"); - if result.truncated { - output.push_str(&format!("\n\n(Results truncated at {} files)", limit)); + return Ok(ToolOutput::new("No matches found.")); + } + + // Format output grouped by file (51 lines at up to 80 characters) + let mut output = String::with_capacity(4096); + let _ = writeln!(&mut output, "Found {} matches", result.match_count); + + for file in &result.files { + let _ = writeln!(&mut output, "\n{}:", file.path); + for m in &file.matches { + let (truncated_text, _) = truncate_line(&m.line_text, MAX_LINE_LENGTH); + // Branch eliminated at compile time due to const generic + if LINE_NUMBERS { + let _ = writeln!(&mut output, " L{}: {}", m.line_num, truncated_text); + } else { + let _ = writeln!(&mut output, " {}", truncated_text); + } } - Ok(output) } + + if result.truncated { + let _ = write!(&mut output, "\n(Results truncated at {} matches)", limit); + } + + Ok(if result.truncated { + ToolOutput::truncated(output) + } else { + ToolOutput::new(output) + }) } } @@ -133,8 +210,8 @@ fn run_grep_search( .binary_detection(BinaryDetection::quit(0)) .build(); - // Collect files with modification times - let mut files_with_mtime: Vec<(String, SystemTime)> = Vec::new(); + // Collect files directly into final structure (pre-allocate ~4KiB) + let mut files = Vec::with_capacity(4096 / size_of::()); let walker = WalkBuilder::new(search_path) .hidden(false) @@ -173,8 +250,9 @@ fn run_grep_search( } } - // Check if file contains a match - if !file_has_match(&matcher, &mut searcher, entry_path) { + // Collect all matches from file + let matches = collect_file_matches(&matcher, &mut searcher, entry_path); + if matches.is_empty() { continue; } @@ -185,49 +263,64 @@ fn run_grep_search( .and_then(|m| m.modified().ok()) .unwrap_or(SystemTime::UNIX_EPOCH); - let path_str = entry_path.to_string_lossy().into_owned(); - files_with_mtime.push((path_str, mtime)); + files.push(GrepFileMatches { + path: entry_path.to_string_lossy().into_owned(), + matches, + mtime, + }); } // Sort by modification time (newest first) - files_with_mtime.sort_by(|a, b| b.1.cmp(&a.1)); - - // Check if truncation is needed - let truncated = files_with_mtime.len() > limit; + files.sort_by(|a, b| b.mtime.cmp(&a.mtime)); + + // Apply limit by truncating matches in place + let mut match_count = 0; + let mut truncate_at = files.len(); + let mut truncated = false; + + for (x, file) in files.iter_mut().enumerate() { + let remaining = limit - match_count; + if file.matches.len() > remaining { + file.matches.truncate(remaining); + match_count += remaining; + truncate_at = x + 1; + truncated = true; + break; + } + match_count += file.matches.len(); + } - // Extract paths, truncating if needed - let files: Vec = files_with_mtime - .into_iter() - .take(limit) - .map(|(path, _)| path) - .collect(); + files.truncate(truncate_at); - Ok(GrepOutput { files, truncated }) + Ok(GrepOutput { + files, + match_count, + truncated, + }) } -/// Check if a file contains at least one match for the pattern. -fn file_has_match(matcher: &RegexMatcher, searcher: &mut Searcher, path: &Path) -> bool { - let mut found = false; +/// Collect all matches from a file with line numbers and content. +fn collect_file_matches( + matcher: &RegexMatcher, + searcher: &mut Searcher, + path: &Path, +) -> Vec { + let mut matches = Vec::new(); - // Use grep searcher to check for matches - let result = searcher.search_path( + // Searcher only invokes sink for lines matching the pattern + let _ = searcher.search_path( matcher, path, - UTF8(|_line_num, line| { - // Check if this line actually contains a match - if matcher.find(line.as_bytes()).ok().flatten().is_some() { - found = true; - // Return false to stop searching after first match - Ok(false) - } else { - Ok(true) - } + UTF8(|line_num, line| { + matches.push(GrepLineMatch { + line_num, + line_text: line.trim_end().to_string(), + }); + Ok(true) }), ); - // If search succeeded and we found a match, return true - // If search failed (e.g., binary file), return false - result.is_ok() && found + matches } #[cfg(test)] @@ -251,7 +344,7 @@ mod tests { #[tokio::test] async fn grep_tool_validates_absolute_path() { - let tool = GrepTool; + let tool: GrepTool = GrepTool::new(); let args = GrepArgs { pattern: "test".into(), path: "relative/path".into(), @@ -264,7 +357,7 @@ mod tests { #[tokio::test] async fn grep_tool_validates_empty_pattern() { - let tool = GrepTool; + let tool: GrepTool = GrepTool::new(); let args = GrepArgs { pattern: " ".into(), path: "/tmp".into(), @@ -277,7 +370,7 @@ mod tests { #[tokio::test] async fn grep_tool_validates_invalid_regex() { - let tool = GrepTool; + let tool: GrepTool = GrepTool::new(); let args = GrepArgs { pattern: "[invalid".into(), path: "/tmp".into(), @@ -297,7 +390,10 @@ mod tests { let result = run_grep_search("hello", None, dir, 10).unwrap(); assert_eq!(result.files.len(), 1); - assert!(result.files[0].ends_with("match.txt")); + assert_eq!(result.match_count, 1); + assert!(result.files[0].path.ends_with("match.txt")); + assert_eq!(result.files[0].matches[0].line_num, 1); + assert_eq!(result.files[0].matches[0].line_text, "hello world"); } #[test] @@ -309,19 +405,18 @@ mod tests { let result = run_grep_search("hello", Some("*.rs"), dir, 10).unwrap(); assert_eq!(result.files.len(), 1); - assert!(result.files[0].ends_with(".rs")); + assert!(result.files[0].path.ends_with(".rs")); } #[test] fn run_grep_search_respects_limit() { let temp = tempdir().unwrap(); let dir = temp.path(); - std::fs::write(dir.join("a.txt"), "pattern").unwrap(); + std::fs::write(dir.join("a.txt"), "pattern\npattern").unwrap(); std::fs::write(dir.join("b.txt"), "pattern").unwrap(); - std::fs::write(dir.join("c.txt"), "pattern").unwrap(); let result = run_grep_search("pattern", None, dir, 2).unwrap(); - assert_eq!(result.files.len(), 2); + assert_eq!(result.match_count, 2); assert!(result.truncated); } @@ -333,6 +428,7 @@ mod tests { let result = run_grep_search("nonexistent", None, dir, 10).unwrap(); assert!(result.files.is_empty()); + assert_eq!(result.match_count, 0); assert!(!result.truncated); } @@ -345,7 +441,8 @@ mod tests { let result = run_grep_search(r"foo\d+bar", None, dir, 10).unwrap(); assert_eq!(result.files.len(), 1); - assert!(result.files[0].ends_with("match.txt")); + assert!(result.files[0].path.ends_with("match.txt")); + assert_eq!(result.files[0].matches[0].line_text, "foo123bar"); } #[test] @@ -365,24 +462,45 @@ mod tests { let result = run_grep_search("hello", None, dir, 10).unwrap(); // Should only find the text file, not the binary assert_eq!(result.files.len(), 1); - assert!(result.files[0].ends_with("text.txt")); + assert!(result.files[0].path.ends_with("text.txt")); } #[test] - fn file_has_match_returns_true_for_matching_file() { + fn run_grep_search_collects_multiple_matches_per_file() { + let temp = tempdir().unwrap(); + let dir = temp.path(); + std::fs::write(dir.join("multi.txt"), "hello\nworld\nhello again").unwrap(); + + let result = run_grep_search("hello", None, dir, 10).unwrap(); + assert_eq!(result.files.len(), 1); + assert_eq!(result.match_count, 2); + let matches = &result.files[0].matches; + assert_eq!(matches[0].line_num, 1); + assert_eq!(matches[0].line_text, "hello"); + assert_eq!(matches[1].line_num, 3); + assert_eq!(matches[1].line_text, "hello again"); + } + + #[test] + fn collect_file_matches_returns_matches_for_matching_file() { let temp = tempdir().unwrap(); let file_path = temp.path().join("test.txt"); - std::fs::write(&file_path, "hello world").unwrap(); + std::fs::write(&file_path, "hello world\ngoodbye\nhello again").unwrap(); let matcher = RegexMatcher::new("hello").unwrap(); let mut searcher = SearcherBuilder::new() .binary_detection(BinaryDetection::quit(0)) .build(); - assert!(file_has_match(&matcher, &mut searcher, &file_path)); + let matches = collect_file_matches(&matcher, &mut searcher, &file_path); + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].line_num, 1); + assert_eq!(matches[0].line_text, "hello world"); + assert_eq!(matches[1].line_num, 3); + assert_eq!(matches[1].line_text, "hello again"); } #[test] - fn file_has_match_returns_false_for_non_matching_file() { + fn collect_file_matches_returns_empty_for_non_matching_file() { let temp = tempdir().unwrap(); let file_path = temp.path().join("test.txt"); std::fs::write(&file_path, "goodbye world").unwrap(); @@ -391,6 +509,44 @@ mod tests { let mut searcher = SearcherBuilder::new() .binary_detection(BinaryDetection::quit(0)) .build(); - assert!(!file_has_match(&matcher, &mut searcher, &file_path)); + let matches = collect_file_matches(&matcher, &mut searcher, &file_path); + assert!(matches.is_empty()); + } + + #[tokio::test] + async fn grep_tool_formats_output_with_line_numbers() { + let temp = tempdir().unwrap(); + let dir = temp.path(); + std::fs::write(dir.join("test.txt"), "hello world").unwrap(); + + let tool: GrepTool = GrepTool::new(); + let args = GrepArgs { + pattern: "hello".into(), + path: dir.to_string_lossy().into_owned(), + include: None, + limit: None, + }; + let result = tool.call(args).await.unwrap(); + assert!(result.content.contains("Found 1 matches")); + assert!(result.content.contains("L1: hello world")); + } + + #[tokio::test] + async fn grep_tool_formats_output_without_line_numbers() { + let temp = tempdir().unwrap(); + let dir = temp.path(); + std::fs::write(dir.join("test.txt"), "hello world").unwrap(); + + let tool: GrepTool = GrepTool::new(); + let args = GrepArgs { + pattern: "hello".into(), + path: dir.to_string_lossy().into_owned(), + include: None, + limit: None, + }; + let result = tool.call(args).await.unwrap(); + assert!(result.content.contains("Found 1 matches")); + assert!(result.content.contains(" hello world")); + assert!(!result.content.contains("L1:")); } } diff --git a/src/rig-coding-tools/src/tools/mod.rs b/src/rig-coding-tools/src/tools/mod.rs index 0d483bc4..c85eccd3 100644 --- a/src/rig-coding-tools/src/tools/mod.rs +++ b/src/rig-coding-tools/src/tools/mod.rs @@ -16,6 +16,7 @@ pub mod write; // Re-exports pub use bash::BashTool; +pub use grep::{GrepArgs, GrepFileMatches, GrepLineMatch, GrepOutput, GrepTool}; pub use read::ReadTool; pub use todo::{Todo, TodoPriority, TodoReadTool, TodoState, TodoStatus, TodoWriteTool}; pub use webfetch::WebFetchTool; From 19cec948e7f2bc2f434e4a4a604343bb505dcc44 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 27 Dec 2025 20:21:10 +0000 Subject: [PATCH 08/64] Optimize ReadTool buffer sizes with shared ESTIMATED_CHARS_PER_LINE constant --- src/rig-coding-tools/src/tools/read.rs | 10 ++++++---- src/rig-coding-tools/src/util.rs | 3 +++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/rig-coding-tools/src/tools/read.rs b/src/rig-coding-tools/src/tools/read.rs index e8f2959c..038c0c07 100644 --- a/src/rig-coding-tools/src/tools/read.rs +++ b/src/rig-coding-tools/src/tools/read.rs @@ -2,7 +2,7 @@ use crate::error::{ToolError, ToolResult}; use crate::output::ToolOutput; -use crate::util::{truncate_line, validate_absolute_path}; +use crate::util::{truncate_line, validate_absolute_path, ESTIMATED_CHARS_PER_LINE}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -115,11 +115,13 @@ async fn read_file( validate_absolute_path(path)?; let file = File::open(path).await?; - let mut reader = BufReader::new(file); + // Size buffer for expected content, rounded to next power of 2 + let buf_capacity = (limit * ESTIMATED_CHARS_PER_LINE).next_power_of_two(); + let mut reader = BufReader::with_capacity(buf_capacity, file); let mut buffer = Vec::new(); - // Pre-allocate output: estimate ~100 chars/line, capped to avoid over-allocation - let estimated_capacity = limit.min(256) * 100; + // Pre-allocate output based on expected content size + let estimated_capacity = limit * ESTIMATED_CHARS_PER_LINE; let mut output = String::with_capacity(estimated_capacity); let mut line_number = 0usize; let mut lines_output = 0usize; diff --git a/src/rig-coding-tools/src/util.rs b/src/rig-coding-tools/src/util.rs index 356d983f..fc56d612 100644 --- a/src/rig-coding-tools/src/util.rs +++ b/src/rig-coding-tools/src/util.rs @@ -3,6 +3,9 @@ use crate::error::{ToolError, ToolResult}; use std::path::Path; +/// Generous estimate of average characters per line for buffer pre-allocation. +pub const ESTIMATED_CHARS_PER_LINE: usize = 64; + /// Validates that a path is absolute. /// /// Returns the path as-is if valid, or an error describing the issue. From fb7436787458e341f25fce82b1b2d63d0d823b08 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 27 Dec 2025 20:43:17 +0000 Subject: [PATCH 09/64] Changed: Preallocate some extra stuff in read.rs --- src/rig-coding-tools/src/tools/read.rs | 6 ++++-- src/rig-coding-tools/src/util.rs | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/rig-coding-tools/src/tools/read.rs b/src/rig-coding-tools/src/tools/read.rs index 038c0c07..d195d5ba 100644 --- a/src/rig-coding-tools/src/tools/read.rs +++ b/src/rig-coding-tools/src/tools/read.rs @@ -2,7 +2,9 @@ use crate::error::{ToolError, ToolResult}; use crate::output::ToolOutput; -use crate::util::{truncate_line, validate_absolute_path, ESTIMATED_CHARS_PER_LINE}; +use crate::util::{ + truncate_line, validate_absolute_path, ESTIMATED_CHARS_PER_LINE, LIKELY_CHARS_PER_LINE_MAX, +}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -118,7 +120,7 @@ async fn read_file( // Size buffer for expected content, rounded to next power of 2 let buf_capacity = (limit * ESTIMATED_CHARS_PER_LINE).next_power_of_two(); let mut reader = BufReader::with_capacity(buf_capacity, file); - let mut buffer = Vec::new(); + let mut buffer = Vec::with_capacity(LIKELY_CHARS_PER_LINE_MAX); // Pre-allocate output based on expected content size let estimated_capacity = limit * ESTIMATED_CHARS_PER_LINE; diff --git a/src/rig-coding-tools/src/util.rs b/src/rig-coding-tools/src/util.rs index fc56d612..b374ceae 100644 --- a/src/rig-coding-tools/src/util.rs +++ b/src/rig-coding-tools/src/util.rs @@ -6,6 +6,9 @@ use std::path::Path; /// Generous estimate of average characters per line for buffer pre-allocation. pub const ESTIMATED_CHARS_PER_LINE: usize = 64; +/// A number of characters per line that's likely to not be exceeded in most files. +pub const LIKELY_CHARS_PER_LINE_MAX: usize = ESTIMATED_CHARS_PER_LINE * 4; + /// Validates that a path is absolute. /// /// Returns the path as-is if valid, or an error describing the issue. From 88c42738f937923b21c0d0a8463ac9570d0eeb9c Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sat, 27 Dec 2025 21:00:32 +0000 Subject: [PATCH 10/64] Optimize ReadTool with zero-copy line reading via fill_buf()/consume() Eliminates unnecessary memory copy from BufReader's internal buffer to user buffer by using fill_buf() + consume() instead of read_until(). Lines are now processed directly from BufReader's buffer with memchr for SIMD-optimized newline scanning. Falls back to overflow buffer only for lines spanning buffer boundaries. --- src/Cargo.lock | 1 + src/rig-coding-tools/Cargo.toml | 1 + src/rig-coding-tools/src/tools/read.rs | 148 ++++++++++++++++++------- 3 files changed, 109 insertions(+), 41 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index 73896e19..51a59b11 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -1241,6 +1241,7 @@ dependencies = [ "glob", "grep", "ignore", + "memchr", "regex", "reqwest", "rig-core", diff --git a/src/rig-coding-tools/Cargo.toml b/src/rig-coding-tools/Cargo.toml index 9af2d832..a32d97fa 100644 --- a/src/rig-coding-tools/Cargo.toml +++ b/src/rig-coding-tools/Cargo.toml @@ -17,6 +17,7 @@ schemars = "1.2.0" thiserror = "2.0" tokio = { version = "1", features = ["fs", "process", "time", "io-util"] } glob = "0.3" +memchr = "2.7" ignore = "0.4" regex = "1.11" grep = "0.4" diff --git a/src/rig-coding-tools/src/tools/read.rs b/src/rig-coding-tools/src/tools/read.rs index d195d5ba..64c4d602 100644 --- a/src/rig-coding-tools/src/tools/read.rs +++ b/src/rig-coding-tools/src/tools/read.rs @@ -2,13 +2,13 @@ use crate::error::{ToolError, ToolResult}; use crate::output::ToolOutput; -use crate::util::{ - truncate_line, validate_absolute_path, ESTIMATED_CHARS_PER_LINE, LIKELY_CHARS_PER_LINE_MAX, -}; +use crate::util::{truncate_line, validate_absolute_path, ESTIMATED_CHARS_PER_LINE}; +use memchr::memchr; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; use serde::Deserialize; +use std::borrow::Cow; use std::fmt::Write; use std::path::Path; use tokio::fs::File; @@ -94,6 +94,49 @@ impl Tool for ReadTool { } } +/// Strips trailing CR from a line (for CRLF handling). +#[inline] +fn strip_cr(line: &[u8]) -> &[u8] { + line.strip_suffix(b"\r").unwrap_or(line) +} + +/// Processes a single line, appending it to output with optional line numbers. +/// +/// This is the hot path - called for every line in the file within the requested range. +/// Uses zero-copy where possible via [`Cow`]. +#[inline] +fn process_line( + line_bytes: &[u8], + line_number: usize, + output: &mut String, + lines_output: &mut usize, +) { + // Strip trailing CR for CRLF line endings + let line_bytes = strip_cr(line_bytes); + + // Convert to string with lossy UTF-8 handling (zero-copy for valid UTF-8) + let content: Cow<'_, str> = String::from_utf8_lossy(line_bytes); + + // Truncate long lines + let (truncated_content, _) = truncate_line(&content, MAX_LINE_LENGTH); + + // Add newline separator for subsequent lines + // We do it here to avoid trailing newline at end of output + if *lines_output > 0 { + output.push('\n'); + } + + // Branch eliminated at compile time due to const generic + if LINE_NUMBERS { + // write! to String is infallible + let _ = write!(output, "L{}: {}", line_number, truncated_content); + } else { + output.push_str(truncated_content); + } + + *lines_output += 1; +} + /// Reads a file and returns formatted content, optionally with line numbers. /// /// When `LINE_NUMBERS` is `true`, each line is prefixed with `L{number}: `. @@ -116,11 +159,14 @@ async fn read_file( let path = Path::new(file_path); validate_absolute_path(path)?; + // Buffer for lines spanning multiple fills (rare case) + // Rare enough for me to not alloc. Most files are under `limit * ESTIMATED_CHARS_PER_LINE`. + let mut overflow: Vec = Vec::new(); + let file = File::open(path).await?; // Size buffer for expected content, rounded to next power of 2 let buf_capacity = (limit * ESTIMATED_CHARS_PER_LINE).next_power_of_two(); let mut reader = BufReader::with_capacity(buf_capacity, file); - let mut buffer = Vec::with_capacity(LIKELY_CHARS_PER_LINE_MAX); // Pre-allocate output based on expected content size let estimated_capacity = limit * ESTIMATED_CHARS_PER_LINE; @@ -129,53 +175,73 @@ async fn read_file( let mut lines_output = 0usize; loop { - buffer.clear(); - let bytes_read = reader.read_until(b'\n', &mut buffer).await?; - - if bytes_read == 0 { + let buf = reader.fill_buf().await?; + if buf.is_empty() { + // EOF - handle any remaining overflow as final line (no trailing newline) + if !overflow.is_empty() { + line_number += 1; + if line_number >= offset && lines_output < limit { + process_line::( + &overflow, + line_number, + &mut output, + &mut lines_output, + ); + } + } break; } - // Strip trailing newline characters - if buffer.last() == Some(&b'\n') { - buffer.pop(); - if buffer.last() == Some(&b'\r') { - buffer.pop(); + let mut pos = 0; + while pos < buf.len() { + if let Some(newline_offset) = memchr(b'\n', &buf[pos..]) { + let newline_pos = pos + newline_offset; + line_number += 1; + + // Only process if within offset..offset+limit range + if line_number >= offset && lines_output < limit { + if overflow.is_empty() { + // Common case: process directly from buffer (zero-copy) + process_line::( + &buf[pos..newline_pos], + line_number, + &mut output, + &mut lines_output, + ); + } else { + // Rare case: complete the accumulated line + overflow.extend_from_slice(&buf[pos..newline_pos]); + process_line::( + &overflow, + line_number, + &mut output, + &mut lines_output, + ); + overflow.clear(); + } + } else if !overflow.is_empty() { + // Line was being accumulated but we're skipping it + overflow.clear(); + } + + pos = newline_pos + 1; + + // Early exit if we've collected enough lines + if lines_output >= limit { + break; + } + } else { + // No newline found - line spans buffer boundary + overflow.extend_from_slice(&buf[pos..]); + pos = buf.len(); } } - line_number += 1; - - // Skip lines before offset - if line_number < offset { - continue; - } + reader.consume(pos); - // Stop if we've output enough lines if lines_output >= limit { break; } - - // Convert to string with lossy UTF-8 handling - let content = String::from_utf8_lossy(&buffer); - - // Truncate long lines - let (truncated_content, _) = truncate_line(&content, MAX_LINE_LENGTH); - - // Add newline separator for subsequent lines - if lines_output > 0 { - output.push('\n'); - } - - // Branch eliminated at compile time due to const generic - if LINE_NUMBERS { - // write! to String is infallible - let _ = write!(&mut output, "L{}: {}", line_number, truncated_content); - } else { - output.push_str(truncated_content); - } - - lines_output += 1; } // Check if offset exceeded file length From a05e9f453d59db004535a2afdf42ec07ddb23aeb Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 28 Dec 2025 01:55:59 +0000 Subject: [PATCH 11/64] Changed: Max output size in Bash to be 30kB --- src/rig-coding-tools/src/tools/bash.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rig-coding-tools/src/tools/bash.rs b/src/rig-coding-tools/src/tools/bash.rs index 99b87fd4..c4108c3e 100644 --- a/src/rig-coding-tools/src/tools/bash.rs +++ b/src/rig-coding-tools/src/tools/bash.rs @@ -15,8 +15,8 @@ use std::process::Stdio; use std::time::Duration; use tokio::process::Command; -/// Maximum output size in bytes before truncation (100KB). -const MAX_OUTPUT_BYTES: usize = 100 * 1024; +/// Maximum output size in bytes before truncation (30KB). +const MAX_OUTPUT_BYTES: usize = 30 * 1024; /// Default command timeout in milliseconds. const DEFAULT_TIMEOUT_MS: u64 = 30_000; From 2e93a73c8752a5efdfa2303b08b777a4d9ae1e29 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Sun, 28 Dec 2025 03:42:56 +0000 Subject: [PATCH 12/64] Replace HTML tag stripping with markdown conversion in WebFetchTool - Add html-to-markdown-rs dependency for proper HTML to markdown conversion - Increase MAX_RESPONSE_SIZE from 1MB to 5MB - Use aggressive preprocessing to remove navigation, forms, scripts - Strip img, svg, script, style, noscript tags - Preserves semantic structure (headings, lists, links) for better LLM comprehension --- src/Cargo.lock | 240 +++++++++++++++++++++ src/rig-coding-tools/Cargo.toml | 1 + src/rig-coding-tools/src/tools/webfetch.rs | 81 +++---- 3 files changed, 285 insertions(+), 37 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index 51a59b11..fcff5a5c 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anyhow" version = "1.0.100" @@ -33,6 +39,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "astral-tl" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d90933ffb0f97e2fc2e0de21da9d3f20597b804012d199843a6fe7c2810d28f3" +dependencies = [ + "memchr", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -274,6 +289,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -283,6 +304,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.31" @@ -527,6 +558,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hermit-abi" @@ -534,6 +570,44 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + +[[package]] +name = "html-to-markdown-rs" +version = "2.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda029e154a976514850a89a56a1f07f03fb0611e0e8fc2357fd4ec739d63acc" +dependencies = [ + "astral-tl", + "base64", + "html-escape", + "html5ever", + "lru", + "markup5ever_rcdom", + "once_cell", + "regex", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "html5ever" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e" +dependencies = [ + "log", + "markup5ever", +] + [[package]] name = "http" version = "1.4.0" @@ -844,12 +918,50 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f" +dependencies = [ + "hashbrown", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.36.0+unofficial" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e5fc8802e8797c0dfdd2ce5c21aa0aee21abbc7b3b18559100651b3352a7b63" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] + [[package]] name = "memchr" version = "2.7.6" @@ -898,6 +1010,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nom" version = "7.1.3" @@ -971,6 +1089,45 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -1021,6 +1178,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "proc-macro2" version = "1.0.103" @@ -1240,6 +1403,7 @@ dependencies = [ "async-trait", "glob", "grep", + "html-to-markdown-rs", "ignore", "memchr", "regex", @@ -1487,6 +1651,12 @@ dependencies = [ "libc", ] +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.11" @@ -1515,6 +1685,31 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1586,6 +1781,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -1815,6 +2021,18 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -1946,6 +2164,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acd0c322f146d0f8aad130ce6c187953889359584497dac6561204c8e17bb43d" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-roots" version = "1.0.4" @@ -2190,6 +2420,16 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xml5ever" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f57dd51b88a4b9f99f9b55b136abb86210629d61c48117ddb87f567e51e66be7" +dependencies = [ + "log", + "markup5ever", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/src/rig-coding-tools/Cargo.toml b/src/rig-coding-tools/Cargo.toml index a32d97fa..e9abf68c 100644 --- a/src/rig-coding-tools/Cargo.toml +++ b/src/rig-coding-tools/Cargo.toml @@ -22,6 +22,7 @@ ignore = "0.4" regex = "1.11" grep = "0.4" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +html-to-markdown-rs = "2.16" [dev-dependencies] tokio = { version = "1", features = ["full", "test-util"] } diff --git a/src/rig-coding-tools/src/tools/webfetch.rs b/src/rig-coding-tools/src/tools/webfetch.rs index 8c590b91..a19574ed 100644 --- a/src/rig-coding-tools/src/tools/webfetch.rs +++ b/src/rig-coding-tools/src/tools/webfetch.rs @@ -4,6 +4,7 @@ use crate::error::ToolError; use crate::util::truncate_text; +use html_to_markdown_rs::{convert, ConversionOptions, PreprocessingOptions, PreprocessingPreset}; use reqwest::redirect::Policy; use rig::completion::ToolDefinition; use rig::tool::Tool; @@ -11,8 +12,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::time::Duration; -/// Maximum response size to accept (1MB). -const MAX_RESPONSE_SIZE: usize = 1_024 * 1_024; +/// Maximum response size to accept (5MB). +const MAX_RESPONSE_SIZE: usize = 5 * 1_024 * 1_024; /// Content truncation threshold (100KB). const CONTENT_TRUNCATE_SIZE: usize = 100 * 1_024; /// Default request timeout in milliseconds. @@ -122,7 +123,7 @@ impl Tool for WebFetchTool { // Process based on content type let processed = if content_type.contains("text/html") { - strip_html_tags(&raw_content) + html_to_markdown(&raw_content) } else if content_type.contains("application/json") { format_json(&raw_content) } else { @@ -178,30 +179,26 @@ fn categorize_reqwest_error(e: reqwest::Error, url: &str) -> ToolError { } } -/// Strips HTML tags to extract text content. -fn strip_html_tags(html: &str) -> String { - // Remove script and style elements entirely - let re_script = regex::Regex::new(r"(?is)]*>.*?").unwrap(); - let re_style = regex::Regex::new(r"(?is)]*>.*?").unwrap(); - let text = re_script.replace_all(html, " "); - let text = re_style.replace_all(&text, " "); - - // Remove all other tags, replacing with space to preserve word boundaries - let re_tags = regex::Regex::new(r"<[^>]+>").unwrap(); - let text = re_tags.replace_all(&text, " "); - - // Decode common HTML entities - let text = text - .replace(" ", " ") - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace(""", "\"") - .replace("'", "'"); - - // Collapse whitespace - let re_whitespace = regex::Regex::new(r"\s+").unwrap(); - re_whitespace.replace_all(&text, " ").trim().to_string() +/// Converts HTML to markdown for LLM-friendly output. +fn html_to_markdown(html: &str) -> String { + let options = ConversionOptions { + preprocessing: PreprocessingOptions { + enabled: true, + preset: PreprocessingPreset::Aggressive, + remove_navigation: true, + remove_forms: true, + }, + strip_tags: vec![ + "img".into(), + "svg".into(), + "script".into(), + "style".into(), + "noscript".into(), + ], + ..Default::default() + }; + + convert(html, Some(options)).unwrap_or_else(|_| html.to_string()) } /// Formats JSON content for readability. @@ -249,7 +246,7 @@ mod tests { } #[tokio::test] - async fn fetches_and_strips_html() { + async fn fetches_and_converts_html_to_markdown() { let server = setup_mock_server().await; let html = r#"Test

Hello

World

"#; @@ -272,8 +269,10 @@ mod tests { .await .unwrap(); + // Should contain markdown heading and content assert!(result.contains("Hello")); assert!(result.contains("World")); + // Should not contain raw HTML tags assert!(!result.contains("

")); assert!(!result.contains("

")); assert!(result.contains("Content-Type: text/html")); @@ -363,24 +362,32 @@ mod tests { } #[test] - fn strip_html_tags_removes_all_tags() { + fn html_to_markdown_converts_structure() { let html = "

Title

Content

"; - let result = strip_html_tags(html); - assert_eq!(result, "Title Content"); + let result = html_to_markdown(html); + // Should preserve heading structure as markdown + assert!(result.contains("Title")); + assert!(result.contains("Content")); + // Should not contain raw HTML + assert!(!result.contains("

")); + assert!(!result.contains("

")); } #[test] - fn strip_html_tags_removes_scripts() { + fn html_to_markdown_strips_scripts() { let html = "

Before

After

"; - let result = strip_html_tags(html); - assert_eq!(result, "Before After"); + let result = html_to_markdown(html); + assert!(result.contains("Before")); + assert!(result.contains("After")); + assert!(!result.contains("alert")); + assert!(!result.contains("

After

"; + let result = html_to_markdown(html); + assert!(!result.contains("alert")); + } + + #[test] + fn format_json_prettifies() { + let json = r#"{"a":1}"#; + let result = format_json(json); + assert!(result.contains("\"a\": 1")); + } + + #[test] + fn format_json_returns_original_on_invalid() { + let invalid = "not json"; + assert_eq!(format_json(invalid), "not json"); + } +} diff --git a/src/coding-tools-core/src/operations/write.rs b/src/coding-tools-core/src/operations/write.rs new file mode 100644 index 00000000..bf5c75db --- /dev/null +++ b/src/coding-tools-core/src/operations/write.rs @@ -0,0 +1,65 @@ +//! File writing operation. + +use crate::error::ToolResult; +use crate::path::PathResolver; + +/// Writes content to a file, creating parent directories if needed. +/// +/// Overwrites existing files. Returns a success message with byte count. +pub async fn write_file( + resolver: &R, + file_path: &str, + content: &str, +) -> ToolResult { + let path = resolver.resolve(file_path)?; + + // Create parent directories if they don't exist + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + tokio::fs::create_dir_all(parent).await?; + } + } + + let bytes = content.as_bytes(); + tokio::fs::write(&path, bytes).await?; + + Ok(format!( + "Successfully wrote {} bytes to {}", + bytes.len(), + path.display() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::path::AbsolutePathResolver; + use tempfile::TempDir; + + #[tokio::test] + async fn write_creates_new_file() { + let temp = TempDir::new().unwrap(); + let file_path = temp.path().join("new_file.txt"); + let resolver = AbsolutePathResolver; + + let result = write_file(&resolver, file_path.to_str().unwrap(), "hello world") + .await + .unwrap(); + + assert!(result.contains("11 bytes")); + assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "hello world"); + } + + #[tokio::test] + async fn write_creates_parent_directories() { + let temp = TempDir::new().unwrap(); + let file_path = temp.path().join("a/b/c/deep.txt"); + let resolver = AbsolutePathResolver; + + write_file(&resolver, file_path.to_str().unwrap(), "nested") + .await + .unwrap(); + + assert!(file_path.exists()); + } +} diff --git a/src/rig-coding-tools/src/output.rs b/src/coding-tools-core/src/output.rs similarity index 100% rename from src/rig-coding-tools/src/output.rs rename to src/coding-tools-core/src/output.rs diff --git a/src/coding-tools-core/src/path/absolute.rs b/src/coding-tools-core/src/path/absolute.rs new file mode 100644 index 00000000..12ed5479 --- /dev/null +++ b/src/coding-tools-core/src/path/absolute.rs @@ -0,0 +1,72 @@ +//! Absolute path resolver implementation. + +use super::PathResolver; +use crate::error::{ToolError, ToolResult}; +use std::path::PathBuf; + +/// Path resolver that requires absolute paths. +/// +/// This is the simplest resolver - it validates that paths are absolute +/// and returns them as-is. No directory restrictions are applied. +/// +/// # Example +/// +/// ``` +/// use coding_tools_core::path::{PathResolver, AbsolutePathResolver}; +/// +/// let resolver = AbsolutePathResolver; +/// assert!(resolver.resolve("/home/user/file.txt").is_ok()); +/// assert!(resolver.resolve("relative/path.txt").is_err()); +/// ``` +#[derive(Debug, Clone, Copy, Default)] +pub struct AbsolutePathResolver; + +impl PathResolver for AbsolutePathResolver { + fn resolve(&self, path: &str) -> ToolResult { + let path = PathBuf::from(path); + if !path.is_absolute() { + return Err(ToolError::InvalidPath(format!( + "path must be absolute: {}", + path.display() + ))); + } + Ok(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_absolute_path() { + let resolver = AbsolutePathResolver; + let result = resolver.resolve("/home/user/file.txt"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PathBuf::from("/home/user/file.txt")); + } + + #[test] + fn rejects_relative_path() { + let resolver = AbsolutePathResolver; + let result = resolver.resolve("relative/path.txt"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, ToolError::InvalidPath(_))); + assert!(err.to_string().contains("must be absolute")); + } + + #[test] + fn rejects_dot_relative_path() { + let resolver = AbsolutePathResolver; + assert!(resolver.resolve("./file.txt").is_err()); + assert!(resolver.resolve("../file.txt").is_err()); + } + + #[cfg(windows)] + #[test] + fn accepts_windows_absolute_path() { + let resolver = AbsolutePathResolver; + assert!(resolver.resolve("C:\\Users\\file.txt").is_ok()); + } +} diff --git a/src/coding-tools-core/src/path/allowed.rs b/src/coding-tools-core/src/path/allowed.rs new file mode 100644 index 00000000..a6f01f15 --- /dev/null +++ b/src/coding-tools-core/src/path/allowed.rs @@ -0,0 +1,224 @@ +//! Allowed directory path resolver implementation. + +use super::PathResolver; +use crate::error::{ToolError, ToolResult}; +use std::path::PathBuf; + +/// Path resolver that restricts access to allowed directories. +/// +/// Paths are resolved relative to configured base directories. +/// Prevents path traversal attacks by validating resolved paths +/// stay within allowed boundaries. +/// +/// # Security +/// +/// This resolver protects against path traversal by: +/// 1. Canonicalizing the resolved path to eliminate `..` and symlinks +/// 2. Verifying the result starts with an allowed base directory +/// +/// # Example +/// +/// ```no_run +/// use coding_tools_core::path::{PathResolver, AllowedPathResolver}; +/// use std::path::PathBuf; +/// +/// let resolver = AllowedPathResolver::new(vec![ +/// PathBuf::from("/home/user/project"), +/// ]).unwrap(); +/// +/// // Relative paths resolved against allowed directories +/// assert!(resolver.resolve("src/main.rs").is_ok()); +/// +/// // Path traversal is blocked +/// assert!(resolver.resolve("../secret.txt").is_err()); +/// ``` +#[derive(Debug, Clone)] +pub struct AllowedPathResolver { + /// Canonicalized allowed base directories. + allowed_paths: Vec, +} + +impl AllowedPathResolver { + /// Creates a new resolver with the given allowed directories. + /// + /// Each directory is canonicalized during construction to ensure + /// consistent path comparison. Returns an error if any directory + /// doesn't exist or can't be canonicalized. + pub fn new(allowed_paths: Vec) -> ToolResult { + let canonicalized: Result, _> = allowed_paths + .into_iter() + .map(|p| { + p.canonicalize().map_err(|e| { + ToolError::InvalidPath(format!( + "failed to canonicalize allowed path '{}': {}", + p.display(), + e + )) + }) + }) + .collect(); + + Ok(Self { + allowed_paths: canonicalized?, + }) + } + + /// Creates a resolver from already-canonicalized paths. + /// + /// Use this when paths are known to be valid and canonicalized, + /// skipping the filesystem check. + /// + /// # Safety + /// + /// Caller must ensure paths are actually canonical. Using non-canonical + /// paths may allow path traversal attacks. + pub fn from_canonical(allowed_paths: Vec) -> Self { + Self { allowed_paths } + } + + /// Returns the allowed base directories. + pub fn allowed_paths(&self) -> &[PathBuf] { + &self.allowed_paths + } +} + +impl PathResolver for AllowedPathResolver { + fn resolve(&self, path: &str) -> ToolResult { + let input_path = PathBuf::from(path); + + // Try each allowed base directory in order + for base in &self.allowed_paths { + let candidate = base.join(&input_path); + + // Try to canonicalize for existing paths + if let Ok(canonical) = candidate.canonicalize() { + // Security check: resolved path must stay within allowed base + if canonical.starts_with(base) { + return Ok(canonical); + } + // Path escaped allowed directory - try next base + continue; + } + + // For non-existent paths (write operations), validate parent + if let Some(parent) = candidate.parent() { + if let Ok(canonical_parent) = parent.canonicalize() { + if canonical_parent.starts_with(base) { + // Parent is valid, construct the final path + let file_name = candidate.file_name().ok_or_else(|| { + ToolError::InvalidPath("path has no file name".into()) + })?; + return Ok(canonical_parent.join(file_name)); + } + } + } + } + + Err(ToolError::InvalidPath(format!( + "path '{}' is not within allowed directories", + path + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn setup_test_dir() -> TempDir { + let dir = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join("subdir")).unwrap(); + fs::write(dir.path().join("file.txt"), "content").unwrap(); + fs::write(dir.path().join("subdir/nested.txt"), "nested").unwrap(); + dir + } + + #[test] + fn resolves_relative_path_in_allowed_dir() { + let dir = setup_test_dir(); + let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap(); + + let result = resolver.resolve("file.txt"); + assert!(result.is_ok()); + assert!(result.unwrap().ends_with("file.txt")); + } + + #[test] + fn resolves_nested_path() { + let dir = setup_test_dir(); + let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap(); + + let result = resolver.resolve("subdir/nested.txt"); + assert!(result.is_ok()); + } + + #[test] + fn rejects_path_traversal() { + let dir = setup_test_dir(); + let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap(); + + let result = resolver.resolve("../../../etc/passwd"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("not within allowed")); + } + + #[test] + fn allows_non_existent_path_for_write() { + let dir = setup_test_dir(); + let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap(); + + let result = resolver.resolve("new_file.txt"); + assert!(result.is_ok()); + } + + #[test] + fn allows_nested_non_existent_path() { + let dir = setup_test_dir(); + let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap(); + + let result = resolver.resolve("subdir/new_file.txt"); + assert!(result.is_ok()); + } + + #[test] + fn rejects_non_existent_path_outside_allowed() { + let dir = setup_test_dir(); + let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap(); + + // Parent traversal in non-existent path + let result = resolver.resolve("subdir/../../../new_file.txt"); + assert!(result.is_err()); + } + + #[test] + fn tries_multiple_allowed_paths() { + let dir1 = setup_test_dir(); + let dir2 = setup_test_dir(); + fs::write(dir2.path().join("only_in_dir2.txt"), "content").unwrap(); + + let resolver = + AllowedPathResolver::new(vec![dir1.path().to_path_buf(), dir2.path().to_path_buf()]) + .unwrap(); + + // File only exists in dir2 + let result = resolver.resolve("only_in_dir2.txt"); + assert!(result.is_ok()); + } + + #[test] + fn returns_canonical_path() { + let dir = setup_test_dir(); + let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap(); + + let result = resolver.resolve("subdir/../file.txt"); + assert!(result.is_ok()); + // Should resolve to the canonical path without ../ + let resolved = result.unwrap(); + assert!(!resolved.to_string_lossy().contains("..")); + } +} diff --git a/src/coding-tools-core/src/path/mod.rs b/src/coding-tools-core/src/path/mod.rs new file mode 100644 index 00000000..89b1ecb8 --- /dev/null +++ b/src/coding-tools-core/src/path/mod.rs @@ -0,0 +1,25 @@ +//! Path resolution strategies for tool security. +//! +//! This module provides [`PathResolver`] trait and implementations: +//! - [`AbsolutePathResolver`] - Requires absolute paths only +//! - [`AllowedPathResolver`] - Restricts to allowed directories + +mod absolute; +mod allowed; + +pub use absolute::AbsolutePathResolver; +pub use allowed::AllowedPathResolver; + +use crate::error::ToolResult; +use std::path::PathBuf; + +/// Strategy for resolving and validating file paths. +/// +/// Implementations control whether paths must be absolute, relative to +/// allowed directories, or follow other constraints. +pub trait PathResolver: Send + Sync { + /// Resolves and validates a path string. + /// + /// Returns the canonical path if valid, or an error describing the issue. + fn resolve(&self, path: &str) -> ToolResult; +} diff --git a/src/rig-coding-tools/src/util.rs b/src/coding-tools-core/src/util.rs similarity index 77% rename from src/rig-coding-tools/src/util.rs rename to src/coding-tools-core/src/util.rs index b374ceae..efe9f37e 100644 --- a/src/rig-coding-tools/src/util.rs +++ b/src/coding-tools-core/src/util.rs @@ -1,27 +1,11 @@ //! Shared utilities for tool implementations. -use crate::error::{ToolError, ToolResult}; -use std::path::Path; - /// Generous estimate of average characters per line for buffer pre-allocation. pub const ESTIMATED_CHARS_PER_LINE: usize = 64; /// A number of characters per line that's likely to not be exceeded in most files. pub const LIKELY_CHARS_PER_LINE_MAX: usize = ESTIMATED_CHARS_PER_LINE * 4; -/// Validates that a path is absolute. -/// -/// Returns the path as-is if valid, or an error describing the issue. -pub fn validate_absolute_path(path: &Path) -> ToolResult<&Path> { - if !path.is_absolute() { - return Err(ToolError::InvalidPath(format!( - "path must be absolute: {}", - path.display() - ))); - } - Ok(path) -} - /// Formats a line with its line number for output. /// /// Uses the format: `{spaces}{line_number}\t{content}` where spaces @@ -69,20 +53,6 @@ pub fn truncate_line(line: &str, max_chars: usize) -> (&str, bool) { #[cfg(test)] mod tests { use super::*; - use std::path::PathBuf; - - #[test] - fn validate_absolute_path_accepts_absolute() { - let path = PathBuf::from("/home/user/file.txt"); - assert!(validate_absolute_path(&path).is_ok()); - } - - #[test] - fn validate_absolute_path_rejects_relative() { - let path = PathBuf::from("relative/path.txt"); - let err = validate_absolute_path(&path).unwrap_err(); - assert!(matches!(err, ToolError::InvalidPath(_))); - } #[test] fn format_numbered_line_pads_correctly() { diff --git a/src/coding-tools-rig/Cargo.toml b/src/coding-tools-rig/Cargo.toml new file mode 100644 index 00000000..f2d37f30 --- /dev/null +++ b/src/coding-tools-rig/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "coding-tools-rig" +version = "0.1.0" +edition = "2021" +description = "Rig framework Tool implementations for coding tools" +repository = "https://github.com/Sewer56/rig-coding-tools" +license = "Apache-2.0" +include = ["src/**/*"] +readme = "README.md" + +[dependencies] +async-trait = "0.1" +coding-tools-core = { version = "0.1.0", path = "../coding-tools-core" } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +rig-core = { version = "0.27", default-features = false, features = ["reqwest-rustls"] } +schemars = "1.2.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.0", features = ["fs", "process", "time", "io-util", "sync"] } + +[dev-dependencies] +tempfile = "3.10" +tokio = { version = "1.0", features = ["rt", "macros"] } diff --git a/src/coding-tools-rig/README.md b/src/coding-tools-rig/README.md new file mode 100644 index 00000000..64b5c8b9 --- /dev/null +++ b/src/coding-tools-rig/README.md @@ -0,0 +1,72 @@ +# coding-tools-rig + +[![Crates.io](https://img.shields.io/crates/v/coding-tools-rig.svg)](https://crates.io/crates/coding-tools-rig) +[![Docs.rs](https://docs.rs/coding-tools-rig/badge.svg)](https://docs.rs/coding-tools-rig) + +Rig framework Tool implementations for coding tools. + +## Features + +- **Absolute-path tools** - Unrestricted file system access with absolute paths +- **Allowed-path tools** - Sandboxed access restricted to configured directories +- **Shell execution** - Cross-platform command execution with timeout +- **Web fetching** - URL content retrieval with format conversion +- **Task delegation** - Sub-agent spawning for complex tasks +- **Todo management** - Persistent todo list tracking + +## Installation + +Add to your `Cargo.toml`: + +```toml +[dependencies] +coding-tools-rig = "0.1.0" +``` + +## Usage + +### Absolute-Path Tools + +For unrestricted file access: + +```rust +use coding_tools_rig::absolute::{ReadTool, WriteTool, EditTool, GlobTool, GrepTool}; +use rig::tool::Tool; + +let read_tool = ReadTool::new(); +// Use with rig agent... +``` + +### Allowed-Path Tools + +For sandboxed file access: + +```rust +use coding_tools_rig::allowed::{ReadTool, WriteTool}; +use coding_tools_rig::AllowedPathResolver; +use std::path::PathBuf; + +let resolver = AllowedPathResolver::new(vec![ + PathBuf::from("/home/user/project"), +]).unwrap(); + +let read_tool = ReadTool::new(resolver); +// Use with rig agent - paths restricted to /home/user/project +``` + +### Standalone Tools + +Tools without path requirements: + +```rust +use coding_tools_rig::{BashTool, TaskTool, WebFetchTool}; +use coding_tools_rig::todo::{TodoReadTool, TodoWriteTool}; + +let bash = BashTool::new(); +let task = TaskTool::with_mock(); // or TaskTool::new(executor) +let webfetch = WebFetchTool::new(); +``` + +## License + +Apache 2.0 diff --git a/src/coding-tools-rig/src/absolute/edit.rs b/src/coding-tools-rig/src/absolute/edit.rs new file mode 100644 index 00000000..483bb6d6 --- /dev/null +++ b/src/coding-tools-rig/src/absolute/edit.rs @@ -0,0 +1,109 @@ +//! Edit file tool using AbsolutePathResolver. + +use coding_tools_core::operations::edit_file; +use coding_tools_core::path::AbsolutePathResolver; +pub use coding_tools_core::EditError; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; + +/// Arguments for file editing. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct EditArgs { + /// Absolute path to the file to modify. + pub file_path: String, + /// Exact text to find and replace. + pub old_string: String, + /// Replacement text. + pub new_string: String, + /// Replace all occurrences (default false). + #[serde(default)] + pub replace_all: bool, +} + +/// Tool for making exact string replacements in files. +#[derive(Debug, Clone, Default)] +pub struct EditTool; + +impl EditTool { + /// Creates a new edit tool instance. + #[inline] + pub fn new() -> Self { + Self + } +} + +impl Tool for EditTool { + const NAME: &'static str = "edit"; + + type Error = EditError; + type Args = EditArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Makes exact string replacements in files. Use replace_all=true to \ + replace all occurrences." + .to_string(), + parameters: serde_json::to_value(schema_for!(EditArgs)) + .expect("EditArgs schema generation should not fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let resolver = AbsolutePathResolver; + edit_file( + &resolver, + &args.file_path, + &args.old_string, + &args.new_string, + args.replace_all, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use coding_tools_core::ToolError; + use std::io::Write as _; + use tempfile::NamedTempFile; + + #[tokio::test] + async fn replaces_single_occurrence() { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b"hello world").unwrap(); + file.flush().unwrap(); + let tool = EditTool::new(); + let result = tool + .call(EditArgs { + file_path: file.path().to_string_lossy().to_string(), + old_string: "world".to_string(), + new_string: "rust".to_string(), + replace_all: false, + }) + .await + .unwrap(); + assert!(result.contains("1 occurrence")); + } + + #[tokio::test] + async fn rejects_relative_path() { + let tool = EditTool::new(); + let result = tool + .call(EditArgs { + file_path: "relative/path.txt".to_string(), + old_string: "old".to_string(), + new_string: "new".to_string(), + replace_all: false, + }) + .await; + assert!(matches!( + result, + Err(EditError::Tool(ToolError::InvalidPath(_))) + )); + } +} diff --git a/src/coding-tools-rig/src/absolute/glob.rs b/src/coding-tools-rig/src/absolute/glob.rs new file mode 100644 index 00000000..96f79b6e --- /dev/null +++ b/src/coding-tools-rig/src/absolute/glob.rs @@ -0,0 +1,89 @@ +//! Glob pattern file finding tool using AbsolutePathResolver. + +use coding_tools_core::operations::glob_files; +use coding_tools_core::path::AbsolutePathResolver; +use coding_tools_core::{GlobOutput, ToolError}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; + +/// Arguments for the glob tool. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GlobArgs { + /// Glob pattern to match files against (e.g., "**/*.rs", "src/**/*.ts"). + pub pattern: String, + /// Absolute directory path to search in. + pub path: String, +} + +/// Tool for finding files matching glob patterns. +#[derive(Debug, Default, Clone, Copy)] +pub struct GlobTool; + +impl GlobTool { + /// Creates a new glob tool instance. + #[inline] + pub fn new() -> Self { + Self + } +} + +impl Tool for GlobTool { + const NAME: &'static str = "glob"; + + type Error = ToolError; + type Args = GlobArgs; + type Output = GlobOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Find files matching a glob pattern. Respects .gitignore and \ + returns paths sorted by modification time (newest first)." + .to_string(), + parameters: serde_json::to_value(schema_for!(GlobArgs)) + .expect("schema serialization should not fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let resolver = AbsolutePathResolver; + glob_files(&resolver, &args.pattern, &args.path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::{self, File}; + use tempfile::TempDir; + + #[tokio::test] + async fn finds_matching_files() { + let dir = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + File::create(dir.path().join("src/lib.rs")).unwrap(); + let tool = GlobTool::new(); + let result = tool + .call(GlobArgs { + pattern: "**/*.rs".to_string(), + path: dir.path().to_string_lossy().to_string(), + }) + .await + .unwrap(); + assert!(result.files.iter().any(|f| f.ends_with("lib.rs"))); + } + + #[tokio::test] + async fn rejects_relative_path() { + let tool = GlobTool::new(); + let result = tool + .call(GlobArgs { + pattern: "*.rs".to_string(), + path: "relative/path".to_string(), + }) + .await; + assert!(matches!(result, Err(ToolError::InvalidPath(_)))); + } +} diff --git a/src/coding-tools-rig/src/absolute/grep.rs b/src/coding-tools-rig/src/absolute/grep.rs new file mode 100644 index 00000000..80c164fa --- /dev/null +++ b/src/coding-tools-rig/src/absolute/grep.rs @@ -0,0 +1,183 @@ +//! Grep content search tool using AbsolutePathResolver. + +use coding_tools_core::operations::grep_search; +use coding_tools_core::path::AbsolutePathResolver; +use coding_tools_core::{ToolError, ToolOutput}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; +use std::fmt::Write; + +const DEFAULT_LIMIT: usize = 100; +const MAX_LIMIT: usize = 2000; +const MAX_LINE_LENGTH: usize = 2000; + +fn default_limit() -> Option { + Some(DEFAULT_LIMIT) +} + +/// Arguments for the grep tool. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GrepArgs { + /// Regex pattern to search for in file contents. + pub pattern: String, + /// Absolute directory path to search in. + pub path: String, + /// Optional file glob filter (e.g., "*.rs", "*.{ts,tsx}"). + #[serde(default)] + pub include: Option, + /// Maximum number of matches to return (default: 100, max: 2000). + #[serde(default = "default_limit")] + pub limit: Option, +} + +/// Tool for searching file contents using regex patterns. +#[derive(Debug, Clone, Default)] +pub struct GrepTool; + +impl GrepTool { + /// Creates a new grep tool instance. + #[inline] + pub fn new() -> Self { + Self + } +} + +impl Tool for GrepTool { + const NAME: &'static str = "grep"; + + type Error = ToolError; + type Args = GrepArgs; + type Output = ToolOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + let description = if LINE_NUMBERS { + "Search file contents using regex patterns. Returns matches with file paths, \ + line numbers, and content, sorted by file modification time." + } else { + "Search file contents using regex patterns. Returns matches with file paths \ + and content, sorted by file modification time." + }; + ToolDefinition { + name: Self::NAME.to_string(), + description: description.to_string(), + parameters: serde_json::to_value(schema_for!(GrepArgs)) + .expect("schema serialization should not fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let pattern = args.pattern.trim(); + if pattern.is_empty() { + return Err(ToolError::InvalidPattern( + "pattern must not be empty".into(), + )); + } + + let limit = args.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + if limit == 0 { + return Err(ToolError::InvalidPattern( + "limit must be greater than zero".into(), + )); + } + + let include = args.include.as_deref().and_then(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }); + + let resolver = AbsolutePathResolver; + let result = grep_search(&resolver, pattern, include, &args.path, limit)?; + + if result.files.is_empty() { + return Ok(ToolOutput::new("No matches found.")); + } + + // Format output grouped by file + let mut output = String::with_capacity(4096); + let _ = writeln!(&mut output, "Found {} matches", result.match_count); + + for file in &result.files { + let _ = writeln!(&mut output, "\n{}:", file.path); + for m in &file.matches { + let truncated_text = if m.line_text.len() > MAX_LINE_LENGTH { + &m.line_text[..MAX_LINE_LENGTH] + } else { + &m.line_text + }; + if LINE_NUMBERS { + let _ = writeln!(&mut output, " L{}: {}", m.line_num, truncated_text); + } else { + let _ = writeln!(&mut output, " {}", truncated_text); + } + } + } + + if result.truncated { + let _ = write!(&mut output, "\n(Results truncated at {} matches)", limit); + } + + Ok(if result.truncated { + ToolOutput::truncated(output) + } else { + ToolOutput::new(output) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn finds_matching_content() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("test.txt"), "hello world").unwrap(); + let tool: GrepTool = GrepTool::new(); + let result = tool + .call(GrepArgs { + pattern: "hello".to_string(), + path: dir.path().to_string_lossy().to_string(), + include: None, + limit: None, + }) + .await + .unwrap(); + assert!(result.content.contains("Found 1 matches")); + assert!(result.content.contains("L1: hello world")); + } + + #[tokio::test] + async fn rejects_relative_path() { + let tool: GrepTool = GrepTool::new(); + let result = tool + .call(GrepArgs { + pattern: "test".to_string(), + path: "relative/path".to_string(), + include: None, + limit: None, + }) + .await; + assert!(matches!(result, Err(ToolError::InvalidPath(_)))); + } + + #[tokio::test] + async fn rejects_empty_pattern() { + let tool: GrepTool = GrepTool::new(); + let result = tool + .call(GrepArgs { + pattern: " ".to_string(), + path: "/tmp".to_string(), + include: None, + limit: None, + }) + .await; + assert!(matches!(result, Err(ToolError::InvalidPattern(_)))); + } +} diff --git a/src/coding-tools-rig/src/absolute/mod.rs b/src/coding-tools-rig/src/absolute/mod.rs new file mode 100644 index 00000000..30be020d --- /dev/null +++ b/src/coding-tools-rig/src/absolute/mod.rs @@ -0,0 +1,24 @@ +//! Tools using `AbsolutePathResolver`. +//! +//! These tools require absolute paths and perform no directory restriction. +//! Use for unrestricted file system access. +//! +//! # Available Tools +//! +//! - [`ReadTool`] - Read file contents with optional line numbers +//! - [`WriteTool`] - Write content to files +//! - [`EditTool`] - Make exact string replacements +//! - [`GlobTool`] - Find files by glob pattern +//! - [`GrepTool`] - Search file contents by regex + +mod edit; +mod glob; +mod grep; +mod read; +mod write; + +pub use edit::{EditArgs, EditError, EditTool}; +pub use glob::{GlobArgs, GlobTool}; +pub use grep::{GrepArgs, GrepTool}; +pub use read::{ReadArgs, ReadTool}; +pub use write::{WriteTool, WriteToolArgs}; diff --git a/src/coding-tools-rig/src/absolute/read.rs b/src/coding-tools-rig/src/absolute/read.rs new file mode 100644 index 00000000..fa3ce4e8 --- /dev/null +++ b/src/coding-tools-rig/src/absolute/read.rs @@ -0,0 +1,105 @@ +//! Read file tool using AbsolutePathResolver. + +use coding_tools_core::operations::read_file; +use coding_tools_core::path::AbsolutePathResolver; +use coding_tools_core::{ToolError, ToolOutput}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; + +const DEFAULT_OFFSET: usize = 1; +const DEFAULT_LIMIT: usize = 2000; + +fn default_offset() -> usize { + DEFAULT_OFFSET +} + +fn default_limit() -> usize { + DEFAULT_LIMIT +} + +/// Arguments for the read file tool. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct ReadArgs { + /// Absolute path to the file to read. + pub file_path: String, + /// 1-indexed line number to start reading from (default: 1). + #[serde(default = "default_offset")] + pub offset: usize, + /// Maximum number of lines to return (default: 2000). + #[serde(default = "default_limit")] + pub limit: usize, +} + +/// Tool for reading file contents with optional line numbers. +#[derive(Debug, Clone, Default)] +pub struct ReadTool; + +impl ReadTool { + /// Creates a new read tool instance. + #[inline] + pub fn new() -> Self { + Self + } +} + +impl Tool for ReadTool { + const NAME: &'static str = "read"; + + type Error = ToolError; + type Args = ReadArgs; + type Output = ToolOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + let description = if LINE_NUMBERS { + "Read file contents with line numbers. Returns lines prefixed with L{number}: format." + } else { + "Read file contents. Returns raw file content without line number prefixes." + }; + ToolDefinition { + name: Self::NAME.to_string(), + description: description.to_string(), + parameters: serde_json::to_value(schema_for!(ReadArgs)) + .expect("schema serialization should never fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let resolver = AbsolutePathResolver; + read_file::<_, LINE_NUMBERS>(&resolver, &args.file_path, args.offset, args.limit).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + use tempfile::NamedTempFile; + + #[tokio::test] + async fn reads_file_with_line_numbers() { + let mut temp = NamedTempFile::new().unwrap(); + temp.write_all(b"hello\nworld\n").unwrap(); + let tool: ReadTool = ReadTool::new(); + let args = ReadArgs { + file_path: temp.path().to_string_lossy().to_string(), + offset: 1, + limit: 2000, + }; + let result = tool.call(args).await.unwrap(); + assert_eq!(result.content, "L1: hello\nL2: world"); + } + + #[tokio::test] + async fn rejects_relative_path() { + let tool: ReadTool = ReadTool::new(); + let args = ReadArgs { + file_path: "relative/path.txt".to_string(), + offset: 1, + limit: 100, + }; + let result = tool.call(args).await; + assert!(matches!(result, Err(ToolError::InvalidPath(_)))); + } +} diff --git a/src/coding-tools-rig/src/absolute/write.rs b/src/coding-tools-rig/src/absolute/write.rs new file mode 100644 index 00000000..3c68c24d --- /dev/null +++ b/src/coding-tools-rig/src/absolute/write.rs @@ -0,0 +1,87 @@ +//! Write file tool using AbsolutePathResolver. + +use coding_tools_core::operations::write_file; +use coding_tools_core::path::AbsolutePathResolver; +use coding_tools_core::ToolError; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; + +/// Arguments for the write tool. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct WriteToolArgs { + /// Absolute path for the file to write. + pub file_path: String, + /// Content to write to the file. + pub content: String, +} + +/// Tool for writing content to files. +#[derive(Debug, Clone, Default)] +pub struct WriteTool; + +impl WriteTool { + /// Creates a new write tool instance. + #[inline] + pub fn new() -> Self { + Self + } +} + +impl Tool for WriteTool { + const NAME: &'static str = "write"; + + type Error = ToolError; + type Args = WriteToolArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Write content to a file, creating parent directories if needed. \ + Overwrites existing files." + .to_string(), + parameters: serde_json::to_value(schema_for!(WriteToolArgs)) + .expect("schema generation should not fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let resolver = AbsolutePathResolver; + write_file(&resolver, &args.file_path, &args.content).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn writes_new_file() { + let temp = TempDir::new().unwrap(); + let file_path = temp.path().join("new.txt"); + let tool = WriteTool::new(); + let result = tool + .call(WriteToolArgs { + file_path: file_path.to_string_lossy().to_string(), + content: "hello".to_string(), + }) + .await + .unwrap(); + assert!(result.contains("5 bytes")); + } + + #[tokio::test] + async fn rejects_relative_path() { + let tool = WriteTool::new(); + let result = tool + .call(WriteToolArgs { + file_path: "relative/path.txt".to_string(), + content: "content".to_string(), + }) + .await; + assert!(matches!(result, Err(ToolError::InvalidPath(_)))); + } +} diff --git a/src/coding-tools-rig/src/allowed/edit.rs b/src/coding-tools-rig/src/allowed/edit.rs new file mode 100644 index 00000000..aab71a86 --- /dev/null +++ b/src/coding-tools-rig/src/allowed/edit.rs @@ -0,0 +1,122 @@ +//! Edit file tool using AllowedPathResolver. + +use coding_tools_core::operations::edit_file; +use coding_tools_core::path::AllowedPathResolver; +pub use coding_tools_core::EditError; +use coding_tools_core::ToolResult; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +/// Arguments for file editing. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct EditArgs { + /// Relative path to the file to modify (within allowed directories). + pub file_path: String, + /// Exact text to find and replace. + pub old_string: String, + /// Replacement text. + pub new_string: String, + /// Replace all occurrences (default false). + #[serde(default)] + pub replace_all: bool, +} + +/// Tool for making exact string replacements in files within allowed directories. +#[derive(Debug, Clone)] +pub struct EditTool { + resolver: AllowedPathResolver, +} + +impl EditTool { + /// Creates a new edit tool restricted to the given directories. + pub fn new(allowed_paths: impl IntoIterator>) -> ToolResult { + let paths: Vec = allowed_paths + .into_iter() + .map(|p| p.as_ref().to_path_buf()) + .collect(); + Ok(Self { + resolver: AllowedPathResolver::new(paths)?, + }) + } + + /// Creates a new edit tool with an existing resolver. + pub fn with_resolver(resolver: AllowedPathResolver) -> Self { + Self { resolver } + } +} + +impl Tool for EditTool { + const NAME: &'static str = "edit"; + + type Error = EditError; + type Args = EditArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Make exact string replacements in files within allowed directories. \ + Paths are relative to configured base directories." + .to_string(), + parameters: serde_json::to_value(schema_for!(EditArgs)) + .expect("EditArgs schema generation should not fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + edit_file( + &self.resolver, + &args.file_path, + &args.old_string, + &args.new_string, + args.replace_all, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use coding_tools_core::ToolError; + use tempfile::TempDir; + + #[tokio::test] + async fn replaces_single_occurrence() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("test.txt"), "hello world").unwrap(); + + let tool = EditTool::new([dir.path()]).unwrap(); + let result = tool + .call(EditArgs { + file_path: "test.txt".to_string(), + old_string: "world".to_string(), + new_string: "rust".to_string(), + replace_all: false, + }) + .await + .unwrap(); + assert!(result.contains("1 occurrence")); + } + + #[tokio::test] + async fn rejects_path_traversal() { + let dir = TempDir::new().unwrap(); + let tool = EditTool::new([dir.path()]).unwrap(); + let result = tool + .call(EditArgs { + file_path: "../../../etc/passwd".to_string(), + old_string: "old".to_string(), + new_string: "new".to_string(), + replace_all: false, + }) + .await; + assert!(matches!( + result, + Err(EditError::Tool(ToolError::InvalidPath(_))) + )); + } +} diff --git a/src/coding-tools-rig/src/allowed/glob.rs b/src/coding-tools-rig/src/allowed/glob.rs new file mode 100644 index 00000000..746db343 --- /dev/null +++ b/src/coding-tools-rig/src/allowed/glob.rs @@ -0,0 +1,103 @@ +//! Glob pattern file finding tool using AllowedPathResolver. + +use coding_tools_core::operations::glob_files; +use coding_tools_core::path::AllowedPathResolver; +use coding_tools_core::{GlobOutput, ToolError, ToolResult}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +/// Arguments for the glob tool. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GlobArgs { + /// Glob pattern to match files against (e.g., "**/*.rs", "src/**/*.ts"). + pub pattern: String, + /// Relative directory path to search in (within allowed directories). + pub path: String, +} + +/// Tool for finding files matching glob patterns within allowed directories. +#[derive(Debug, Clone)] +pub struct GlobTool { + resolver: AllowedPathResolver, +} + +impl GlobTool { + /// Creates a new glob tool restricted to the given directories. + pub fn new(allowed_paths: impl IntoIterator>) -> ToolResult { + let paths: Vec = allowed_paths + .into_iter() + .map(|p| p.as_ref().to_path_buf()) + .collect(); + Ok(Self { + resolver: AllowedPathResolver::new(paths)?, + }) + } + + /// Creates a new glob tool with an existing resolver. + pub fn with_resolver(resolver: AllowedPathResolver) -> Self { + Self { resolver } + } +} + +impl Tool for GlobTool { + const NAME: &'static str = "glob"; + + type Error = ToolError; + type Args = GlobArgs; + type Output = GlobOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Find files matching a glob pattern within allowed directories. \ + Paths are relative to configured base directories." + .to_string(), + parameters: serde_json::to_value(schema_for!(GlobArgs)) + .expect("schema serialization should not fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + glob_files(&self.resolver, &args.pattern, &args.path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::{self, File}; + use tempfile::TempDir; + + #[tokio::test] + async fn finds_matching_files() { + let dir = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + File::create(dir.path().join("src/lib.rs")).unwrap(); + + let tool = GlobTool::new([dir.path()]).unwrap(); + let result = tool + .call(GlobArgs { + pattern: "**/*.rs".to_string(), + path: ".".to_string(), + }) + .await + .unwrap(); + assert!(result.files.iter().any(|f| f.ends_with("lib.rs"))); + } + + #[tokio::test] + async fn rejects_path_traversal() { + let dir = TempDir::new().unwrap(); + let tool = GlobTool::new([dir.path()]).unwrap(); + let result = tool + .call(GlobArgs { + pattern: "*.rs".to_string(), + path: "../../../etc".to_string(), + }) + .await; + assert!(matches!(result, Err(ToolError::InvalidPath(_)))); + } +} diff --git a/src/coding-tools-rig/src/allowed/grep.rs b/src/coding-tools-rig/src/allowed/grep.rs new file mode 100644 index 00000000..0b83ed23 --- /dev/null +++ b/src/coding-tools-rig/src/allowed/grep.rs @@ -0,0 +1,193 @@ +//! Grep content search tool using AllowedPathResolver. + +use coding_tools_core::operations::grep_search; +use coding_tools_core::path::AllowedPathResolver; +use coding_tools_core::{ToolError, ToolOutput, ToolResult}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; +use std::fmt::Write; +use std::path::{Path, PathBuf}; + +const DEFAULT_LIMIT: usize = 100; +const MAX_LIMIT: usize = 2000; +const MAX_LINE_LENGTH: usize = 2000; + +fn default_limit() -> Option { + Some(DEFAULT_LIMIT) +} + +/// Arguments for the grep tool. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GrepArgs { + /// Regex pattern to search for in file contents. + pub pattern: String, + /// Relative directory path to search in (within allowed directories). + pub path: String, + /// Optional file glob filter (e.g., "*.rs", "*.{ts,tsx}"). + #[serde(default)] + pub include: Option, + /// Maximum number of matches to return (default: 100, max: 2000). + #[serde(default = "default_limit")] + pub limit: Option, +} + +/// Tool for searching file contents within allowed directories. +#[derive(Debug, Clone)] +pub struct GrepTool { + resolver: AllowedPathResolver, +} + +impl GrepTool { + /// Creates a new grep tool restricted to the given directories. + pub fn new(allowed_paths: impl IntoIterator>) -> ToolResult { + let paths: Vec = allowed_paths + .into_iter() + .map(|p| p.as_ref().to_path_buf()) + .collect(); + Ok(Self { + resolver: AllowedPathResolver::new(paths)?, + }) + } + + /// Creates a new grep tool with an existing resolver. + pub fn with_resolver(resolver: AllowedPathResolver) -> Self { + Self { resolver } + } +} + +impl Tool for GrepTool { + const NAME: &'static str = "grep"; + + type Error = ToolError; + type Args = GrepArgs; + type Output = ToolOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Search file contents using regex patterns within allowed directories. \ + Paths are relative to configured base directories." + .to_string(), + parameters: serde_json::to_value(schema_for!(GrepArgs)) + .expect("schema serialization should not fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let pattern = args.pattern.trim(); + if pattern.is_empty() { + return Err(ToolError::InvalidPattern( + "pattern must not be empty".into(), + )); + } + + let limit = args.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + if limit == 0 { + return Err(ToolError::InvalidPattern( + "limit must be greater than zero".into(), + )); + } + + let include = args.include.as_deref().and_then(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }); + + let result = grep_search(&self.resolver, pattern, include, &args.path, limit)?; + + if result.files.is_empty() { + return Ok(ToolOutput::new("No matches found.")); + } + + // Format output grouped by file + let mut output = String::with_capacity(4096); + let _ = writeln!(&mut output, "Found {} matches", result.match_count); + + for file in &result.files { + let _ = writeln!(&mut output, "\n{}:", file.path); + for m in &file.matches { + let truncated_text = if m.line_text.len() > MAX_LINE_LENGTH { + &m.line_text[..MAX_LINE_LENGTH] + } else { + &m.line_text + }; + if LINE_NUMBERS { + let _ = writeln!(&mut output, " L{}: {}", m.line_num, truncated_text); + } else { + let _ = writeln!(&mut output, " {}", truncated_text); + } + } + } + + if result.truncated { + let _ = write!(&mut output, "\n(Results truncated at {} matches)", limit); + } + + Ok(if result.truncated { + ToolOutput::truncated(output) + } else { + ToolOutput::new(output) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn finds_matching_content() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("test.txt"), "hello world").unwrap(); + + let tool: GrepTool = GrepTool::new([dir.path()]).unwrap(); + let result = tool + .call(GrepArgs { + pattern: "hello".to_string(), + path: ".".to_string(), + include: None, + limit: None, + }) + .await + .unwrap(); + assert!(result.content.contains("Found 1 matches")); + assert!(result.content.contains("L1: hello world")); + } + + #[tokio::test] + async fn rejects_path_traversal() { + let dir = TempDir::new().unwrap(); + let tool: GrepTool = GrepTool::new([dir.path()]).unwrap(); + let result = tool + .call(GrepArgs { + pattern: "test".to_string(), + path: "../../../etc".to_string(), + include: None, + limit: None, + }) + .await; + assert!(matches!(result, Err(ToolError::InvalidPath(_)))); + } + + #[tokio::test] + async fn rejects_empty_pattern() { + let dir = TempDir::new().unwrap(); + let tool: GrepTool = GrepTool::new([dir.path()]).unwrap(); + let result = tool + .call(GrepArgs { + pattern: " ".to_string(), + path: ".".to_string(), + include: None, + limit: None, + }) + .await; + assert!(matches!(result, Err(ToolError::InvalidPattern(_)))); + } +} diff --git a/src/coding-tools-rig/src/allowed/mod.rs b/src/coding-tools-rig/src/allowed/mod.rs new file mode 100644 index 00000000..8662ac03 --- /dev/null +++ b/src/coding-tools-rig/src/allowed/mod.rs @@ -0,0 +1,24 @@ +//! Tools using `AllowedPathResolver`. +//! +//! These tools restrict file access to configured allowed directories. +//! Use for sandboxed file system access. +//! +//! # Available Tools +//! +//! - [`ReadTool`] - Read file contents within allowed paths +//! - [`WriteTool`] - Write file contents within allowed paths +//! - [`EditTool`] - Edit file with search/replace within allowed paths +//! - [`GlobTool`] - Find files by pattern within allowed paths +//! - [`GrepTool`] - Search file contents within allowed paths + +mod edit; +mod glob; +mod grep; +mod read; +mod write; + +pub use edit::{EditArgs, EditError, EditTool}; +pub use glob::{GlobArgs, GlobTool}; +pub use grep::{GrepArgs, GrepTool}; +pub use read::{ReadArgs, ReadTool}; +pub use write::{WriteTool, WriteToolArgs}; diff --git a/src/coding-tools-rig/src/allowed/read.rs b/src/coding-tools-rig/src/allowed/read.rs new file mode 100644 index 00000000..0b22d9fe --- /dev/null +++ b/src/coding-tools-rig/src/allowed/read.rs @@ -0,0 +1,123 @@ +//! Read file tool using AllowedPathResolver. + +use coding_tools_core::operations::read_file; +use coding_tools_core::path::AllowedPathResolver; +use coding_tools_core::{ToolError, ToolOutput, ToolResult}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +const DEFAULT_OFFSET: usize = 1; +const DEFAULT_LIMIT: usize = 2000; + +fn default_offset() -> usize { + DEFAULT_OFFSET +} + +fn default_limit() -> usize { + DEFAULT_LIMIT +} + +/// Arguments for the read file tool. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct ReadArgs { + /// Relative path to the file to read (within allowed directories). + pub file_path: String, + /// 1-indexed line number to start reading from (default: 1). + #[serde(default = "default_offset")] + pub offset: usize, + /// Maximum number of lines to return (default: 2000). + #[serde(default = "default_limit")] + pub limit: usize, +} + +/// Tool for reading file contents with optional line numbers. +/// +/// Restricts access to configured allowed directories. +#[derive(Debug, Clone)] +pub struct ReadTool { + resolver: AllowedPathResolver, +} + +impl ReadTool { + /// Creates a new read tool restricted to the given directories. + pub fn new(allowed_paths: impl IntoIterator>) -> ToolResult { + let paths: Vec = allowed_paths + .into_iter() + .map(|p| p.as_ref().to_path_buf()) + .collect(); + Ok(Self { + resolver: AllowedPathResolver::new(paths)?, + }) + } + + /// Creates a new read tool with an existing resolver. + pub fn with_resolver(resolver: AllowedPathResolver) -> Self { + Self { resolver } + } +} + +impl Tool for ReadTool { + const NAME: &'static str = "read"; + + type Error = ToolError; + type Args = ReadArgs; + type Output = ToolOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + let description = if LINE_NUMBERS { + "Read file contents with line numbers from allowed directories. \ + Paths are relative to configured base directories." + } else { + "Read file contents from allowed directories. \ + Paths are relative to configured base directories." + }; + ToolDefinition { + name: Self::NAME.to_string(), + description: description.to_string(), + parameters: serde_json::to_value(schema_for!(ReadArgs)) + .expect("schema serialization should never fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + read_file::<_, LINE_NUMBERS>(&self.resolver, &args.file_path, args.offset, args.limit).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn reads_file_with_line_numbers() { + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("test.txt"); + std::fs::write(&file_path, "hello\nworld\n").unwrap(); + + let tool: ReadTool = ReadTool::new([dir.path()]).unwrap(); + let args = ReadArgs { + file_path: "test.txt".to_string(), + offset: 1, + limit: 2000, + }; + let result = tool.call(args).await.unwrap(); + assert_eq!(result.content, "L1: hello\nL2: world"); + } + + #[tokio::test] + async fn rejects_path_traversal() { + let dir = TempDir::new().unwrap(); + let tool: ReadTool = ReadTool::new([dir.path()]).unwrap(); + let args = ReadArgs { + file_path: "../../../etc/passwd".to_string(), + offset: 1, + limit: 100, + }; + let result = tool.call(args).await; + assert!(matches!(result, Err(ToolError::InvalidPath(_)))); + } +} diff --git a/src/coding-tools-rig/src/allowed/write.rs b/src/coding-tools-rig/src/allowed/write.rs new file mode 100644 index 00000000..bbead9da --- /dev/null +++ b/src/coding-tools-rig/src/allowed/write.rs @@ -0,0 +1,100 @@ +//! Write file tool using AllowedPathResolver. + +use coding_tools_core::operations::write_file; +use coding_tools_core::path::AllowedPathResolver; +use coding_tools_core::{ToolError, ToolResult}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +/// Arguments for the write tool. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct WriteToolArgs { + /// Relative path for the file to write (within allowed directories). + pub file_path: String, + /// Content to write to the file. + pub content: String, +} + +/// Tool for writing content to files within allowed directories. +#[derive(Debug, Clone)] +pub struct WriteTool { + resolver: AllowedPathResolver, +} + +impl WriteTool { + /// Creates a new write tool restricted to the given directories. + pub fn new(allowed_paths: impl IntoIterator>) -> ToolResult { + let paths: Vec = allowed_paths + .into_iter() + .map(|p| p.as_ref().to_path_buf()) + .collect(); + Ok(Self { + resolver: AllowedPathResolver::new(paths)?, + }) + } + + /// Creates a new write tool with an existing resolver. + pub fn with_resolver(resolver: AllowedPathResolver) -> Self { + Self { resolver } + } +} + +impl Tool for WriteTool { + const NAME: &'static str = "write"; + + type Error = ToolError; + type Args = WriteToolArgs; + type Output = String; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Write content to a file within allowed directories. \ + Paths are relative to configured base directories." + .to_string(), + parameters: serde_json::to_value(schema_for!(WriteToolArgs)) + .expect("schema generation should not fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + write_file(&self.resolver, &args.file_path, &args.content).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[tokio::test] + async fn writes_new_file() { + let dir = TempDir::new().unwrap(); + let tool = WriteTool::new([dir.path()]).unwrap(); + let result = tool + .call(WriteToolArgs { + file_path: "new.txt".to_string(), + content: "hello".to_string(), + }) + .await + .unwrap(); + assert!(result.contains("5 bytes")); + assert!(dir.path().join("new.txt").exists()); + } + + #[tokio::test] + async fn rejects_path_traversal() { + let dir = TempDir::new().unwrap(); + let tool = WriteTool::new([dir.path()]).unwrap(); + let result = tool + .call(WriteToolArgs { + file_path: "../../../tmp/escape.txt".to_string(), + content: "content".to_string(), + }) + .await; + assert!(matches!(result, Err(ToolError::InvalidPath(_)))); + } +} diff --git a/src/coding-tools-rig/src/bash.rs b/src/coding-tools-rig/src/bash.rs new file mode 100644 index 00000000..4f7fdf81 --- /dev/null +++ b/src/coding-tools-rig/src/bash.rs @@ -0,0 +1,131 @@ +//! Shell command execution tool. +//! +//! Provides cross-platform shell command execution with timeout support. + +use coding_tools_core::operations::execute_command; +use coding_tools_core::{BashOutput, ToolError, ToolOutput}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; +use std::path::Path; +use std::time::Duration; + +/// Default timeout: 2 minutes. +const DEFAULT_TIMEOUT_MS: u64 = 120_000; + +fn default_timeout_ms() -> u64 { + DEFAULT_TIMEOUT_MS +} + +/// Arguments for the bash tool. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct BashArgs { + /// The shell command to execute. + pub command: String, + /// Optional working directory (must be absolute path). + pub workdir: Option, + /// Timeout in milliseconds (default: 120000). + #[serde(default = "default_timeout_ms")] + pub timeout_ms: u64, +} + +/// Tool for executing shell commands. +/// +/// Uses bash on Unix, cmd on Windows. +#[derive(Debug, Clone, Copy, Default)] +pub struct BashTool; + +impl BashTool { + /// Creates a new bash tool instance. + #[inline] + pub fn new() -> Self { + Self + } +} + +impl Tool for BashTool { + const NAME: &'static str = "bash"; + + type Error = ToolError; + type Args = BashArgs; + type Output = ToolOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Execute a shell command with optional working directory and timeout." + .to_string(), + parameters: serde_json::to_value(schema_for!(BashArgs)) + .expect("schema serialization should never fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let workdir = args.workdir.as_ref().map(Path::new); + let timeout = Duration::from_millis(args.timeout_ms); + + let result = execute_command(&args.command, workdir, timeout).await?; + Ok(format_bash_output(&result)) + } +} + +fn format_bash_output(output: &BashOutput) -> ToolOutput { + let mut content = String::new(); + + if !output.stdout.is_empty() { + content.push_str(&output.stdout); + } + if !output.stderr.is_empty() { + if !content.is_empty() { + content.push('\n'); + } + content.push_str("[stderr]\n"); + content.push_str(&output.stderr); + } + + if let Some(code) = output.exit_code { + if code != 0 { + if !content.is_empty() { + content.push('\n'); + } + content.push_str(&format!("[exit code: {}]", code)); + } + } + + ToolOutput::new(content) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn executes_echo() { + let tool = BashTool::new(); + let args = BashArgs { + command: "echo hello".to_string(), + workdir: None, + timeout_ms: 5000, + }; + let result = tool.call(args).await.unwrap(); + assert!(result.content.contains("hello")); + } + + #[tokio::test] + async fn timeout_returns_error() { + let tool = BashTool::new(); + let cmd = if cfg!(target_os = "windows") { + "ping -n 10 127.0.0.1" + } else { + "sleep 10" + }; + let args = BashArgs { + command: cmd.to_string(), + workdir: None, + timeout_ms: 100, + }; + let result = tool.call(args).await; + assert!(matches!(result, Err(ToolError::Timeout(_)))); + } +} diff --git a/src/coding-tools-rig/src/lib.rs b/src/coding-tools-rig/src/lib.rs new file mode 100644 index 00000000..1029e652 --- /dev/null +++ b/src/coding-tools-rig/src/lib.rs @@ -0,0 +1,59 @@ +//! Rig framework Tool implementations for coding tools. +//! +//! This crate provides `rig_core::tool::Tool` implementations wrapping +//! the core operations from [`coding_tools_core`]. +//! +//! # Module Organization +//! +//! - [`absolute`] - Tools requiring absolute paths (no path restriction) +//! - [`allowed`] - Tools restricted to allowed directories +//! - Standalone tools (bash, task, todo, webfetch) at crate root +//! +//! # Example +//! +//! ```ignore +//! use coding_tools_rig::absolute::ReadTool; +//! use coding_tools_rig::BashTool; +//! ``` + +#![warn(missing_docs)] + +pub mod absolute; +pub mod allowed; +pub mod bash; +pub mod task; +pub mod todo; +pub mod webfetch; + +// Re-export core types for convenience +pub use coding_tools_core::{ToolError, ToolOutput, ToolResult}; + +// Re-export path resolvers +pub use coding_tools_core::path::{AbsolutePathResolver, AllowedPathResolver, PathResolver}; + +// Re-export core operation types used by tools +pub use coding_tools_core::{ + BashOutput, EditError, GlobOutput, GrepFileMatches, GrepLineMatch, GrepOutput, + MockTaskExecutor, TaskExecutor, TaskResult, Todo, TodoPriority, TodoState, TodoStatus, + WebFetchOutput, +}; + +// Re-export absolute module tool types +pub use absolute::{ + EditArgs, EditTool, GlobArgs, GlobTool, GrepArgs, GrepTool, ReadArgs, ReadTool, WriteTool, + WriteToolArgs, +}; + +/// Re-export allowed module tool types (namespaced to avoid conflicts) +pub mod allowed_tools { + pub use crate::allowed::{ + EditArgs, EditError, EditTool, GlobArgs, GlobTool, GrepArgs, GrepTool, ReadArgs, ReadTool, + WriteTool, WriteToolArgs, + }; +} + +// Re-export standalone tools +pub use bash::{BashArgs, BashTool}; +pub use task::{TaskArgs, TaskTool}; +pub use todo::{TodoReadArgs, TodoReadTool, TodoTools, TodoWriteArgs, TodoWriteTool}; +pub use webfetch::{WebFetchArgs, WebFetchTool}; diff --git a/src/coding-tools-rig/src/task.rs b/src/coding-tools-rig/src/task.rs new file mode 100644 index 00000000..6a71acc8 --- /dev/null +++ b/src/coding-tools-rig/src/task.rs @@ -0,0 +1,118 @@ +//! Task tool for launching autonomous sub-agents. +//! +//! Provides `TaskTool` for spawning sub-agents to handle complex tasks. + +use coding_tools_core::{ToolError, ToolOutput}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; +use std::sync::Arc; + +// Re-export core types +pub use coding_tools_core::{MockTaskExecutor, TaskArgs as CoreTaskArgs, TaskExecutor, TaskResult}; + +/// Arguments for the task tool (with JsonSchema for rig). +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct TaskArgs { + /// Short 3-5 word task description. + pub description: String, + /// Detailed instructions for the sub-agent. + pub prompt: String, + /// Type of agent to use (e.g., "general", "coder"). + pub subagent_type: String, + /// Existing session to continue. + #[serde(default)] + pub session_id: Option, +} + +impl From for CoreTaskArgs { + fn from(args: TaskArgs) -> Self { + CoreTaskArgs { + description: args.description, + prompt: args.prompt, + subagent_type: args.subagent_type, + session_id: args.session_id, + } + } +} + +/// Tool for delegating tasks to sub-agents. +/// +/// Generic over the executor implementation. +#[derive(Debug, Clone)] +pub struct TaskTool { + executor: Arc, +} + +impl TaskTool { + /// Creates a new task tool with the given executor. + pub fn new(executor: Arc) -> Self { + Self { executor } + } +} + +impl TaskTool { + /// Creates a task tool with mock executor for testing. + pub fn with_mock() -> (Self, Arc) { + let executor = Arc::new(MockTaskExecutor::new()); + (Self::new(executor.clone()), executor) + } +} + +impl Tool for TaskTool { + const NAME: &'static str = "task"; + + type Error = ToolError; + type Args = TaskArgs; + type Output = ToolOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Delegate a task to a specialized sub-agent.".to_string(), + parameters: serde_json::to_value(schema_for!(TaskArgs)) + .expect("schema serialization should never fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let core_args = CoreTaskArgs::from(args); + let result = self.executor.execute(&core_args).await?; + Ok(ToolOutput::new(result.format())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn mock_executor_works() { + let (tool, _executor) = TaskTool::with_mock(); + let args = TaskArgs { + description: "test task".to_string(), + prompt: "do something".to_string(), + subagent_type: "general".to_string(), + session_id: None, + }; + let result = tool.call(args).await.unwrap(); + assert!(result.content.contains("test task")); + assert!(result.content.contains("completed")); + } + + #[tokio::test] + async fn custom_mock_response() { + let (tool, executor) = TaskTool::with_mock(); + executor.set_response("custom", "Custom result!"); + + let args = TaskArgs { + description: "custom".to_string(), + prompt: "details".to_string(), + subagent_type: "coder".to_string(), + session_id: None, + }; + let result = tool.call(args).await.unwrap(); + assert!(result.content.contains("Custom result!")); + } +} diff --git a/src/coding-tools-rig/src/todo.rs b/src/coding-tools-rig/src/todo.rs new file mode 100644 index 00000000..5f863715 --- /dev/null +++ b/src/coding-tools-rig/src/todo.rs @@ -0,0 +1,181 @@ +//! Todo list management tools. +//! +//! Provides tools for reading and writing todo items. + +use coding_tools_core::operations::{read_todos, write_todos}; +use coding_tools_core::{ToolError, ToolOutput}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; + +// Re-export core types +pub use coding_tools_core::{Todo, TodoPriority, TodoState, TodoStatus}; + +/// Arguments for writing todos. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct TodoWriteArgs { + /// The complete list of todos to set. + pub todos: Vec, +} + +/// Arguments for reading todos (empty). +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct TodoReadArgs {} + +/// Tool for writing/replacing the todo list. +#[derive(Debug, Clone)] +pub struct TodoWriteTool { + state: TodoState, +} + +impl TodoWriteTool { + /// Creates a new todo write tool with the given state. + pub fn new(state: TodoState) -> Self { + Self { state } + } +} + +impl Tool for TodoWriteTool { + const NAME: &'static str = "todowrite"; + + type Error = ToolError; + type Args = TodoWriteArgs; + type Output = ToolOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Replace the todo list with new items.".to_string(), + parameters: serde_json::to_value(schema_for!(TodoWriteArgs)) + .expect("schema serialization should never fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let message = write_todos(&self.state, args.todos).await?; + Ok(ToolOutput::new(message)) + } +} + +/// Tool for reading the current todo list. +#[derive(Debug, Clone)] +pub struct TodoReadTool { + state: TodoState, +} + +impl TodoReadTool { + /// Creates a new todo read tool with the given state. + pub fn new(state: TodoState) -> Self { + Self { state } + } +} + +impl Tool for TodoReadTool { + const NAME: &'static str = "todoread"; + + type Error = ToolError; + type Args = TodoReadArgs; + type Output = ToolOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: "Read the current todo list.".to_string(), + parameters: serde_json::to_value(schema_for!(TodoReadArgs)) + .expect("schema serialization should never fail"), + } + } + + async fn call(&self, _args: Self::Args) -> Result { + let content = read_todos(&self.state).await; + Ok(ToolOutput::new(content)) + } +} + +/// Helper for creating paired todo tools with shared state. +pub struct TodoTools { + /// Tool for writing todos. + pub write: TodoWriteTool, + /// Tool for reading todos. + pub read: TodoReadTool, +} + +impl TodoTools { + /// Creates new todo tools with shared state. + pub fn new() -> Self { + let state = TodoState::new(); + Self { + write: TodoWriteTool::new(state.clone()), + read: TodoReadTool::new(state), + } + } + + /// Creates todo tools with existing state. + pub fn with_state(state: TodoState) -> Self { + Self { + write: TodoWriteTool::new(state.clone()), + read: TodoReadTool::new(state), + } + } +} + +impl Default for TodoTools { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_todo(id: &str, status: TodoStatus) -> Todo { + Todo { + id: id.to_string(), + content: format!("Task {id}"), + status, + priority: TodoPriority::Medium, + } + } + + #[tokio::test] + async fn write_and_read_todos() { + let tools = TodoTools::new(); + + let write_args = TodoWriteArgs { + todos: vec![ + make_todo("1", TodoStatus::Pending), + make_todo("2", TodoStatus::Completed), + ], + }; + let write_result = tools.write.call(write_args).await.unwrap(); + assert!(write_result.content.contains("2 task(s)")); + + let read_result = tools.read.call(TodoReadArgs {}).await.unwrap(); + assert!(read_result.content.contains("Task 1")); + assert!(read_result.content.contains("Task 2")); + } + + #[tokio::test] + async fn shared_state_works() { + let state = TodoState::new(); + let write_tool = TodoWriteTool::new(state.clone()); + let read_tool = TodoReadTool::new(state); + + let write_args = TodoWriteArgs { + todos: vec![make_todo("shared", TodoStatus::InProgress)], + }; + write_tool.call(write_args).await.unwrap(); + + let read_result = read_tool.call(TodoReadArgs {}).await.unwrap(); + assert!(read_result.content.contains("shared")); + } + + #[tokio::test] + async fn empty_list_returns_no_tasks() { + let tools = TodoTools::new(); + let result = tools.read.call(TodoReadArgs {}).await.unwrap(); + assert_eq!(result.content, "No tasks."); + } +} diff --git a/src/coding-tools-rig/src/webfetch.rs b/src/coding-tools-rig/src/webfetch.rs new file mode 100644 index 00000000..23c7bff7 --- /dev/null +++ b/src/coding-tools-rig/src/webfetch.rs @@ -0,0 +1,107 @@ +//! Web content fetching tool. +//! +//! Provides URL fetching with format conversion support. + +use coding_tools_core::operations::fetch_url; +use coding_tools_core::{ToolError, ToolOutput}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::{schema_for, JsonSchema}; +use serde::Deserialize; +use std::time::Duration; + +/// Default timeout: 30 seconds. +const DEFAULT_TIMEOUT_MS: u64 = 30_000; + +fn default_timeout_ms() -> u64 { + DEFAULT_TIMEOUT_MS +} + +/// Arguments for the webfetch tool. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct WebFetchArgs { + /// The URL to fetch. + pub url: String, + /// Timeout in milliseconds (default: 30000). + #[serde(default = "default_timeout_ms")] + pub timeout_ms: u64, +} + +/// Tool for fetching web content. +/// +/// - HTML is converted to markdown +/// - JSON is pretty-printed +/// - Other content returned as-is +#[derive(Debug, Clone)] +pub struct WebFetchTool { + client: reqwest::Client, +} + +impl Default for WebFetchTool { + fn default() -> Self { + Self::new() + } +} + +impl WebFetchTool { + /// Creates a new webfetch tool with default client. + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } + + /// Creates a webfetch tool with a custom client. + pub fn with_client(client: reqwest::Client) -> Self { + Self { client } + } +} + +impl Tool for WebFetchTool { + const NAME: &'static str = "webfetch"; + + type Error = ToolError; + type Args = WebFetchArgs; + type Output = ToolOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: + "Fetch content from a URL. HTML is converted to markdown, JSON is prettified." + .to_string(), + parameters: serde_json::to_value(schema_for!(WebFetchArgs)) + .expect("schema serialization should never fail"), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let timeout = Duration::from_millis(args.timeout_ms); + let result = fetch_url(&self.client, &args.url, timeout).await?; + + let content = format!( + "[{} - {} bytes]\n\n{}", + result.content_type, result.byte_length, result.content + ); + Ok(ToolOutput::new(content)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn creates_with_default_client() { + let _tool = WebFetchTool::new(); + } + + #[test] + fn creates_with_custom_client() { + let client = reqwest::Client::builder() + .user_agent("test") + .build() + .unwrap(); + let _tool = WebFetchTool::with_client(client); + } +} diff --git a/src/rig-coding-tools/README.MD b/src/rig-coding-tools/README.MD deleted file mode 100644 index ca025f5b..00000000 --- a/src/rig-coding-tools/README.MD +++ /dev/null @@ -1,58 +0,0 @@ -# rig-coding-tools - -[![Crates.io](https://img.shields.io/crates/v/rig-coding-tools.svg)](https://crates.io/crates/rig-coding-tools) -[![Docs.rs](https://docs.rs/rig-coding-tools/badge.svg)](https://docs.rs/rig-coding-tools) -[![CI](https://github.com/Sewer56/rig-coding-tools/actions/workflows/rust.yml/badge.svg)](https://github.com/Sewer56/rig-coding-tools/actions) - - - -Basic coding tools for rig based LLM agents - -## Features - - - -- Feature 1 -- Feature 2 -- Feature 3 - -## Installation - -Add this to your `Cargo.toml`: - -```toml -[dependencies] -rig-coding-tools = "0.1.0" -``` -### Feature Flags - -| Feature | Description | -| ------- | ----------- | -| `std` | Enable standard library support (disabled by default for `no_std` compatibility) | - -## Usage - - - -### Basic Example - -```rust -use rig_coding_tools::{ToolError, ToolResult}; - -// Tool implementations will be added in future releases -fn example() -> ToolResult<()> { - // Tools will follow the rig-core Tool trait pattern - Ok(()) -} -``` - -### Advanced Example - -Examples will be added as tools are implemented. - -## License - -Licensed under Apache 2.0 diff --git a/src/rig-coding-tools/src/lib.rs b/src/rig-coding-tools/src/lib.rs deleted file mode 100644 index 37bea69d..00000000 --- a/src/rig-coding-tools/src/lib.rs +++ /dev/null @@ -1,19 +0,0 @@ -#![doc = include_str!(concat!("../", env!("CARGO_PKG_README")))] -#![warn(missing_docs)] - -pub mod error; -pub mod output; -pub mod tools; -pub mod util; - -// Re-export primary types at crate root -pub use error::{ToolError, ToolResult}; -pub use output::ToolOutput; -pub use tools::bash::BashTool; -pub use tools::edit::{EditArgs, EditError, EditTool}; -pub use tools::grep::{GrepArgs, GrepFileMatches, GrepLineMatch, GrepOutput, GrepTool}; -pub use tools::read::{ReadArgs, ReadTool}; -pub use tools::task::{MockTaskExecutor, TaskArgs, TaskExecutor, TaskResult, TaskTool}; -pub use tools::todo::{Todo, TodoPriority, TodoReadTool, TodoState, TodoStatus, TodoWriteTool}; -pub use tools::webfetch::WebFetchTool; -pub use tools::write::{WriteTool, WriteToolArgs}; diff --git a/src/rig-coding-tools/src/tools/bash.rs b/src/rig-coding-tools/src/tools/bash.rs deleted file mode 100644 index c4108c3e..00000000 --- a/src/rig-coding-tools/src/tools/bash.rs +++ /dev/null @@ -1,346 +0,0 @@ -//! Shell command execution tool. -//! -//! Provides cross-platform shell command execution with timeout support -//! for rig-based LLM agents. - -use crate::error::{ToolError, ToolResult}; -use crate::output::ToolOutput; -use crate::util::truncate_text; -use rig::completion::ToolDefinition; -use rig::tool::Tool; -use schemars::{schema_for, JsonSchema}; -use serde::{Deserialize, Serialize}; -use std::path::Path; -use std::process::Stdio; -use std::time::Duration; -use tokio::process::Command; - -/// Maximum output size in bytes before truncation (30KB). -const MAX_OUTPUT_BYTES: usize = 30 * 1024; - -/// Default command timeout in milliseconds. -const DEFAULT_TIMEOUT_MS: u64 = 30_000; - -/// Arguments for executing a shell command. -#[derive(Debug, Clone, Deserialize, JsonSchema)] -pub struct BashArgs { - /// The shell command to execute. - pub command: String, - /// Working directory for command execution. - #[serde(default)] - pub workdir: Option, - /// Command timeout in milliseconds (default: 30000). - #[serde(default = "default_timeout")] - pub timeout_ms: u64, -} - -fn default_timeout() -> u64 { - DEFAULT_TIMEOUT_MS -} - -/// Result of shell command execution. -#[derive(Debug, Clone, Serialize)] -pub struct BashOutput { - /// Exit code from the command (None if killed by timeout). - pub exit_code: Option, - /// Standard output from the command. - pub stdout: String, - /// Standard error output from the command. - pub stderr: String, -} - -/// Shell command execution tool. -/// -/// Executes commands using the system shell (bash on Unix, cmd on Windows) -/// and captures stdout, stderr, and exit code. -/// -/// # Example -/// -/// ```rust,ignore -/// use rig_coding_tools::tools::bash::BashTool; -/// use rig::tool::Tool; -/// -/// let tool = BashTool; -/// let result = tool.call(BashArgs { -/// command: "echo hello".into(), -/// workdir: None, -/// timeout_ms: 5000, -/// }).await?; -/// ``` -#[derive(Debug, Clone, Default)] -pub struct BashTool; - -impl BashTool { - /// Creates a new [`BashTool`] instance. - pub fn new() -> Self { - Self - } - - /// Builds a [`Command`] for the given shell command string. - fn build_command(command: &str, workdir: Option<&Path>) -> Command { - let mut cmd = if cfg!(target_os = "windows") { - let mut c = Command::new("cmd"); - c.args(["/C", command]); - c - } else { - let mut c = Command::new("bash"); - c.args(["-c", command]); - c - }; - - if let Some(dir) = workdir { - cmd.current_dir(dir); - } - - cmd.stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - - cmd - } - - /// Executes the command and returns structured output. - async fn execute(args: &BashArgs) -> ToolResult { - let workdir = args.workdir.as_ref().map(Path::new); - - // Validate workdir exists if specified - if let Some(dir) = workdir { - if !dir.is_dir() { - return Err(ToolError::InvalidPath(format!( - "working directory does not exist: {}", - dir.display() - ))); - } - } - - let mut cmd = Self::build_command(&args.command, workdir); - let timeout = Duration::from_millis(args.timeout_ms); - - let result = tokio::time::timeout(timeout, cmd.output()).await; - - match result { - Ok(Ok(output)) => { - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - - Ok(BashOutput { - exit_code: output.status.code(), - stdout, - stderr, - }) - } - Ok(Err(e)) => Err(ToolError::Execution(e.to_string())), - Err(_) => Err(ToolError::Timeout(format!( - "command timed out after {}ms", - args.timeout_ms - ))), - } - } - - /// Formats output for display, handling truncation. - fn format_output(output: BashOutput) -> ToolOutput { - let (stdout, stdout_truncated) = truncate_text(&output.stdout, MAX_OUTPUT_BYTES); - let (stderr, stderr_truncated) = truncate_text(&output.stderr, MAX_OUTPUT_BYTES); - let truncated = stdout_truncated || stderr_truncated; - - let exit_display = output - .exit_code - .map(|c| c.to_string()) - .unwrap_or_else(|| "killed".to_string()); - - let content = format!( - "Exit code: {}\nstdout:\n{}\nstderr:\n{}", - exit_display, stdout, stderr - ); - - if truncated { - ToolOutput::truncated(content) - } else { - ToolOutput::new(content) - } - } -} - -impl Tool for BashTool { - const NAME: &'static str = "bash"; - - type Error = ToolError; - type Args = BashArgs; - type Output = ToolOutput; - - async fn definition(&self, _prompt: String) -> ToolDefinition { - ToolDefinition { - name: Self::NAME.to_string(), - description: "Execute a shell command and return its output.".to_string(), - parameters: serde_json::to_value(schema_for!(BashArgs)) - .expect("BashArgs schema generation should not fail"), - } - } - - async fn call(&self, args: Self::Args) -> Result { - let output = Self::execute(&args).await?; - Ok(Self::format_output(output)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[tokio::test] - async fn echo_hello_returns_output() { - let tool = BashTool::new(); - let result = tool - .call(BashArgs { - command: "echo hello".into(), - workdir: None, - timeout_ms: 5000, - }) - .await - .unwrap(); - - assert!(result.content.contains("Exit code: 0")); - assert!(result.content.contains("hello")); - } - - #[tokio::test] - async fn respects_working_directory() { - let temp = TempDir::new().unwrap(); - let tool = BashTool::new(); - - let cmd = if cfg!(target_os = "windows") { - "cd" - } else { - "pwd" - }; - - let result = tool - .call(BashArgs { - command: cmd.into(), - workdir: Some(temp.path().to_string_lossy().into_owned()), - timeout_ms: 5000, - }) - .await - .unwrap(); - - assert!(result.content.contains("Exit code: 0")); - // Output should contain the temp directory path - let temp_path = temp.path().to_string_lossy(); - assert!( - result.content.contains(temp_path.as_ref()), - "Expected path {} in output: {}", - temp_path, - result.content - ); - } - - #[tokio::test] - async fn timeout_kills_long_running_command() { - let tool = BashTool::new(); - - let cmd = if cfg!(target_os = "windows") { - "ping -n 10 127.0.0.1" - } else { - "sleep 10" - }; - - let result = tool - .call(BashArgs { - command: cmd.into(), - workdir: None, - timeout_ms: 100, - }) - .await; - - assert!(matches!(result, Err(ToolError::Timeout(_)))); - } - - #[tokio::test] - async fn captures_exit_code() { - let tool = BashTool::new(); - - let cmd = if cfg!(target_os = "windows") { - "exit /b 42" - } else { - "exit 42" - }; - - let result = tool - .call(BashArgs { - command: cmd.into(), - workdir: None, - timeout_ms: 5000, - }) - .await - .unwrap(); - - assert!(result.content.contains("Exit code: 42")); - } - - #[tokio::test] - async fn captures_stderr() { - let tool = BashTool::new(); - - let cmd = if cfg!(target_os = "windows") { - "echo error message 1>&2" - } else { - "echo 'error message' >&2" - }; - - let result = tool - .call(BashArgs { - command: cmd.into(), - workdir: None, - timeout_ms: 5000, - }) - .await - .unwrap(); - - assert!(result.content.contains("stderr:")); - assert!(result.content.contains("error message")); - } - - #[tokio::test] - async fn invalid_workdir_returns_error() { - let tool = BashTool::new(); - - let result = tool - .call(BashArgs { - command: "echo hello".into(), - workdir: Some("/nonexistent/path/that/does/not/exist".into()), - timeout_ms: 5000, - }) - .await; - - assert!(matches!(result, Err(ToolError::InvalidPath(_)))); - } - - #[tokio::test] - async fn command_not_found_returns_error_output() { - let tool = BashTool::new(); - - let result = tool - .call(BashArgs { - command: "this_command_definitely_does_not_exist_12345".into(), - workdir: None, - timeout_ms: 5000, - }) - .await - .unwrap(); - - // Command should complete but with non-zero exit - assert!(!result.content.contains("Exit code: 0")); - } - - #[test] - fn bash_args_deserializes_with_defaults() { - let json = r#"{"command": "echo test"}"#; - let args: BashArgs = serde_json::from_str(json).unwrap(); - - assert_eq!(args.command, "echo test"); - assert!(args.workdir.is_none()); - assert_eq!(args.timeout_ms, DEFAULT_TIMEOUT_MS); - } -} diff --git a/src/rig-coding-tools/src/tools/edit.rs b/src/rig-coding-tools/src/tools/edit.rs deleted file mode 100644 index 7960332c..00000000 --- a/src/rig-coding-tools/src/tools/edit.rs +++ /dev/null @@ -1,255 +0,0 @@ -//! Edit tool for exact string replacements in files. - -use crate::error::ToolError; -use crate::util::validate_absolute_path; -use rig::completion::ToolDefinition; -use rig::tool::Tool; -use schemars::{schema_for, JsonSchema}; -use serde::{Deserialize, Serialize}; -use std::path::Path; -use thiserror::Error; - -/// Tool arguments for file editing. -#[derive(Debug, Clone, Deserialize, JsonSchema)] -pub struct EditArgs { - /// Absolute path to the file to modify. - pub file_path: String, - /// Exact text to find and replace. - pub old_string: String, - /// Replacement text. - pub new_string: String, - /// Replace all occurrences (default false). - #[serde(default)] - pub replace_all: bool, -} - -/// Errors specific to edit operations. -#[derive(Debug, Error)] -pub enum EditError { - /// I/O or path validation failed. - #[error(transparent)] - Tool(#[from] ToolError), - /// old_string was empty. - #[error("old_string must not be empty")] - EmptyOldString, - /// old_string and new_string are identical. - #[error("old_string and new_string must be different")] - IdenticalStrings, - /// old_string not found in file. - #[error("old_string not found in file content")] - NotFound, - /// Multiple matches found when replace_all=false. - #[error("oldString found {0} times and requires more code context to uniquely identify the intended match")] - AmbiguousMatch(usize), -} - -/// Tool for making exact string replacements in files. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct EditTool; - -impl EditTool { - /// Creates a new [`EditTool`] instance. - #[inline] - pub fn new() -> Self { - Self - } - - /// Performs the edit operation. - async fn execute(args: EditArgs) -> Result { - // Validate arguments - if args.old_string.is_empty() { - return Err(EditError::EmptyOldString); - } - if args.old_string == args.new_string { - return Err(EditError::IdenticalStrings); - } - - let path = Path::new(&args.file_path); - validate_absolute_path(path)?; - - // Read file content - let content = tokio::fs::read_to_string(path) - .await - .map_err(ToolError::from)?; - - // Count occurrences - let count = content.matches(&args.old_string).count(); - - if count == 0 { - return Err(EditError::NotFound); - } - - if !args.replace_all && count > 1 { - return Err(EditError::AmbiguousMatch(count)); - } - - // Perform replacement - let new_content = if args.replace_all { - content.replace(&args.old_string, &args.new_string) - } else { - content.replacen(&args.old_string, &args.new_string, 1) - }; - - // Write back - tokio::fs::write(path, &new_content) - .await - .map_err(ToolError::from)?; - - Ok(format!("Successfully replaced {} occurrence(s)", count)) - } -} - -impl Tool for EditTool { - const NAME: &'static str = "edit"; - - type Error = EditError; - type Args = EditArgs; - type Output = String; - - async fn definition(&self, _prompt: String) -> ToolDefinition { - ToolDefinition { - name: Self::NAME.to_string(), - description: "Makes exact string replacements in files. Use replace_all=true to replace all occurrences.".to_string(), - parameters: serde_json::to_value(schema_for!(EditArgs)) - .expect("EditArgs schema generation should not fail"), - } - } - - async fn call(&self, args: Self::Args) -> Result { - Self::execute(args).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use tempfile::NamedTempFile; - - async fn create_temp_file(content: &str) -> NamedTempFile { - let mut file = NamedTempFile::new().unwrap(); - file.write_all(content.as_bytes()).unwrap(); - file.flush().unwrap(); - file - } - - #[tokio::test] - async fn single_replacement_succeeds() { - let file = create_temp_file("hello world").await; - let args = EditArgs { - file_path: file.path().to_string_lossy().to_string(), - old_string: "world".to_string(), - new_string: "rust".to_string(), - replace_all: false, - }; - let result = EditTool::execute(args).await.unwrap(); - assert!(result.contains("1 occurrence")); - let content = tokio::fs::read_to_string(file.path()).await.unwrap(); - assert_eq!(content, "hello rust"); - } - - #[tokio::test] - async fn replace_all_succeeds() { - let file = create_temp_file("foo bar foo baz foo").await; - let args = EditArgs { - file_path: file.path().to_string_lossy().to_string(), - old_string: "foo".to_string(), - new_string: "qux".to_string(), - replace_all: true, - }; - let result = EditTool::execute(args).await.unwrap(); - assert!(result.contains("3 occurrence")); - let content = tokio::fs::read_to_string(file.path()).await.unwrap(); - assert_eq!(content, "qux bar qux baz qux"); - } - - #[tokio::test] - async fn no_match_returns_error() { - let file = create_temp_file("hello world").await; - let args = EditArgs { - file_path: file.path().to_string_lossy().to_string(), - old_string: "missing".to_string(), - new_string: "replacement".to_string(), - replace_all: false, - }; - let err = EditTool::execute(args).await.unwrap_err(); - assert!(matches!(err, EditError::NotFound)); - } - - #[tokio::test] - async fn ambiguous_match_returns_error() { - let file = create_temp_file("foo bar foo").await; - let args = EditArgs { - file_path: file.path().to_string_lossy().to_string(), - old_string: "foo".to_string(), - new_string: "baz".to_string(), - replace_all: false, - }; - let err = EditTool::execute(args).await.unwrap_err(); - assert!(matches!(err, EditError::AmbiguousMatch(2))); - } - - #[tokio::test] - async fn empty_old_string_returns_error() { - let file = create_temp_file("content").await; - let args = EditArgs { - file_path: file.path().to_string_lossy().to_string(), - old_string: "".to_string(), - new_string: "replacement".to_string(), - replace_all: false, - }; - let err = EditTool::execute(args).await.unwrap_err(); - assert!(matches!(err, EditError::EmptyOldString)); - } - - #[tokio::test] - async fn identical_strings_returns_error() { - let file = create_temp_file("content").await; - let args = EditArgs { - file_path: file.path().to_string_lossy().to_string(), - old_string: "same".to_string(), - new_string: "same".to_string(), - replace_all: false, - }; - let err = EditTool::execute(args).await.unwrap_err(); - assert!(matches!(err, EditError::IdenticalStrings)); - } - - #[tokio::test] - async fn relative_path_returns_error() { - let args = EditArgs { - file_path: "relative/path.txt".to_string(), - old_string: "old".to_string(), - new_string: "new".to_string(), - replace_all: false, - }; - let err = EditTool::execute(args).await.unwrap_err(); - assert!(matches!(err, EditError::Tool(ToolError::InvalidPath(_)))); - } - - #[tokio::test] - async fn file_not_found_returns_error() { - let args = EditArgs { - file_path: "/nonexistent/path/file.txt".to_string(), - old_string: "old".to_string(), - new_string: "new".to_string(), - replace_all: false, - }; - let err = EditTool::execute(args).await.unwrap_err(); - assert!(matches!(err, EditError::Tool(ToolError::Io(_)))); - } - - #[tokio::test] - async fn preserves_whitespace_exactly() { - let file = create_temp_file(" indented\n\tmore\n").await; - let args = EditArgs { - file_path: file.path().to_string_lossy().to_string(), - old_string: "indented".to_string(), - new_string: "REPLACED".to_string(), - replace_all: false, - }; - EditTool::execute(args).await.unwrap(); - let content = tokio::fs::read_to_string(file.path()).await.unwrap(); - assert_eq!(content, " REPLACED\n\tmore\n"); - } -} diff --git a/src/rig-coding-tools/src/tools/glob.rs b/src/rig-coding-tools/src/tools/glob.rs deleted file mode 100644 index e94e2abf..00000000 --- a/src/rig-coding-tools/src/tools/glob.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! Glob pattern file finding tool. -//! -//! Finds files matching glob patterns like `**/*.rs` while respecting `.gitignore`. - -use crate::error::{ToolError, ToolResult}; -use crate::util::validate_absolute_path; -use glob::Pattern; -use ignore::WalkBuilder; -use rig::completion::ToolDefinition; -use rig::tool::Tool; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::path::Path; -use std::time::SystemTime; - -/// Maximum number of file matches to return. -const MAX_RESULTS: usize = 1000; - -/// Arguments for the glob tool. -#[derive(Debug, Deserialize, JsonSchema)] -pub struct GlobArgs { - /// Glob pattern to match files against (e.g., "**/*.rs", "src/**/*.ts"). - pub pattern: String, - /// Absolute directory path to search in. - pub path: String, -} - -/// Output from the glob tool. -#[derive(Debug, Serialize)] -pub struct GlobOutput { - /// Matched file paths relative to search directory, sorted by mtime (newest first). - pub files: Vec, - /// Whether results were truncated due to limit. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub truncated: bool, -} - -/// Tool for finding files matching glob patterns. -/// -/// Walks directory trees matching files against glob patterns while respecting -/// `.gitignore` files. Results are sorted by modification time (newest first). -#[derive(Debug, Default, Clone, Copy)] -pub struct GlobTool; - -impl Tool for GlobTool { - const NAME: &'static str = "glob"; - - type Error = ToolError; - type Args = GlobArgs; - type Output = GlobOutput; - - async fn definition(&self, _prompt: String) -> ToolDefinition { - ToolDefinition { - name: Self::NAME.to_string(), - description: "Find files matching a glob pattern. Respects .gitignore and \ - returns paths sorted by modification time (newest first)." - .to_string(), - parameters: serde_json::to_value(schemars::schema_for!(GlobArgs)) - .expect("schema serialization should not fail"), - } - } - - async fn call(&self, args: Self::Args) -> Result { - glob_files(&args.pattern, &args.path) - } -} - -/// Finds files matching a glob pattern in the given directory. -fn glob_files(pattern: &str, search_path: &str) -> ToolResult { - let path = Path::new(search_path); - validate_absolute_path(path)?; - - if !path.is_dir() { - return Err(ToolError::InvalidPath(format!( - "path is not a directory: {}", - path.display() - ))); - } - - // Compile the glob pattern for matching - let compiled_pattern = - Pattern::new(pattern).map_err(|e| ToolError::InvalidPattern(e.to_string()))?; - - // Collect files with modification times - let mut files_with_mtime: Vec<(String, SystemTime)> = Vec::new(); - - let walker = WalkBuilder::new(path) - .hidden(false) // Include hidden files - .git_ignore(true) // Respect .gitignore - .git_global(true) // Respect global gitignore - .git_exclude(true) // Respect .git/info/exclude - .build(); - - for entry_result in walker { - let entry = match entry_result { - Ok(e) => e, - Err(_) => continue, // Skip permission errors - }; - - // Skip directories - if let Some(ft) = entry.file_type() { - if ft.is_dir() { - continue; - } - } else { - continue; - } - - // Get relative path - let rel_path = match entry.path().strip_prefix(path) { - Ok(p) => p.to_string_lossy().into_owned(), - Err(_) => continue, - }; - - // Skip empty paths (root directory itself) - if rel_path.is_empty() { - continue; - } - - // Check if relative path matches the pattern - if !compiled_pattern.matches(&rel_path) { - continue; - } - - // Get modification time - let mtime = entry - .metadata() - .ok() - .and_then(|m| m.modified().ok()) - .unwrap_or(SystemTime::UNIX_EPOCH); - - files_with_mtime.push((rel_path, mtime)); - } - - // Sort by modification time (newest first) - files_with_mtime.sort_by(|a, b| b.1.cmp(&a.1)); - - // Check if truncation is needed - let truncated = files_with_mtime.len() > MAX_RESULTS; - - // Extract paths, truncating if needed - let files: Vec = files_with_mtime - .into_iter() - .take(MAX_RESULTS) - .map(|(path, _)| path) - .collect(); - - Ok(GlobOutput { files, truncated }) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs::{self, File}; - use std::io::Write; - use std::thread; - use std::time::Duration; - use tempfile::TempDir; - - fn create_test_tree() -> TempDir { - let dir = TempDir::new().unwrap(); - let base = dir.path(); - - // Create .git directory so ignore crate recognizes this as a git repo - fs::create_dir_all(base.join(".git")).unwrap(); - - // Create directory structure - fs::create_dir_all(base.join("src")).unwrap(); - fs::create_dir_all(base.join("tests")).unwrap(); - fs::create_dir_all(base.join("target/debug")).unwrap(); - - // Create files with slight delays for mtime ordering - File::create(base.join("src/lib.rs")).unwrap(); - thread::sleep(Duration::from_millis(10)); - File::create(base.join("src/main.rs")).unwrap(); - thread::sleep(Duration::from_millis(10)); - File::create(base.join("tests/test.rs")).unwrap(); - File::create(base.join("Cargo.toml")).unwrap(); - File::create(base.join("target/debug/binary")).unwrap(); - - // Create .gitignore - let mut gitignore = File::create(base.join(".gitignore")).unwrap(); - writeln!(gitignore, "target/").unwrap(); - - dir - } - - #[test] - fn glob_matches_simple_pattern() { - let dir = create_test_tree(); - let result = glob_files("*.toml", dir.path().to_str().unwrap()).unwrap(); - assert_eq!(result.files, vec!["Cargo.toml"]); - assert!(!result.truncated); - } - - #[test] - fn glob_matches_recursive_pattern() { - let dir = create_test_tree(); - let result = glob_files("**/*.rs", dir.path().to_str().unwrap()).unwrap(); - assert_eq!(result.files.len(), 3); - assert!(result.files.iter().any(|f| f.ends_with("lib.rs"))); - assert!(result.files.iter().any(|f| f.ends_with("main.rs"))); - assert!(result.files.iter().any(|f| f.ends_with("test.rs"))); - } - - #[test] - fn glob_respects_gitignore() { - let dir = create_test_tree(); - let result = glob_files("**/*", dir.path().to_str().unwrap()).unwrap(); - // target/ should be excluded - assert!(!result.files.iter().any(|f| f.contains("target"))); - } - - #[test] - fn glob_sorts_by_mtime_newest_first() { - let dir = create_test_tree(); - let result = glob_files("src/*.rs", dir.path().to_str().unwrap()).unwrap(); - assert_eq!(result.files.len(), 2); - // main.rs was created after lib.rs, so should be first - assert!(result.files[0].ends_with("main.rs")); - assert!(result.files[1].ends_with("lib.rs")); - } - - #[test] - fn glob_rejects_relative_path() { - let result = glob_files("*.rs", "relative/path"); - assert!(matches!(result, Err(ToolError::InvalidPath(_)))); - } - - #[test] - fn glob_rejects_nonexistent_directory() { - let result = glob_files("*.rs", "/nonexistent/path/that/does/not/exist"); - assert!(result.is_err()); - } - - #[test] - fn glob_handles_invalid_pattern() { - let dir = TempDir::new().unwrap(); - let result = glob_files("[invalid", dir.path().to_str().unwrap()); - assert!(matches!(result, Err(ToolError::InvalidPattern(_)))); - } -} diff --git a/src/rig-coding-tools/src/tools/grep.rs b/src/rig-coding-tools/src/tools/grep.rs deleted file mode 100644 index 3b24f90a..00000000 --- a/src/rig-coding-tools/src/tools/grep.rs +++ /dev/null @@ -1,552 +0,0 @@ -//! Grep tool for searching file contents using regex patterns. - -use crate::error::{ToolError, ToolResult}; -use crate::output::ToolOutput; -use crate::util::{truncate_line, validate_absolute_path}; -use glob::Pattern; -use grep::regex::RegexMatcher; -use grep::searcher::sinks::UTF8; -use grep::searcher::{BinaryDetection, Searcher, SearcherBuilder}; -use ignore::WalkBuilder; -use rig::completion::ToolDefinition; -use rig::tool::Tool; -use schemars::{schema_for, JsonSchema}; -use serde::{Deserialize, Serialize}; -use std::fmt::Write; -use std::path::Path; -use std::time::SystemTime; - -const DEFAULT_LIMIT: usize = 100; -const MAX_LIMIT: usize = 2000; -const MAX_LINE_LENGTH: usize = 2000; - -fn default_limit() -> Option { - Some(DEFAULT_LIMIT) -} - -/// Arguments for the grep tool. -#[derive(Debug, Deserialize, JsonSchema)] -pub struct GrepArgs { - /// Regex pattern to search for in file contents. - pub pattern: String, - /// Absolute directory path to search in. - pub path: String, - /// Optional file glob filter (e.g., "*.rs", "*.{ts,tsx}"). - #[serde(default)] - pub include: Option, - /// Maximum number of matches to return. - #[serde(default = "default_limit")] - pub limit: Option, -} - -/// A single line match within a file. -#[derive(Debug, Clone, Serialize)] -pub struct GrepLineMatch { - /// 1-indexed line number. - pub line_num: u64, - /// Content of the matched line. - pub line_text: String, -} - -/// All matches within a single file. -#[derive(Debug, Clone, Serialize)] -pub struct GrepFileMatches { - /// File path. - pub path: String, - /// Matches in this file, in line order. - pub matches: Vec, - /// Modification time (used for sorting, not serialized). - #[serde(skip)] - mtime: SystemTime, -} - -/// Output from the grep tool. -#[derive(Debug, Serialize)] -pub struct GrepOutput { - /// Files with matches, sorted by modification time (newest first). - pub files: Vec, - /// Total match count across all files. - pub match_count: usize, - /// Whether results were truncated due to limit. - pub truncated: bool, -} - -/// Tool for searching file contents using regex patterns. -/// -/// Finds files containing content matching a regex pattern within a directory. -/// Results are sorted by modification time (most recent first). -/// Binary files are automatically skipped. -/// -/// The const generic `LINE_NUMBERS` controls whether lines are prefixed -/// with `L{number}: `. When `true` (default), output includes line numbers -/// for easier navigation. When `false`, only file paths and content are shown. -/// -/// # Examples -/// -/// ``` -/// use rig_coding_tools::GrepTool; -/// -/// // With line numbers (default) -/// let tool: GrepTool = GrepTool::new(); -/// // or: GrepTool::::new() -/// -/// // Without line numbers -/// let raw_tool = GrepTool::::new(); -/// ``` -#[derive(Debug, Clone, Default)] -pub struct GrepTool; - -impl GrepTool { - /// Creates a new grep tool instance. - #[inline] - pub fn new() -> Self { - Self - } -} - -impl Tool for GrepTool { - const NAME: &'static str = "grep"; - - type Error = ToolError; - type Args = GrepArgs; - type Output = ToolOutput; - - async fn definition(&self, _prompt: String) -> ToolDefinition { - let description = if LINE_NUMBERS { - "Search file contents using regex patterns. Returns matches with file paths, \ - line numbers, and content, sorted by file modification time." - } else { - "Search file contents using regex patterns. Returns matches with file paths \ - and content, sorted by file modification time." - }; - ToolDefinition { - name: Self::NAME.to_string(), - description: description.to_string(), - parameters: serde_json::to_value(schema_for!(GrepArgs)) - .expect("schema serialization should not fail"), - } - } - - async fn call(&self, args: Self::Args) -> Result { - let path = Path::new(&args.path); - validate_absolute_path(path)?; - - let pattern = args.pattern.trim(); - if pattern.is_empty() { - return Err(ToolError::InvalidPattern( - "pattern must not be empty".into(), - )); - } - - let limit = args.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); - if limit == 0 { - return Err(ToolError::InvalidPattern( - "limit must be greater than zero".into(), - )); - } - - let include = args.include.as_deref().and_then(|s| { - let trimmed = s.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed) - } - }); - - let result = run_grep_search(pattern, include, path, limit)?; - - if result.files.is_empty() { - return Ok(ToolOutput::new("No matches found.")); - } - - // Format output grouped by file (51 lines at up to 80 characters) - let mut output = String::with_capacity(4096); - let _ = writeln!(&mut output, "Found {} matches", result.match_count); - - for file in &result.files { - let _ = writeln!(&mut output, "\n{}:", file.path); - for m in &file.matches { - let (truncated_text, _) = truncate_line(&m.line_text, MAX_LINE_LENGTH); - // Branch eliminated at compile time due to const generic - if LINE_NUMBERS { - let _ = writeln!(&mut output, " L{}: {}", m.line_num, truncated_text); - } else { - let _ = writeln!(&mut output, " {}", truncated_text); - } - } - } - - if result.truncated { - let _ = write!(&mut output, "\n(Results truncated at {} matches)", limit); - } - - Ok(if result.truncated { - ToolOutput::truncated(output) - } else { - ToolOutput::new(output) - }) - } -} - -/// Execute grep search using the grep crate library. -fn run_grep_search( - pattern: &str, - include: Option<&str>, - search_path: &Path, - limit: usize, -) -> ToolResult { - // Compile the regex matcher for content searching - let matcher = - RegexMatcher::new(pattern).map_err(|e| ToolError::InvalidPattern(e.to_string()))?; - - // Compile glob pattern if provided - let glob_pattern = include - .map(|g| Pattern::new(g).map_err(|e| ToolError::InvalidPattern(e.to_string()))) - .transpose()?; - - // Build searcher once, reuse for all files (as recommended by grep-searcher docs) - let mut searcher = SearcherBuilder::new() - .binary_detection(BinaryDetection::quit(0)) - .build(); - - // Collect files directly into final structure (pre-allocate ~4KiB) - let mut files = Vec::with_capacity(4096 / size_of::()); - - let walker = WalkBuilder::new(search_path) - .hidden(false) - .git_ignore(true) - .git_global(true) - .git_exclude(true) - .build(); - - for entry_result in walker { - let entry = match entry_result { - Ok(e) => e, - Err(_) => continue, - }; - - // Skip directories - let file_type = match entry.file_type() { - Some(ft) if ft.is_file() => ft, - _ => continue, - }; - - // Skip symlinks - if file_type.is_symlink() { - continue; - } - - let entry_path = entry.path(); - - // Apply glob filter if provided - if let Some(ref glob) = glob_pattern { - let file_name = match entry_path.file_name().and_then(|n| n.to_str()) { - Some(name) => name, - None => continue, - }; - if !glob.matches(file_name) { - continue; - } - } - - // Collect all matches from file - let matches = collect_file_matches(&matcher, &mut searcher, entry_path); - if matches.is_empty() { - continue; - } - - // Get modification time - let mtime = entry - .metadata() - .ok() - .and_then(|m| m.modified().ok()) - .unwrap_or(SystemTime::UNIX_EPOCH); - - files.push(GrepFileMatches { - path: entry_path.to_string_lossy().into_owned(), - matches, - mtime, - }); - } - - // Sort by modification time (newest first) - files.sort_by(|a, b| b.mtime.cmp(&a.mtime)); - - // Apply limit by truncating matches in place - let mut match_count = 0; - let mut truncate_at = files.len(); - let mut truncated = false; - - for (x, file) in files.iter_mut().enumerate() { - let remaining = limit - match_count; - if file.matches.len() > remaining { - file.matches.truncate(remaining); - match_count += remaining; - truncate_at = x + 1; - truncated = true; - break; - } - match_count += file.matches.len(); - } - - files.truncate(truncate_at); - - Ok(GrepOutput { - files, - match_count, - truncated, - }) -} - -/// Collect all matches from a file with line numbers and content. -fn collect_file_matches( - matcher: &RegexMatcher, - searcher: &mut Searcher, - path: &Path, -) -> Vec { - let mut matches = Vec::new(); - - // Searcher only invokes sink for lines matching the pattern - let _ = searcher.search_path( - matcher, - path, - UTF8(|line_num, line| { - matches.push(GrepLineMatch { - line_num, - line_text: line.trim_end().to_string(), - }); - Ok(true) - }), - ); - - matches -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[test] - fn grep_validates_empty_pattern() { - let result = run_grep_search("", None, Path::new("/tmp"), 10); - // Empty pattern after trim should be caught before this function - // but RegexMatcher will accept empty pattern, so this tests the flow - assert!(result.is_ok() || matches!(result, Err(ToolError::InvalidPattern(_)))); - } - - #[test] - fn grep_validates_invalid_regex() { - let result = run_grep_search("[invalid", None, Path::new("/tmp"), 10); - assert!(matches!(result, Err(ToolError::InvalidPattern(_)))); - } - - #[tokio::test] - async fn grep_tool_validates_absolute_path() { - let tool: GrepTool = GrepTool::new(); - let args = GrepArgs { - pattern: "test".into(), - path: "relative/path".into(), - include: None, - limit: None, - }; - let result = tool.call(args).await; - assert!(matches!(result, Err(ToolError::InvalidPath(_)))); - } - - #[tokio::test] - async fn grep_tool_validates_empty_pattern() { - let tool: GrepTool = GrepTool::new(); - let args = GrepArgs { - pattern: " ".into(), - path: "/tmp".into(), - include: None, - limit: None, - }; - let result = tool.call(args).await; - assert!(matches!(result, Err(ToolError::InvalidPattern(_)))); - } - - #[tokio::test] - async fn grep_tool_validates_invalid_regex() { - let tool: GrepTool = GrepTool::new(); - let args = GrepArgs { - pattern: "[invalid".into(), - path: "/tmp".into(), - include: None, - limit: None, - }; - let result = tool.call(args).await; - assert!(matches!(result, Err(ToolError::InvalidPattern(_)))); - } - - #[test] - fn run_grep_search_finds_matches() { - let temp = tempdir().unwrap(); - let dir = temp.path(); - std::fs::write(dir.join("match.txt"), "hello world").unwrap(); - std::fs::write(dir.join("other.txt"), "goodbye").unwrap(); - - let result = run_grep_search("hello", None, dir, 10).unwrap(); - assert_eq!(result.files.len(), 1); - assert_eq!(result.match_count, 1); - assert!(result.files[0].path.ends_with("match.txt")); - assert_eq!(result.files[0].matches[0].line_num, 1); - assert_eq!(result.files[0].matches[0].line_text, "hello world"); - } - - #[test] - fn run_grep_search_respects_glob_filter() { - let temp = tempdir().unwrap(); - let dir = temp.path(); - std::fs::write(dir.join("match.rs"), "hello world").unwrap(); - std::fs::write(dir.join("match.txt"), "hello world").unwrap(); - - let result = run_grep_search("hello", Some("*.rs"), dir, 10).unwrap(); - assert_eq!(result.files.len(), 1); - assert!(result.files[0].path.ends_with(".rs")); - } - - #[test] - fn run_grep_search_respects_limit() { - let temp = tempdir().unwrap(); - let dir = temp.path(); - std::fs::write(dir.join("a.txt"), "pattern\npattern").unwrap(); - std::fs::write(dir.join("b.txt"), "pattern").unwrap(); - - let result = run_grep_search("pattern", None, dir, 2).unwrap(); - assert_eq!(result.match_count, 2); - assert!(result.truncated); - } - - #[test] - fn run_grep_search_returns_empty_on_no_match() { - let temp = tempdir().unwrap(); - let dir = temp.path(); - std::fs::write(dir.join("file.txt"), "content").unwrap(); - - let result = run_grep_search("nonexistent", None, dir, 10).unwrap(); - assert!(result.files.is_empty()); - assert_eq!(result.match_count, 0); - assert!(!result.truncated); - } - - #[test] - fn run_grep_search_supports_regex() { - let temp = tempdir().unwrap(); - let dir = temp.path(); - std::fs::write(dir.join("match.txt"), "foo123bar").unwrap(); - std::fs::write(dir.join("nomatch.txt"), "foobar").unwrap(); - - let result = run_grep_search(r"foo\d+bar", None, dir, 10).unwrap(); - assert_eq!(result.files.len(), 1); - assert!(result.files[0].path.ends_with("match.txt")); - assert_eq!(result.files[0].matches[0].line_text, "foo123bar"); - } - - #[test] - fn run_grep_search_skips_binary_files() { - let temp = tempdir().unwrap(); - let dir = temp.path(); - - // Create a text file with a match - std::fs::write(dir.join("text.txt"), "hello world").unwrap(); - - // Create a binary file with null bytes before the match text - // Binary detection triggers when null bytes are encountered - let mut binary_content = vec![0u8; 10]; // Null bytes first - binary_content.extend_from_slice(b"hello world"); - std::fs::write(dir.join("binary.bin"), &binary_content).unwrap(); - - let result = run_grep_search("hello", None, dir, 10).unwrap(); - // Should only find the text file, not the binary - assert_eq!(result.files.len(), 1); - assert!(result.files[0].path.ends_with("text.txt")); - } - - #[test] - fn run_grep_search_collects_multiple_matches_per_file() { - let temp = tempdir().unwrap(); - let dir = temp.path(); - std::fs::write(dir.join("multi.txt"), "hello\nworld\nhello again").unwrap(); - - let result = run_grep_search("hello", None, dir, 10).unwrap(); - assert_eq!(result.files.len(), 1); - assert_eq!(result.match_count, 2); - let matches = &result.files[0].matches; - assert_eq!(matches[0].line_num, 1); - assert_eq!(matches[0].line_text, "hello"); - assert_eq!(matches[1].line_num, 3); - assert_eq!(matches[1].line_text, "hello again"); - } - - #[test] - fn collect_file_matches_returns_matches_for_matching_file() { - let temp = tempdir().unwrap(); - let file_path = temp.path().join("test.txt"); - std::fs::write(&file_path, "hello world\ngoodbye\nhello again").unwrap(); - - let matcher = RegexMatcher::new("hello").unwrap(); - let mut searcher = SearcherBuilder::new() - .binary_detection(BinaryDetection::quit(0)) - .build(); - let matches = collect_file_matches(&matcher, &mut searcher, &file_path); - assert_eq!(matches.len(), 2); - assert_eq!(matches[0].line_num, 1); - assert_eq!(matches[0].line_text, "hello world"); - assert_eq!(matches[1].line_num, 3); - assert_eq!(matches[1].line_text, "hello again"); - } - - #[test] - fn collect_file_matches_returns_empty_for_non_matching_file() { - let temp = tempdir().unwrap(); - let file_path = temp.path().join("test.txt"); - std::fs::write(&file_path, "goodbye world").unwrap(); - - let matcher = RegexMatcher::new("hello").unwrap(); - let mut searcher = SearcherBuilder::new() - .binary_detection(BinaryDetection::quit(0)) - .build(); - let matches = collect_file_matches(&matcher, &mut searcher, &file_path); - assert!(matches.is_empty()); - } - - #[tokio::test] - async fn grep_tool_formats_output_with_line_numbers() { - let temp = tempdir().unwrap(); - let dir = temp.path(); - std::fs::write(dir.join("test.txt"), "hello world").unwrap(); - - let tool: GrepTool = GrepTool::new(); - let args = GrepArgs { - pattern: "hello".into(), - path: dir.to_string_lossy().into_owned(), - include: None, - limit: None, - }; - let result = tool.call(args).await.unwrap(); - assert!(result.content.contains("Found 1 matches")); - assert!(result.content.contains("L1: hello world")); - } - - #[tokio::test] - async fn grep_tool_formats_output_without_line_numbers() { - let temp = tempdir().unwrap(); - let dir = temp.path(); - std::fs::write(dir.join("test.txt"), "hello world").unwrap(); - - let tool: GrepTool = GrepTool::new(); - let args = GrepArgs { - pattern: "hello".into(), - path: dir.to_string_lossy().into_owned(), - include: None, - limit: None, - }; - let result = tool.call(args).await.unwrap(); - assert!(result.content.contains("Found 1 matches")); - assert!(result.content.contains(" hello world")); - assert!(!result.content.contains("L1:")); - } -} diff --git a/src/rig-coding-tools/src/tools/mod.rs b/src/rig-coding-tools/src/tools/mod.rs deleted file mode 100644 index c85eccd3..00000000 --- a/src/rig-coding-tools/src/tools/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Tool implementations for rig-based LLM agents. -//! -//! Each submodule implements a specific tool following the `rig_core::tool::Tool` trait. - -// Tool submodules will be added here as they are implemented: -pub mod bash; -pub mod edit; -pub mod glob; -pub mod grep; -pub mod read; -pub mod task; -pub mod todo; -pub mod webfetch; -pub mod write; -// pub mod skill; - -// Re-exports -pub use bash::BashTool; -pub use grep::{GrepArgs, GrepFileMatches, GrepLineMatch, GrepOutput, GrepTool}; -pub use read::ReadTool; -pub use todo::{Todo, TodoPriority, TodoReadTool, TodoState, TodoStatus, TodoWriteTool}; -pub use webfetch::WebFetchTool; diff --git a/src/rig-coding-tools/src/tools/read.rs b/src/rig-coding-tools/src/tools/read.rs deleted file mode 100644 index 64c4d602..00000000 --- a/src/rig-coding-tools/src/tools/read.rs +++ /dev/null @@ -1,419 +0,0 @@ -//! Read file tool for reading file contents with optional line numbers. - -use crate::error::{ToolError, ToolResult}; -use crate::output::ToolOutput; -use crate::util::{truncate_line, validate_absolute_path, ESTIMATED_CHARS_PER_LINE}; -use memchr::memchr; -use rig::completion::ToolDefinition; -use rig::tool::Tool; -use schemars::{schema_for, JsonSchema}; -use serde::Deserialize; -use std::borrow::Cow; -use std::fmt::Write; -use std::path::Path; -use tokio::fs::File; -use tokio::io::{AsyncBufReadExt, BufReader}; - -const MAX_LINE_LENGTH: usize = 2000; -const DEFAULT_OFFSET: usize = 1; -const DEFAULT_LIMIT: usize = 2000; - -fn default_offset() -> usize { - DEFAULT_OFFSET -} - -fn default_limit() -> usize { - DEFAULT_LIMIT -} - -/// Arguments for the read file tool. -#[derive(Debug, Clone, Deserialize, JsonSchema)] -pub struct ReadArgs { - /// Absolute path to the file to read. - pub file_path: String, - /// 1-indexed line number to start reading from (default: 1). - #[serde(default = "default_offset")] - pub offset: usize, - /// Maximum number of lines to return (default: 2000). - #[serde(default = "default_limit")] - pub limit: usize, -} - -/// Tool for reading file contents with optional line numbers. -/// -/// The const generic `LINE_NUMBERS` controls whether lines are prefixed -/// with `L{number}: `. When `true` (default), output includes line numbers -/// for easier editing. When `false`, raw content is returned. -/// -/// # Examples -/// -/// ``` -/// use rig_coding_tools::tools::ReadTool; -/// -/// // With line numbers (explicit type needed for inference) -/// let tool: ReadTool = ReadTool::new(); -/// // or: ReadTool::::new() -/// -/// // Without line numbers -/// let raw_tool = ReadTool::::new(); -/// ``` -#[derive(Debug, Clone, Default)] -pub struct ReadTool; - -impl ReadTool { - /// Creates a new read tool instance. - #[inline] - pub fn new() -> Self { - Self - } -} - -impl Tool for ReadTool { - const NAME: &'static str = "read"; - - type Error = ToolError; - type Args = ReadArgs; - type Output = ToolOutput; - - async fn definition(&self, _prompt: String) -> ToolDefinition { - let description = if LINE_NUMBERS { - "Read file contents with line numbers. Returns lines prefixed with L{number}: format." - } else { - "Read file contents. Returns raw file content without line number prefixes." - }; - ToolDefinition { - name: Self::NAME.to_string(), - description: description.to_string(), - parameters: serde_json::to_value(schema_for!(ReadArgs)) - .expect("schema serialization should never fail"), - } - } - - async fn call(&self, args: Self::Args) -> Result { - read_file::(&args.file_path, args.offset, args.limit).await - } -} - -/// Strips trailing CR from a line (for CRLF handling). -#[inline] -fn strip_cr(line: &[u8]) -> &[u8] { - line.strip_suffix(b"\r").unwrap_or(line) -} - -/// Processes a single line, appending it to output with optional line numbers. -/// -/// This is the hot path - called for every line in the file within the requested range. -/// Uses zero-copy where possible via [`Cow`]. -#[inline] -fn process_line( - line_bytes: &[u8], - line_number: usize, - output: &mut String, - lines_output: &mut usize, -) { - // Strip trailing CR for CRLF line endings - let line_bytes = strip_cr(line_bytes); - - // Convert to string with lossy UTF-8 handling (zero-copy for valid UTF-8) - let content: Cow<'_, str> = String::from_utf8_lossy(line_bytes); - - // Truncate long lines - let (truncated_content, _) = truncate_line(&content, MAX_LINE_LENGTH); - - // Add newline separator for subsequent lines - // We do it here to avoid trailing newline at end of output - if *lines_output > 0 { - output.push('\n'); - } - - // Branch eliminated at compile time due to const generic - if LINE_NUMBERS { - // write! to String is infallible - let _ = write!(output, "L{}: {}", line_number, truncated_content); - } else { - output.push_str(truncated_content); - } - - *lines_output += 1; -} - -/// Reads a file and returns formatted content, optionally with line numbers. -/// -/// When `LINE_NUMBERS` is `true`, each line is prefixed with `L{number}: `. -/// When `false`, raw content is returned without prefixes. -async fn read_file( - file_path: &str, - offset: usize, - limit: usize, -) -> ToolResult { - // Validate arguments - if offset == 0 { - return Err(ToolError::OutOfBounds( - "offset must be >= 1 (1-indexed)".into(), - )); - } - if limit == 0 { - return Err(ToolError::OutOfBounds("limit must be >= 1".into())); - } - - let path = Path::new(file_path); - validate_absolute_path(path)?; - - // Buffer for lines spanning multiple fills (rare case) - // Rare enough for me to not alloc. Most files are under `limit * ESTIMATED_CHARS_PER_LINE`. - let mut overflow: Vec = Vec::new(); - - let file = File::open(path).await?; - // Size buffer for expected content, rounded to next power of 2 - let buf_capacity = (limit * ESTIMATED_CHARS_PER_LINE).next_power_of_two(); - let mut reader = BufReader::with_capacity(buf_capacity, file); - - // Pre-allocate output based on expected content size - let estimated_capacity = limit * ESTIMATED_CHARS_PER_LINE; - let mut output = String::with_capacity(estimated_capacity); - let mut line_number = 0usize; - let mut lines_output = 0usize; - - loop { - let buf = reader.fill_buf().await?; - if buf.is_empty() { - // EOF - handle any remaining overflow as final line (no trailing newline) - if !overflow.is_empty() { - line_number += 1; - if line_number >= offset && lines_output < limit { - process_line::( - &overflow, - line_number, - &mut output, - &mut lines_output, - ); - } - } - break; - } - - let mut pos = 0; - while pos < buf.len() { - if let Some(newline_offset) = memchr(b'\n', &buf[pos..]) { - let newline_pos = pos + newline_offset; - line_number += 1; - - // Only process if within offset..offset+limit range - if line_number >= offset && lines_output < limit { - if overflow.is_empty() { - // Common case: process directly from buffer (zero-copy) - process_line::( - &buf[pos..newline_pos], - line_number, - &mut output, - &mut lines_output, - ); - } else { - // Rare case: complete the accumulated line - overflow.extend_from_slice(&buf[pos..newline_pos]); - process_line::( - &overflow, - line_number, - &mut output, - &mut lines_output, - ); - overflow.clear(); - } - } else if !overflow.is_empty() { - // Line was being accumulated but we're skipping it - overflow.clear(); - } - - pos = newline_pos + 1; - - // Early exit if we've collected enough lines - if lines_output >= limit { - break; - } - } else { - // No newline found - line spans buffer boundary - overflow.extend_from_slice(&buf[pos..]); - pos = buf.len(); - } - } - - reader.consume(pos); - - if lines_output >= limit { - break; - } - } - - // Check if offset exceeded file length - if line_number < offset { - return Err(ToolError::OutOfBounds(format!( - "offset {} exceeds file length of {} lines", - offset, line_number - ))); - } - - Ok(ToolOutput::new(output)) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use tempfile::NamedTempFile; - - async fn read_temp_file( - content: &[u8], - offset: usize, - limit: usize, - ) -> ToolResult { - let mut temp = NamedTempFile::new().unwrap(); - temp.write_all(content).unwrap(); - read_file::(temp.path().to_str().unwrap(), offset, limit).await - } - - #[tokio::test] - async fn reads_basic_file() { - let result = read_temp_file::(b"hello\nworld\n", 1, 2000) - .await - .unwrap(); - assert_eq!(result.content, "L1: hello\nL2: world"); - } - - #[tokio::test] - async fn reads_basic_file_no_line_numbers() { - let result = read_temp_file::(b"hello\nworld\n", 1, 2000) - .await - .unwrap(); - assert_eq!(result.content, "hello\nworld"); - } - - #[tokio::test] - async fn reads_with_offset() { - let result = read_temp_file::(b"one\ntwo\nthree\n", 2, 2000) - .await - .unwrap(); - assert_eq!(result.content, "L2: two\nL3: three"); - } - - #[tokio::test] - async fn reads_with_offset_no_line_numbers() { - let result = read_temp_file::(b"one\ntwo\nthree\n", 2, 2000) - .await - .unwrap(); - assert_eq!(result.content, "two\nthree"); - } - - #[tokio::test] - async fn reads_with_limit() { - let result = read_temp_file::(b"one\ntwo\nthree\n", 1, 2) - .await - .unwrap(); - assert_eq!(result.content, "L1: one\nL2: two"); - } - - #[tokio::test] - async fn reads_with_offset_and_limit() { - let result = read_temp_file::(b"one\ntwo\nthree\nfour\n", 2, 2) - .await - .unwrap(); - assert_eq!(result.content, "L2: two\nL3: three"); - } - - #[tokio::test] - async fn handles_crlf_line_endings() { - let result = read_temp_file::(b"line1\r\nline2\r\n", 1, 2000) - .await - .unwrap(); - assert_eq!(result.content, "L1: line1\nL2: line2"); - } - - #[tokio::test] - async fn handles_non_utf8_content() { - let result = read_temp_file::(b"\xff\xfe\nplain\n", 1, 2000) - .await - .unwrap(); - assert!(result.content.contains("L1:")); - assert!(result.content.contains('\u{FFFD}')); // replacement char - assert!(result.content.contains("L2: plain")); - } - - #[tokio::test] - async fn truncates_long_lines() { - let long_line = "x".repeat(MAX_LINE_LENGTH + 100); - let content = format!("{}\n", long_line); - let result = read_temp_file::(content.as_bytes(), 1, 1) - .await - .unwrap(); - let expected = format!("L1: {}", "x".repeat(MAX_LINE_LENGTH)); - assert_eq!(result.content, expected); - } - - #[tokio::test] - async fn truncates_long_lines_no_line_numbers() { - let long_line = "x".repeat(MAX_LINE_LENGTH + 100); - let content = format!("{}\n", long_line); - let result = read_temp_file::(content.as_bytes(), 1, 1) - .await - .unwrap(); - assert_eq!(result.content, "x".repeat(MAX_LINE_LENGTH)); - } - - #[tokio::test] - async fn errors_on_offset_zero() { - let err = read_temp_file::(b"test\n", 0, 10).await.unwrap_err(); - assert!(matches!(err, ToolError::OutOfBounds(_))); - } - - #[tokio::test] - async fn errors_on_limit_zero() { - let err = read_temp_file::(b"test\n", 1, 0).await.unwrap_err(); - assert!(matches!(err, ToolError::OutOfBounds(_))); - } - - #[tokio::test] - async fn errors_on_offset_exceeds_file() { - let err = read_temp_file::(b"one\ntwo\n", 10, 100) - .await - .unwrap_err(); - assert!(matches!(err, ToolError::OutOfBounds(_))); - } - - #[tokio::test] - async fn errors_on_relative_path() { - let err = read_file::("relative/path.txt", 1, 100) - .await - .unwrap_err(); - assert!(matches!(err, ToolError::InvalidPath(_))); - } - - #[tokio::test] - async fn errors_on_nonexistent_file() { - let err = read_file::("/nonexistent/file.txt", 1, 100) - .await - .unwrap_err(); - assert!(matches!(err, ToolError::Io(_))); - } - - #[tokio::test] - async fn handles_empty_file() { - let result = read_temp_file::(b"", 1, 100).await; - // Empty file with offset 1 should error - assert!(matches!(result, Err(ToolError::OutOfBounds(_)))); - } - - #[tokio::test] - async fn handles_file_without_trailing_newline() { - let result = read_temp_file::(b"no trailing newline", 1, 100) - .await - .unwrap(); - assert_eq!(result.content, "L1: no trailing newline"); - } - - #[tokio::test] - async fn handles_file_without_trailing_newline_no_line_numbers() { - let result = read_temp_file::(b"no trailing newline", 1, 100) - .await - .unwrap(); - assert_eq!(result.content, "no trailing newline"); - } -} diff --git a/src/rig-coding-tools/src/tools/todo.rs b/src/rig-coding-tools/src/tools/todo.rs deleted file mode 100644 index 6c826642..00000000 --- a/src/rig-coding-tools/src/tools/todo.rs +++ /dev/null @@ -1,322 +0,0 @@ -//! Todo list management tools for task tracking. -//! -//! Provides [`TodoWriteTool`] and [`TodoReadTool`] for managing a session-scoped -//! task list that LLM agents can use to track multi-step work. - -use crate::error::ToolError; -use rig::completion::ToolDefinition; -use rig::tool::Tool; -use schemars::{schema_for, JsonSchema}; -use serde::{Deserialize, Serialize}; -use std::fmt::Write; -use std::sync::Arc; -use tokio::sync::RwLock; - -/// Task status with display icons. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum TodoStatus { - /// Not yet started. - Pending, - /// Currently being worked on. - InProgress, - /// Successfully finished. - Completed, - /// Abandoned or no longer relevant. - Cancelled, -} - -impl TodoStatus { - /// Returns the status indicator icon. - #[inline] - pub const fn icon(self) -> &'static str { - match self { - Self::Pending => "[ ]", - Self::InProgress => "[>]", - Self::Completed => "[x]", - Self::Cancelled => "[-]", - } - } -} - -/// Task priority level. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum TodoPriority { - /// Urgent, should be addressed first. - High, - /// Normal priority. - Medium, - /// Can be deferred. - Low, -} - -/// A single task item. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct Todo { - /// Unique identifier for the task. - pub id: String, - /// Task description. - pub content: String, - /// Current status. - pub status: TodoStatus, - /// Priority level. - pub priority: TodoPriority, -} - -/// Thread-safe shared state for todo list. -#[derive(Debug, Clone, Default)] -pub struct TodoState { - todos: Arc>>, -} - -impl TodoState { - /// Creates a new empty todo state. - #[inline] - pub fn new() -> Self { - Self::default() - } -} - -// ============================================================================ -// TodoWriteTool -// ============================================================================ - -/// Arguments for [`TodoWriteTool`]. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct TodoWriteArgs { - /// The complete updated todo list. - pub todos: Vec, -} - -/// Tool for writing/replacing the todo list. -#[derive(Debug, Clone)] -pub struct TodoWriteTool { - state: TodoState, -} - -impl TodoWriteTool { - /// Creates a new write tool with the given shared state. - #[inline] - pub fn new(state: TodoState) -> Self { - Self { state } - } -} - -impl Tool for TodoWriteTool { - const NAME: &'static str = "todowrite"; - - type Error = ToolError; - type Args = TodoWriteArgs; - type Output = String; - - async fn definition(&self, _prompt: String) -> ToolDefinition { - let schema = schema_for!(TodoWriteArgs); - ToolDefinition { - name: Self::NAME.to_string(), - description: "Replace the entire todo list with a new list of tasks.".to_string(), - parameters: serde_json::to_value(schema).unwrap_or_default(), - } - } - - async fn call(&self, args: Self::Args) -> Result { - // Validate all todos have non-empty id and content - for todo in &args.todos { - if todo.id.trim().is_empty() { - return Err(ToolError::Validation("todo id cannot be empty".into())); - } - if todo.content.trim().is_empty() { - return Err(ToolError::Validation("todo content cannot be empty".into())); - } - } - - let count = args.todos.len(); - *self.state.todos.write().await = args.todos; - Ok(format!("Updated todo list with {count} task(s).")) - } -} - -// ============================================================================ -// TodoReadTool -// ============================================================================ - -/// Arguments for [`TodoReadTool`] (empty). -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct TodoReadArgs {} - -/// Tool for reading the current todo list. -#[derive(Debug, Clone)] -pub struct TodoReadTool { - state: TodoState, -} - -impl TodoReadTool { - /// Creates a new read tool with the given shared state. - #[inline] - pub fn new(state: TodoState) -> Self { - Self { state } - } -} - -impl Tool for TodoReadTool { - const NAME: &'static str = "todoread"; - - type Error = ToolError; - type Args = TodoReadArgs; - type Output = String; - - async fn definition(&self, _prompt: String) -> ToolDefinition { - let schema = schema_for!(TodoReadArgs); - ToolDefinition { - name: Self::NAME.to_string(), - description: "Read the current todo list.".to_string(), - parameters: serde_json::to_value(schema).unwrap_or_default(), - } - } - - async fn call(&self, _args: Self::Args) -> Result { - let todos = self.state.todos.read().await; - - if todos.is_empty() { - return Ok("No tasks.".to_string()); - } - - let mut output = format!("Tasks ({} total):\n", todos.len()); - for todo in todos.iter() { - let _ = writeln!( - output, - "{} ({:?}) {}: {}", - todo.status.icon(), - todo.priority, - todo.id, - todo.content - ); - } - - // Remove trailing newline - output.truncate(output.trim_end().len()); - Ok(output) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_todo(id: &str, status: TodoStatus) -> Todo { - Todo { - id: id.to_string(), - content: format!("Task {id}"), - status, - priority: TodoPriority::Medium, - } - } - - #[tokio::test] - async fn write_and_read_todos() { - let state = TodoState::new(); - let write_tool = TodoWriteTool::new(state.clone()); - let read_tool = TodoReadTool::new(state); - - let todos = vec![ - make_todo("1", TodoStatus::Completed), - make_todo("2", TodoStatus::InProgress), - make_todo("3", TodoStatus::Pending), - ]; - - let result = write_tool.call(TodoWriteArgs { todos }).await.unwrap(); - assert!(result.contains("3 task(s)")); - - let output = read_tool.call(TodoReadArgs {}).await.unwrap(); - assert!(output.contains("[x]")); // completed - assert!(output.contains("[>]")); // in_progress - assert!(output.contains("[ ]")); // pending - } - - #[tokio::test] - async fn read_empty_list() { - let state = TodoState::new(); - let read_tool = TodoReadTool::new(state); - let output = read_tool.call(TodoReadArgs {}).await.unwrap(); - assert_eq!(output, "No tasks."); - } - - #[tokio::test] - async fn write_replaces_existing() { - let state = TodoState::new(); - let write_tool = TodoWriteTool::new(state.clone()); - let read_tool = TodoReadTool::new(state); - - // First write - write_tool - .call(TodoWriteArgs { - todos: vec![make_todo("a", TodoStatus::Pending)], - }) - .await - .unwrap(); - - // Second write replaces - write_tool - .call(TodoWriteArgs { - todos: vec![make_todo("b", TodoStatus::Completed)], - }) - .await - .unwrap(); - - let output = read_tool.call(TodoReadArgs {}).await.unwrap(); - assert!(!output.contains("Task a")); // Check that todo "a" is not present - assert!(output.contains("Task b")); // Check that todo "b" is present - } - - #[tokio::test] - async fn write_validates_empty_id() { - let state = TodoState::new(); - let write_tool = TodoWriteTool::new(state); - let todo = Todo { - id: "".to_string(), - content: "Task".to_string(), - status: TodoStatus::Pending, - priority: TodoPriority::Low, - }; - let result = write_tool.call(TodoWriteArgs { todos: vec![todo] }).await; - assert!(matches!(result, Err(ToolError::Validation(_)))); - } - - #[tokio::test] - async fn write_validates_empty_content() { - let state = TodoState::new(); - let write_tool = TodoWriteTool::new(state); - let todo = Todo { - id: "1".to_string(), - content: " ".to_string(), - status: TodoStatus::Pending, - priority: TodoPriority::Low, - }; - let result = write_tool.call(TodoWriteArgs { todos: vec![todo] }).await; - assert!(matches!(result, Err(ToolError::Validation(_)))); - } - - #[test] - fn status_icons_are_correct() { - assert_eq!(TodoStatus::Pending.icon(), "[ ]"); - assert_eq!(TodoStatus::InProgress.icon(), "[>]"); - assert_eq!(TodoStatus::Completed.icon(), "[x]"); - assert_eq!(TodoStatus::Cancelled.icon(), "[-]"); - } - - #[test] - fn status_serde_roundtrip() { - let json = serde_json::to_string(&TodoStatus::InProgress).unwrap(); - assert_eq!(json, "\"in_progress\""); - let parsed: TodoStatus = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, TodoStatus::InProgress); - } - - #[test] - fn priority_serde_roundtrip() { - let json = serde_json::to_string(&TodoPriority::High).unwrap(); - assert_eq!(json, "\"high\""); - let parsed: TodoPriority = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, TodoPriority::High); - } -} diff --git a/src/rig-coding-tools/src/tools/webfetch.rs b/src/rig-coding-tools/src/tools/webfetch.rs deleted file mode 100644 index a19574ed..00000000 --- a/src/rig-coding-tools/src/tools/webfetch.rs +++ /dev/null @@ -1,407 +0,0 @@ -//! Web content fetching tool. -//! -//! Fetches URLs and returns content in a text-friendly format. - -use crate::error::ToolError; -use crate::util::truncate_text; -use html_to_markdown_rs::{convert, ConversionOptions, PreprocessingOptions, PreprocessingPreset}; -use reqwest::redirect::Policy; -use rig::completion::ToolDefinition; -use rig::tool::Tool; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::time::Duration; - -/// Maximum response size to accept (5MB). -const MAX_RESPONSE_SIZE: usize = 5 * 1_024 * 1_024; -/// Content truncation threshold (100KB). -const CONTENT_TRUNCATE_SIZE: usize = 100 * 1_024; -/// Default request timeout in milliseconds. -const DEFAULT_TIMEOUT_MS: u64 = 30_000; -/// Maximum redirects to follow. -const MAX_REDIRECTS: usize = 10; - -fn default_timeout_ms() -> u64 { - DEFAULT_TIMEOUT_MS -} - -/// Arguments for [`WebFetchTool`]. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct WebFetchArgs { - /// URL to fetch content from. - pub url: String, - /// Request timeout in milliseconds. - #[serde(default = "default_timeout_ms")] - #[schemars(default = "default_timeout_ms")] - pub timeout_ms: u64, -} - -/// Tool for fetching web content from URLs. -/// -/// Supports HTML (with tag stripping), JSON (formatted), and plain text. -#[derive(Clone)] -pub struct WebFetchTool { - client: reqwest::Client, -} - -impl Default for WebFetchTool { - fn default() -> Self { - Self::new() - } -} - -impl WebFetchTool { - /// Creates a new [`WebFetchTool`] with default settings. - pub fn new() -> Self { - let client = reqwest::Client::builder() - .redirect(Policy::limited(MAX_REDIRECTS)) - .build() - .expect("failed to build HTTP client"); - Self { client } - } - - /// Creates a [`WebFetchTool`] with a custom client. - pub fn with_client(client: reqwest::Client) -> Self { - Self { client } - } -} - -impl Tool for WebFetchTool { - const NAME: &'static str = "webfetch"; - - type Error = ToolError; - type Args = WebFetchArgs; - type Output = String; - - async fn definition(&self, _prompt: String) -> ToolDefinition { - let schema = schemars::schema_for!(WebFetchArgs); - ToolDefinition { - name: Self::NAME.to_string(), - description: "Fetches content from a URL and returns it as text.".to_string(), - parameters: serde_json::to_value(schema).unwrap_or_default(), - } - } - - async fn call(&self, args: Self::Args) -> Result { - let timeout = Duration::from_millis(args.timeout_ms); - - let response = self - .client - .get(&args.url) - .timeout(timeout) - .send() - .await - .map_err(|e| categorize_reqwest_error(e, &args.url))?; - - let status = response.status(); - if !status.is_success() { - return Err(ToolError::Http(format!("HTTP {} for {}", status, args.url))); - } - - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("text/plain") - .to_string(); - - let content_length = response.content_length(); - - // Check Content-Length if available - if let Some(len) = content_length { - if len as usize > MAX_RESPONSE_SIZE { - return Err(ToolError::Http(format!( - "Response too large: {} bytes (max {})", - len, MAX_RESPONSE_SIZE - ))); - } - } - - // Read response body with size limit - let bytes = read_limited_body(response, MAX_RESPONSE_SIZE).await?; - let raw_content = String::from_utf8_lossy(&bytes); - - // Process based on content type - let processed = if content_type.contains("text/html") { - html_to_markdown(&raw_content) - } else if content_type.contains("application/json") { - format_json(&raw_content) - } else { - raw_content.into_owned() - }; - - // Truncate if needed - let (content, truncated) = truncate_text(&processed, CONTENT_TRUNCATE_SIZE); - - // Format output - let mut output = format!( - "URL: {}\nContent-Type: {}\nLength: {} bytes\n\n{}", - args.url, - content_type, - bytes.len(), - content - ); - - if truncated { - output.push_str("\n\n[Content truncated]"); - } - - Ok(output) - } -} - -/// Reads response body up to a size limit. -async fn read_limited_body( - response: reqwest::Response, - max_size: usize, -) -> Result, ToolError> { - let bytes = response.bytes().await?; - if bytes.len() > max_size { - return Err(ToolError::Http(format!( - "Response too large: {} bytes (max {})", - bytes.len(), - max_size - ))); - } - Ok(bytes.to_vec()) -} - -/// Categorizes reqwest errors into appropriate ToolError variants. -fn categorize_reqwest_error(e: reqwest::Error, url: &str) -> ToolError { - if e.is_timeout() { - ToolError::Timeout(format!("Request timed out for {}", url)) - } else if e.is_connect() { - ToolError::Http(format!("Connection failed for {}: {}", url, e)) - } else if e.is_redirect() { - ToolError::Http(format!("Too many redirects for {}", url)) - } else { - ToolError::Http(e.to_string()) - } -} - -/// Converts HTML to markdown for LLM-friendly output. -fn html_to_markdown(html: &str) -> String { - let options = ConversionOptions { - preprocessing: PreprocessingOptions { - enabled: true, - preset: PreprocessingPreset::Aggressive, - remove_navigation: true, - remove_forms: true, - }, - strip_tags: vec![ - "img".into(), - "svg".into(), - "script".into(), - "style".into(), - "noscript".into(), - ], - ..Default::default() - }; - - convert(html, Some(options)).unwrap_or_else(|_| html.to_string()) -} - -/// Formats JSON content for readability. -fn format_json(json_str: &str) -> String { - match serde_json::from_str::(json_str) { - Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| json_str.to_string()), - Err(_) => json_str.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - async fn setup_mock_server() -> MockServer { - MockServer::start().await - } - - #[tokio::test] - async fn fetches_plain_text() { - let server = setup_mock_server().await; - Mock::given(method("GET")) - .and(path("/text")) - .respond_with( - ResponseTemplate::new(200) - .set_body_bytes("Hello, world!") - .insert_header("content-type", "text/plain; charset=utf-8"), - ) - .mount(&server) - .await; - - let tool = WebFetchTool::new(); - let result = tool - .call(WebFetchArgs { - url: format!("{}/text", server.uri()), - timeout_ms: 5000, - }) - .await - .unwrap(); - - assert!(result.contains("Hello, world!")); - assert!(result.contains("Content-Type: text/plain")); - } - - #[tokio::test] - async fn fetches_and_converts_html_to_markdown() { - let server = setup_mock_server().await; - let html = r#"Test -

Hello

World

"#; - Mock::given(method("GET")) - .and(path("/html")) - .respond_with( - ResponseTemplate::new(200) - .set_body_bytes(html) - .insert_header("content-type", "text/html; charset=utf-8"), - ) - .mount(&server) - .await; - - let tool = WebFetchTool::new(); - let result = tool - .call(WebFetchArgs { - url: format!("{}/html", server.uri()), - timeout_ms: 5000, - }) - .await - .unwrap(); - - // Should contain markdown heading and content - assert!(result.contains("Hello")); - assert!(result.contains("World")); - // Should not contain raw HTML tags - assert!(!result.contains("

")); - assert!(!result.contains("

")); - assert!(result.contains("Content-Type: text/html")); - } - - #[tokio::test] - async fn fetches_and_formats_json() { - let server = setup_mock_server().await; - Mock::given(method("GET")) - .and(path("/json")) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(serde_json::json!({"key":"value","number":42})), - ) - .mount(&server) - .await; - - let tool = WebFetchTool::new(); - let result = tool - .call(WebFetchArgs { - url: format!("{}/json", server.uri()), - timeout_ms: 5000, - }) - .await - .unwrap(); - - assert!(result.contains("\"key\"")); - assert!(result.contains("\"value\"")); - assert!(result.contains("\"number\"")); - assert!(result.contains("42")); - assert!(result.contains("Content-Type: application/json")); - } - - #[tokio::test] - async fn handles_http_error_status() { - let server = setup_mock_server().await; - Mock::given(method("GET")) - .and(path("/notfound")) - .respond_with(ResponseTemplate::new(404)) - .mount(&server) - .await; - - let tool = WebFetchTool::new(); - let result = tool - .call(WebFetchArgs { - url: format!("{}/notfound", server.uri()), - timeout_ms: 5000, - }) - .await; - - assert!(matches!(result, Err(ToolError::Http(_)))); - let err = result.unwrap_err(); - assert!(err.to_string().contains("404")); - } - - #[tokio::test] - async fn handles_timeout() { - let server = setup_mock_server().await; - Mock::given(method("GET")) - .and(path("/slow")) - .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(5))) - .mount(&server) - .await; - - let tool = WebFetchTool::new(); - let result = tool - .call(WebFetchArgs { - url: format!("{}/slow", server.uri()), - timeout_ms: 100, // Very short timeout - }) - .await; - - assert!(matches!(result, Err(ToolError::Timeout(_)))); - } - - #[tokio::test] - async fn handles_connection_refused() { - let tool = WebFetchTool::new(); - let result = tool - .call(WebFetchArgs { - url: "http://127.0.0.1:1".to_string(), // Invalid port - timeout_ms: 1000, - }) - .await; - - assert!(matches!(result, Err(ToolError::Http(_)))); - } - - #[test] - fn html_to_markdown_converts_structure() { - let html = "

Title

Content

"; - let result = html_to_markdown(html); - // Should preserve heading structure as markdown - assert!(result.contains("Title")); - assert!(result.contains("Content")); - // Should not contain raw HTML - assert!(!result.contains("

")); - assert!(!result.contains("

")); - } - - #[test] - fn html_to_markdown_strips_scripts() { - let html = "

Before

After

"; - let result = html_to_markdown(html); - assert!(result.contains("Before")); - assert!(result.contains("After")); - assert!(!result.contains("alert")); - assert!(!result.contains("

After

"; - let result = html_to_markdown(html); - assert!(!result.contains("alert")); - } - - #[test] - fn format_json_prettifies() { - let json = r#"{"a":1}"#; - let result = format_json(json); - assert!(result.contains("\"a\": 1")); - } - - #[test] - fn format_json_returns_original_on_invalid() { - let invalid = "not json"; - assert_eq!(format_json(invalid), "not json"); - } } diff --git a/src/coding-tools-core/src/operations/webfetch/blocking_impl.rs b/src/coding-tools-core/src/operations/webfetch/blocking_impl.rs new file mode 100644 index 00000000..5dfbdcca --- /dev/null +++ b/src/coding-tools-core/src/operations/webfetch/blocking_impl.rs @@ -0,0 +1,99 @@ +//! Blocking web content fetching. + +use super::{categorize_reqwest_error, check_size, process_content, WebFetchOutput}; +use crate::error::{ToolError, ToolResult}; +use std::time::Duration; + +/// Fetches content from a URL and returns processed content. +/// +/// - HTML is converted to markdown +/// - JSON is pretty-printed +/// - Other content types returned as-is +pub fn fetch_url( + client: &reqwest::blocking::Client, + url: &str, + timeout: Duration, +) -> ToolResult { + let response = client + .get(url) + .timeout(timeout) + .send() + .map_err(|e| categorize_reqwest_error(e, url))?; + + let status = response.status(); + if !status.is_success() { + return Err(ToolError::Http(format!("HTTP {} for {}", status, url))); + } + + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("text/plain") + .to_string(); + + // Check Content-Length if available + if let Some(len) = response.content_length() { + check_size(len as usize, url)?; + } + + let bytes = response + .bytes() + .map_err(|e| ToolError::Http(e.to_string()))?; + + check_size(bytes.len(), url)?; + + let byte_length = bytes.len(); + let raw_content = String::from_utf8_lossy(&bytes); + let content = process_content(&raw_content, &content_type); + + Ok(WebFetchOutput { + content, + content_type, + byte_length, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_client() -> reqwest::blocking::Client { + reqwest::blocking::Client::builder() + .build() + .expect("client build failed") + } + + #[test] + fn fetches_plain_text() { + // Use httpbin.org for blocking tests since wiremock is async-only + let client = test_client(); + let result = fetch_url( + &client, + "https://httpbin.org/robots.txt", + Duration::from_secs(10), + ); + + // This test requires network access, so we just check it doesn't panic + // In CI, this might fail due to network restrictions + if let Ok(output) = result { + assert!(!output.content.is_empty()); + assert!(!output.content_type.is_empty()); + } + } + + #[test] + fn handles_404() { + let client = test_client(); + let result = fetch_url( + &client, + "https://httpbin.org/status/404", + Duration::from_secs(10), + ); + + // In case of network issues, just verify we get some result + if let Err(e) = result { + assert!(matches!(e, ToolError::Http(_))); + } + } +} diff --git a/src/coding-tools-core/src/operations/webfetch/mod.rs b/src/coding-tools-core/src/operations/webfetch/mod.rs new file mode 100644 index 00000000..0846d516 --- /dev/null +++ b/src/coding-tools-core/src/operations/webfetch/mod.rs @@ -0,0 +1,128 @@ +//! Web content fetching operation. + +use crate::error::{ToolError, ToolResult}; +use html_to_markdown_rs::{convert, ConversionOptions, PreprocessingOptions, PreprocessingPreset}; + +/// Maximum response size to accept (5MB). +pub(crate) const MAX_RESPONSE_SIZE: usize = 5 * 1_024 * 1_024; + +/// Result from URL fetch operation. +#[derive(Debug, Clone)] +pub struct WebFetchOutput { + /// The processed content (HTML converted to markdown, JSON prettified). + pub content: String, + /// The Content-Type header value. + pub content_type: String, + /// Original byte length before processing. + pub byte_length: usize, +} + +/// Processes raw response content based on content type. +pub(crate) fn process_content(raw_content: &str, content_type: &str) -> String { + if content_type.contains("text/html") { + html_to_markdown(raw_content) + } else if content_type.contains("application/json") { + format_json(raw_content) + } else { + raw_content.to_owned() + } +} + +/// Categorizes reqwest errors into appropriate [`ToolError`] variants. +pub(crate) fn categorize_reqwest_error(e: reqwest::Error, url: &str) -> ToolError { + if e.is_timeout() { + ToolError::Timeout(format!("Request timed out for {}", url)) + } else if e.is_connect() { + ToolError::Http(format!("Connection failed for {}: {}", url, e)) + } else if e.is_redirect() { + ToolError::Http(format!("Too many redirects for {}", url)) + } else { + ToolError::Http(e.to_string()) + } +} + +/// Returns an error if the response size exceeds the maximum. +pub(crate) fn check_size(len: usize, url: &str) -> ToolResult<()> { + if len > MAX_RESPONSE_SIZE { + return Err(ToolError::Http(format!( + "Response too large: {} bytes (max {}) for {}", + len, MAX_RESPONSE_SIZE, url + ))); + } + Ok(()) +} + +/// Converts HTML to markdown for LLM-friendly output. +pub fn html_to_markdown(html: &str) -> String { + let options = ConversionOptions { + preprocessing: PreprocessingOptions { + enabled: true, + preset: PreprocessingPreset::Aggressive, + remove_navigation: true, + remove_forms: true, + }, + strip_tags: vec![ + "img".into(), + "svg".into(), + "script".into(), + "style".into(), + "noscript".into(), + ], + ..Default::default() + }; + + convert(html, Some(options)).unwrap_or_else(|_| html.to_string()) +} + +/// Formats JSON content for readability. +pub fn format_json(json_str: &str) -> String { + match serde_json::from_str::(json_str) { + Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| json_str.to_string()), + Err(_) => json_str.to_string(), + } +} + +#[cfg(not(feature = "blocking"))] +mod async_impl; +#[cfg(not(feature = "blocking"))] +pub use async_impl::fetch_url; + +#[cfg(feature = "blocking")] +mod blocking_impl; +#[cfg(feature = "blocking")] +pub use blocking_impl::fetch_url; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn html_to_markdown_strips_scripts() { + let html = "

Before

After

"; + let result = html_to_markdown(html); + assert!(!result.contains("alert")); + } + + #[test] + fn format_json_prettifies() { + let json = r#"{"a":1}"#; + let result = format_json(json); + assert!(result.contains("\"a\": 1")); + } + + #[test] + fn format_json_returns_original_on_invalid() { + let invalid = "not json"; + assert_eq!(format_json(invalid), "not json"); + } + + #[test] + fn check_size_ok_for_small_content() { + assert!(check_size(1000, "http://example.com").is_ok()); + } + + #[test] + fn check_size_fails_for_large_content() { + assert!(check_size(MAX_RESPONSE_SIZE + 1, "http://example.com").is_err()); + } +} From fadc6cc45c738fb3a9589ccb9ca0d6a2448a7cbc Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 05:00:18 +0000 Subject: [PATCH 21/64] =?UTF-8?q?Refactor:=20Restructure=20feature=20hiera?= =?UTF-8?q?rchy=20with=20tokio=20=E2=86=92=20async=20=E2=86=92=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce feature hierarchy: 'tokio' (default) depends on 'async' for compile-time safety - 'async' is now a base feature requiring a runtime; tokio runtime is provided by 'tokio' feature - Add compile_error! macros to prevent invalid feature combinations (async without runtime, async + blocking) - Update coding-tools-rig to depend on 'tokio' feature explicitly - Update CI workflow to use explicit --features tokio instead of --all-features - Document feature flags in AGENTS.md with clear guidance on usage --- .github/workflows/rust.yml | 8 ++++---- src/AGENTS.md | 8 ++++++++ src/coding-tools-core/Cargo.toml | 10 ++++++---- src/coding-tools-core/src/lib.rs | 8 +++++++- src/coding-tools-rig/Cargo.toml | 2 +- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index fa24222e..829ef4be 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -76,17 +76,17 @@ jobs: working-directory: src env: RUSTDOCFLAGS: "-D warnings" - # Note: Can't use --all-features at workspace level because async/blocking are mutually exclusive + # Note: Can't use --all-features at workspace level because tokio/blocking are mutually exclusive run: | - cargo doc -p coding-tools-core --all-features --document-private-items --target ${{ matrix.target }} + cargo doc -p coding-tools-core --features tokio --document-private-items --target ${{ matrix.target }} cargo doc -p coding-tools-rig --document-private-items --target ${{ matrix.target }} - name: Run linter if: github.event_name == 'pull_request' || startsWith(github.ref, 'refs/tags/') working-directory: src - # Note: Can't use --all-features at workspace level because async/blocking are mutually exclusive + # Note: Can't use --all-features at workspace level because tokio/blocking are mutually exclusive run: | - cargo clippy -p coding-tools-core --all-features --target ${{ matrix.target }} -- -D warnings + cargo clippy -p coding-tools-core --features tokio --target ${{ matrix.target }} -- -D warnings cargo clippy -p coding-tools-rig --target ${{ matrix.target }} -- -D warnings - name: Run formatter check diff --git a/src/AGENTS.md b/src/AGENTS.md index 37140217..fa37c6f9 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -2,6 +2,14 @@ Basic coding tools for rig based LLM agents +# Feature Flags (coding-tools-core) + +- `tokio` (default): Async mode with tokio runtime. Enables async function signatures. +- `blocking`: Sync/blocking mode. Mutually exclusive with `tokio`/`async`. +- `async`: Base async signatures (internal use). Do not enable directly; use `tokio`. + +The `async` and `blocking` features are mutually exclusive - enabling both causes a compile error. + # Project Structure - `coding-tools-core/` - Framework-agnostic core library diff --git a/src/coding-tools-core/Cargo.toml b/src/coding-tools-core/Cargo.toml index 12840e86..72e6ee82 100644 --- a/src/coding-tools-core/Cargo.toml +++ b/src/coding-tools-core/Cargo.toml @@ -9,10 +9,12 @@ include = ["src/**/*"] readme = "README.md" [features] -default = ["async"] -# Enables async function signatures and async-only modules (task requires async) -async = ["dep:async-trait", "dep:reqwest", "dep:tokio"] -# Enables blocking/sync mode - mutually exclusive with async +default = ["tokio"] +# Base async signatures - requires a runtime, do not enable directly +async = ["dep:async-trait"] +# Async with tokio runtime (default) +tokio = ["async", "dep:tokio", "dep:reqwest"] +# Blocking/sync mode - mutually exclusive with async blocking = ["maybe-async/is_sync", "dep:reqwest", "reqwest?/blocking"] [dependencies] diff --git a/src/coding-tools-core/src/lib.rs b/src/coding-tools-core/src/lib.rs index 2e145dde..e1b664a0 100644 --- a/src/coding-tools-core/src/lib.rs +++ b/src/coding-tools-core/src/lib.rs @@ -1,3 +1,4 @@ +#![warn(missing_docs)] //! Core types and utilities for coding tools. //! //! This crate provides framework-agnostic building blocks: @@ -11,7 +12,12 @@ //! - `tokio` (default): Enables async via tokio runtime (implies `async`). //! When disabled, all operations are synchronous. -#![warn(missing_docs)] +// Validate feature combinations at compile time +#[cfg(all(feature = "async", not(feature = "tokio")))] +compile_error!("Feature `async` requires a runtime. Enable `tokio` feature instead."); + +#[cfg(all(feature = "async", feature = "blocking"))] +compile_error!("Features `async` and `blocking` are mutually exclusive."); pub mod error; pub mod fs; diff --git a/src/coding-tools-rig/Cargo.toml b/src/coding-tools-rig/Cargo.toml index afccdfa2..7b22ad62 100644 --- a/src/coding-tools-rig/Cargo.toml +++ b/src/coding-tools-rig/Cargo.toml @@ -10,7 +10,7 @@ readme = "README.md" [dependencies] # Core tool operations (file read/write/edit, glob, grep, bash, etc.) -coding-tools-core = { version = "0.1.0", path = "../coding-tools-core", features = ["async"] } +coding-tools-core = { version = "0.1.0", path = "../coding-tools-core", features = ["tokio"] } # Implements rig_core::tool::Tool trait for each tool rig-core = { version = "0.27", default-features = false, features = ["reqwest-rustls"] } From a5154bddc1b70bf432cd6f8f83d957102bb305f8 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 05:07:52 +0000 Subject: [PATCH 22/64] Added: Context module with tool guidance strings for LLM integration - Create new context module in coding-tools-core with 15 static string constants - Add 15 context .txt files: 5 for non-path tools (bash, task, todoread, todowrite, webfetch) - Add 10 context files for path tool variants (read/write/edit/glob/grep with absolute/allowed) - Each context string provides LLM-focused guidance for proper tool usage - Re-export context module from coding-tools-rig for public API access - Update Cargo.toml include directive to package README.md --- src/coding-tools-core/Cargo.toml | 2 +- src/coding-tools-core/src/context/bash.txt | 48 +++++ .../src/context/edit_absolute.txt | 10 ++ .../src/context/edit_allowed.txt | 12 ++ .../src/context/glob_absolute.txt | 6 + .../src/context/glob_allowed.txt | 8 + .../src/context/grep_absolute.txt | 8 + .../src/context/grep_allowed.txt | 10 ++ src/coding-tools-core/src/context/mod.rs | 166 ++++++++++++++++++ .../src/context/read_absolute.txt | 12 ++ .../src/context/read_allowed.txt | 13 ++ src/coding-tools-core/src/context/task.txt | 60 +++++++ .../src/context/todoread.txt | 14 ++ .../src/context/todowrite.txt | 166 ++++++++++++++++++ .../src/context/webfetch.txt | 13 ++ .../src/context/write_absolute.txt | 8 + .../src/context/write_allowed.txt | 10 ++ src/coding-tools-core/src/lib.rs | 1 + src/coding-tools-rig/src/lib.rs | 3 + 19 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 src/coding-tools-core/src/context/bash.txt create mode 100644 src/coding-tools-core/src/context/edit_absolute.txt create mode 100644 src/coding-tools-core/src/context/edit_allowed.txt create mode 100644 src/coding-tools-core/src/context/glob_absolute.txt create mode 100644 src/coding-tools-core/src/context/glob_allowed.txt create mode 100644 src/coding-tools-core/src/context/grep_absolute.txt create mode 100644 src/coding-tools-core/src/context/grep_allowed.txt create mode 100644 src/coding-tools-core/src/context/mod.rs create mode 100644 src/coding-tools-core/src/context/read_absolute.txt create mode 100644 src/coding-tools-core/src/context/read_allowed.txt create mode 100644 src/coding-tools-core/src/context/task.txt create mode 100644 src/coding-tools-core/src/context/todoread.txt create mode 100644 src/coding-tools-core/src/context/todowrite.txt create mode 100644 src/coding-tools-core/src/context/webfetch.txt create mode 100644 src/coding-tools-core/src/context/write_absolute.txt create mode 100644 src/coding-tools-core/src/context/write_allowed.txt diff --git a/src/coding-tools-core/Cargo.toml b/src/coding-tools-core/Cargo.toml index 72e6ee82..cbe80fe0 100644 --- a/src/coding-tools-core/Cargo.toml +++ b/src/coding-tools-core/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" description = "Core types and utilities for coding tools - framework agnostic" repository = "https://github.com/Sewer56/rig-coding-tools" license = "Apache-2.0" -include = ["src/**/*"] +include = ["src/**/*", "README.md"] readme = "README.md" [features] diff --git a/src/coding-tools-core/src/context/bash.txt b/src/coding-tools-core/src/context/bash.txt new file mode 100644 index 00000000..49f0d3f2 --- /dev/null +++ b/src/coding-tools-core/src/context/bash.txt @@ -0,0 +1,48 @@ +Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. + +All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. + +IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") + - Examples of proper quoting: + - mkdir "/Users/name/My Documents" (correct) + - mkdir /Users/name/My Documents (incorrect - will fail) + - python "/path/with spaces/script.py" (correct) + - python /path/with spaces/script.py (incorrect - will fail) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds 30000 characters, output will be truncated before being returned to you. + - You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter. + + - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT find or ls) + - Content search: Use Grep (NOT grep or rg) + - Read files: Use Read (NOT cat/head/tail) + - Edit files: Use Edit (NOT sed/awk) + - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead. + + Use workdir="/foo/bar" with command: pytest tests + + + cd /foo/bar && pytest tests + diff --git a/src/coding-tools-core/src/context/edit_absolute.txt b/src/coding-tools-core/src/context/edit_absolute.txt new file mode 100644 index 00000000..26fae469 --- /dev/null +++ b/src/coding-tools-core/src/context/edit_absolute.txt @@ -0,0 +1,10 @@ +Performs exact string replacements in files. + +Usage: +- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. +- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the oldString or newString. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. +- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content". +- The edit will FAIL if `oldString` is found multiple times in the file with an error "oldString found multiple times and requires more code context to uniquely identify the intended match". Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`. +- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. diff --git a/src/coding-tools-core/src/context/edit_allowed.txt b/src/coding-tools-core/src/context/edit_allowed.txt new file mode 100644 index 00000000..90e27f40 --- /dev/null +++ b/src/coding-tools-core/src/context/edit_allowed.txt @@ -0,0 +1,12 @@ +Performs exact string replacements in files within allowed directories. + +Usage: +- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. +- Paths can be relative to configured allowed directories, or absolute paths within allowed directories +- Paths outside allowed directories will be rejected +- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the oldString or newString. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. +- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content". +- The edit will FAIL if `oldString` is found multiple times in the file with an error "oldString found multiple times and requires more code context to uniquely identify the intended match". Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`. +- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. diff --git a/src/coding-tools-core/src/context/glob_absolute.txt b/src/coding-tools-core/src/context/glob_absolute.txt new file mode 100644 index 00000000..627da6ca --- /dev/null +++ b/src/coding-tools-core/src/context/glob_absolute.txt @@ -0,0 +1,6 @@ +- Fast file pattern matching tool that works with any codebase size +- Supports glob patterns like "**/*.js" or "src/**/*.ts" +- Returns matching file paths sorted by modification time +- Use this tool when you need to find files by name patterns +- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead +- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. diff --git a/src/coding-tools-core/src/context/glob_allowed.txt b/src/coding-tools-core/src/context/glob_allowed.txt new file mode 100644 index 00000000..e05a64eb --- /dev/null +++ b/src/coding-tools-core/src/context/glob_allowed.txt @@ -0,0 +1,8 @@ +- Fast file pattern matching tool that works with any codebase size +- Searches within configured allowed directories only +- Supports glob patterns like "**/*.js" or "src/**/*.ts" +- Paths can be relative to allowed directories; paths outside will be rejected +- Returns matching file paths sorted by modification time +- Use this tool when you need to find files by name patterns +- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead +- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. diff --git a/src/coding-tools-core/src/context/grep_absolute.txt b/src/coding-tools-core/src/context/grep_absolute.txt new file mode 100644 index 00000000..adf58369 --- /dev/null +++ b/src/coding-tools-core/src/context/grep_absolute.txt @@ -0,0 +1,8 @@ +- Fast content search tool that works with any codebase size +- Searches file contents using regular expressions +- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.) +- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}") +- Returns file paths and line numbers with at least one match sorted by modification time +- Use this tool when you need to find files containing specific patterns +- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`. +- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead diff --git a/src/coding-tools-core/src/context/grep_allowed.txt b/src/coding-tools-core/src/context/grep_allowed.txt new file mode 100644 index 00000000..fa3f84a1 --- /dev/null +++ b/src/coding-tools-core/src/context/grep_allowed.txt @@ -0,0 +1,10 @@ +- Fast content search tool that works with any codebase size +- Searches within configured allowed directories only +- Searches file contents using regular expressions +- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.) +- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}") +- Paths can be relative to allowed directories; paths outside will be rejected +- Returns file paths and line numbers with at least one match sorted by modification time +- Use this tool when you need to find files containing specific patterns +- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`. +- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead diff --git a/src/coding-tools-core/src/context/mod.rs b/src/coding-tools-core/src/context/mod.rs new file mode 100644 index 00000000..ccd376a3 --- /dev/null +++ b/src/coding-tools-core/src/context/mod.rs @@ -0,0 +1,166 @@ +//! Tool context strings for LLM agents. +//! +//! These provide usage guidance, best practices, and behavioral instructions +//! for LLM agents when using coding tools. Context strings are sourced from +//! OpenCode's tool documentation. +//! +//! # Path-based Tools +//! +//! Tools operating on file paths have two variants: +//! - `*_ABSOLUTE`: For unrestricted filesystem access (absolute paths required) +//! - `*_ALLOWED`: For sandboxed access (paths relative to allowed directories) +//! +//! # Example +//! +//! ```rust +//! use coding_tools_core::context::{BASH, READ_ABSOLUTE, READ_ALLOWED}; +//! +//! // Use BASH context for bash tool +//! println!("Bash guidance: {}", BASH); +//! +//! // Use appropriate read context based on path resolver +//! let sandboxed = true; +//! let read_context = if sandboxed { READ_ALLOWED } else { READ_ABSOLUTE }; +//! ``` + +/// Bash tool context - shell command execution guidance. +pub const BASH: &str = include_str!("bash.txt"); + +/// Task tool context - agent delegation guidance. +pub const TASK: &str = include_str!("task.txt"); + +/// Todo read tool context - reading task lists. +pub const TODO_READ: &str = include_str!("todoread.txt"); + +/// Todo write tool context - managing task lists. +pub const TODO_WRITE: &str = include_str!("todowrite.txt"); + +/// Webfetch tool context - URL content retrieval. +pub const WEBFETCH: &str = include_str!("webfetch.txt"); + +/// Read tool context for absolute path mode. +pub const READ_ABSOLUTE: &str = include_str!("read_absolute.txt"); + +/// Read tool context for allowed/sandboxed path mode. +pub const READ_ALLOWED: &str = include_str!("read_allowed.txt"); + +/// Write tool context for absolute path mode. +pub const WRITE_ABSOLUTE: &str = include_str!("write_absolute.txt"); + +/// Write tool context for allowed/sandboxed path mode. +pub const WRITE_ALLOWED: &str = include_str!("write_allowed.txt"); + +/// Edit tool context for absolute path mode. +pub const EDIT_ABSOLUTE: &str = include_str!("edit_absolute.txt"); + +/// Edit tool context for allowed/sandboxed path mode. +pub const EDIT_ALLOWED: &str = include_str!("edit_allowed.txt"); + +/// Glob tool context for absolute path mode. +pub const GLOB_ABSOLUTE: &str = include_str!("glob_absolute.txt"); + +/// Glob tool context for allowed/sandboxed path mode. +pub const GLOB_ALLOWED: &str = include_str!("glob_allowed.txt"); + +/// Grep tool context for absolute path mode. +pub const GREP_ABSOLUTE: &str = include_str!("grep_absolute.txt"); + +/// Grep tool context for allowed/sandboxed path mode. +pub const GREP_ALLOWED: &str = include_str!("grep_allowed.txt"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn context_strings_are_not_empty() { + // Non-path tools + assert!(!BASH.is_empty(), "BASH context should not be empty"); + assert!(!TASK.is_empty(), "TASK context should not be empty"); + assert!( + !TODO_READ.is_empty(), + "TODO_READ context should not be empty" + ); + assert!( + !TODO_WRITE.is_empty(), + "TODO_WRITE context should not be empty" + ); + assert!(!WEBFETCH.is_empty(), "WEBFETCH context should not be empty"); + + // Path-based tools (absolute variants) + assert!( + !READ_ABSOLUTE.is_empty(), + "READ_ABSOLUTE context should not be empty" + ); + assert!( + !WRITE_ABSOLUTE.is_empty(), + "WRITE_ABSOLUTE context should not be empty" + ); + assert!( + !EDIT_ABSOLUTE.is_empty(), + "EDIT_ABSOLUTE context should not be empty" + ); + assert!( + !GLOB_ABSOLUTE.is_empty(), + "GLOB_ABSOLUTE context should not be empty" + ); + assert!( + !GREP_ABSOLUTE.is_empty(), + "GREP_ABSOLUTE context should not be empty" + ); + + // Path-based tools (allowed variants) + assert!( + !READ_ALLOWED.is_empty(), + "READ_ALLOWED context should not be empty" + ); + assert!( + !WRITE_ALLOWED.is_empty(), + "WRITE_ALLOWED context should not be empty" + ); + assert!( + !EDIT_ALLOWED.is_empty(), + "EDIT_ALLOWED context should not be empty" + ); + assert!( + !GLOB_ALLOWED.is_empty(), + "GLOB_ALLOWED context should not be empty" + ); + assert!( + !GREP_ALLOWED.is_empty(), + "GREP_ALLOWED context should not be empty" + ); + } + + #[test] + fn absolute_variants_mention_absolute_path() { + assert!( + READ_ABSOLUTE.contains("absolute path"), + "READ_ABSOLUTE should mention absolute path" + ); + } + + #[test] + fn allowed_variants_mention_allowed_directories() { + assert!( + READ_ALLOWED.contains("allowed directories"), + "READ_ALLOWED should mention allowed directories" + ); + assert!( + WRITE_ALLOWED.contains("allowed directories"), + "WRITE_ALLOWED should mention allowed directories" + ); + assert!( + EDIT_ALLOWED.contains("allowed directories"), + "EDIT_ALLOWED should mention allowed directories" + ); + assert!( + GLOB_ALLOWED.contains("allowed directories"), + "GLOB_ALLOWED should mention allowed directories" + ); + assert!( + GREP_ALLOWED.contains("allowed directories"), + "GREP_ALLOWED should mention allowed directories" + ); + } +} diff --git a/src/coding-tools-core/src/context/read_absolute.txt b/src/coding-tools-core/src/context/read_absolute.txt new file mode 100644 index 00000000..b5bffee2 --- /dev/null +++ b/src/coding-tools-core/src/context/read_absolute.txt @@ -0,0 +1,12 @@ +Reads a file from the local filesystem. You can access any file directly by using this tool. +Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. + +Usage: +- The filePath parameter must be an absolute path, not a relative path +- By default, it reads up to 2000 lines starting from the beginning of the file +- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters +- Any lines longer than 2000 characters will be truncated +- Results are returned using cat -n format, with line numbers starting at 1 +- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful. +- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents. +- You can read image files using this tool. diff --git a/src/coding-tools-core/src/context/read_allowed.txt b/src/coding-tools-core/src/context/read_allowed.txt new file mode 100644 index 00000000..d295103c --- /dev/null +++ b/src/coding-tools-core/src/context/read_allowed.txt @@ -0,0 +1,13 @@ +Reads a file from the local filesystem within allowed directories. +Assume this tool is able to read files within the configured allowed directories. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. + +Usage: +- Paths can be relative to configured allowed directories, or absolute paths within allowed directories +- Paths outside allowed directories will be rejected +- By default, it reads up to 2000 lines starting from the beginning of the file +- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters +- Any lines longer than 2000 characters will be truncated +- Results are returned using cat -n format, with line numbers starting at 1 +- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful. +- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents. +- You can read image files using this tool. diff --git a/src/coding-tools-core/src/context/task.txt b/src/coding-tools-core/src/context/task.txt new file mode 100644 index 00000000..7af2a6f6 --- /dev/null +++ b/src/coding-tools-core/src/context/task.txt @@ -0,0 +1,60 @@ +Launch a new agent to handle complex, multistep tasks autonomously. + +Available agent types and the tools they have access to: +{agents} + +When using the Task tool, you must specify a subagent_type parameter to select which agent type to use. + +When to use the Task tool: +- When you are instructed to execute custom slash commands. Use the Task tool with the slash command invocation as the entire prompt. The slash command can take arguments. For example: Task(description="Check the file", prompt="/check-file path/to/file.py") + +When NOT to use the Task tool: +- If you want to read a specific file path, use the Read or Glob tool instead of the Task tool, to find the match more quickly +- If you are searching for a specific class definition like "class Foo", use the Glob tool instead, to find the match more quickly +- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Task tool, to find the match more quickly +- Other tasks that are not related to the agent descriptions above + + +Usage notes: +1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses +2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. +3. Each agent invocation is stateless unless you provide a session_id. Your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. +4. The agent's outputs should generally be trusted +5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent +6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. + +Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above): + + +"code-reviewer": use this agent after you are done writing a significant piece of code +"greeting-responder": use this agent when to respond to user greetings with a friendly joke + + + +user: "Please write a function that checks if a number is prime" +assistant: Sure let me write a function that checks if a number is prime +assistant: First let me use the Write tool to write a function that checks if a number is prime +assistant: I'm going to use the Write tool to write the following code: + +function isPrime(n) { + if (n <= 1) return false + for (let i = 2; i * i <= n; i++) { + if (n % i === 0) return false + } + return true +} + + +Since a significant piece of code was written and the task was completed, now use the code-reviewer agent to review the code + +assistant: Now let me use the code-reviewer agent to review the code +assistant: Uses the Task tool to launch the code-reviewer agent + + + +user: "Hello" + +Since the user is greeting, use the greeting-responder agent to respond with a friendly joke + +assistant: "I'm going to use the Task tool to launch the with the greeting-responder agent" + diff --git a/src/coding-tools-core/src/context/todoread.txt b/src/coding-tools-core/src/context/todoread.txt new file mode 100644 index 00000000..9ef8d913 --- /dev/null +++ b/src/coding-tools-core/src/context/todoread.txt @@ -0,0 +1,14 @@ +Use this tool to read the current to-do list for the session. This tool should be used proactively and frequently to ensure that you are aware of +the status of the current task list. You should make use of this tool as often as possible, especially in the following situations: +- At the beginning of conversations to see what's pending +- Before starting new tasks to prioritize work +- When the user asks about previous tasks or plans +- Whenever you're uncertain about what to do next +- After completing tasks to update your understanding of remaining work +- After every few messages to ensure you're on track + +Usage: +- This tool takes in no parameters. So leave the input blank or empty. DO NOT include a dummy object, placeholder string or a key like "input" or "empty". LEAVE IT BLANK. +- Returns a list of todo items with their status, priority, and content +- Use this information to track progress and plan next steps +- If no todos exist yet, an empty list will be returned diff --git a/src/coding-tools-core/src/context/todowrite.txt b/src/coding-tools-core/src/context/todowrite.txt new file mode 100644 index 00000000..d7a111a7 --- /dev/null +++ b/src/coding-tools-core/src/context/todowrite.txt @@ -0,0 +1,166 @@ +Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user. +It also helps the user understand the progress of the task and overall progress of their requests. + +## When to Use This Tool +Use this tool proactively in these scenarios: + +1. Complex multistep tasks - When a task requires 3 or more distinct steps or actions +2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations +3. User explicitly requests todo list - When the user directly asks you to use the todo list +4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated) +5. After receiving new instructions - Immediately capture user requirements as todos. Feel free to edit the todo list based on new information. +6. After completing a task - Mark it complete and add any new follow-up tasks +7. When you start working on a new task, mark the todo as in_progress. Ideally you should only have one todo as in_progress at a time. Complete existing tasks before starting new ones. + +## When NOT to Use This Tool + +Skip using this tool when: +1. There is only a single, straightforward task +2. The task is trivial and tracking it provides no organizational benefit +3. The task can be completed in less than 3 trivial steps +4. The task is purely conversational or informational + +NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly. + +## Examples of When to Use the Todo List + + +User: I want to add a dark mode toggle to the application settings. Make sure you run the tests and build when you're done! +Assistant: I'll help add a dark mode toggle to your application settings. Let me create a todo list to track this implementation. +*Creates todo list with the following items:* +1. Create dark mode toggle component in Settings page +2. Add dark mode state management (context/store) +3. Implement CSS-in-JS styles for dark theme +4. Update existing components to support theme switching +5. Run tests and build process, addressing any failures or errors that occur +*Begins working on the first task* + + +The assistant used the todo list because: +1. Adding dark mode is a multi-step feature requiring UI, state management, and styling changes +2. The user explicitly requested tests and build be run afterward +3. The assistant inferred that tests and build need to pass by adding "Ensure tests and build succeed" as the final task + + + + +User: Help me rename the function getCwd to getCurrentWorkingDirectory across my project +Assistant: Let me first search through your codebase to find all occurrences of 'getCwd'. +*Uses grep or search tools to locate all instances of getCwd in the codebase* +Assistant: I've found 15 instances of 'getCwd' across 8 different files. Let me create a todo list to track these changes. +*Creates todo list with specific items for each file that needs updating* + + +The assistant used the todo list because: +1. First, the assistant searched to understand the scope of the task +2. Upon finding multiple occurrences across different files, it determined this was a complex task with multiple steps +3. The todo list helps ensure every instance is tracked and updated systematically +4. This approach prevents missing any occurrences and maintains code consistency + + + + + +User: I need to implement these features for my e-commerce site: user registration, product catalog, shopping cart, and checkout flow. +Assistant: I'll help implement these features. First, let's add all the features to the todo list. +*Creates a todo list breaking down each feature into specific tasks based on the project architecture* +Assistant: Let's start with user registration. This will involve creating database models, API endpoints, and frontend forms that integrate with your existing authentication system. + + +The assistant used the todo list because: +1. The user provided multiple complex features to implement in a comma separated list +2. The todo list helps organize these large features into manageable tasks +3. This approach allows for tracking progress across the entire implementation + + + + +User: Can you help optimize my React application? It's rendering slowly and has performance issues. +Assistant: I'll help optimize your React application. First, let me examine your codebase to identify potential performance bottlenecks. +*Reviews component structure, render patterns, state management, and data fetching* +Assistant: After analyzing your codebase, I've identified several performance issues. Let me create a todo list to track our optimization efforts. +*Creates todo list with items like: 1) Implement memoization for expensive calculations in ProductList, 2) Add virtualization for long lists in Dashboard, 3) Optimize image loading in Gallery component, 4) Fix state update loops in ShoppingCart, 5) Review bundle size and implement code splitting* +Let's start by implementing memoization for the expensive calculations in your ProductList component. + + +The assistant used the todo list because: +1. First, the assistant examined the codebase to identify specific performance issues +2. Based on this analysis, it identified multiple optimization opportunities +3. Performance optimization is a non-trivial task requiring multiple steps +4. The todo list helps methodically track improvements across different components +5. This systematic approach ensures all performance bottlenecks are addressed + + + +## Examples of When NOT to Use the Todo List + + +User: How do I print 'Hello World' in Python? +Assistant: In Python, you can print "Hello World" with this simple code: + +python +print("Hello World") + +This will output the text "Hello World" to the console when executed. + + +The assistant did not use the todo list because this is a single, trivial task that can be completed in one step. There's no need to track multiple tasks or steps for such a straightforward request. + + + + +User: What does the git status command do? +Assistant: The git status command shows the current state of your working directory and staging area. It displays which changes have been staged, which haven't, and which files aren't being tracked by Git. This is a fundamental command for understanding the current state of your repository before making commits. + + +The assistant did not use the todo list because this is an informational request with no actual coding task to complete. The user is simply asking for an explanation, not for the assistant to perform multiple steps or tasks. + + + + +User: Can you add a comment to the calculateTotal function to explain what it does? +Assistant: Sure, let me add a comment to the calculateTotal function to explain what it does. +* Uses the Edit tool to add a comment to the calculateTotal function * + + +The assistant did not use the todo list because this is a single, straightforward task confined to one location in the code. Adding a comment doesn't require tracking multiple steps or systematic organization. + + + + +User: Run npm install for me and tell me what happens. +Assistant: I'll run the npm install command for you. + +*Executes: npm install* + +The command completed successfully. Here's the output: +[Output of npm install command] + +All dependencies have been installed according to your package.json file. + + +The assistant did not use the todo list because this is a single command execution with immediate results. There are no multiple steps to track or organize, making the todo list unnecessary for this straightforward task. + + + +## Task States and Management + +1. **Task States**: Use these states to track progress: + - pending: Task not yet started + - in_progress: Currently working on (limit to ONE task at a time) + - completed: Task finished successfully + - cancelled: Task no longer needed + +2. **Task Management**: + - Update task status in real-time as you work + - Mark tasks complete IMMEDIATELY after finishing (don't batch completions) + - Only have ONE task in_progress at any time + - Complete current tasks before starting new ones + - Cancel tasks that become irrelevant + +3. **Task Breakdown**: + - Create specific, actionable items + - Break complex tasks into smaller, manageable steps + - Use clear, descriptive task names + +When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully. diff --git a/src/coding-tools-core/src/context/webfetch.txt b/src/coding-tools-core/src/context/webfetch.txt new file mode 100644 index 00000000..169aadef --- /dev/null +++ b/src/coding-tools-core/src/context/webfetch.txt @@ -0,0 +1,13 @@ +- Fetches content from a specified URL +- Takes a URL and optional format as input +- Fetches the URL content, converts to requested format (markdown by default) +- Returns the content in the specified format +- Use this tool when you need to retrieve and analyze web content + +Usage notes: + - IMPORTANT: if another tool is present that offers better web fetching capabilities, is more targeted to the task, or has fewer restrictions, prefer using that tool instead of this one. + - The URL must be a fully-formed valid URL + - HTTP URLs will be automatically upgraded to HTTPS + - Format options: "markdown" (default), "text", or "html" + - This tool is read-only and does not modify any files + - Results may be summarized if the content is very large diff --git a/src/coding-tools-core/src/context/write_absolute.txt b/src/coding-tools-core/src/context/write_absolute.txt new file mode 100644 index 00000000..063cbb1f --- /dev/null +++ b/src/coding-tools-core/src/context/write_absolute.txt @@ -0,0 +1,8 @@ +Writes a file to the local filesystem. + +Usage: +- This tool will overwrite the existing file if there is one at the provided path. +- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. +- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. diff --git a/src/coding-tools-core/src/context/write_allowed.txt b/src/coding-tools-core/src/context/write_allowed.txt new file mode 100644 index 00000000..cddcec8c --- /dev/null +++ b/src/coding-tools-core/src/context/write_allowed.txt @@ -0,0 +1,10 @@ +Writes a file to the local filesystem within allowed directories. + +Usage: +- This tool will overwrite the existing file if there is one at the provided path. +- Paths can be relative to configured allowed directories, or absolute paths within allowed directories +- Paths outside allowed directories will be rejected +- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. +- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. diff --git a/src/coding-tools-core/src/lib.rs b/src/coding-tools-core/src/lib.rs index e1b664a0..97b3f7dd 100644 --- a/src/coding-tools-core/src/lib.rs +++ b/src/coding-tools-core/src/lib.rs @@ -19,6 +19,7 @@ compile_error!("Feature `async` requires a runtime. Enable `tokio` feature inste #[cfg(all(feature = "async", feature = "blocking"))] compile_error!("Features `async` and `blocking` are mutually exclusive."); +pub mod context; pub mod error; pub mod fs; pub mod operations; diff --git a/src/coding-tools-rig/src/lib.rs b/src/coding-tools-rig/src/lib.rs index 1029e652..4c2b3f6b 100644 --- a/src/coding-tools-rig/src/lib.rs +++ b/src/coding-tools-rig/src/lib.rs @@ -28,6 +28,9 @@ pub mod webfetch; // Re-export core types for convenience pub use coding_tools_core::{ToolError, ToolOutput, ToolResult}; +// Re-export context module for convenience +pub use coding_tools_core::context; + // Re-export path resolvers pub use coding_tools_core::path::{AbsolutePathResolver, AllowedPathResolver, PathResolver}; From 0ba2701d865c0fb0356b5d84ba421afeede23346 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 05:14:05 +0000 Subject: [PATCH 23/64] Added: Basic example demonstrating Rig tool integration - Create examples/basic.rs showing absolute and allowed path tools setup - Demonstrate context string usage for LLM system prompts - Include ToolSet builder pattern for dynamic tool management - Compile and run without API keys (cargo run --example basic) - Update tokio dev-dependency to include rt-multi-thread feature --- src/coding-tools-rig/Cargo.toml | 2 +- src/coding-tools-rig/examples/basic.rs | 48 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/coding-tools-rig/examples/basic.rs diff --git a/src/coding-tools-rig/Cargo.toml b/src/coding-tools-rig/Cargo.toml index 7b22ad62..757d5293 100644 --- a/src/coding-tools-rig/Cargo.toml +++ b/src/coding-tools-rig/Cargo.toml @@ -25,4 +25,4 @@ serde_json = "1.0" [dev-dependencies] tempfile = "3.10" -tokio = { version = "1.0", features = ["rt", "macros"] } +tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] } diff --git a/src/coding-tools-rig/examples/basic.rs b/src/coding-tools-rig/examples/basic.rs new file mode 100644 index 00000000..b7c3626e --- /dev/null +++ b/src/coding-tools-rig/examples/basic.rs @@ -0,0 +1,48 @@ +//! Basic example showing coding-tools-rig tool setup. +//! +//! Run: cargo run --example basic -p coding-tools-rig + +use coding_tools_rig::absolute::{GlobTool, GrepTool, ReadTool}; +use coding_tools_rig::allowed_tools::ReadTool as AllowedReadTool; +use coding_tools_rig::context::{BASH, GLOB_ABSOLUTE, READ_ABSOLUTE, READ_ALLOWED}; +use coding_tools_rig::BashTool; +use rig::tool::ToolSet; + +#[tokio::main] +async fn main() { + // === Absolute path tools (unrestricted filesystem access) === + let read: ReadTool = ReadTool::new(); + let glob = GlobTool::new(); + let grep: GrepTool = GrepTool::new(); + let bash = BashTool::new(); + + // === Allowed path tools (sandboxed to specific directories) === + let read_sandboxed: AllowedReadTool = + AllowedReadTool::new([std::env::current_dir().unwrap()]).unwrap(); + + // === ToolSet for dynamic tool management === + let toolset = ToolSet::builder() + .static_tool(read) + .static_tool(glob) + .static_tool(grep) + .static_tool(bash) + .static_tool(read_sandboxed) + .build(); + + // === Print tool definitions === + println!("Available tools:"); + for def in toolset.get_tool_definitions().await.unwrap() { + println!( + " - {}: {}", + def.name, + &def.description[..50.min(def.description.len())] + ); + } + + // === Context strings for LLM system prompts === + println!("\nContext string snippets:"); + println!(" READ_ABSOLUTE: {}...", &READ_ABSOLUTE[..60]); + println!(" READ_ALLOWED: {}...", &READ_ALLOWED[..60]); + println!(" GLOB_ABSOLUTE: {}...", &GLOB_ABSOLUTE[..60]); + println!(" BASH: {}...", &BASH[..60]); +} From f1160c970d0ccc385a29c17eda444ab85d1c3b66 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 05:19:53 +0000 Subject: [PATCH 24/64] Added: Comprehensive documentation for features and quick start - Document Feature Flags section in root README with tokio/blocking modes - Add Context Module documentation to coding-tools-core README - Document context strings usage with examples for path-based tools - Add Quick Start section to coding-tools-rig README with example reference - Update example code in root README with generic type annotations - Enhance rig README with complete usage examples for tool instantiation - Add ToolSet builder pattern and context string re-export documentation - Update documentation links and provide clearer guidance on available tools --- README.MD | 18 ++++++++---- src/coding-tools-core/README.md | 39 ++++++++++++++++++++++++++ src/coding-tools-rig/README.md | 49 ++++++++++++++++++++++++++------- 3 files changed, 91 insertions(+), 15 deletions(-) diff --git a/README.MD b/README.MD index 7f0c000c..788a75be 100644 --- a/README.MD +++ b/README.MD @@ -22,6 +22,12 @@ This workspace contains multiple Rust crates for integrating coding tools with L - **Web**: URL fetching with HTML-to-markdown conversion - **Task Delegation**: Sub-agent spawning for complex workflows - **Path Security**: Choose between unrestricted or sandboxed file access +- **Context Strings**: Embedded LLM guidance for tool usage + +## Feature Flags (coding-tools-core) + +- `tokio` (default): Async mode with tokio runtime +- `blocking`: Sync/blocking mode, mutually exclusive with `async` ## Quick Start @@ -37,18 +43,21 @@ use coding_tools_rig::absolute::{ReadTool, WriteTool}; use coding_tools_rig::BashTool; // Create tools for unrestricted file access -let read = ReadTool::new(); -let write = WriteTool::new(); +let read: ReadTool = ReadTool::new(); +let write: WriteTool = WriteTool::new(); let bash = BashTool::new(); // Use with rig agent builder... ``` -For more examples and documentation, see [README.md](./README.md) and the individual crate READMEs. +Run the example: + +```bash +cargo run --example basic -p coding-tools-rig +``` ## Documentation -- [Workspace Overview](./README.md) - [coding-tools-core README](./src/coding-tools-core/README.md) - [coding-tools-rig README](./src/coding-tools-rig/README.md) - [Developer Guidelines](./src/AGENTS.md) @@ -60,4 +69,3 @@ Contributions are welcome! Please ensure all tests pass and the code follows our ## License Licensed under [Apache 2.0](./LICENSE). - diff --git a/src/coding-tools-core/README.md b/src/coding-tools-core/README.md index fb9f962d..89c7b00e 100644 --- a/src/coding-tools-core/README.md +++ b/src/coding-tools-core/README.md @@ -10,6 +10,17 @@ This crate provides the foundational building blocks for coding tool implementat - `ToolResult` - Result type alias using ToolError - `ToolOutput` - Wrapper for tool responses with truncation metadata - Utility functions for text processing and formatting +- `context` module - LLM guidance strings for tool usage + +## Features + +- `tokio` (default): Async mode with tokio runtime. Enables async function signatures. +- `blocking`: Sync/blocking mode. Mutually exclusive with `tokio`/`async`. +- `async`: Base async signatures (internal). Requires a runtime; use `tokio` instead. + +The `async` and `blocking` features are mutually exclusive - enabling both causes a compile error. + +Future runtimes (smol, async-std) can be added following the same pattern as `tokio`. ## Usage @@ -18,6 +29,34 @@ use coding_tools_core::{ToolError, ToolResult, ToolOutput}; use coding_tools_core::util::{truncate_text, format_numbered_line}; ``` +## Context Module + +The `context` module provides embedded strings containing usage guidance for LLM agents. +These can be appended to tool descriptions or system prompts. + +Path-based tools have two variants: +- `*_ABSOLUTE`: For unrestricted filesystem access (absolute paths required) +- `*_ALLOWED`: For sandboxed access (paths relative to allowed directories) + +```rust +use coding_tools_core::context::{BASH, READ_ABSOLUTE, READ_ALLOWED}; + +// Non-path tools have a single variant +println!("{}", BASH); + +// Path-based tools have absolute and allowed variants +println!("{}", READ_ABSOLUTE); +println!("{}", READ_ALLOWED); +``` + +Available context strings: +- `BASH`, `TASK`, `TODO_READ`, `TODO_WRITE`, `WEBFETCH` - standalone tools +- `READ_ABSOLUTE`, `READ_ALLOWED` - file reading +- `WRITE_ABSOLUTE`, `WRITE_ALLOWED` - file writing +- `EDIT_ABSOLUTE`, `EDIT_ALLOWED` - file editing +- `GLOB_ABSOLUTE`, `GLOB_ALLOWED` - pattern matching +- `GREP_ABSOLUTE`, `GREP_ALLOWED` - content search + ## Design Principles - No framework-specific dependencies, plug and play into any LLM framework/library diff --git a/src/coding-tools-rig/README.md b/src/coding-tools-rig/README.md index 64b5c8b9..fb077b4e 100644 --- a/src/coding-tools-rig/README.md +++ b/src/coding-tools-rig/README.md @@ -13,6 +13,7 @@ Rig framework Tool implementations for coding tools. - **Web fetching** - URL content retrieval with format conversion - **Task delegation** - Sub-agent spawning for complex tasks - **Todo management** - Persistent todo list tracking +- **Context strings** - LLM guidance text for tool usage (re-exported from core) ## Installation @@ -20,7 +21,15 @@ Add to your `Cargo.toml`: ```toml [dependencies] -coding-tools-rig = "0.1.0" +coding-tools-rig = "0.1" +``` + +## Quick Start + +Run the included example: + +```bash +cargo run --example basic -p coding-tools-rig ``` ## Usage @@ -31,10 +40,18 @@ For unrestricted file access: ```rust use coding_tools_rig::absolute::{ReadTool, WriteTool, EditTool, GlobTool, GrepTool}; -use rig::tool::Tool; +use coding_tools_rig::BashTool; +use rig::tool::ToolSet; -let read_tool = ReadTool::new(); -// Use with rig agent... +let read: ReadTool = ReadTool::new(); +let glob = GlobTool::new(); +let bash = BashTool::new(); + +let toolset = ToolSet::builder() + .static_tool(read) + .static_tool(glob) + .static_tool(bash) + .build(); ``` ### Allowed-Path Tools @@ -42,16 +59,13 @@ let read_tool = ReadTool::new(); For sandboxed file access: ```rust -use coding_tools_rig::allowed::{ReadTool, WriteTool}; -use coding_tools_rig::AllowedPathResolver; +use coding_tools_rig::allowed::ReadTool; use std::path::PathBuf; -let resolver = AllowedPathResolver::new(vec![ +let read: ReadTool = ReadTool::new([ PathBuf::from("/home/user/project"), ]).unwrap(); - -let read_tool = ReadTool::new(resolver); -// Use with rig agent - paths restricted to /home/user/project +// Paths are restricted to /home/user/project ``` ### Standalone Tools @@ -67,6 +81,21 @@ let task = TaskTool::with_mock(); // or TaskTool::new(executor) let webfetch = WebFetchTool::new(); ``` +### Context Strings + +LLM guidance strings are re-exported from `coding_tools_core`: + +```rust +use coding_tools_rig::context::{BASH, READ_ABSOLUTE, READ_ALLOWED}; + +// Use context strings in system prompts or tool descriptions +println!("{}", BASH); + +// Path-based tools have absolute and allowed variants +println!("{}", READ_ABSOLUTE); // For absolute::ReadTool +println!("{}", READ_ALLOWED); // For allowed::ReadTool +``` + ## License Apache 2.0 From 8922d1cee8a76848d77464aedc240ebcf85686bc Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 06:45:05 +0000 Subject: [PATCH 25/64] Added: PreambleBuilder and ToolContext trait for tool-agnostic preamble generation - Introduce ToolContext trait in coding-tools-core for tools to provide preamble context - Implement PreambleBuilder in core crate for framework-agnostic preamble tracking and generation - Implement ToolContext for all 15 tools in coding-tools-rig (absolute/allowed variants and standalone) - Use pass-through tracking pattern: track() records context and returns tool unchanged - Zero-cost abstraction with trait method returning &'static str - Update re-exports in both core and rig crates for public API access - Demonstrate PreambleBuilder usage in examples/basic.rs --- src/coding-tools-core/src/context/mod.rs | 33 +++++ src/coding-tools-core/src/lib.rs | 3 + src/coding-tools-core/src/preamble.rs | 164 +++++++++++++++++++++ src/coding-tools-rig/examples/basic.rs | 66 +++++---- src/coding-tools-rig/src/absolute/edit.rs | 11 +- src/coding-tools-rig/src/absolute/glob.rs | 12 +- src/coding-tools-rig/src/absolute/grep.rs | 12 +- src/coding-tools-rig/src/absolute/mod.rs | 2 +- src/coding-tools-rig/src/absolute/read.rs | 12 +- src/coding-tools-rig/src/absolute/write.rs | 12 +- src/coding-tools-rig/src/allowed/edit.rs | 12 +- src/coding-tools-rig/src/allowed/glob.rs | 12 +- src/coding-tools-rig/src/allowed/grep.rs | 12 +- src/coding-tools-rig/src/allowed/mod.rs | 2 +- src/coding-tools-rig/src/allowed/read.rs | 12 +- src/coding-tools-rig/src/allowed/write.rs | 12 +- src/coding-tools-rig/src/bash.rs | 12 +- src/coding-tools-rig/src/lib.rs | 29 +++- src/coding-tools-rig/src/task.rs | 12 +- src/coding-tools-rig/src/todo.rs | 22 ++- src/coding-tools-rig/src/webfetch.rs | 12 +- 21 files changed, 417 insertions(+), 59 deletions(-) create mode 100644 src/coding-tools-core/src/preamble.rs diff --git a/src/coding-tools-core/src/context/mod.rs b/src/coding-tools-core/src/context/mod.rs index ccd376a3..5957166f 100644 --- a/src/coding-tools-core/src/context/mod.rs +++ b/src/coding-tools-core/src/context/mod.rs @@ -68,6 +68,39 @@ pub const GREP_ABSOLUTE: &str = include_str!("grep_absolute.txt"); /// Grep tool context for allowed/sandboxed path mode. pub const GREP_ALLOWED: &str = include_str!("grep_allowed.txt"); +/// Trait for tools that provide usage context for LLM preambles. +/// +/// Implement this trait on tool types (for frameworks like rig) to enable automatic preamble +/// generation via [`PreambleBuilder`](crate::PreambleBuilder). +/// +/// # Example +/// +/// ```rust +/// use coding_tools_core::context::ToolContext; +/// +/// struct MyTool; +/// +/// impl ToolContext for MyTool { +/// const NAME: &'static str = "mytool"; +/// +/// fn context(&self) -> &'static str { +/// "Instructions for using MyTool..." +/// } +/// } +/// ``` +pub trait ToolContext { + /// Tool name used for section headers in generated preamble. + /// + /// Should be lowercase (e.g., "read", "bash", "glob"). + /// PreambleBuilder capitalizes this for display. + const NAME: &'static str; + + /// Returns the tool's context string for preamble generation. + /// + /// This should return one of the context constants from this module. + fn context(&self) -> &'static str; +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/coding-tools-core/src/lib.rs b/src/coding-tools-core/src/lib.rs index 97b3f7dd..dd944e55 100644 --- a/src/coding-tools-core/src/lib.rs +++ b/src/coding-tools-core/src/lib.rs @@ -25,11 +25,14 @@ pub mod fs; pub mod operations; pub mod output; pub mod path; +pub mod preamble; pub mod util; +pub use context::ToolContext; pub use error::{ToolError, ToolResult}; pub use output::ToolOutput; pub use path::{AbsolutePathResolver, AllowedPathResolver, PathResolver}; +pub use preamble::PreambleBuilder; // Re-export operations (always available, sync or async based on runtime feature) pub use operations::{ diff --git a/src/coding-tools-core/src/preamble.rs b/src/coding-tools-core/src/preamble.rs new file mode 100644 index 00000000..7536b929 --- /dev/null +++ b/src/coding-tools-core/src/preamble.rs @@ -0,0 +1,164 @@ +//! Preamble generation for LLM agents. +//! +//! Provides [`PreambleBuilder`] for tracking tools and generating formatted +//! preambles containing tool usage context. + +use crate::context::ToolContext; + +/// Entry storing tool name and context string. +struct ContextEntry { + name: &'static str, + context: &'static str, +} + +/// Builder that tracks tools and generates formatted preambles. +/// +/// Use `.track()` to record a tool's context while passing it through +/// to `ToolSet::builder()`. This gives full access to Rig's API. +/// +/// # Example +/// +/// ```ignore +/// use coding_tools_rig::absolute::{ReadTool, GlobTool}; +/// use coding_tools_rig::{BashTool, PreambleBuilder}; +/// use rig::tool::ToolSet; +/// +/// let mut pb = PreambleBuilder::new(); +/// +/// let toolset = ToolSet::builder() +/// .static_tool(pb.track(ReadTool::::new())) +/// .static_tool(pb.track(GlobTool::new())) +/// .static_tool(pb.track(BashTool::new())) +/// .build(); +/// +/// let preamble = pb.build(); +/// // Pass preamble to agent builder via .preamble(&preamble) +/// ``` +#[derive(Default)] +pub struct PreambleBuilder { + entries: Vec, +} + +impl PreambleBuilder { + /// Creates a new preamble builder. + #[inline] + pub fn new() -> Self { + Self::default() + } + + /// Records context and returns tool unchanged for ToolSet. + /// + /// Use this to wrap tools when adding to `ToolSet::builder()`: + /// ```ignore + /// let mut pb = PreambleBuilder::new(); + /// let toolset = ToolSet::builder() + /// .static_tool(pb.track(ReadTool::new())) + /// .build(); + /// ``` + pub fn track(&mut self, tool: T) -> T { + self.entries.push(ContextEntry { + name: T::NAME, + context: tool.context(), + }); + tool + } + + /// Generates the preamble string. + /// + /// Call this after tracking all tools, then pass the result + /// to Rig's `.preamble()` method on the agent builder. + pub fn build(self) -> String { + if self.entries.is_empty() { + return String::new(); + } + + let mut output = String::with_capacity( + self.entries + .iter() + .map(|e| e.context.len() + e.name.len() + 20) + .sum(), + ); + + output.push_str("# Tool Usage Guidelines\n\n"); + + for entry in self.entries { + output.push_str("## "); + // Capitalize first letter + let mut chars = entry.name.chars(); + if let Some(first) = chars.next() { + output.push(first.to_ascii_uppercase()); + output.push_str(chars.as_str()); + } + output.push_str(" Tool\n\n"); + output.push_str(entry.context); + output.push_str("\n\n"); + } + + output.truncate(output.trim_end().len()); + output + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MockTool { + id: u32, + } + + impl ToolContext for MockTool { + const NAME: &'static str = "mock"; + fn context(&self) -> &'static str { + "Mock tool context." + } + } + + #[test] + fn empty_builder_returns_empty_string() { + let preamble = PreambleBuilder::new().build(); + assert!(preamble.is_empty()); + } + + #[test] + fn track_returns_tool_unchanged() { + let mut pb = PreambleBuilder::new(); + let tool = MockTool { id: 42 }; + let returned = pb.track(tool); + assert_eq!(returned.id, 42); + } + + #[test] + fn single_tool_formats_correctly() { + let mut pb = PreambleBuilder::new(); + let _ = pb.track(MockTool { id: 1 }); + let preamble = pb.build(); + + assert!(preamble.contains("# Tool Usage Guidelines")); + assert!(preamble.contains("## Mock Tool")); + assert!(preamble.contains("Mock tool context.")); + } + + #[test] + fn multiple_tools_preserve_order() { + struct OtherTool; + impl ToolContext for OtherTool { + const NAME: &'static str = "other"; + fn context(&self) -> &'static str { + "Other context." + } + } + + let mut pb = PreambleBuilder::new(); + let _ = pb.track(MockTool { id: 1 }); + let _ = pb.track(OtherTool); + let preamble = pb.build(); + + let mock_pos = preamble.find("## Mock Tool").unwrap(); + let other_pos = preamble.find("## Other Tool").unwrap(); + assert!( + mock_pos < other_pos, + "Tools should appear in insertion order" + ); + } +} diff --git a/src/coding-tools-rig/examples/basic.rs b/src/coding-tools-rig/examples/basic.rs index b7c3626e..e2205c4a 100644 --- a/src/coding-tools-rig/examples/basic.rs +++ b/src/coding-tools-rig/examples/basic.rs @@ -1,48 +1,58 @@ -//! Basic example showing coding-tools-rig tool setup. +//! PreambleBuilder example - pass-through tracking for ToolSet. +//! +//! Demonstrates: +//! - Using PreambleBuilder alongside ToolSet::builder() +//! - Full access to Rig's API (no wrapper limitations) +//! - Generating and using the preamble string //! //! Run: cargo run --example basic -p coding-tools-rig use coding_tools_rig::absolute::{GlobTool, GrepTool, ReadTool}; -use coding_tools_rig::allowed_tools::ReadTool as AllowedReadTool; -use coding_tools_rig::context::{BASH, GLOB_ABSOLUTE, READ_ABSOLUTE, READ_ALLOWED}; -use coding_tools_rig::BashTool; +use coding_tools_rig::{BashTool, PreambleBuilder}; use rig::tool::ToolSet; #[tokio::main] async fn main() { - // === Absolute path tools (unrestricted filesystem access) === - let read: ReadTool = ReadTool::new(); - let glob = GlobTool::new(); - let grep: GrepTool = GrepTool::new(); - let bash = BashTool::new(); - - // === Allowed path tools (sandboxed to specific directories) === - let read_sandboxed: AllowedReadTool = - AllowedReadTool::new([std::env::current_dir().unwrap()]).unwrap(); + // === Create preamble builder to track tools === + let mut pb = PreambleBuilder::new(); - // === ToolSet for dynamic tool management === + // === Use ToolSet::builder() directly - full Rig API! === let toolset = ToolSet::builder() - .static_tool(read) - .static_tool(glob) - .static_tool(grep) - .static_tool(bash) - .static_tool(read_sandboxed) + .static_tool(pb.track(ReadTool::::new())) + .static_tool(pb.track(GlobTool::new())) + .static_tool(pb.track(GrepTool::::new())) + .static_tool(pb.track(BashTool::new())) + // Can use any ToolSet method here - dynamic_tool, etc. .build(); - // === Print tool definitions === - println!("Available tools:"); + // === Generate preamble string === + let preamble = pb.build(); + + // === Print tool definitions from ToolSet === + println!("=== Tools in ToolSet ==="); for def in toolset.get_tool_definitions().await.unwrap() { println!( " - {}: {}", def.name, - &def.description[..50.min(def.description.len())] + &def.description[..60.min(def.description.len())] ); } - // === Context strings for LLM system prompts === - println!("\nContext string snippets:"); - println!(" READ_ABSOLUTE: {}...", &READ_ABSOLUTE[..60]); - println!(" READ_ALLOWED: {}...", &READ_ALLOWED[..60]); - println!(" GLOB_ABSOLUTE: {}...", &GLOB_ABSOLUTE[..60]); - println!(" BASH: {}...", &BASH[..60]); + // === Print generated preamble === + println!("\n=== Generated Preamble ({} chars) ===\n", preamble.len()); + println!("{}", &preamble[..1000.min(preamble.len())]); + if preamble.len() > 1000 { + println!("\n... ({} more chars)", preamble.len() - 1000); + } + + // === Integration with Rig agent === + // IMPORTANT: You must call .preamble() to actually use the generated string! + // + // let agent = openai::Client::from_env() + // .agent("gpt-4o") + // .preamble(&preamble) // <-- Pass preamble to Rig + // .tools(toolset) + // .build(); + // + // let response = agent.prompt("Read main.rs").await?; } diff --git a/src/coding-tools-rig/src/absolute/edit.rs b/src/coding-tools-rig/src/absolute/edit.rs index 2ee1ec9e..999f41c4 100644 --- a/src/coding-tools-rig/src/absolute/edit.rs +++ b/src/coding-tools-rig/src/absolute/edit.rs @@ -3,6 +3,7 @@ use coding_tools_core::operations::edit_file; use coding_tools_core::path::AbsolutePathResolver; pub use coding_tools_core::EditError; +use coding_tools_core::ToolContext; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -43,7 +44,7 @@ impl Tool for EditTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { - name: Self::NAME.to_string(), + name: ::NAME.to_string(), description: "Makes exact string replacements in files. Use replace_all=true to \ replace all occurrences." .to_string(), @@ -65,6 +66,14 @@ impl Tool for EditTool { } } +impl ToolContext for EditTool { + const NAME: &'static str = "edit"; + + fn context(&self) -> &'static str { + coding_tools_core::context::EDIT_ABSOLUTE + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/coding-tools-rig/src/absolute/glob.rs b/src/coding-tools-rig/src/absolute/glob.rs index 0cb22093..327c98e1 100644 --- a/src/coding-tools-rig/src/absolute/glob.rs +++ b/src/coding-tools-rig/src/absolute/glob.rs @@ -2,7 +2,7 @@ use coding_tools_core::operations::glob_files; use coding_tools_core::path::AbsolutePathResolver; -use coding_tools_core::{GlobOutput, ToolError}; +use coding_tools_core::{GlobOutput, ToolContext, ToolError}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -38,7 +38,7 @@ impl Tool for GlobTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { - name: Self::NAME.to_string(), + name: ::NAME.to_string(), description: "Find files matching a glob pattern. Respects .gitignore and \ returns paths sorted by modification time (newest first)." .to_string(), @@ -53,6 +53,14 @@ impl Tool for GlobTool { } } +impl ToolContext for GlobTool { + const NAME: &'static str = "glob"; + + fn context(&self) -> &'static str { + coding_tools_core::context::GLOB_ABSOLUTE + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/coding-tools-rig/src/absolute/grep.rs b/src/coding-tools-rig/src/absolute/grep.rs index f8ba28c8..39e7f36b 100644 --- a/src/coding-tools-rig/src/absolute/grep.rs +++ b/src/coding-tools-rig/src/absolute/grep.rs @@ -2,7 +2,7 @@ use coding_tools_core::operations::grep_search; use coding_tools_core::path::AbsolutePathResolver; -use coding_tools_core::{ToolError, ToolOutput}; +use coding_tools_core::{ToolContext, ToolError, ToolOutput}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -60,7 +60,7 @@ impl Tool for GrepTool { and content, sorted by file modification time." }; ToolDefinition { - name: Self::NAME.to_string(), + name: ::NAME.to_string(), description: description.to_string(), parameters: serde_json::to_value(schema_for!(GrepArgs)) .expect("schema serialization should not fail"), @@ -130,6 +130,14 @@ impl Tool for GrepTool { } } +impl ToolContext for GrepTool { + const NAME: &'static str = "grep"; + + fn context(&self) -> &'static str { + coding_tools_core::context::GREP_ABSOLUTE + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/coding-tools-rig/src/absolute/mod.rs b/src/coding-tools-rig/src/absolute/mod.rs index bbceaf38..7d0b6604 100644 --- a/src/coding-tools-rig/src/absolute/mod.rs +++ b/src/coding-tools-rig/src/absolute/mod.rs @@ -1,4 +1,4 @@ -//! Tools using [`AbsolutePathResolver`]. +//! Tools using [`coding_tools_core::path::AbsolutePathResolver`]. //! //! These tools require absolute paths and perform no directory restriction. //! Use for unrestricted file system access. diff --git a/src/coding-tools-rig/src/absolute/read.rs b/src/coding-tools-rig/src/absolute/read.rs index be69ff2b..448a93f7 100644 --- a/src/coding-tools-rig/src/absolute/read.rs +++ b/src/coding-tools-rig/src/absolute/read.rs @@ -2,7 +2,7 @@ use coding_tools_core::operations::read_file; use coding_tools_core::path::AbsolutePathResolver; -use coding_tools_core::{ToolError, ToolOutput}; +use coding_tools_core::{ToolContext, ToolError, ToolOutput}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -58,7 +58,7 @@ impl Tool for ReadTool { "Read file contents. Returns raw file content without line number prefixes." }; ToolDefinition { - name: Self::NAME.to_string(), + name: ::NAME.to_string(), description: description.to_string(), parameters: serde_json::to_value(schema_for!(ReadArgs)) .expect("schema serialization should never fail"), @@ -71,6 +71,14 @@ impl Tool for ReadTool { } } +impl ToolContext for ReadTool { + const NAME: &'static str = "read"; + + fn context(&self) -> &'static str { + coding_tools_core::context::READ_ABSOLUTE + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/coding-tools-rig/src/absolute/write.rs b/src/coding-tools-rig/src/absolute/write.rs index 7d082a27..0a0dd03f 100644 --- a/src/coding-tools-rig/src/absolute/write.rs +++ b/src/coding-tools-rig/src/absolute/write.rs @@ -2,7 +2,7 @@ use coding_tools_core::operations::write_file; use coding_tools_core::path::AbsolutePathResolver; -use coding_tools_core::ToolError; +use coding_tools_core::{ToolContext, ToolError}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -38,7 +38,7 @@ impl Tool for WriteTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { - name: Self::NAME.to_string(), + name: ::NAME.to_string(), description: "Write content to a file, creating parent directories if needed. \ Overwrites existing files." .to_string(), @@ -53,6 +53,14 @@ impl Tool for WriteTool { } } +impl ToolContext for WriteTool { + const NAME: &'static str = "write"; + + fn context(&self) -> &'static str { + coding_tools_core::context::WRITE_ABSOLUTE + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/coding-tools-rig/src/allowed/edit.rs b/src/coding-tools-rig/src/allowed/edit.rs index ba606c8b..5a8289f6 100644 --- a/src/coding-tools-rig/src/allowed/edit.rs +++ b/src/coding-tools-rig/src/allowed/edit.rs @@ -3,7 +3,7 @@ use coding_tools_core::operations::edit_file; use coding_tools_core::path::AllowedPathResolver; pub use coding_tools_core::EditError; -use coding_tools_core::ToolResult; +use coding_tools_core::{ToolContext, ToolResult}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -57,7 +57,7 @@ impl Tool for EditTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { - name: Self::NAME.to_string(), + name: ::NAME.to_string(), description: "Make exact string replacements in files within allowed directories. \ Paths are relative to configured base directories." .to_string(), @@ -78,6 +78,14 @@ impl Tool for EditTool { } } +impl ToolContext for EditTool { + const NAME: &'static str = "edit"; + + fn context(&self) -> &'static str { + coding_tools_core::context::EDIT_ALLOWED + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/coding-tools-rig/src/allowed/glob.rs b/src/coding-tools-rig/src/allowed/glob.rs index 373f29ad..f1f297a9 100644 --- a/src/coding-tools-rig/src/allowed/glob.rs +++ b/src/coding-tools-rig/src/allowed/glob.rs @@ -2,7 +2,7 @@ use coding_tools_core::operations::glob_files; use coding_tools_core::path::AllowedPathResolver; -use coding_tools_core::{GlobOutput, ToolError, ToolResult}; +use coding_tools_core::{GlobOutput, ToolContext, ToolError, ToolResult}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -51,7 +51,7 @@ impl Tool for GlobTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { - name: Self::NAME.to_string(), + name: ::NAME.to_string(), description: "Find files matching a glob pattern within allowed directories. \ Paths are relative to configured base directories." .to_string(), @@ -65,6 +65,14 @@ impl Tool for GlobTool { } } +impl ToolContext for GlobTool { + const NAME: &'static str = "glob"; + + fn context(&self) -> &'static str { + coding_tools_core::context::GLOB_ALLOWED + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/coding-tools-rig/src/allowed/grep.rs b/src/coding-tools-rig/src/allowed/grep.rs index 6804d7e8..87e11906 100644 --- a/src/coding-tools-rig/src/allowed/grep.rs +++ b/src/coding-tools-rig/src/allowed/grep.rs @@ -2,7 +2,7 @@ use coding_tools_core::operations::grep_search; use coding_tools_core::path::AllowedPathResolver; -use coding_tools_core::{ToolError, ToolOutput, ToolResult}; +use coding_tools_core::{ToolContext, ToolError, ToolOutput, ToolResult}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -66,7 +66,7 @@ impl Tool for GrepTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { - name: Self::NAME.to_string(), + name: ::NAME.to_string(), description: "Search file contents using regex patterns within allowed directories. \ Paths are relative to configured base directories." .to_string(), @@ -137,6 +137,14 @@ impl Tool for GrepTool { } } +impl ToolContext for GrepTool { + const NAME: &'static str = "grep"; + + fn context(&self) -> &'static str { + coding_tools_core::context::GREP_ALLOWED + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/coding-tools-rig/src/allowed/mod.rs b/src/coding-tools-rig/src/allowed/mod.rs index 9c6b2077..23a16241 100644 --- a/src/coding-tools-rig/src/allowed/mod.rs +++ b/src/coding-tools-rig/src/allowed/mod.rs @@ -1,4 +1,4 @@ -//! Tools using [`AllowedPathResolver`]. +//! Tools using [`coding_tools_core::path::AllowedPathResolver`]. //! //! These tools restrict file access to configured allowed directories. //! Use for sandboxed file system access. diff --git a/src/coding-tools-rig/src/allowed/read.rs b/src/coding-tools-rig/src/allowed/read.rs index 165c6141..6849937f 100644 --- a/src/coding-tools-rig/src/allowed/read.rs +++ b/src/coding-tools-rig/src/allowed/read.rs @@ -2,7 +2,7 @@ use coding_tools_core::operations::read_file; use coding_tools_core::path::AllowedPathResolver; -use coding_tools_core::{ToolError, ToolOutput, ToolResult}; +use coding_tools_core::{ToolContext, ToolError, ToolOutput, ToolResult}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -75,7 +75,7 @@ impl Tool for ReadTool { Paths are relative to configured base directories." }; ToolDefinition { - name: Self::NAME.to_string(), + name: ::NAME.to_string(), description: description.to_string(), parameters: serde_json::to_value(schema_for!(ReadArgs)) .expect("schema serialization should never fail"), @@ -87,6 +87,14 @@ impl Tool for ReadTool { } } +impl ToolContext for ReadTool { + const NAME: &'static str = "read"; + + fn context(&self) -> &'static str { + coding_tools_core::context::READ_ALLOWED + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/coding-tools-rig/src/allowed/write.rs b/src/coding-tools-rig/src/allowed/write.rs index ec413604..d6867674 100644 --- a/src/coding-tools-rig/src/allowed/write.rs +++ b/src/coding-tools-rig/src/allowed/write.rs @@ -2,7 +2,7 @@ use coding_tools_core::operations::write_file; use coding_tools_core::path::AllowedPathResolver; -use coding_tools_core::{ToolError, ToolResult}; +use coding_tools_core::{ToolContext, ToolError, ToolResult}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -51,7 +51,7 @@ impl Tool for WriteTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { - name: Self::NAME.to_string(), + name: ::NAME.to_string(), description: "Write content to a file within allowed directories. \ Paths are relative to configured base directories." .to_string(), @@ -65,6 +65,14 @@ impl Tool for WriteTool { } } +impl ToolContext for WriteTool { + const NAME: &'static str = "write"; + + fn context(&self) -> &'static str { + coding_tools_core::context::WRITE_ALLOWED + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/coding-tools-rig/src/bash.rs b/src/coding-tools-rig/src/bash.rs index 4f7fdf81..c2038328 100644 --- a/src/coding-tools-rig/src/bash.rs +++ b/src/coding-tools-rig/src/bash.rs @@ -3,7 +3,7 @@ //! Provides cross-platform shell command execution with timeout support. use coding_tools_core::operations::execute_command; -use coding_tools_core::{BashOutput, ToolError, ToolOutput}; +use coding_tools_core::{BashOutput, ToolContext, ToolError, ToolOutput}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -53,7 +53,7 @@ impl Tool for BashTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { - name: Self::NAME.to_string(), + name: ::NAME.to_string(), description: "Execute a shell command with optional working directory and timeout." .to_string(), parameters: serde_json::to_value(schema_for!(BashArgs)) @@ -70,6 +70,14 @@ impl Tool for BashTool { } } +impl ToolContext for BashTool { + const NAME: &'static str = "bash"; + + fn context(&self) -> &'static str { + coding_tools_core::context::BASH + } +} + fn format_bash_output(output: &BashOutput) -> ToolOutput { let mut content = String::new(); diff --git a/src/coding-tools-rig/src/lib.rs b/src/coding-tools-rig/src/lib.rs index 4c2b3f6b..09815eb2 100644 --- a/src/coding-tools-rig/src/lib.rs +++ b/src/coding-tools-rig/src/lib.rs @@ -28,8 +28,12 @@ pub mod webfetch; // Re-export core types for convenience pub use coding_tools_core::{ToolError, ToolOutput, ToolResult}; -// Re-export context module for convenience +// Re-export context module and ToolContext trait for convenience pub use coding_tools_core::context; +pub use coding_tools_core::ToolContext; + +// Re-export PreambleBuilder from core +pub use coding_tools_core::PreambleBuilder; // Re-export path resolvers pub use coding_tools_core::path::{AbsolutePathResolver, AllowedPathResolver, PathResolver}; @@ -60,3 +64,26 @@ pub use bash::{BashArgs, BashTool}; pub use task::{TaskArgs, TaskTool}; pub use todo::{TodoReadArgs, TodoReadTool, TodoTools, TodoWriteArgs, TodoWriteTool}; pub use webfetch::{WebFetchArgs, WebFetchTool}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preamble_builder_with_real_tools() { + let mut pb = PreambleBuilder::new(); + let read: absolute::ReadTool = pb.track(absolute::ReadTool::new()); + let bash = pb.track(BashTool::new()); + + let preamble = pb.build(); + + assert!(preamble.contains("## Read Tool")); + assert!(preamble.contains("## Bash Tool")); + assert!(preamble.contains("absolute path")); // From READ_ABSOLUTE + + // Tools are returned unchanged + assert_eq!( as rig::tool::Tool>::NAME, "read"); + let _ = read; + let _ = bash; + } +} diff --git a/src/coding-tools-rig/src/task.rs b/src/coding-tools-rig/src/task.rs index 784dc598..67aace20 100644 --- a/src/coding-tools-rig/src/task.rs +++ b/src/coding-tools-rig/src/task.rs @@ -2,7 +2,7 @@ //! //! Provides [`TaskTool`] for spawning sub-agents to handle complex tasks. -use coding_tools_core::{ToolError, ToolOutput}; +use coding_tools_core::{ToolContext, ToolError, ToolOutput}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -69,7 +69,7 @@ impl Tool for TaskTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { - name: Self::NAME.to_string(), + name: ::NAME.to_string(), description: "Delegate a task to a specialized sub-agent.".to_string(), parameters: serde_json::to_value(schema_for!(TaskArgs)) .expect("schema serialization should never fail"), @@ -83,6 +83,14 @@ impl Tool for TaskTool { } } +impl ToolContext for TaskTool { + const NAME: &'static str = "task"; + + fn context(&self) -> &'static str { + coding_tools_core::context::TASK + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/coding-tools-rig/src/todo.rs b/src/coding-tools-rig/src/todo.rs index 42572914..b6e3e07b 100644 --- a/src/coding-tools-rig/src/todo.rs +++ b/src/coding-tools-rig/src/todo.rs @@ -3,7 +3,7 @@ //! Provides tools for reading and writing todo items. use coding_tools_core::operations::{read_todos, write_todos}; -use coding_tools_core::{ToolError, ToolOutput}; +use coding_tools_core::{ToolContext, ToolError, ToolOutput}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -45,7 +45,7 @@ impl Tool for TodoWriteTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { - name: Self::NAME.to_string(), + name: ::NAME.to_string(), description: "Replace the todo list with new items.".to_string(), parameters: serde_json::to_value(schema_for!(TodoWriteArgs)) .expect("schema serialization should never fail"), @@ -80,7 +80,7 @@ impl Tool for TodoReadTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { - name: Self::NAME.to_string(), + name: ::NAME.to_string(), description: "Read the current todo list.".to_string(), parameters: serde_json::to_value(schema_for!(TodoReadArgs)) .expect("schema serialization should never fail"), @@ -93,6 +93,22 @@ impl Tool for TodoReadTool { } } +impl ToolContext for TodoWriteTool { + const NAME: &'static str = "todowrite"; + + fn context(&self) -> &'static str { + coding_tools_core::context::TODO_WRITE + } +} + +impl ToolContext for TodoReadTool { + const NAME: &'static str = "todoread"; + + fn context(&self) -> &'static str { + coding_tools_core::context::TODO_READ + } +} + /// Helper for creating paired todo tools with shared state. pub struct TodoTools { /// Tool for writing todos. diff --git a/src/coding-tools-rig/src/webfetch.rs b/src/coding-tools-rig/src/webfetch.rs index 23c7bff7..6a2d0ebf 100644 --- a/src/coding-tools-rig/src/webfetch.rs +++ b/src/coding-tools-rig/src/webfetch.rs @@ -3,7 +3,7 @@ //! Provides URL fetching with format conversion support. use coding_tools_core::operations::fetch_url; -use coding_tools_core::{ToolError, ToolOutput}; +use coding_tools_core::{ToolContext, ToolError, ToolOutput}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -66,7 +66,7 @@ impl Tool for WebFetchTool { async fn definition(&self, _prompt: String) -> ToolDefinition { ToolDefinition { - name: Self::NAME.to_string(), + name: ::NAME.to_string(), description: "Fetch content from a URL. HTML is converted to markdown, JSON is prettified." .to_string(), @@ -87,6 +87,14 @@ impl Tool for WebFetchTool { } } +impl ToolContext for WebFetchTool { + const NAME: &'static str = "webfetch"; + + fn context(&self) -> &'static str { + coding_tools_core::context::WEBFETCH + } +} + #[cfg(test)] mod tests { use super::*; From ecae1ace6b417bb33ecd8851b1af1149dea2365c Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 06:51:03 +0000 Subject: [PATCH 26/64] Removed: Unused README file --- README.md | 50 -------------------------------------------------- 1 file changed, 50 deletions(-) delete mode 100644 README.md diff --git a/README.md b/README.md deleted file mode 100644 index c09feda4..00000000 --- a/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# rig-coding-tools - -[![CI](https://github.com/Sewer56/rig-coding-tools/actions/workflows/rust.yml/badge.svg)](https://github.com/Sewer56/rig-coding-tools/actions/workflows/rust.yml) -[![codecov](https://codecov.io/gh/Sewer56/rig-coding-tools/graph/badge.svg)](https://codecov.io/gh/Sewer56/rig-coding-tools) - -Coding tools for building LLM-powered development agents with [Rig](https://github.com/0xPlaygrounds/rig). - -## Crates - -| Crate | Description | Docs | -|-------|-------------|------| -| [`coding-tools-core`](src/coding-tools-core) | Framework-agnostic core operations | [![docs.rs](https://docs.rs/coding-tools-core/badge.svg)](https://docs.rs/coding-tools-core) | -| [`coding-tools-rig`](src/coding-tools-rig) | Rig framework Tool implementations | [![docs.rs](https://docs.rs/coding-tools-rig/badge.svg)](https://docs.rs/coding-tools-rig) | - -## Features - -- **File Operations**: Read, write, edit files with line-numbered output -- **Search**: Glob pattern matching and regex content search -- **Shell**: Cross-platform command execution with timeout -- **Web**: URL fetching with HTML-to-markdown conversion -- **Task Delegation**: Sub-agent spawning for complex workflows -- **Path Security**: Choose between unrestricted or sandboxed file access - -## Quick Start - -```toml -[dependencies] -coding-tools-rig = "0.1" -``` - -```rust -use coding_tools_rig::absolute::{ReadTool, WriteTool, GlobTool, GrepTool}; -use coding_tools_rig::{BashTool, WebFetchTool}; - -// Create tools for unrestricted file access -let read = ReadTool::new(); -let write = WriteTool::new(); -let glob = GlobTool::new(); -let grep = GrepTool::new(); -let bash = BashTool::new(); -let webfetch = WebFetchTool::new(); - -// Use with rig agent builder... -``` - -For sandboxed file access, see the [coding-tools-rig README](src/coding-tools-rig/README.md). - -## License - -Licensed under [Apache 2.0](./LICENSE). From 245846db51fe4b68a2a55ba7fa959894e307c4ff Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 07:05:13 +0000 Subject: [PATCH 27/64] Improved: Documentation and examples for better library usage clarity - Fix WriteTool bug in root README (WriteTool has no const generic) - Restructure rig README to distinguish file tools vs other tools - Add PreambleBuilder section with usage pattern to rig README - Add full_agent.rs example showing complete agent configuration - Add sandboxed.rs example demonstrating allowed::* tools - Enhance basic.rs with TodoTools demonstration - Add Examples section to both READMEs --- README.MD | 43 ++++++-- src/coding-tools-rig/README.md | 106 +++++++++++++++----- src/coding-tools-rig/examples/basic.rs | 11 +- src/coding-tools-rig/examples/full_agent.rs | 105 +++++++++++++++++++ src/coding-tools-rig/examples/sandboxed.rs | 91 +++++++++++++++++ 5 files changed, 318 insertions(+), 38 deletions(-) create mode 100644 src/coding-tools-rig/examples/full_agent.rs create mode 100644 src/coding-tools-rig/examples/sandboxed.rs diff --git a/README.MD b/README.MD index 788a75be..370006f7 100644 --- a/README.MD +++ b/README.MD @@ -39,21 +39,44 @@ coding-tools-rig = "0.1" ``` ```rust -use coding_tools_rig::absolute::{ReadTool, WriteTool}; -use coding_tools_rig::BashTool; - -// Create tools for unrestricted file access -let read: ReadTool = ReadTool::new(); -let write: WriteTool = WriteTool::new(); -let bash = BashTool::new(); - -// Use with rig agent builder... +use coding_tools_rig::absolute::{ReadTool, WriteTool, GlobTool}; +use coding_tools_rig::{BashTool, PreambleBuilder, TodoTools}; +use rig::tool::ToolSet; + +// Track tools and generate LLM guidance +let mut pb = PreambleBuilder::new(); +let todos = TodoTools::new(); + +let toolset = ToolSet::builder() + .static_tool(pb.track(ReadTool::::new())) + .static_tool(pb.track(WriteTool::new())) + .static_tool(pb.track(GlobTool::new())) + .static_tool(pb.track(BashTool::new())) + .static_tool(pb.track(todos.read)) + .static_tool(pb.track(todos.write)) + .build(); + +// Generate preamble for agent system prompt +let preamble = pb.build(); + +// Use with rig agent: +// let agent = client.agent("gpt-4o") +// .preamble(&preamble) +// .tools(toolset) +// .build(); ``` -Run the example: +## Examples ```bash +# Basic toolset setup cargo run --example basic -p coding-tools-rig + +# Complete agent configuration (recommended starting point) +cargo run --example full_agent -p coding-tools-rig + +# Sandboxed file access +cargo run --example sandboxed -p coding-tools-rig ``` ## Documentation diff --git a/src/coding-tools-rig/README.md b/src/coding-tools-rig/README.md index fb077b4e..62f5b51f 100644 --- a/src/coding-tools-rig/README.md +++ b/src/coding-tools-rig/README.md @@ -7,12 +7,13 @@ Rig framework Tool implementations for coding tools. ## Features -- **Absolute-path tools** - Unrestricted file system access with absolute paths -- **Allowed-path tools** - Sandboxed access restricted to configured directories +- **File operations** - Read, write, edit, glob, grep with two access modes: + - `absolute::*` - Unrestricted filesystem access + - `allowed::*` - Sandboxed to configured directories - **Shell execution** - Cross-platform command execution with timeout - **Web fetching** - URL content retrieval with format conversion - **Task delegation** - Sub-agent spawning for complex tasks -- **Todo management** - Persistent todo list tracking +- **Todo management** - Shared-state todo list tracking - **Context strings** - LLM guidance text for tool usage (re-exported from core) ## Installation @@ -34,51 +35,89 @@ cargo run --example basic -p coding-tools-rig ## Usage -### Absolute-Path Tools +### File Operation Tools -For unrestricted file access: +File tools (Read, Write, Edit, Glob, Grep) come in two variants: + +**`absolute::*`** - Unrestricted filesystem access, requires absolute paths: ```rust use coding_tools_rig::absolute::{ReadTool, WriteTool, EditTool, GlobTool, GrepTool}; -use coding_tools_rig::BashTool; -use rig::tool::ToolSet; -let read: ReadTool = ReadTool::new(); +let read: ReadTool = ReadTool::new(); // enables line numbers +let write = WriteTool::new(); +let edit = EditTool::new(); let glob = GlobTool::new(); -let bash = BashTool::new(); - -let toolset = ToolSet::builder() - .static_tool(read) - .static_tool(glob) - .static_tool(bash) - .build(); +let grep: GrepTool = GrepTool::new(); ``` -### Allowed-Path Tools - -For sandboxed file access: +**`allowed::*`** - Sandboxed to configured directories: ```rust -use coding_tools_rig::allowed::ReadTool; +use coding_tools_rig::allowed::{ReadTool, WriteTool}; +use coding_tools_rig::AllowedPathResolver; use std::path::PathBuf; +// Option 1: Pass paths directly let read: ReadTool = ReadTool::new([ PathBuf::from("/home/user/project"), + PathBuf::from("/tmp/workspace"), ]).unwrap(); -// Paths are restricted to /home/user/project + +// Option 2: Share a resolver across tools (recommended) +let resolver = AllowedPathResolver::new([ + PathBuf::from("/home/user/project"), +]).unwrap(); +let read: ReadTool = ReadTool::with_resolver(resolver.clone()); +let write = WriteTool::with_resolver(resolver); ``` -### Standalone Tools +### Other Tools -Tools without path requirements: +Tools that don't operate on files: ```rust -use coding_tools_rig::{BashTool, TaskTool, WebFetchTool}; -use coding_tools_rig::todo::{TodoReadTool, TodoWriteTool}; +use coding_tools_rig::{BashTool, TaskTool, WebFetchTool, TodoTools}; -let bash = BashTool::new(); -let task = TaskTool::with_mock(); // or TaskTool::new(executor) -let webfetch = WebFetchTool::new(); +let bash = BashTool::new(); // Shell command execution +let webfetch = WebFetchTool::new(); // URL content fetching +let task = TaskTool::with_mock(); // Sub-agent delegation +let todos = TodoTools::new(); // Todo list (todos.read, todos.write) +``` + +### PreambleBuilder + +`PreambleBuilder` tracks registered tools and generates a combined context string +for the agent's system prompt. This provides LLM guidance on using each tool effectively. + +```rust +use coding_tools_rig::absolute::{ReadTool, GlobTool}; +use coding_tools_rig::{BashTool, PreambleBuilder, TodoTools}; +use rig::tool::ToolSet; + +// Create preamble builder to track tools +let mut pb = PreambleBuilder::new(); + +// Create todo tools with shared state +let todos = TodoTools::new(); + +// Build toolset - pb.track() registers each tool and passes it through +let toolset = ToolSet::builder() + .static_tool(pb.track(ReadTool::::new())) + .static_tool(pb.track(GlobTool::new())) + .static_tool(pb.track(BashTool::new())) + .static_tool(pb.track(todos.read)) + .static_tool(pb.track(todos.write)) + .build(); + +// Generate preamble with usage instructions for all tracked tools +let preamble = pb.build(); + +// Use with rig agent: +// let agent = client.agent("gpt-4o") +// .preamble(&preamble) // <-- Pass preamble here +// .tools(toolset) +// .build(); ``` ### Context Strings @@ -96,6 +135,19 @@ println!("{}", READ_ABSOLUTE); // For absolute::ReadTool println!("{}", READ_ALLOWED); // For allowed::ReadTool ``` +## Examples + +```bash +# Basic toolset setup with PreambleBuilder +cargo run --example basic -p coding-tools-rig + +# Complete agent configuration (recommended starting point) +cargo run --example full_agent -p coding-tools-rig + +# Sandboxed file access with allowed::* tools +cargo run --example sandboxed -p coding-tools-rig +``` + ## License Apache 2.0 diff --git a/src/coding-tools-rig/examples/basic.rs b/src/coding-tools-rig/examples/basic.rs index e2205c4a..3f62c7ec 100644 --- a/src/coding-tools-rig/examples/basic.rs +++ b/src/coding-tools-rig/examples/basic.rs @@ -3,16 +3,22 @@ //! Demonstrates: //! - Using PreambleBuilder alongside ToolSet::builder() //! - Full access to Rig's API (no wrapper limitations) +//! - TodoTools with shared state //! - Generating and using the preamble string //! //! Run: cargo run --example basic -p coding-tools-rig +//! +//! For a complete agent setup, see: cargo run --example full_agent -p coding-tools-rig use coding_tools_rig::absolute::{GlobTool, GrepTool, ReadTool}; -use coding_tools_rig::{BashTool, PreambleBuilder}; +use coding_tools_rig::{BashTool, PreambleBuilder, TodoTools}; use rig::tool::ToolSet; #[tokio::main] async fn main() { + // === Create shared state for todos === + let todos = TodoTools::new(); + // === Create preamble builder to track tools === let mut pb = PreambleBuilder::new(); @@ -22,6 +28,9 @@ async fn main() { .static_tool(pb.track(GlobTool::new())) .static_tool(pb.track(GrepTool::::new())) .static_tool(pb.track(BashTool::new())) + // Todo tools share state for read/write coordination + .static_tool(pb.track(todos.read)) + .static_tool(pb.track(todos.write)) // Can use any ToolSet method here - dynamic_tool, etc. .build(); diff --git a/src/coding-tools-rig/examples/full_agent.rs b/src/coding-tools-rig/examples/full_agent.rs new file mode 100644 index 00000000..98b03951 --- /dev/null +++ b/src/coding-tools-rig/examples/full_agent.rs @@ -0,0 +1,105 @@ +//! Complete agent example - demonstrates full integration pattern. +//! +//! This example shows the recommended way to build an LLM coding agent +//! with all available tools. Agent execution is commented out as it +//! requires API credentials. +//! +//! Run: cargo run --example full_agent -p coding-tools-rig + +use coding_tools_rig::absolute::{EditTool, GlobTool, GrepTool, ReadTool, WriteTool}; +use coding_tools_rig::{BashTool, PreambleBuilder, TodoTools, WebFetchTool}; +use rig::tool::ToolSet; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // === 1. Create shared state for todos === + // + // TodoTools provides paired read/write tools that share state. + // This allows the LLM to maintain a task list across the conversation. + let todos = TodoTools::new(); + + // === 2. Create preamble builder === + // + // PreambleBuilder tracks which tools are registered and generates + // a combined context string for the system prompt. This gives the + // LLM detailed guidance on how to use each tool effectively. + let mut pb = PreambleBuilder::new(); + + // === 3. Build toolset with all tools === + // + // Use pb.track() to wrap each tool - this registers it with the + // preamble builder while passing it through unchanged to the toolset. + let toolset = ToolSet::builder() + // File operations (with line numbers enabled) + .static_tool(pb.track(ReadTool::::new())) + .static_tool(pb.track(WriteTool::new())) + .static_tool(pb.track(EditTool::new())) + .static_tool(pb.track(GlobTool::new())) + .static_tool(pb.track(GrepTool::::new())) + // Shell execution + .static_tool(pb.track(BashTool::new())) + // Web content fetching + .static_tool(pb.track(WebFetchTool::new())) + // Todo management (shared state between read and write) + .static_tool(pb.track(todos.read)) + .static_tool(pb.track(todos.write)) + .build(); + + // === 4. Generate preamble === + // + // The preamble contains usage instructions for all tracked tools. + // Pass this to the agent's .preamble() method so the LLM knows + // how to use the tools correctly. + let preamble = pb.build(); + + // === 5. Agent integration (requires API key) === + // + // Uncomment and configure with your preferred LLM provider: + // + // ``` + // use rig::providers::openai; + // + // let client = openai::Client::from_env(); + // let agent = client + // .agent("gpt-4o") + // .preamble(&preamble) + // .tools(toolset) + // .build(); + // + // // Example prompts this agent can handle: + // let response = agent.prompt("Find all Rust files in src/").await?; + // let response = agent.prompt("Read Cargo.toml and summarize dependencies").await?; + // let response = agent.prompt("Search for TODO comments in the codebase").await?; + // let response = agent.prompt("Run 'cargo test' and report results").await?; + // let response = agent.prompt("Fetch https://example.com and summarize").await?; + // ``` + + // === Demo output === + let tool_count = toolset.get_tool_definitions().await?.len(); + + println!("=== Full Agent Configuration ===\n"); + println!("Tools registered: {}", tool_count); + println!("Preamble size: {} chars\n", preamble.len()); + + println!("=== Registered Tools ==="); + for def in toolset.get_tool_definitions().await? { + // Show first 60 chars of description + let desc = &def.description[..60.min(def.description.len())]; + println!(" {}: {}...", def.name, desc); + } + + println!("\n=== Example Prompts ==="); + println!(" - \"Find all Rust files in src/\""); + println!(" - \"Read Cargo.toml and list dependencies\""); + println!(" - \"Search for TODO comments\""); + println!(" - \"Run 'cargo test' and report results\""); + println!(" - \"Create a todo list for implementing feature X\""); + + println!("\n=== Preamble Preview (first 500 chars) ===\n"); + println!("{}", &preamble[..500.min(preamble.len())]); + if preamble.len() > 500 { + println!("\n... ({} more chars)", preamble.len() - 500); + } + + Ok(()) +} diff --git a/src/coding-tools-rig/examples/sandboxed.rs b/src/coding-tools-rig/examples/sandboxed.rs new file mode 100644 index 00000000..5878afa8 --- /dev/null +++ b/src/coding-tools-rig/examples/sandboxed.rs @@ -0,0 +1,91 @@ +//! Sandboxed tools example - restricted file access. +//! +//! Demonstrates using `allowed::*` tools that restrict file operations +//! to specific directories only. This is useful for: +//! +//! - Multi-tenant environments where agents should only access their workspace +//! - Security-conscious deployments limiting filesystem exposure +//! - Project-scoped agents that shouldn't touch system files +//! +//! Run: cargo run --example sandboxed -p coding-tools-rig + +use coding_tools_rig::allowed::{EditTool, GlobTool, GrepTool, ReadTool, WriteTool}; +use coding_tools_rig::{AllowedPathResolver, PreambleBuilder}; +use rig::tool::ToolSet; +use std::path::PathBuf; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // === Define allowed directories === + // + // Only these directories (and their subdirectories) will be accessible. + // Attempts to read/write outside these paths will fail with an error. + // + // NOTE: Paths must exist - AllowedPathResolver canonicalizes them. + // Using current directory and /tmp as they exist on most systems. + let current_dir = std::env::current_dir()?; + let allowed_paths = vec![ + current_dir.clone(), // Current working directory + PathBuf::from("/tmp"), // Temp directory + ]; + + println!("=== Sandboxed Agent Configuration ===\n"); + println!("Allowed directories:"); + for path in &allowed_paths { + println!(" - {}", path.display()); + } + + // === Option 1: Create tools individually === + // + // Each tool gets its own copy of the allowed paths. + // Simple but duplicates the path list. + let _read: ReadTool = ReadTool::new(allowed_paths.clone())?; + let _write = WriteTool::new(allowed_paths.clone())?; + + // === Option 2: Share a resolver (recommended) === + // + // Create one resolver and share it across tools. + // More efficient and ensures consistency. + let resolver = AllowedPathResolver::new(allowed_paths)?; + + let read: ReadTool = ReadTool::with_resolver(resolver.clone()); + let write = WriteTool::with_resolver(resolver.clone()); + let edit = EditTool::with_resolver(resolver.clone()); + let glob = GlobTool::with_resolver(resolver.clone()); + let grep: GrepTool = GrepTool::with_resolver(resolver); + + // === Build toolset === + let mut pb = PreambleBuilder::new(); + let toolset = ToolSet::builder() + .static_tool(pb.track(read)) + .static_tool(pb.track(write)) + .static_tool(pb.track(edit)) + .static_tool(pb.track(glob)) + .static_tool(pb.track(grep)) + .build(); + + let preamble = pb.build(); + + // === Demo output === + println!( + "\nTools registered: {}", + toolset.get_tool_definitions().await?.len() + ); + println!("Preamble size: {} chars", preamble.len()); + + println!("\n=== Security Behavior ==="); + println!(" Allowed: read(\"{}/Cargo.toml\")", current_dir.display()); + println!(" Allowed: glob(\"/tmp/**/*.txt\")"); + println!(" BLOCKED: read(\"/etc/passwd\")"); + println!(" BLOCKED: write(\"/home/user/.ssh/config\")"); + + println!("\n=== Error Handling ==="); + println!(" When a path is outside allowed directories, tools return:"); + println!(" ToolError::InvalidPath(\"path not within allowed directories\")"); + + println!("\n=== Agent Integration ==="); + println!(" The preamble automatically includes 'allowed path' context,"); + println!(" informing the LLM that paths are relative to allowed directories."); + + Ok(()) +} From fdf825526436cdbd710e40c8a5dfa6d9055b0591 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 07:14:33 +0000 Subject: [PATCH 28/64] Renamed: Repository and crates from rig-coding-tools to llm-coding-tools - Renamed repo: rig-coding-tools -> llm-coding-tools - Renamed crates: coding-tools-core -> llm-coding-tools-core coding-tools-rig -> llm-coding-tools-rig - Updated all imports, docs, examples, and CI workflows - Removed non-existent CONTRIBUTING.md reference from PR template --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 +- .github/pull_request_template.md | 6 - .github/workflows/rust.yml | 16 +-- README.MD | 32 ++--- src/AGENTS.md | 26 ++-- src/Cargo.lock | 124 +++++++++--------- src/Cargo.toml | 2 +- .../Cargo.toml | 4 +- .../README.md | 10 +- .../src/context/bash.txt | 0 .../src/context/edit_absolute.txt | 0 .../src/context/edit_allowed.txt | 0 .../src/context/glob_absolute.txt | 0 .../src/context/glob_allowed.txt | 0 .../src/context/grep_absolute.txt | 0 .../src/context/grep_allowed.txt | 0 .../src/context/mod.rs | 4 +- .../src/context/read_absolute.txt | 0 .../src/context/read_allowed.txt | 0 .../src/context/task.txt | 0 .../src/context/todoread.txt | 0 .../src/context/todowrite.txt | 0 .../src/context/webfetch.txt | 0 .../src/context/write_absolute.txt | 0 .../src/context/write_allowed.txt | 0 .../src/error.rs | 0 .../src/fs.rs | 0 .../src/lib.rs | 0 .../src/operations/bash/async_impl.rs | 0 .../src/operations/bash/blocking_impl.rs | 0 .../src/operations/bash/mod.rs | 0 .../src/operations/edit.rs | 0 .../src/operations/glob.rs | 0 .../src/operations/grep.rs | 0 .../src/operations/mod.rs | 0 .../src/operations/read.rs | 0 .../src/operations/task.rs | 0 .../src/operations/todo.rs | 0 .../src/operations/webfetch/async_impl.rs | 0 .../src/operations/webfetch/blocking_impl.rs | 0 .../src/operations/webfetch/mod.rs | 0 .../src/operations/write.rs | 0 .../src/output.rs | 0 .../src/path/absolute.rs | 2 +- .../src/path/allowed.rs | 2 +- .../src/path/mod.rs | 0 .../src/preamble.rs | 4 +- .../src/util.rs | 0 .../Cargo.toml | 6 +- .../README.md | 32 ++--- .../examples/basic.rs | 8 +- .../examples/full_agent.rs | 6 +- .../examples/sandboxed.rs | 6 +- .../src/absolute/edit.rs | 12 +- .../src/absolute/glob.rs | 8 +- .../src/absolute/grep.rs | 8 +- .../src/absolute/mod.rs | 2 +- .../src/absolute/read.rs | 8 +- .../src/absolute/write.rs | 8 +- .../src/allowed/edit.rs | 12 +- .../src/allowed/glob.rs | 8 +- .../src/allowed/grep.rs | 8 +- .../src/allowed/mod.rs | 2 +- .../src/allowed/read.rs | 8 +- .../src/allowed/write.rs | 8 +- .../src/bash.rs | 6 +- .../src/lib.rs | 18 +-- .../src/task.rs | 6 +- .../src/todo.rs | 10 +- .../src/webfetch.rs | 6 +- 70 files changed, 213 insertions(+), 219 deletions(-) rename src/{coding-tools-core => llm-coding-tools-core}/Cargo.toml (95%) rename src/{coding-tools-core => llm-coding-tools-core}/README.md (84%) rename src/{coding-tools-core => llm-coding-tools-core}/src/context/bash.txt (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/context/edit_absolute.txt (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/context/edit_allowed.txt (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/context/glob_absolute.txt (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/context/glob_allowed.txt (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/context/grep_absolute.txt (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/context/grep_allowed.txt (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/context/mod.rs (97%) rename src/{coding-tools-core => llm-coding-tools-core}/src/context/read_absolute.txt (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/context/read_allowed.txt (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/context/task.txt (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/context/todoread.txt (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/context/todowrite.txt (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/context/webfetch.txt (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/context/write_absolute.txt (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/context/write_allowed.txt (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/error.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/fs.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/lib.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/operations/bash/async_impl.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/operations/bash/blocking_impl.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/operations/bash/mod.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/operations/edit.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/operations/glob.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/operations/grep.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/operations/mod.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/operations/read.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/operations/task.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/operations/todo.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/operations/webfetch/async_impl.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/operations/webfetch/blocking_impl.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/operations/webfetch/mod.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/operations/write.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/output.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/path/absolute.rs (96%) rename src/{coding-tools-core => llm-coding-tools-core}/src/path/allowed.rs (98%) rename src/{coding-tools-core => llm-coding-tools-core}/src/path/mod.rs (100%) rename src/{coding-tools-core => llm-coding-tools-core}/src/preamble.rs (97%) rename src/{coding-tools-core => llm-coding-tools-core}/src/util.rs (100%) rename src/{coding-tools-rig => llm-coding-tools-rig}/Cargo.toml (80%) rename src/{coding-tools-rig => llm-coding-tools-rig}/README.md (77%) rename src/{coding-tools-rig => llm-coding-tools-rig}/examples/basic.rs (90%) rename src/{coding-tools-rig => llm-coding-tools-rig}/examples/full_agent.rs (94%) rename src/{coding-tools-rig => llm-coding-tools-rig}/examples/sandboxed.rs (93%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/absolute/edit.rs (91%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/absolute/glob.rs (92%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/absolute/grep.rs (96%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/absolute/mod.rs (90%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/absolute/read.rs (93%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/absolute/write.rs (92%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/allowed/edit.rs (92%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/allowed/glob.rs (93%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/allowed/grep.rs (96%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/allowed/mod.rs (91%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/allowed/read.rs (94%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/allowed/write.rs (93%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/bash.rs (95%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/lib.rs (82%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/task.rs (94%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/todo.rs (94%) rename src/{coding-tools-rig => llm-coding-tools-rig}/src/webfetch.rs (94%) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a44a2b35..6d8e1ee5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -77,8 +77,8 @@ body: - type: input id: version attributes: - label: "rig-coding-tools version" - description: The version of rig-coding-tools you're using + label: "llm-coding-tools version" + description: The version of llm-coding-tools you're using placeholder: e.g. 0.1.0 validations: required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 08e2d585..0c46a46d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1 @@ - - diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 829ef4be..a9a7ac07 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -46,7 +46,7 @@ jobs: codecov-token: ${{ secrets.CODECOV_TOKEN }} target: ${{ matrix.target }} use-cross: ${{ matrix.use-cross }} - cargo-test-args: "-p coding-tools-core --no-default-features --features blocking" + cargo-test-args: "-p llm-coding-tools-core --no-default-features --features blocking" # Note: The GitHub Runner Images will contain an up to date Rust Stable Toolchain # thus as per recommendation of cargo-semver-checks, we're using stable here. @@ -61,7 +61,7 @@ jobs: cargo +stable binstall --no-confirm cargo-semver-checks --force rustup +stable target add ${{ matrix.target }} - for CRATE in "coding-tools-core" "coding-tools-rig"; do + for CRATE in "llm-coding-tools-core" "llm-coding-tools-rig"; do SEARCH_RESULT=$(cargo search "^${CRATE}$" --limit 1) if echo "$SEARCH_RESULT" | grep -q "^${CRATE} "; then echo "Running semver checks for ${CRATE}..." @@ -78,16 +78,16 @@ jobs: RUSTDOCFLAGS: "-D warnings" # Note: Can't use --all-features at workspace level because tokio/blocking are mutually exclusive run: | - cargo doc -p coding-tools-core --features tokio --document-private-items --target ${{ matrix.target }} - cargo doc -p coding-tools-rig --document-private-items --target ${{ matrix.target }} + cargo doc -p llm-coding-tools-core --features tokio --document-private-items --target ${{ matrix.target }} + cargo doc -p llm-coding-tools-rig --document-private-items --target ${{ matrix.target }} - name: Run linter if: github.event_name == 'pull_request' || startsWith(github.ref, 'refs/tags/') working-directory: src # Note: Can't use --all-features at workspace level because tokio/blocking are mutually exclusive run: | - cargo clippy -p coding-tools-core --features tokio --target ${{ matrix.target }} -- -D warnings - cargo clippy -p coding-tools-rig --target ${{ matrix.target }} -- -D warnings + cargo clippy -p llm-coding-tools-core --features tokio --target ${{ matrix.target }} -- -D warnings + cargo clippy -p llm-coding-tools-rig --target ${{ matrix.target }} -- -D warnings - name: Run formatter check uses: actions-rust-lang/rustfmt@v1 @@ -109,8 +109,8 @@ jobs: with: rust-crates-io-token: ${{ secrets.CRATES_IO_TOKEN }} rust-cargo-project-paths: | - src/coding-tools-core - src/coding-tools-rig + src/llm-coding-tools-core + src/llm-coding-tools-rig compression-tool: 7z artifact-groups-file: .github/artifact-groups.yml changelog-enabled: "true" diff --git a/README.MD b/README.MD index 370006f7..3a60a2dc 100644 --- a/README.MD +++ b/README.MD @@ -1,9 +1,9 @@ -# rig-coding-tools +# llm-coding-tools -[![Crates.io - coding-tools-core](https://img.shields.io/crates/v/coding-tools-core.svg)](https://crates.io/crates/coding-tools-core) -[![Crates.io - coding-tools-rig](https://img.shields.io/crates/v/coding-tools-rig.svg)](https://crates.io/crates/coding-tools-rig) -[![Docs.rs](https://docs.rs/coding-tools-rig/badge.svg)](https://docs.rs/coding-tools-rig) -[![CI](https://github.com/Sewer56/rig-coding-tools/actions/workflows/rust.yml/badge.svg)](https://github.com/Sewer56/rig-coding-tools/actions) +[![Crates.io - llm-coding-tools-core](https://img.shields.io/crates/v/llm-coding-tools-core.svg)](https://crates.io/crates/llm-coding-tools-core) +[![Crates.io - llm-coding-tools-rig](https://img.shields.io/crates/v/llm-coding-tools-rig.svg)](https://crates.io/crates/llm-coding-tools-rig) +[![Docs.rs](https://docs.rs/llm-coding-tools-rig/badge.svg)](https://docs.rs/llm-coding-tools-rig) +[![CI](https://github.com/Sewer56/llm-coding-tools/actions/workflows/rust.yml/badge.svg)](https://github.com/Sewer56/llm-coding-tools/actions) Coding tools for building LLM-powered development agents with [Rig](https://github.com/0xPlaygrounds/rig). @@ -11,8 +11,8 @@ Coding tools for building LLM-powered development agents with [Rig](https://gith This workspace contains multiple Rust crates for integrating coding tools with LLM agents: -- **[coding-tools-core](./src/coding-tools-core/)**: Framework-agnostic core operations and utilities -- **[coding-tools-rig](./src/coding-tools-rig/)**: Rig framework-specific Tool implementations +- **[llm-coding-tools-core](./src/llm-coding-tools-core/)**: Framework-agnostic core operations and utilities +- **[llm-coding-tools-rig](./src/llm-coding-tools-rig/)**: Rig framework-specific Tool implementations ## Features @@ -24,7 +24,7 @@ This workspace contains multiple Rust crates for integrating coding tools with L - **Path Security**: Choose between unrestricted or sandboxed file access - **Context Strings**: Embedded LLM guidance for tool usage -## Feature Flags (coding-tools-core) +## Feature Flags (llm-coding-tools-core) - `tokio` (default): Async mode with tokio runtime - `blocking`: Sync/blocking mode, mutually exclusive with `async` @@ -35,12 +35,12 @@ Add to your `Cargo.toml`: ```toml [dependencies] -coding-tools-rig = "0.1" +llm-coding-tools-rig = "0.1" ``` ```rust -use coding_tools_rig::absolute::{ReadTool, WriteTool, GlobTool}; -use coding_tools_rig::{BashTool, PreambleBuilder, TodoTools}; +use llm_coding_tools_rig::absolute::{ReadTool, WriteTool, GlobTool}; +use llm_coding_tools_rig::{BashTool, PreambleBuilder, TodoTools}; use rig::tool::ToolSet; // Track tools and generate LLM guidance @@ -70,19 +70,19 @@ let preamble = pb.build(); ```bash # Basic toolset setup -cargo run --example basic -p coding-tools-rig +cargo run --example basic -p llm-coding-tools-rig # Complete agent configuration (recommended starting point) -cargo run --example full_agent -p coding-tools-rig +cargo run --example full_agent -p llm-coding-tools-rig # Sandboxed file access -cargo run --example sandboxed -p coding-tools-rig +cargo run --example sandboxed -p llm-coding-tools-rig ``` ## Documentation -- [coding-tools-core README](./src/coding-tools-core/README.md) -- [coding-tools-rig README](./src/coding-tools-rig/README.md) +- [llm-coding-tools-core README](./src/llm-coding-tools-core/README.md) +- [llm-coding-tools-rig README](./src/llm-coding-tools-rig/README.md) - [Developer Guidelines](./src/AGENTS.md) ## Contributing diff --git a/src/AGENTS.md b/src/AGENTS.md index fa37c6f9..7883ad05 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -1,8 +1,8 @@ -# rig-coding-tools +# llm-coding-tools Basic coding tools for rig based LLM agents -# Feature Flags (coding-tools-core) +# Feature Flags (llm-coding-tools-core) - `tokio` (default): Async mode with tokio runtime. Enables async function signatures. - `blocking`: Sync/blocking mode. Mutually exclusive with `tokio`/`async`. @@ -12,13 +12,13 @@ The `async` and `blocking` features are mutually exclusive - enabling both cause # Project Structure -- `coding-tools-core/` - Framework-agnostic core library +- `llm-coding-tools-core/` - Framework-agnostic core library - `src/operations/` - Core operation implementations (read, write, edit, glob, grep, bash, etc.) - `src/path/` - Path resolution (absolute and allowed) - `src/error.rs` - Unified error types - `src/output.rs` - Tool output formatting - `src/util.rs` - Shared utilities -- `coding-tools-rig/` - Rig framework Tool implementations +- `llm-coding-tools-rig/` - Rig framework Tool implementations - `src/absolute/` - Unrestricted file system tools - `src/allowed/` - Sandboxed file system tools - `src/bash.rs`, `src/task.rs`, etc. - Standalone tools @@ -44,22 +44,22 @@ All must pass without warnings: ```bash # Test async mode (default) -cargo build -p coding-tools-core && cargo build -p coding-tools-rig --quiet -cargo test -p coding-tools-core && cargo test -p coding-tools-rig --quiet -cargo clippy -p coding-tools-core && cargo clippy -p coding-tools-rig --quiet -- -D warnings +cargo build -p llm-coding-tools-core && cargo build -p llm-coding-tools-rig --quiet +cargo test -p llm-coding-tools-core && cargo test -p llm-coding-tools-rig --quiet +cargo clippy -p llm-coding-tools-core && cargo clippy -p llm-coding-tools-rig --quiet -- -D warnings -# Test blocking mode (coding-tools-core only, rig is inherently async) -cargo test -p coding-tools-core --no-default-features --features blocking --quiet +# Test blocking mode (llm-coding-tools-core only, rig is inherently async) +cargo test -p llm-coding-tools-core --no-default-features --features blocking --quiet cargo doc --workspace --no-deps --quiet cargo fmt --all --quiet ``` -Note: `coding-tools-rig` is async-only (implements rig's async `Tool` trait). -The `blocking` feature only applies to `coding-tools-core`. +Note: `llm-coding-tools-rig` is async-only (implements rig's async `Tool` trait). +The `blocking` feature only applies to `llm-coding-tools-core`. For individual crates: ```bash -cargo publish --dry-run -p coding-tools-core --quiet -cargo publish --dry-run -p coding-tools-rig --quiet +cargo publish --dry-run -p llm-coding-tools-core --quiet +cargo publish --dry-run -p llm-coding-tools-rig --quiet ``` diff --git a/src/Cargo.lock b/src/Cargo.lock index 37afe8fc..c20a181f 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -144,44 +144,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "coding-tools-core" -version = "0.1.0" -dependencies = [ - "async-trait", - "glob", - "grep-regex", - "grep-searcher", - "html-to-markdown-rs", - "ignore", - "maybe-async", - "memchr", - "parking_lot", - "regex", - "reqwest", - "schemars", - "serde", - "serde_json", - "tempfile", - "thiserror", - "tokio", - "wiremock", -] - -[[package]] -name = "coding-tools-rig" -version = "0.1.0" -dependencies = [ - "coding-tools-core", - "reqwest", - "rig-core", - "schemars", - "serde", - "serde_json", - "tempfile", - "tokio", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -571,9 +533,9 @@ dependencies = [ [[package]] name = "html-to-markdown-rs" -version = "2.16.1" +version = "2.19.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda029e154a976514850a89a56a1f07f03fb0611e0e8fc2357fd4ec739d63acc" +checksum = "782ff8afa04f9390777430ee8d9acfd2452c707322ad264433594e9db8b33803" dependencies = [ "astral-tl", "base64", @@ -845,9 +807,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", @@ -855,9 +817,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "js-sys" @@ -877,9 +839,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.178" +version = "0.2.179" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" [[package]] name = "linux-raw-sys" @@ -893,6 +855,44 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "llm-coding-tools-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "glob", + "grep-regex", + "grep-searcher", + "html-to-markdown-rs", + "ignore", + "maybe-async", + "memchr", + "parking_lot", + "regex", + "reqwest", + "schemars", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "wiremock", +] + +[[package]] +name = "llm-coding-tools-rig" +version = "0.1.0" +dependencies = [ + "llm-coding-tools-core", + "reqwest", + "rig-core", + "schemars", + "serde", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -1187,9 +1187,9 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" dependencies = [ "unicode-ident", ] @@ -1504,9 +1504,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "same-file" @@ -1591,9 +1591,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.147" +version = "1.0.148" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6af14725505314343e673e9ecb7cd7e8a36aa9791eb936235a3567cc31447ae4" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" dependencies = [ "itoa", "memchr", @@ -1697,9 +1697,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.111" +version = "2.0.113" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4" dependencies = [ "proc-macro2", "quote", @@ -1818,9 +1818,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", @@ -1855,9 +1855,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -2147,9 +2147,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" dependencies = [ "rustls-pki-types", ] @@ -2504,6 +2504,6 @@ dependencies = [ [[package]] name = "zmij" -version = "0.1.10" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4af59da1029247450b54ba43e0b62c8e376582464bbe5504dd525fe521e7e8fd" +checksum = "30e0d8dffbae3d840f64bda38e28391faef673a7b5a6017840f2a106c8145868" diff --git a/src/Cargo.toml b/src/Cargo.toml index 4b96172f..6584a8a7 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" -members = ["coding-tools-core", "coding-tools-rig"] +members = ["llm-coding-tools-core", "llm-coding-tools-rig"] # Profile Build [profile.profile] diff --git a/src/coding-tools-core/Cargo.toml b/src/llm-coding-tools-core/Cargo.toml similarity index 95% rename from src/coding-tools-core/Cargo.toml rename to src/llm-coding-tools-core/Cargo.toml index cbe80fe0..5bbb99af 100644 --- a/src/coding-tools-core/Cargo.toml +++ b/src/llm-coding-tools-core/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "coding-tools-core" +name = "llm-coding-tools-core" version = "0.1.0" edition = "2021" description = "Core types and utilities for coding tools - framework agnostic" -repository = "https://github.com/Sewer56/rig-coding-tools" +repository = "https://github.com/Sewer56/llm-coding-tools" license = "Apache-2.0" include = ["src/**/*", "README.md"] readme = "README.md" diff --git a/src/coding-tools-core/README.md b/src/llm-coding-tools-core/README.md similarity index 84% rename from src/coding-tools-core/README.md rename to src/llm-coding-tools-core/README.md index 89c7b00e..374c31f2 100644 --- a/src/coding-tools-core/README.md +++ b/src/llm-coding-tools-core/README.md @@ -1,4 +1,4 @@ -# coding-tools-core +# llm-coding-tools-core Core types and utilities for coding tools - framework agnostic. @@ -25,8 +25,8 @@ Future runtimes (smol, async-std) can be added following the same pattern as `to ## Usage ```rust -use coding_tools_core::{ToolError, ToolResult, ToolOutput}; -use coding_tools_core::util::{truncate_text, format_numbered_line}; +use llm_coding_tools_core::{ToolError, ToolResult, ToolOutput}; +use llm_coding_tools_core::util::{truncate_text, format_numbered_line}; ``` ## Context Module @@ -39,7 +39,7 @@ Path-based tools have two variants: - `*_ALLOWED`: For sandboxed access (paths relative to allowed directories) ```rust -use coding_tools_core::context::{BASH, READ_ABSOLUTE, READ_ALLOWED}; +use llm_coding_tools_core::context::{BASH, READ_ABSOLUTE, READ_ALLOWED}; // Non-path tools have a single variant println!("{}", BASH); @@ -60,6 +60,6 @@ Available context strings: ## Design Principles - No framework-specific dependencies, plug and play into any LLM framework/library - - See [coding-tools-rig](https://crates.io/crates/coding-tools-rig) for an integration example with [rig](https://crates.io/crates/rig) + - See [llm-coding-tools-rig](https://crates.io/crates/llm-coding-tools-rig) for an integration example with [rig](https://crates.io/crates/rig) - Minimal dependency footprint - Performance-oriented (optimized) with zero-cost abstractions diff --git a/src/coding-tools-core/src/context/bash.txt b/src/llm-coding-tools-core/src/context/bash.txt similarity index 100% rename from src/coding-tools-core/src/context/bash.txt rename to src/llm-coding-tools-core/src/context/bash.txt diff --git a/src/coding-tools-core/src/context/edit_absolute.txt b/src/llm-coding-tools-core/src/context/edit_absolute.txt similarity index 100% rename from src/coding-tools-core/src/context/edit_absolute.txt rename to src/llm-coding-tools-core/src/context/edit_absolute.txt diff --git a/src/coding-tools-core/src/context/edit_allowed.txt b/src/llm-coding-tools-core/src/context/edit_allowed.txt similarity index 100% rename from src/coding-tools-core/src/context/edit_allowed.txt rename to src/llm-coding-tools-core/src/context/edit_allowed.txt diff --git a/src/coding-tools-core/src/context/glob_absolute.txt b/src/llm-coding-tools-core/src/context/glob_absolute.txt similarity index 100% rename from src/coding-tools-core/src/context/glob_absolute.txt rename to src/llm-coding-tools-core/src/context/glob_absolute.txt diff --git a/src/coding-tools-core/src/context/glob_allowed.txt b/src/llm-coding-tools-core/src/context/glob_allowed.txt similarity index 100% rename from src/coding-tools-core/src/context/glob_allowed.txt rename to src/llm-coding-tools-core/src/context/glob_allowed.txt diff --git a/src/coding-tools-core/src/context/grep_absolute.txt b/src/llm-coding-tools-core/src/context/grep_absolute.txt similarity index 100% rename from src/coding-tools-core/src/context/grep_absolute.txt rename to src/llm-coding-tools-core/src/context/grep_absolute.txt diff --git a/src/coding-tools-core/src/context/grep_allowed.txt b/src/llm-coding-tools-core/src/context/grep_allowed.txt similarity index 100% rename from src/coding-tools-core/src/context/grep_allowed.txt rename to src/llm-coding-tools-core/src/context/grep_allowed.txt diff --git a/src/coding-tools-core/src/context/mod.rs b/src/llm-coding-tools-core/src/context/mod.rs similarity index 97% rename from src/coding-tools-core/src/context/mod.rs rename to src/llm-coding-tools-core/src/context/mod.rs index 5957166f..97339a79 100644 --- a/src/coding-tools-core/src/context/mod.rs +++ b/src/llm-coding-tools-core/src/context/mod.rs @@ -13,7 +13,7 @@ //! # Example //! //! ```rust -//! use coding_tools_core::context::{BASH, READ_ABSOLUTE, READ_ALLOWED}; +//! use llm_coding_tools_core::context::{BASH, READ_ABSOLUTE, READ_ALLOWED}; //! //! // Use BASH context for bash tool //! println!("Bash guidance: {}", BASH); @@ -76,7 +76,7 @@ pub const GREP_ALLOWED: &str = include_str!("grep_allowed.txt"); /// # Example /// /// ```rust -/// use coding_tools_core::context::ToolContext; +/// use llm_coding_tools_core::context::ToolContext; /// /// struct MyTool; /// diff --git a/src/coding-tools-core/src/context/read_absolute.txt b/src/llm-coding-tools-core/src/context/read_absolute.txt similarity index 100% rename from src/coding-tools-core/src/context/read_absolute.txt rename to src/llm-coding-tools-core/src/context/read_absolute.txt diff --git a/src/coding-tools-core/src/context/read_allowed.txt b/src/llm-coding-tools-core/src/context/read_allowed.txt similarity index 100% rename from src/coding-tools-core/src/context/read_allowed.txt rename to src/llm-coding-tools-core/src/context/read_allowed.txt diff --git a/src/coding-tools-core/src/context/task.txt b/src/llm-coding-tools-core/src/context/task.txt similarity index 100% rename from src/coding-tools-core/src/context/task.txt rename to src/llm-coding-tools-core/src/context/task.txt diff --git a/src/coding-tools-core/src/context/todoread.txt b/src/llm-coding-tools-core/src/context/todoread.txt similarity index 100% rename from src/coding-tools-core/src/context/todoread.txt rename to src/llm-coding-tools-core/src/context/todoread.txt diff --git a/src/coding-tools-core/src/context/todowrite.txt b/src/llm-coding-tools-core/src/context/todowrite.txt similarity index 100% rename from src/coding-tools-core/src/context/todowrite.txt rename to src/llm-coding-tools-core/src/context/todowrite.txt diff --git a/src/coding-tools-core/src/context/webfetch.txt b/src/llm-coding-tools-core/src/context/webfetch.txt similarity index 100% rename from src/coding-tools-core/src/context/webfetch.txt rename to src/llm-coding-tools-core/src/context/webfetch.txt diff --git a/src/coding-tools-core/src/context/write_absolute.txt b/src/llm-coding-tools-core/src/context/write_absolute.txt similarity index 100% rename from src/coding-tools-core/src/context/write_absolute.txt rename to src/llm-coding-tools-core/src/context/write_absolute.txt diff --git a/src/coding-tools-core/src/context/write_allowed.txt b/src/llm-coding-tools-core/src/context/write_allowed.txt similarity index 100% rename from src/coding-tools-core/src/context/write_allowed.txt rename to src/llm-coding-tools-core/src/context/write_allowed.txt diff --git a/src/coding-tools-core/src/error.rs b/src/llm-coding-tools-core/src/error.rs similarity index 100% rename from src/coding-tools-core/src/error.rs rename to src/llm-coding-tools-core/src/error.rs diff --git a/src/coding-tools-core/src/fs.rs b/src/llm-coding-tools-core/src/fs.rs similarity index 100% rename from src/coding-tools-core/src/fs.rs rename to src/llm-coding-tools-core/src/fs.rs diff --git a/src/coding-tools-core/src/lib.rs b/src/llm-coding-tools-core/src/lib.rs similarity index 100% rename from src/coding-tools-core/src/lib.rs rename to src/llm-coding-tools-core/src/lib.rs diff --git a/src/coding-tools-core/src/operations/bash/async_impl.rs b/src/llm-coding-tools-core/src/operations/bash/async_impl.rs similarity index 100% rename from src/coding-tools-core/src/operations/bash/async_impl.rs rename to src/llm-coding-tools-core/src/operations/bash/async_impl.rs diff --git a/src/coding-tools-core/src/operations/bash/blocking_impl.rs b/src/llm-coding-tools-core/src/operations/bash/blocking_impl.rs similarity index 100% rename from src/coding-tools-core/src/operations/bash/blocking_impl.rs rename to src/llm-coding-tools-core/src/operations/bash/blocking_impl.rs diff --git a/src/coding-tools-core/src/operations/bash/mod.rs b/src/llm-coding-tools-core/src/operations/bash/mod.rs similarity index 100% rename from src/coding-tools-core/src/operations/bash/mod.rs rename to src/llm-coding-tools-core/src/operations/bash/mod.rs diff --git a/src/coding-tools-core/src/operations/edit.rs b/src/llm-coding-tools-core/src/operations/edit.rs similarity index 100% rename from src/coding-tools-core/src/operations/edit.rs rename to src/llm-coding-tools-core/src/operations/edit.rs diff --git a/src/coding-tools-core/src/operations/glob.rs b/src/llm-coding-tools-core/src/operations/glob.rs similarity index 100% rename from src/coding-tools-core/src/operations/glob.rs rename to src/llm-coding-tools-core/src/operations/glob.rs diff --git a/src/coding-tools-core/src/operations/grep.rs b/src/llm-coding-tools-core/src/operations/grep.rs similarity index 100% rename from src/coding-tools-core/src/operations/grep.rs rename to src/llm-coding-tools-core/src/operations/grep.rs diff --git a/src/coding-tools-core/src/operations/mod.rs b/src/llm-coding-tools-core/src/operations/mod.rs similarity index 100% rename from src/coding-tools-core/src/operations/mod.rs rename to src/llm-coding-tools-core/src/operations/mod.rs diff --git a/src/coding-tools-core/src/operations/read.rs b/src/llm-coding-tools-core/src/operations/read.rs similarity index 100% rename from src/coding-tools-core/src/operations/read.rs rename to src/llm-coding-tools-core/src/operations/read.rs diff --git a/src/coding-tools-core/src/operations/task.rs b/src/llm-coding-tools-core/src/operations/task.rs similarity index 100% rename from src/coding-tools-core/src/operations/task.rs rename to src/llm-coding-tools-core/src/operations/task.rs diff --git a/src/coding-tools-core/src/operations/todo.rs b/src/llm-coding-tools-core/src/operations/todo.rs similarity index 100% rename from src/coding-tools-core/src/operations/todo.rs rename to src/llm-coding-tools-core/src/operations/todo.rs diff --git a/src/coding-tools-core/src/operations/webfetch/async_impl.rs b/src/llm-coding-tools-core/src/operations/webfetch/async_impl.rs similarity index 100% rename from src/coding-tools-core/src/operations/webfetch/async_impl.rs rename to src/llm-coding-tools-core/src/operations/webfetch/async_impl.rs diff --git a/src/coding-tools-core/src/operations/webfetch/blocking_impl.rs b/src/llm-coding-tools-core/src/operations/webfetch/blocking_impl.rs similarity index 100% rename from src/coding-tools-core/src/operations/webfetch/blocking_impl.rs rename to src/llm-coding-tools-core/src/operations/webfetch/blocking_impl.rs diff --git a/src/coding-tools-core/src/operations/webfetch/mod.rs b/src/llm-coding-tools-core/src/operations/webfetch/mod.rs similarity index 100% rename from src/coding-tools-core/src/operations/webfetch/mod.rs rename to src/llm-coding-tools-core/src/operations/webfetch/mod.rs diff --git a/src/coding-tools-core/src/operations/write.rs b/src/llm-coding-tools-core/src/operations/write.rs similarity index 100% rename from src/coding-tools-core/src/operations/write.rs rename to src/llm-coding-tools-core/src/operations/write.rs diff --git a/src/coding-tools-core/src/output.rs b/src/llm-coding-tools-core/src/output.rs similarity index 100% rename from src/coding-tools-core/src/output.rs rename to src/llm-coding-tools-core/src/output.rs diff --git a/src/coding-tools-core/src/path/absolute.rs b/src/llm-coding-tools-core/src/path/absolute.rs similarity index 96% rename from src/coding-tools-core/src/path/absolute.rs rename to src/llm-coding-tools-core/src/path/absolute.rs index 12ed5479..8f9a2354 100644 --- a/src/coding-tools-core/src/path/absolute.rs +++ b/src/llm-coding-tools-core/src/path/absolute.rs @@ -12,7 +12,7 @@ use std::path::PathBuf; /// # Example /// /// ``` -/// use coding_tools_core::path::{PathResolver, AbsolutePathResolver}; +/// use llm_coding_tools_core::path::{PathResolver, AbsolutePathResolver}; /// /// let resolver = AbsolutePathResolver; /// assert!(resolver.resolve("/home/user/file.txt").is_ok()); diff --git a/src/coding-tools-core/src/path/allowed.rs b/src/llm-coding-tools-core/src/path/allowed.rs similarity index 98% rename from src/coding-tools-core/src/path/allowed.rs rename to src/llm-coding-tools-core/src/path/allowed.rs index a6f01f15..e0ce67ef 100644 --- a/src/coding-tools-core/src/path/allowed.rs +++ b/src/llm-coding-tools-core/src/path/allowed.rs @@ -19,7 +19,7 @@ use std::path::PathBuf; /// # Example /// /// ```no_run -/// use coding_tools_core::path::{PathResolver, AllowedPathResolver}; +/// use llm_coding_tools_core::path::{PathResolver, AllowedPathResolver}; /// use std::path::PathBuf; /// /// let resolver = AllowedPathResolver::new(vec![ diff --git a/src/coding-tools-core/src/path/mod.rs b/src/llm-coding-tools-core/src/path/mod.rs similarity index 100% rename from src/coding-tools-core/src/path/mod.rs rename to src/llm-coding-tools-core/src/path/mod.rs diff --git a/src/coding-tools-core/src/preamble.rs b/src/llm-coding-tools-core/src/preamble.rs similarity index 97% rename from src/coding-tools-core/src/preamble.rs rename to src/llm-coding-tools-core/src/preamble.rs index 7536b929..8c298c65 100644 --- a/src/coding-tools-core/src/preamble.rs +++ b/src/llm-coding-tools-core/src/preamble.rs @@ -19,8 +19,8 @@ struct ContextEntry { /// # Example /// /// ```ignore -/// use coding_tools_rig::absolute::{ReadTool, GlobTool}; -/// use coding_tools_rig::{BashTool, PreambleBuilder}; +/// use llm_coding_tools_rig::absolute::{ReadTool, GlobTool}; +/// use llm_coding_tools_rig::{BashTool, PreambleBuilder}; /// use rig::tool::ToolSet; /// /// let mut pb = PreambleBuilder::new(); diff --git a/src/coding-tools-core/src/util.rs b/src/llm-coding-tools-core/src/util.rs similarity index 100% rename from src/coding-tools-core/src/util.rs rename to src/llm-coding-tools-core/src/util.rs diff --git a/src/coding-tools-rig/Cargo.toml b/src/llm-coding-tools-rig/Cargo.toml similarity index 80% rename from src/coding-tools-rig/Cargo.toml rename to src/llm-coding-tools-rig/Cargo.toml index 757d5293..fe767fa9 100644 --- a/src/coding-tools-rig/Cargo.toml +++ b/src/llm-coding-tools-rig/Cargo.toml @@ -1,16 +1,16 @@ [package] -name = "coding-tools-rig" +name = "llm-coding-tools-rig" version = "0.1.0" edition = "2021" description = "Rig framework Tool implementations for coding tools" -repository = "https://github.com/Sewer56/rig-coding-tools" +repository = "https://github.com/Sewer56/llm-coding-tools" license = "Apache-2.0" include = ["src/**/*"] readme = "README.md" [dependencies] # Core tool operations (file read/write/edit, glob, grep, bash, etc.) -coding-tools-core = { version = "0.1.0", path = "../coding-tools-core", features = ["tokio"] } +llm-coding-tools-core = { version = "0.1.0", path = "../llm-coding-tools-core", features = ["tokio"] } # Implements rig_core::tool::Tool trait for each tool rig-core = { version = "0.27", default-features = false, features = ["reqwest-rustls"] } diff --git a/src/coding-tools-rig/README.md b/src/llm-coding-tools-rig/README.md similarity index 77% rename from src/coding-tools-rig/README.md rename to src/llm-coding-tools-rig/README.md index 62f5b51f..19b205eb 100644 --- a/src/coding-tools-rig/README.md +++ b/src/llm-coding-tools-rig/README.md @@ -1,7 +1,7 @@ -# coding-tools-rig +# llm-coding-tools-rig -[![Crates.io](https://img.shields.io/crates/v/coding-tools-rig.svg)](https://crates.io/crates/coding-tools-rig) -[![Docs.rs](https://docs.rs/coding-tools-rig/badge.svg)](https://docs.rs/coding-tools-rig) +[![Crates.io](https://img.shields.io/crates/v/llm-coding-tools-rig.svg)](https://crates.io/crates/llm-coding-tools-rig) +[![Docs.rs](https://docs.rs/llm-coding-tools-rig/badge.svg)](https://docs.rs/llm-coding-tools-rig) Rig framework Tool implementations for coding tools. @@ -22,7 +22,7 @@ Add to your `Cargo.toml`: ```toml [dependencies] -coding-tools-rig = "0.1" +llm-coding-tools-rig = "0.1" ``` ## Quick Start @@ -30,7 +30,7 @@ coding-tools-rig = "0.1" Run the included example: ```bash -cargo run --example basic -p coding-tools-rig +cargo run --example basic -p llm-coding-tools-rig ``` ## Usage @@ -42,7 +42,7 @@ File tools (Read, Write, Edit, Glob, Grep) come in two variants: **`absolute::*`** - Unrestricted filesystem access, requires absolute paths: ```rust -use coding_tools_rig::absolute::{ReadTool, WriteTool, EditTool, GlobTool, GrepTool}; +use llm_coding_tools_rig::absolute::{ReadTool, WriteTool, EditTool, GlobTool, GrepTool}; let read: ReadTool = ReadTool::new(); // enables line numbers let write = WriteTool::new(); @@ -54,8 +54,8 @@ let grep: GrepTool = GrepTool::new(); **`allowed::*`** - Sandboxed to configured directories: ```rust -use coding_tools_rig::allowed::{ReadTool, WriteTool}; -use coding_tools_rig::AllowedPathResolver; +use llm_coding_tools_rig::allowed::{ReadTool, WriteTool}; +use llm_coding_tools_rig::AllowedPathResolver; use std::path::PathBuf; // Option 1: Pass paths directly @@ -77,7 +77,7 @@ let write = WriteTool::with_resolver(resolver); Tools that don't operate on files: ```rust -use coding_tools_rig::{BashTool, TaskTool, WebFetchTool, TodoTools}; +use llm_coding_tools_rig::{BashTool, TaskTool, WebFetchTool, TodoTools}; let bash = BashTool::new(); // Shell command execution let webfetch = WebFetchTool::new(); // URL content fetching @@ -91,8 +91,8 @@ let todos = TodoTools::new(); // Todo list (todos.read, todos.write) for the agent's system prompt. This provides LLM guidance on using each tool effectively. ```rust -use coding_tools_rig::absolute::{ReadTool, GlobTool}; -use coding_tools_rig::{BashTool, PreambleBuilder, TodoTools}; +use llm_coding_tools_rig::absolute::{ReadTool, GlobTool}; +use llm_coding_tools_rig::{BashTool, PreambleBuilder, TodoTools}; use rig::tool::ToolSet; // Create preamble builder to track tools @@ -122,10 +122,10 @@ let preamble = pb.build(); ### Context Strings -LLM guidance strings are re-exported from `coding_tools_core`: +LLM guidance strings are re-exported from `llm_coding_tools_core`: ```rust -use coding_tools_rig::context::{BASH, READ_ABSOLUTE, READ_ALLOWED}; +use llm_coding_tools_rig::context::{BASH, READ_ABSOLUTE, READ_ALLOWED}; // Use context strings in system prompts or tool descriptions println!("{}", BASH); @@ -139,13 +139,13 @@ println!("{}", READ_ALLOWED); // For allowed::ReadTool ```bash # Basic toolset setup with PreambleBuilder -cargo run --example basic -p coding-tools-rig +cargo run --example basic -p llm-coding-tools-rig # Complete agent configuration (recommended starting point) -cargo run --example full_agent -p coding-tools-rig +cargo run --example full_agent -p llm-coding-tools-rig # Sandboxed file access with allowed::* tools -cargo run --example sandboxed -p coding-tools-rig +cargo run --example sandboxed -p llm-coding-tools-rig ``` ## License diff --git a/src/coding-tools-rig/examples/basic.rs b/src/llm-coding-tools-rig/examples/basic.rs similarity index 90% rename from src/coding-tools-rig/examples/basic.rs rename to src/llm-coding-tools-rig/examples/basic.rs index 3f62c7ec..6b31e98d 100644 --- a/src/coding-tools-rig/examples/basic.rs +++ b/src/llm-coding-tools-rig/examples/basic.rs @@ -6,12 +6,12 @@ //! - TodoTools with shared state //! - Generating and using the preamble string //! -//! Run: cargo run --example basic -p coding-tools-rig +//! Run: cargo run --example basic -p llm-coding-tools-rig //! -//! For a complete agent setup, see: cargo run --example full_agent -p coding-tools-rig +//! For a complete agent setup, see: cargo run --example full_agent -p llm-coding-tools-rig -use coding_tools_rig::absolute::{GlobTool, GrepTool, ReadTool}; -use coding_tools_rig::{BashTool, PreambleBuilder, TodoTools}; +use llm_coding_tools_rig::absolute::{GlobTool, GrepTool, ReadTool}; +use llm_coding_tools_rig::{BashTool, PreambleBuilder, TodoTools}; use rig::tool::ToolSet; #[tokio::main] diff --git a/src/coding-tools-rig/examples/full_agent.rs b/src/llm-coding-tools-rig/examples/full_agent.rs similarity index 94% rename from src/coding-tools-rig/examples/full_agent.rs rename to src/llm-coding-tools-rig/examples/full_agent.rs index 98b03951..2e3ac85f 100644 --- a/src/coding-tools-rig/examples/full_agent.rs +++ b/src/llm-coding-tools-rig/examples/full_agent.rs @@ -4,10 +4,10 @@ //! with all available tools. Agent execution is commented out as it //! requires API credentials. //! -//! Run: cargo run --example full_agent -p coding-tools-rig +//! Run: cargo run --example full_agent -p llm-coding-tools-rig -use coding_tools_rig::absolute::{EditTool, GlobTool, GrepTool, ReadTool, WriteTool}; -use coding_tools_rig::{BashTool, PreambleBuilder, TodoTools, WebFetchTool}; +use llm_coding_tools_rig::absolute::{EditTool, GlobTool, GrepTool, ReadTool, WriteTool}; +use llm_coding_tools_rig::{BashTool, PreambleBuilder, TodoTools, WebFetchTool}; use rig::tool::ToolSet; #[tokio::main] diff --git a/src/coding-tools-rig/examples/sandboxed.rs b/src/llm-coding-tools-rig/examples/sandboxed.rs similarity index 93% rename from src/coding-tools-rig/examples/sandboxed.rs rename to src/llm-coding-tools-rig/examples/sandboxed.rs index 5878afa8..7e70d584 100644 --- a/src/coding-tools-rig/examples/sandboxed.rs +++ b/src/llm-coding-tools-rig/examples/sandboxed.rs @@ -7,10 +7,10 @@ //! - Security-conscious deployments limiting filesystem exposure //! - Project-scoped agents that shouldn't touch system files //! -//! Run: cargo run --example sandboxed -p coding-tools-rig +//! Run: cargo run --example sandboxed -p llm-coding-tools-rig -use coding_tools_rig::allowed::{EditTool, GlobTool, GrepTool, ReadTool, WriteTool}; -use coding_tools_rig::{AllowedPathResolver, PreambleBuilder}; +use llm_coding_tools_rig::allowed::{EditTool, GlobTool, GrepTool, ReadTool, WriteTool}; +use llm_coding_tools_rig::{AllowedPathResolver, PreambleBuilder}; use rig::tool::ToolSet; use std::path::PathBuf; diff --git a/src/coding-tools-rig/src/absolute/edit.rs b/src/llm-coding-tools-rig/src/absolute/edit.rs similarity index 91% rename from src/coding-tools-rig/src/absolute/edit.rs rename to src/llm-coding-tools-rig/src/absolute/edit.rs index 999f41c4..fcd9e803 100644 --- a/src/coding-tools-rig/src/absolute/edit.rs +++ b/src/llm-coding-tools-rig/src/absolute/edit.rs @@ -1,9 +1,9 @@ //! Edit file tool using [`AbsolutePathResolver`]. -use coding_tools_core::operations::edit_file; -use coding_tools_core::path::AbsolutePathResolver; -pub use coding_tools_core::EditError; -use coding_tools_core::ToolContext; +use llm_coding_tools_core::operations::edit_file; +use llm_coding_tools_core::path::AbsolutePathResolver; +pub use llm_coding_tools_core::EditError; +use llm_coding_tools_core::ToolContext; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -70,14 +70,14 @@ impl ToolContext for EditTool { const NAME: &'static str = "edit"; fn context(&self) -> &'static str { - coding_tools_core::context::EDIT_ABSOLUTE + llm_coding_tools_core::context::EDIT_ABSOLUTE } } #[cfg(test)] mod tests { use super::*; - use coding_tools_core::ToolError; + use llm_coding_tools_core::ToolError; use std::io::Write as _; use tempfile::NamedTempFile; diff --git a/src/coding-tools-rig/src/absolute/glob.rs b/src/llm-coding-tools-rig/src/absolute/glob.rs similarity index 92% rename from src/coding-tools-rig/src/absolute/glob.rs rename to src/llm-coding-tools-rig/src/absolute/glob.rs index 327c98e1..b3b34fe8 100644 --- a/src/coding-tools-rig/src/absolute/glob.rs +++ b/src/llm-coding-tools-rig/src/absolute/glob.rs @@ -1,8 +1,8 @@ //! Glob pattern file finding tool using [`AbsolutePathResolver`]. -use coding_tools_core::operations::glob_files; -use coding_tools_core::path::AbsolutePathResolver; -use coding_tools_core::{GlobOutput, ToolContext, ToolError}; +use llm_coding_tools_core::operations::glob_files; +use llm_coding_tools_core::path::AbsolutePathResolver; +use llm_coding_tools_core::{GlobOutput, ToolContext, ToolError}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -57,7 +57,7 @@ impl ToolContext for GlobTool { const NAME: &'static str = "glob"; fn context(&self) -> &'static str { - coding_tools_core::context::GLOB_ABSOLUTE + llm_coding_tools_core::context::GLOB_ABSOLUTE } } diff --git a/src/coding-tools-rig/src/absolute/grep.rs b/src/llm-coding-tools-rig/src/absolute/grep.rs similarity index 96% rename from src/coding-tools-rig/src/absolute/grep.rs rename to src/llm-coding-tools-rig/src/absolute/grep.rs index 39e7f36b..30d80627 100644 --- a/src/coding-tools-rig/src/absolute/grep.rs +++ b/src/llm-coding-tools-rig/src/absolute/grep.rs @@ -1,8 +1,8 @@ //! Grep content search tool using [`AbsolutePathResolver`]. -use coding_tools_core::operations::grep_search; -use coding_tools_core::path::AbsolutePathResolver; -use coding_tools_core::{ToolContext, ToolError, ToolOutput}; +use llm_coding_tools_core::operations::grep_search; +use llm_coding_tools_core::path::AbsolutePathResolver; +use llm_coding_tools_core::{ToolContext, ToolError, ToolOutput}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -134,7 +134,7 @@ impl ToolContext for GrepTool { const NAME: &'static str = "grep"; fn context(&self) -> &'static str { - coding_tools_core::context::GREP_ABSOLUTE + llm_coding_tools_core::context::GREP_ABSOLUTE } } diff --git a/src/coding-tools-rig/src/absolute/mod.rs b/src/llm-coding-tools-rig/src/absolute/mod.rs similarity index 90% rename from src/coding-tools-rig/src/absolute/mod.rs rename to src/llm-coding-tools-rig/src/absolute/mod.rs index 7d0b6604..540c67de 100644 --- a/src/coding-tools-rig/src/absolute/mod.rs +++ b/src/llm-coding-tools-rig/src/absolute/mod.rs @@ -1,4 +1,4 @@ -//! Tools using [`coding_tools_core::path::AbsolutePathResolver`]. +//! Tools using [`llm_coding_tools_core::path::AbsolutePathResolver`]. //! //! These tools require absolute paths and perform no directory restriction. //! Use for unrestricted file system access. diff --git a/src/coding-tools-rig/src/absolute/read.rs b/src/llm-coding-tools-rig/src/absolute/read.rs similarity index 93% rename from src/coding-tools-rig/src/absolute/read.rs rename to src/llm-coding-tools-rig/src/absolute/read.rs index 448a93f7..b11aadc3 100644 --- a/src/coding-tools-rig/src/absolute/read.rs +++ b/src/llm-coding-tools-rig/src/absolute/read.rs @@ -1,8 +1,8 @@ //! Read file tool using [`AbsolutePathResolver`]. -use coding_tools_core::operations::read_file; -use coding_tools_core::path::AbsolutePathResolver; -use coding_tools_core::{ToolContext, ToolError, ToolOutput}; +use llm_coding_tools_core::operations::read_file; +use llm_coding_tools_core::path::AbsolutePathResolver; +use llm_coding_tools_core::{ToolContext, ToolError, ToolOutput}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -75,7 +75,7 @@ impl ToolContext for ReadTool { const NAME: &'static str = "read"; fn context(&self) -> &'static str { - coding_tools_core::context::READ_ABSOLUTE + llm_coding_tools_core::context::READ_ABSOLUTE } } diff --git a/src/coding-tools-rig/src/absolute/write.rs b/src/llm-coding-tools-rig/src/absolute/write.rs similarity index 92% rename from src/coding-tools-rig/src/absolute/write.rs rename to src/llm-coding-tools-rig/src/absolute/write.rs index 0a0dd03f..93cdafa7 100644 --- a/src/coding-tools-rig/src/absolute/write.rs +++ b/src/llm-coding-tools-rig/src/absolute/write.rs @@ -1,8 +1,8 @@ //! Write file tool using [`AbsolutePathResolver`]. -use coding_tools_core::operations::write_file; -use coding_tools_core::path::AbsolutePathResolver; -use coding_tools_core::{ToolContext, ToolError}; +use llm_coding_tools_core::operations::write_file; +use llm_coding_tools_core::path::AbsolutePathResolver; +use llm_coding_tools_core::{ToolContext, ToolError}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -57,7 +57,7 @@ impl ToolContext for WriteTool { const NAME: &'static str = "write"; fn context(&self) -> &'static str { - coding_tools_core::context::WRITE_ABSOLUTE + llm_coding_tools_core::context::WRITE_ABSOLUTE } } diff --git a/src/coding-tools-rig/src/allowed/edit.rs b/src/llm-coding-tools-rig/src/allowed/edit.rs similarity index 92% rename from src/coding-tools-rig/src/allowed/edit.rs rename to src/llm-coding-tools-rig/src/allowed/edit.rs index 5a8289f6..0e7933e7 100644 --- a/src/coding-tools-rig/src/allowed/edit.rs +++ b/src/llm-coding-tools-rig/src/allowed/edit.rs @@ -1,9 +1,9 @@ //! Edit file tool using [`AllowedPathResolver`]. -use coding_tools_core::operations::edit_file; -use coding_tools_core::path::AllowedPathResolver; -pub use coding_tools_core::EditError; -use coding_tools_core::{ToolContext, ToolResult}; +use llm_coding_tools_core::operations::edit_file; +use llm_coding_tools_core::path::AllowedPathResolver; +pub use llm_coding_tools_core::EditError; +use llm_coding_tools_core::{ToolContext, ToolResult}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -82,14 +82,14 @@ impl ToolContext for EditTool { const NAME: &'static str = "edit"; fn context(&self) -> &'static str { - coding_tools_core::context::EDIT_ALLOWED + llm_coding_tools_core::context::EDIT_ALLOWED } } #[cfg(test)] mod tests { use super::*; - use coding_tools_core::ToolError; + use llm_coding_tools_core::ToolError; use tempfile::TempDir; #[tokio::test] diff --git a/src/coding-tools-rig/src/allowed/glob.rs b/src/llm-coding-tools-rig/src/allowed/glob.rs similarity index 93% rename from src/coding-tools-rig/src/allowed/glob.rs rename to src/llm-coding-tools-rig/src/allowed/glob.rs index f1f297a9..bea6019d 100644 --- a/src/coding-tools-rig/src/allowed/glob.rs +++ b/src/llm-coding-tools-rig/src/allowed/glob.rs @@ -1,8 +1,8 @@ //! Glob pattern file finding tool using [`AllowedPathResolver`]. -use coding_tools_core::operations::glob_files; -use coding_tools_core::path::AllowedPathResolver; -use coding_tools_core::{GlobOutput, ToolContext, ToolError, ToolResult}; +use llm_coding_tools_core::operations::glob_files; +use llm_coding_tools_core::path::AllowedPathResolver; +use llm_coding_tools_core::{GlobOutput, ToolContext, ToolError, ToolResult}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -69,7 +69,7 @@ impl ToolContext for GlobTool { const NAME: &'static str = "glob"; fn context(&self) -> &'static str { - coding_tools_core::context::GLOB_ALLOWED + llm_coding_tools_core::context::GLOB_ALLOWED } } diff --git a/src/coding-tools-rig/src/allowed/grep.rs b/src/llm-coding-tools-rig/src/allowed/grep.rs similarity index 96% rename from src/coding-tools-rig/src/allowed/grep.rs rename to src/llm-coding-tools-rig/src/allowed/grep.rs index 87e11906..3d773a78 100644 --- a/src/coding-tools-rig/src/allowed/grep.rs +++ b/src/llm-coding-tools-rig/src/allowed/grep.rs @@ -1,8 +1,8 @@ //! Grep content search tool using [`AllowedPathResolver`]. -use coding_tools_core::operations::grep_search; -use coding_tools_core::path::AllowedPathResolver; -use coding_tools_core::{ToolContext, ToolError, ToolOutput, ToolResult}; +use llm_coding_tools_core::operations::grep_search; +use llm_coding_tools_core::path::AllowedPathResolver; +use llm_coding_tools_core::{ToolContext, ToolError, ToolOutput, ToolResult}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -141,7 +141,7 @@ impl ToolContext for GrepTool { const NAME: &'static str = "grep"; fn context(&self) -> &'static str { - coding_tools_core::context::GREP_ALLOWED + llm_coding_tools_core::context::GREP_ALLOWED } } diff --git a/src/coding-tools-rig/src/allowed/mod.rs b/src/llm-coding-tools-rig/src/allowed/mod.rs similarity index 91% rename from src/coding-tools-rig/src/allowed/mod.rs rename to src/llm-coding-tools-rig/src/allowed/mod.rs index 23a16241..76b56336 100644 --- a/src/coding-tools-rig/src/allowed/mod.rs +++ b/src/llm-coding-tools-rig/src/allowed/mod.rs @@ -1,4 +1,4 @@ -//! Tools using [`coding_tools_core::path::AllowedPathResolver`]. +//! Tools using [`llm_coding_tools_core::path::AllowedPathResolver`]. //! //! These tools restrict file access to configured allowed directories. //! Use for sandboxed file system access. diff --git a/src/coding-tools-rig/src/allowed/read.rs b/src/llm-coding-tools-rig/src/allowed/read.rs similarity index 94% rename from src/coding-tools-rig/src/allowed/read.rs rename to src/llm-coding-tools-rig/src/allowed/read.rs index 6849937f..6ae3bf16 100644 --- a/src/coding-tools-rig/src/allowed/read.rs +++ b/src/llm-coding-tools-rig/src/allowed/read.rs @@ -1,8 +1,8 @@ //! Read file tool using [`AllowedPathResolver`]. -use coding_tools_core::operations::read_file; -use coding_tools_core::path::AllowedPathResolver; -use coding_tools_core::{ToolContext, ToolError, ToolOutput, ToolResult}; +use llm_coding_tools_core::operations::read_file; +use llm_coding_tools_core::path::AllowedPathResolver; +use llm_coding_tools_core::{ToolContext, ToolError, ToolOutput, ToolResult}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -91,7 +91,7 @@ impl ToolContext for ReadTool { const NAME: &'static str = "read"; fn context(&self) -> &'static str { - coding_tools_core::context::READ_ALLOWED + llm_coding_tools_core::context::READ_ALLOWED } } diff --git a/src/coding-tools-rig/src/allowed/write.rs b/src/llm-coding-tools-rig/src/allowed/write.rs similarity index 93% rename from src/coding-tools-rig/src/allowed/write.rs rename to src/llm-coding-tools-rig/src/allowed/write.rs index d6867674..a3f3fd8f 100644 --- a/src/coding-tools-rig/src/allowed/write.rs +++ b/src/llm-coding-tools-rig/src/allowed/write.rs @@ -1,8 +1,8 @@ //! Write file tool using [`AllowedPathResolver`]. -use coding_tools_core::operations::write_file; -use coding_tools_core::path::AllowedPathResolver; -use coding_tools_core::{ToolContext, ToolError, ToolResult}; +use llm_coding_tools_core::operations::write_file; +use llm_coding_tools_core::path::AllowedPathResolver; +use llm_coding_tools_core::{ToolContext, ToolError, ToolResult}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -69,7 +69,7 @@ impl ToolContext for WriteTool { const NAME: &'static str = "write"; fn context(&self) -> &'static str { - coding_tools_core::context::WRITE_ALLOWED + llm_coding_tools_core::context::WRITE_ALLOWED } } diff --git a/src/coding-tools-rig/src/bash.rs b/src/llm-coding-tools-rig/src/bash.rs similarity index 95% rename from src/coding-tools-rig/src/bash.rs rename to src/llm-coding-tools-rig/src/bash.rs index c2038328..89671eaa 100644 --- a/src/coding-tools-rig/src/bash.rs +++ b/src/llm-coding-tools-rig/src/bash.rs @@ -2,8 +2,8 @@ //! //! Provides cross-platform shell command execution with timeout support. -use coding_tools_core::operations::execute_command; -use coding_tools_core::{BashOutput, ToolContext, ToolError, ToolOutput}; +use llm_coding_tools_core::operations::execute_command; +use llm_coding_tools_core::{BashOutput, ToolContext, ToolError, ToolOutput}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -74,7 +74,7 @@ impl ToolContext for BashTool { const NAME: &'static str = "bash"; fn context(&self) -> &'static str { - coding_tools_core::context::BASH + llm_coding_tools_core::context::BASH } } diff --git a/src/coding-tools-rig/src/lib.rs b/src/llm-coding-tools-rig/src/lib.rs similarity index 82% rename from src/coding-tools-rig/src/lib.rs rename to src/llm-coding-tools-rig/src/lib.rs index 09815eb2..0913926c 100644 --- a/src/coding-tools-rig/src/lib.rs +++ b/src/llm-coding-tools-rig/src/lib.rs @@ -1,7 +1,7 @@ //! Rig framework Tool implementations for coding tools. //! //! This crate provides `rig_core::tool::Tool` implementations wrapping -//! the core operations from [`coding_tools_core`]. +//! the core operations from [`llm_coding_tools_core`]. //! //! # Module Organization //! @@ -12,8 +12,8 @@ //! # Example //! //! ```ignore -//! use coding_tools_rig::absolute::ReadTool; -//! use coding_tools_rig::BashTool; +//! use llm_coding_tools_rig::absolute::ReadTool; +//! use llm_coding_tools_rig::BashTool; //! ``` #![warn(missing_docs)] @@ -26,20 +26,20 @@ pub mod todo; pub mod webfetch; // Re-export core types for convenience -pub use coding_tools_core::{ToolError, ToolOutput, ToolResult}; +pub use llm_coding_tools_core::{ToolError, ToolOutput, ToolResult}; // Re-export context module and ToolContext trait for convenience -pub use coding_tools_core::context; -pub use coding_tools_core::ToolContext; +pub use llm_coding_tools_core::context; +pub use llm_coding_tools_core::ToolContext; // Re-export PreambleBuilder from core -pub use coding_tools_core::PreambleBuilder; +pub use llm_coding_tools_core::PreambleBuilder; // Re-export path resolvers -pub use coding_tools_core::path::{AbsolutePathResolver, AllowedPathResolver, PathResolver}; +pub use llm_coding_tools_core::path::{AbsolutePathResolver, AllowedPathResolver, PathResolver}; // Re-export core operation types used by tools -pub use coding_tools_core::{ +pub use llm_coding_tools_core::{ BashOutput, EditError, GlobOutput, GrepFileMatches, GrepLineMatch, GrepOutput, MockTaskExecutor, TaskExecutor, TaskResult, Todo, TodoPriority, TodoState, TodoStatus, WebFetchOutput, diff --git a/src/coding-tools-rig/src/task.rs b/src/llm-coding-tools-rig/src/task.rs similarity index 94% rename from src/coding-tools-rig/src/task.rs rename to src/llm-coding-tools-rig/src/task.rs index 67aace20..1dcb7440 100644 --- a/src/coding-tools-rig/src/task.rs +++ b/src/llm-coding-tools-rig/src/task.rs @@ -2,7 +2,7 @@ //! //! Provides [`TaskTool`] for spawning sub-agents to handle complex tasks. -use coding_tools_core::{ToolContext, ToolError, ToolOutput}; +use llm_coding_tools_core::{ToolContext, ToolError, ToolOutput}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -10,7 +10,7 @@ use serde::Deserialize; use std::sync::Arc; // Re-export core types -pub use coding_tools_core::{MockTaskExecutor, TaskArgs as CoreTaskArgs, TaskExecutor, TaskResult}; +pub use llm_coding_tools_core::{MockTaskExecutor, TaskArgs as CoreTaskArgs, TaskExecutor, TaskResult}; /// Arguments for the task tool (with JsonSchema for rig). #[derive(Debug, Clone, Deserialize, JsonSchema)] @@ -87,7 +87,7 @@ impl ToolContext for TaskTool { const NAME: &'static str = "task"; fn context(&self) -> &'static str { - coding_tools_core::context::TASK + llm_coding_tools_core::context::TASK } } diff --git a/src/coding-tools-rig/src/todo.rs b/src/llm-coding-tools-rig/src/todo.rs similarity index 94% rename from src/coding-tools-rig/src/todo.rs rename to src/llm-coding-tools-rig/src/todo.rs index b6e3e07b..e3e66b14 100644 --- a/src/coding-tools-rig/src/todo.rs +++ b/src/llm-coding-tools-rig/src/todo.rs @@ -2,15 +2,15 @@ //! //! Provides tools for reading and writing todo items. -use coding_tools_core::operations::{read_todos, write_todos}; -use coding_tools_core::{ToolContext, ToolError, ToolOutput}; +use llm_coding_tools_core::operations::{read_todos, write_todos}; +use llm_coding_tools_core::{ToolContext, ToolError, ToolOutput}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; use serde::Deserialize; // Re-export core types -pub use coding_tools_core::{Todo, TodoPriority, TodoState, TodoStatus}; +pub use llm_coding_tools_core::{Todo, TodoPriority, TodoState, TodoStatus}; /// Arguments for writing todos. #[derive(Debug, Clone, Deserialize, JsonSchema)] @@ -97,7 +97,7 @@ impl ToolContext for TodoWriteTool { const NAME: &'static str = "todowrite"; fn context(&self) -> &'static str { - coding_tools_core::context::TODO_WRITE + llm_coding_tools_core::context::TODO_WRITE } } @@ -105,7 +105,7 @@ impl ToolContext for TodoReadTool { const NAME: &'static str = "todoread"; fn context(&self) -> &'static str { - coding_tools_core::context::TODO_READ + llm_coding_tools_core::context::TODO_READ } } diff --git a/src/coding-tools-rig/src/webfetch.rs b/src/llm-coding-tools-rig/src/webfetch.rs similarity index 94% rename from src/coding-tools-rig/src/webfetch.rs rename to src/llm-coding-tools-rig/src/webfetch.rs index 6a2d0ebf..824fc7e2 100644 --- a/src/coding-tools-rig/src/webfetch.rs +++ b/src/llm-coding-tools-rig/src/webfetch.rs @@ -2,8 +2,8 @@ //! //! Provides URL fetching with format conversion support. -use coding_tools_core::operations::fetch_url; -use coding_tools_core::{ToolContext, ToolError, ToolOutput}; +use llm_coding_tools_core::operations::fetch_url; +use llm_coding_tools_core::{ToolContext, ToolError, ToolOutput}; use rig::completion::ToolDefinition; use rig::tool::Tool; use schemars::{schema_for, JsonSchema}; @@ -91,7 +91,7 @@ impl ToolContext for WebFetchTool { const NAME: &'static str = "webfetch"; fn context(&self) -> &'static str { - coding_tools_core::context::WEBFETCH + llm_coding_tools_core::context::WEBFETCH } } From c88e347840006c5069527ef4e5961e5071c7e824 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 07:23:06 +0000 Subject: [PATCH 29/64] Updated: Descriptions to highlight lightweight, high-performance implementations --- README.MD | 2 +- src/llm-coding-tools-core/Cargo.toml | 2 +- src/llm-coding-tools-core/README.md | 2 +- src/llm-coding-tools-rig/Cargo.toml | 2 +- src/llm-coding-tools-rig/README.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.MD b/README.MD index 3a60a2dc..c2f926b4 100644 --- a/README.MD +++ b/README.MD @@ -5,7 +5,7 @@ [![Docs.rs](https://docs.rs/llm-coding-tools-rig/badge.svg)](https://docs.rs/llm-coding-tools-rig) [![CI](https://github.com/Sewer56/llm-coding-tools/actions/workflows/rust.yml/badge.svg)](https://github.com/Sewer56/llm-coding-tools/actions) -Coding tools for building LLM-powered development agents with [Rig](https://github.com/0xPlaygrounds/rig). +Lightweight, high-performance coding tool implementations for LLM-powered development agents. Plug and play into your favourite frameworks like [Rig](https://github.com/0xPlaygrounds/rig). ## About This Workspace diff --git a/src/llm-coding-tools-core/Cargo.toml b/src/llm-coding-tools-core/Cargo.toml index 5bbb99af..769101cb 100644 --- a/src/llm-coding-tools-core/Cargo.toml +++ b/src/llm-coding-tools-core/Cargo.toml @@ -2,7 +2,7 @@ name = "llm-coding-tools-core" version = "0.1.0" edition = "2021" -description = "Core types and utilities for coding tools - framework agnostic" +description = "Lightweight, high-performance core types and utilities for coding tools - framework agnostic" repository = "https://github.com/Sewer56/llm-coding-tools" license = "Apache-2.0" include = ["src/**/*", "README.md"] diff --git a/src/llm-coding-tools-core/README.md b/src/llm-coding-tools-core/README.md index 374c31f2..4b8a6e8c 100644 --- a/src/llm-coding-tools-core/README.md +++ b/src/llm-coding-tools-core/README.md @@ -1,6 +1,6 @@ # llm-coding-tools-core -Core types and utilities for coding tools - framework agnostic. +Lightweight, high-performance core types and utilities for coding tools - framework agnostic. ## Overview diff --git a/src/llm-coding-tools-rig/Cargo.toml b/src/llm-coding-tools-rig/Cargo.toml index fe767fa9..70bcfbe6 100644 --- a/src/llm-coding-tools-rig/Cargo.toml +++ b/src/llm-coding-tools-rig/Cargo.toml @@ -2,7 +2,7 @@ name = "llm-coding-tools-rig" version = "0.1.0" edition = "2021" -description = "Rig framework Tool implementations for coding tools" +description = "Lightweight, high-performance Rig framework Tool implementations for coding tools" repository = "https://github.com/Sewer56/llm-coding-tools" license = "Apache-2.0" include = ["src/**/*"] diff --git a/src/llm-coding-tools-rig/README.md b/src/llm-coding-tools-rig/README.md index 19b205eb..edbc2776 100644 --- a/src/llm-coding-tools-rig/README.md +++ b/src/llm-coding-tools-rig/README.md @@ -3,7 +3,7 @@ [![Crates.io](https://img.shields.io/crates/v/llm-coding-tools-rig.svg)](https://crates.io/crates/llm-coding-tools-rig) [![Docs.rs](https://docs.rs/llm-coding-tools-rig/badge.svg)](https://docs.rs/llm-coding-tools-rig) -Rig framework Tool implementations for coding tools. +Lightweight, high-performance Rig framework Tool implementations for coding tools. ## Features From ffd7dccb3b00ba6655a79c865537547fc51901f2 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 07:34:23 +0000 Subject: [PATCH 30/64] Change: Run cargo fmt --- src/llm-coding-tools-rig/src/task.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/llm-coding-tools-rig/src/task.rs b/src/llm-coding-tools-rig/src/task.rs index 1dcb7440..f2932cfa 100644 --- a/src/llm-coding-tools-rig/src/task.rs +++ b/src/llm-coding-tools-rig/src/task.rs @@ -10,7 +10,9 @@ use serde::Deserialize; use std::sync::Arc; // Re-export core types -pub use llm_coding_tools_core::{MockTaskExecutor, TaskArgs as CoreTaskArgs, TaskExecutor, TaskResult}; +pub use llm_coding_tools_core::{ + MockTaskExecutor, TaskArgs as CoreTaskArgs, TaskExecutor, TaskResult, +}; /// Arguments for the task tool (with JsonSchema for rig). #[derive(Debug, Clone, Deserialize, JsonSchema)] From 94e3c9ae977d210d0867b62a26da76393e199dbb Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 07:35:07 +0000 Subject: [PATCH 31/64] Fixed: Pull request template. --- .github/pull_request_template.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0c46a46d..d0737b29 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1 +1,6 @@ + + From f60dd3e34ddc751b904d1cb63286159eb568814c Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 07:38:54 +0000 Subject: [PATCH 32/64] Fixed: VSCode tasks to watch correct crate directories --- src/.vscode/tasks.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/.vscode/tasks.json b/src/.vscode/tasks.json index b6eb6b6a..76f58d74 100644 --- a/src/.vscode/tasks.json +++ b/src/.vscode/tasks.json @@ -4,7 +4,7 @@ { "label": "Auto Test on Save", "type": "shell", - "command": "cargo install cargo-watch --quiet && cargo watch -x \"test\" -w coding-tools-core/src -w coding-tools-rig/src", + "command": "cargo install cargo-watch --quiet && cargo watch -x \"test\" -w llm-coding-tools-core/src -w llm-coding-tools-rig/src", "group": "test", "presentation": { "reveal": "always" @@ -14,7 +14,7 @@ { "label": "Auto Coverage on Save", "type": "shell", - "command": "cargo install cargo-watch --quiet && cargo install cargo-tarpaulin --quiet && cargo watch -x \"tarpaulin --skip-clean --out Xml --out Html --engine llvm --target-dir target/coverage-build\" -w coding-tools-core/src -w coding-tools-rig/src", + "command": "cargo install cargo-watch --quiet && cargo install cargo-tarpaulin --quiet && cargo watch -x \"tarpaulin --skip-clean --out Xml --out Html --engine llvm --target-dir target/coverage-build\" -w llm-coding-tools-core/src -w llm-coding-tools-rig/src", "group": "test", "presentation": { "reveal": "always" From 03a83f5156254118f9f4a5e4a4ad97dca3507ea7 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 07:39:38 +0000 Subject: [PATCH 33/64] Fix: Mismatched XML closing tag in task.txt example_agent_descriptions --- src/llm-coding-tools-core/src/context/task.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/llm-coding-tools-core/src/context/task.txt b/src/llm-coding-tools-core/src/context/task.txt index 7af2a6f6..c940bc94 100644 --- a/src/llm-coding-tools-core/src/context/task.txt +++ b/src/llm-coding-tools-core/src/context/task.txt @@ -28,7 +28,7 @@ Example usage (NOTE: The agents below are fictional examples for illustration on "code-reviewer": use this agent after you are done writing a significant piece of code "greeting-responder": use this agent when to respond to user greetings with a friendly joke - + user: "Please write a function that checks if a number is prime" From e07fb89b392c0e21e2bfbc88615ba23062671879 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 07:43:09 +0000 Subject: [PATCH 34/64] Fix: Cross-platform support for absolute path tests on Windows --- src/llm-coding-tools-core/src/path/absolute.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/llm-coding-tools-core/src/path/absolute.rs b/src/llm-coding-tools-core/src/path/absolute.rs index 8f9a2354..ce9a3961 100644 --- a/src/llm-coding-tools-core/src/path/absolute.rs +++ b/src/llm-coding-tools-core/src/path/absolute.rs @@ -15,6 +15,9 @@ use std::path::PathBuf; /// use llm_coding_tools_core::path::{PathResolver, AbsolutePathResolver}; /// /// let resolver = AbsolutePathResolver; +/// #[cfg(windows)] +/// assert!(resolver.resolve("C:\\Users\\user\\file.txt").is_ok()); +/// #[cfg(not(windows))] /// assert!(resolver.resolve("/home/user/file.txt").is_ok()); /// assert!(resolver.resolve("relative/path.txt").is_err()); /// ``` @@ -41,9 +44,14 @@ mod tests { #[test] fn accepts_absolute_path() { let resolver = AbsolutePathResolver; - let result = resolver.resolve("/home/user/file.txt"); + #[cfg(windows)] + let path = "C:\\Users\\user\\file.txt"; + #[cfg(not(windows))] + let path = "/home/user/file.txt"; + + let result = resolver.resolve(path); assert!(result.is_ok()); - assert_eq!(result.unwrap(), PathBuf::from("/home/user/file.txt")); + assert_eq!(result.unwrap(), PathBuf::from(path)); } #[test] From 8bcc8a7b98184e0de90ce268ef3029cc146fac53 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 07:45:42 +0000 Subject: [PATCH 35/64] Fixed: Invalid claim of requiring to read before writing --- src/llm-coding-tools-core/src/context/write_absolute.txt | 1 - src/llm-coding-tools-core/src/context/write_allowed.txt | 1 - 2 files changed, 2 deletions(-) diff --git a/src/llm-coding-tools-core/src/context/write_absolute.txt b/src/llm-coding-tools-core/src/context/write_absolute.txt index 063cbb1f..d974350d 100644 --- a/src/llm-coding-tools-core/src/context/write_absolute.txt +++ b/src/llm-coding-tools-core/src/context/write_absolute.txt @@ -2,7 +2,6 @@ Writes a file to the local filesystem. Usage: - This tool will overwrite the existing file if there is one at the provided path. -- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. - Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. diff --git a/src/llm-coding-tools-core/src/context/write_allowed.txt b/src/llm-coding-tools-core/src/context/write_allowed.txt index cddcec8c..f699dbe7 100644 --- a/src/llm-coding-tools-core/src/context/write_allowed.txt +++ b/src/llm-coding-tools-core/src/context/write_allowed.txt @@ -4,7 +4,6 @@ Usage: - This tool will overwrite the existing file if there is one at the provided path. - Paths can be relative to configured allowed directories, or absolute paths within allowed directories - Paths outside allowed directories will be rejected -- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. - Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. From 23302cb3dda425932dead4084aea99271add0fc5 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 07:46:52 +0000 Subject: [PATCH 36/64] Fix: Use ToolError::Validation for limit validation in grep tools InvalidPattern was semantically incorrect for limit == 0 validation since it has nothing to do with regex/glob patterns. --- src/llm-coding-tools-rig/src/absolute/grep.rs | 2 +- src/llm-coding-tools-rig/src/allowed/grep.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/llm-coding-tools-rig/src/absolute/grep.rs b/src/llm-coding-tools-rig/src/absolute/grep.rs index 30d80627..7fdc1cbd 100644 --- a/src/llm-coding-tools-rig/src/absolute/grep.rs +++ b/src/llm-coding-tools-rig/src/absolute/grep.rs @@ -77,7 +77,7 @@ impl Tool for GrepTool { let limit = args.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); if limit == 0 { - return Err(ToolError::InvalidPattern( + return Err(ToolError::Validation( "limit must be greater than zero".into(), )); } diff --git a/src/llm-coding-tools-rig/src/allowed/grep.rs b/src/llm-coding-tools-rig/src/allowed/grep.rs index 3d773a78..d2055a8e 100644 --- a/src/llm-coding-tools-rig/src/allowed/grep.rs +++ b/src/llm-coding-tools-rig/src/allowed/grep.rs @@ -85,7 +85,7 @@ impl Tool for GrepTool { let limit = args.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); if limit == 0 { - return Err(ToolError::InvalidPattern( + return Err(ToolError::Validation( "limit must be greater than zero".into(), )); } From 3c079b29884b57c458503b85447d10423fd14b02 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 07:51:48 +0000 Subject: [PATCH 37/64] Fix: Stream response body to prevent memory exhaustion in webfetch Replace buffered response.bytes() with incremental streaming to enforce MAX_RESPONSE_SIZE during download rather than after full buffering. Preallocates Vec when Content-Length header is available. --- .../src/operations/webfetch/async_impl.rs | 40 ++++++++++++----- .../src/operations/webfetch/blocking_impl.rs | 43 ++++++++++++++----- 2 files changed, 62 insertions(+), 21 deletions(-) diff --git a/src/llm-coding-tools-core/src/operations/webfetch/async_impl.rs b/src/llm-coding-tools-core/src/operations/webfetch/async_impl.rs index 4d20f20e..b2827682 100644 --- a/src/llm-coding-tools-core/src/operations/webfetch/async_impl.rs +++ b/src/llm-coding-tools-core/src/operations/webfetch/async_impl.rs @@ -1,6 +1,6 @@ //! Async web content fetching. -use super::{categorize_reqwest_error, check_size, process_content, WebFetchOutput}; +use super::{categorize_reqwest_error, process_content, WebFetchOutput, MAX_RESPONSE_SIZE}; use crate::error::{ToolError, ToolResult}; use std::time::Duration; @@ -14,7 +14,7 @@ pub async fn fetch_url( url: &str, timeout: Duration, ) -> ToolResult { - let response = client + let mut response = client .get(url) .timeout(timeout) .send() @@ -33,19 +33,37 @@ pub async fn fetch_url( .unwrap_or("text/plain") .to_string(); - // Check Content-Length if available - if let Some(len) = response.content_length() { - check_size(len as usize, url)?; + // Check Content-Length header if available for early rejection and preallocation + let content_length = response.content_length().map(|len| len as usize); + if let Some(len) = content_length { + if len > MAX_RESPONSE_SIZE { + return Err(ToolError::Http(format!( + "Response too large: {} bytes (max {}) for {}", + len, MAX_RESPONSE_SIZE, url + ))); + } } - let bytes = response - .bytes() - .await - .map_err(|e| ToolError::Http(e.to_string()))?; + // Stream response body with incremental size checks to avoid memory exhaustion + let mut bytes = content_length.map_or_else(Vec::new, Vec::with_capacity); + let mut total_len: usize = 0; - check_size(bytes.len(), url)?; + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| ToolError::Http(e.to_string()))? + { + total_len += chunk.len(); + if total_len > MAX_RESPONSE_SIZE { + return Err(ToolError::Http(format!( + "Response too large: {} bytes (max {}) for {}", + total_len, MAX_RESPONSE_SIZE, url + ))); + } + bytes.extend_from_slice(&chunk); + } - let byte_length = bytes.len(); + let byte_length = total_len; let raw_content = String::from_utf8_lossy(&bytes); let content = process_content(&raw_content, &content_type); diff --git a/src/llm-coding-tools-core/src/operations/webfetch/blocking_impl.rs b/src/llm-coding-tools-core/src/operations/webfetch/blocking_impl.rs index 5dfbdcca..6dd053c8 100644 --- a/src/llm-coding-tools-core/src/operations/webfetch/blocking_impl.rs +++ b/src/llm-coding-tools-core/src/operations/webfetch/blocking_impl.rs @@ -1,7 +1,8 @@ //! Blocking web content fetching. -use super::{categorize_reqwest_error, check_size, process_content, WebFetchOutput}; +use super::{categorize_reqwest_error, process_content, WebFetchOutput, MAX_RESPONSE_SIZE}; use crate::error::{ToolError, ToolResult}; +use std::io::Read; use std::time::Duration; /// Fetches content from a URL and returns processed content. @@ -14,7 +15,7 @@ pub fn fetch_url( url: &str, timeout: Duration, ) -> ToolResult { - let response = client + let mut response = client .get(url) .timeout(timeout) .send() @@ -32,18 +33,40 @@ pub fn fetch_url( .unwrap_or("text/plain") .to_string(); - // Check Content-Length if available - if let Some(len) = response.content_length() { - check_size(len as usize, url)?; + // Check Content-Length header if available for early rejection and preallocation + let content_length = response.content_length().map(|len| len as usize); + if let Some(len) = content_length { + if len > MAX_RESPONSE_SIZE { + return Err(ToolError::Http(format!( + "Response too large: {} bytes (max {}) for {}", + len, MAX_RESPONSE_SIZE, url + ))); + } } - let bytes = response - .bytes() - .map_err(|e| ToolError::Http(e.to_string()))?; + // Stream response body with incremental size checks to avoid memory exhaustion + let mut bytes = content_length.map_or_else(Vec::new, Vec::with_capacity); + let mut total_len: usize = 0; + let mut buffer = [0u8; 8192]; - check_size(bytes.len(), url)?; + loop { + let n = response + .read(&mut buffer) + .map_err(|e| ToolError::Http(e.to_string()))?; + if n == 0 { + break; + } + total_len += n; + if total_len > MAX_RESPONSE_SIZE { + return Err(ToolError::Http(format!( + "Response too large: {} bytes (max {}) for {}", + total_len, MAX_RESPONSE_SIZE, url + ))); + } + bytes.extend_from_slice(&buffer[..n]); + } - let byte_length = bytes.len(); + let byte_length = total_len; let raw_content = String::from_utf8_lossy(&bytes); let content = process_content(&raw_content, &content_type); From 2264c62d1d73db1bf3f26bcdfb3f54b119044a48 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 08:03:51 +0000 Subject: [PATCH 38/64] Expand AGENTS.md with detailed performance guidelines --- src/AGENTS.md | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/src/AGENTS.md b/src/AGENTS.md index 7883ad05..5c6bca28 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -23,12 +23,42 @@ The `async` and `blocking` features are mutually exclusive - enabling both cause - `src/allowed/` - Sandboxed file system tools - `src/bash.rs`, `src/task.rs`, etc. - Standalone tools -# Code Guidelines +# Code & Performance Guidelines -- Optimize for performance; use zero-cost abstractions, avoid allocations. -- Keep modules under 500 lines (excluding tests); split if larger. -- Place `use` inside functions only for `#[cfg]` conditional compilation. -- Prefer `core` over `std` where possible (`core::mem` over `std::mem`). +This is a high-performance library. Optimize aggressively. + +## Memory & Allocation + +- Preallocate collections when size is known or estimable: + - `String::with_capacity(estimated_len)` + - `Vec::with_capacity(count)` + - `BufReader::with_capacity(size, reader)` +- Use power-of-two sizes for allocator efficiency: `.next_power_of_two()` +- Prefer `&str` / `&[T]` returns over owned types when lifetime allows +- Use `Cow<'_, str>` for conditional ownership (e.g., `String::from_utf8_lossy`) +- Use `&'static str` for compile-time constant strings +- Reuse buffers: `.clear()` and reuse `Vec`/`String` instead of reallocating + +## Zero-Cost Abstractions + +- Use const generics for compile-time branching (e.g., ``) +- Use `#[inline]` on small, hot-path functions +- Prefer `core` over `std` where possible (`core::mem` over `std::mem`) + +## I/O Efficiency + +- Stream data instead of loading entire files when possible +- Use `memchr` for fast byte searching over manual iteration + +## Dependencies + +- Prefer performance-oriented crates: `parking_lot` over `std::sync`, `memchr` for byte search +- Keep dependency footprint minimal + +## General + +- Keep modules under 500 lines (excluding tests); split if larger +- Place `use` inside functions only for `#[cfg]` conditional compilation # Documentation Standards From 22501de25026f8642e2eec23e39280eac54018df Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 08:10:40 +0000 Subject: [PATCH 39/64] Fix: Normalize Windows path separators in glob output Glob patterns use forward slashes, but Windows paths use backslashes. Normalize rel_path to forward slashes on Windows for correct matching and consistent output across platforms. --- .../src/operations/glob.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/llm-coding-tools-core/src/operations/glob.rs b/src/llm-coding-tools-core/src/operations/glob.rs index 2d2573cc..6384b409 100644 --- a/src/llm-coding-tools-core/src/operations/glob.rs +++ b/src/llm-coding-tools-core/src/operations/glob.rs @@ -67,6 +67,10 @@ pub fn glob_files( Err(_) => continue, }; + // Normalize Windows backslashes to forward slashes for glob pattern matching + #[cfg(windows)] + let rel_path = rel_path.replace('\\', "/"); + if rel_path.is_empty() { continue; } @@ -134,4 +138,22 @@ mod tests { let result = glob_files(&resolver, "**/*", dir.path().to_str().unwrap()).unwrap(); assert!(!result.files.iter().any(|f| f.contains("target"))); } + + #[test] + fn glob_returns_forward_slash_paths() { + // Patterns and returned paths use forward slashes on all platforms + let dir = create_test_tree(); + let resolver = AbsolutePathResolver; + let result = glob_files(&resolver, "**/*.rs", dir.path().to_str().unwrap()).unwrap(); + + // Verify matching works with forward-slash patterns + assert_eq!(result.files.len(), 1); + assert!(result.files[0].ends_with("lib.rs")); + + // Verify returned paths use forward slashes (critical for Windows) + for path in &result.files { + assert!(!path.contains('\\'), "expected forward slashes: {path}"); + } + assert!(result.files.iter().any(|f| f.contains('/'))); + } } From 3e7e8f99393967f6bef292d820b4d051b02c5cf7 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 08:12:20 +0000 Subject: [PATCH 40/64] Fix: Remove unreachable symlink check in grep operation --- src/llm-coding-tools-core/src/operations/grep.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/llm-coding-tools-core/src/operations/grep.rs b/src/llm-coding-tools-core/src/operations/grep.rs index 8fc50363..346a09e1 100644 --- a/src/llm-coding-tools-core/src/operations/grep.rs +++ b/src/llm-coding-tools-core/src/operations/grep.rs @@ -81,13 +81,9 @@ pub fn grep_search( Err(_) => continue, }; - let file_type = match entry.file_type() { - Some(ft) if ft.is_file() => ft, + match entry.file_type() { + Some(ft) if ft.is_file() => {} _ => continue, - }; - - if file_type.is_symlink() { - continue; } let entry_path = entry.path(); From fa635b64e0ae7bed518b588bf9dafaae4e26dee3 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 08:12:51 +0000 Subject: [PATCH 41/64] Fixed: absolute.rs line endings --- src/llm-coding-tools-core/src/path/absolute.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/llm-coding-tools-core/src/path/absolute.rs b/src/llm-coding-tools-core/src/path/absolute.rs index ce9a3961..d957598f 100644 --- a/src/llm-coding-tools-core/src/path/absolute.rs +++ b/src/llm-coding-tools-core/src/path/absolute.rs @@ -48,7 +48,7 @@ mod tests { let path = "C:\\Users\\user\\file.txt"; #[cfg(not(windows))] let path = "/home/user/file.txt"; - + let result = resolver.resolve(path); assert!(result.is_ok()); assert_eq!(result.unwrap(), PathBuf::from(path)); From 0526a68f969c02730a805e77e373e3d472852107 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 08:14:42 +0000 Subject: [PATCH 42/64] Refactor: Use check_size helper in webfetch impl modules --- .../src/operations/webfetch/async_impl.rs | 16 +++------------- .../src/operations/webfetch/blocking_impl.rs | 16 +++------------- .../src/operations/webfetch/mod.rs | 1 + 3 files changed, 7 insertions(+), 26 deletions(-) diff --git a/src/llm-coding-tools-core/src/operations/webfetch/async_impl.rs b/src/llm-coding-tools-core/src/operations/webfetch/async_impl.rs index b2827682..e2b3d76f 100644 --- a/src/llm-coding-tools-core/src/operations/webfetch/async_impl.rs +++ b/src/llm-coding-tools-core/src/operations/webfetch/async_impl.rs @@ -1,6 +1,6 @@ //! Async web content fetching. -use super::{categorize_reqwest_error, process_content, WebFetchOutput, MAX_RESPONSE_SIZE}; +use super::{categorize_reqwest_error, check_size, process_content, WebFetchOutput}; use crate::error::{ToolError, ToolResult}; use std::time::Duration; @@ -36,12 +36,7 @@ pub async fn fetch_url( // Check Content-Length header if available for early rejection and preallocation let content_length = response.content_length().map(|len| len as usize); if let Some(len) = content_length { - if len > MAX_RESPONSE_SIZE { - return Err(ToolError::Http(format!( - "Response too large: {} bytes (max {}) for {}", - len, MAX_RESPONSE_SIZE, url - ))); - } + check_size(len, url)?; } // Stream response body with incremental size checks to avoid memory exhaustion @@ -54,12 +49,7 @@ pub async fn fetch_url( .map_err(|e| ToolError::Http(e.to_string()))? { total_len += chunk.len(); - if total_len > MAX_RESPONSE_SIZE { - return Err(ToolError::Http(format!( - "Response too large: {} bytes (max {}) for {}", - total_len, MAX_RESPONSE_SIZE, url - ))); - } + check_size(total_len, url)?; bytes.extend_from_slice(&chunk); } diff --git a/src/llm-coding-tools-core/src/operations/webfetch/blocking_impl.rs b/src/llm-coding-tools-core/src/operations/webfetch/blocking_impl.rs index 6dd053c8..aa5fdd66 100644 --- a/src/llm-coding-tools-core/src/operations/webfetch/blocking_impl.rs +++ b/src/llm-coding-tools-core/src/operations/webfetch/blocking_impl.rs @@ -1,6 +1,6 @@ //! Blocking web content fetching. -use super::{categorize_reqwest_error, process_content, WebFetchOutput, MAX_RESPONSE_SIZE}; +use super::{categorize_reqwest_error, check_size, process_content, WebFetchOutput}; use crate::error::{ToolError, ToolResult}; use std::io::Read; use std::time::Duration; @@ -36,12 +36,7 @@ pub fn fetch_url( // Check Content-Length header if available for early rejection and preallocation let content_length = response.content_length().map(|len| len as usize); if let Some(len) = content_length { - if len > MAX_RESPONSE_SIZE { - return Err(ToolError::Http(format!( - "Response too large: {} bytes (max {}) for {}", - len, MAX_RESPONSE_SIZE, url - ))); - } + check_size(len, url)?; } // Stream response body with incremental size checks to avoid memory exhaustion @@ -57,12 +52,7 @@ pub fn fetch_url( break; } total_len += n; - if total_len > MAX_RESPONSE_SIZE { - return Err(ToolError::Http(format!( - "Response too large: {} bytes (max {}) for {}", - total_len, MAX_RESPONSE_SIZE, url - ))); - } + check_size(total_len, url)?; bytes.extend_from_slice(&buffer[..n]); } diff --git a/src/llm-coding-tools-core/src/operations/webfetch/mod.rs b/src/llm-coding-tools-core/src/operations/webfetch/mod.rs index 0846d516..ccbd8873 100644 --- a/src/llm-coding-tools-core/src/operations/webfetch/mod.rs +++ b/src/llm-coding-tools-core/src/operations/webfetch/mod.rs @@ -42,6 +42,7 @@ pub(crate) fn categorize_reqwest_error(e: reqwest::Error, url: &str) -> ToolErro } /// Returns an error if the response size exceeds the maximum. +#[inline] pub(crate) fn check_size(len: usize, url: &str) -> ToolResult<()> { if len > MAX_RESPONSE_SIZE { return Err(ToolError::Http(format!( From 27f1d8f4c57e6b954019e46d0d5ff471faf89b7d Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Mon, 5 Jan 2026 08:19:12 +0000 Subject: [PATCH 43/64] Fix: Use floor_char_boundary to prevent UTF-8 panic in grep truncation Slicing by byte index at MAX_LINE_LENGTH (2000) could panic if it landed in the middle of a multibyte UTF-8 character. Using floor_char_boundary ensures truncation occurs at a valid char boundary. --- src/llm-coding-tools-rig/src/absolute/grep.rs | 35 ++++++++++++++++++- src/llm-coding-tools-rig/src/allowed/grep.rs | 35 ++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/llm-coding-tools-rig/src/absolute/grep.rs b/src/llm-coding-tools-rig/src/absolute/grep.rs index 7fdc1cbd..b273164a 100644 --- a/src/llm-coding-tools-rig/src/absolute/grep.rs +++ b/src/llm-coding-tools-rig/src/absolute/grep.rs @@ -105,8 +105,9 @@ impl Tool for GrepTool { for file in &result.files { let _ = writeln!(&mut output, "\n{}:", file.path); for m in &file.matches { + // Use floor_char_boundary to avoid panicking on UTF-8 multibyte boundaries let truncated_text = if m.line_text.len() > MAX_LINE_LENGTH { - &m.line_text[..MAX_LINE_LENGTH] + &m.line_text[..m.line_text.floor_char_boundary(MAX_LINE_LENGTH)] } else { &m.line_text }; @@ -188,4 +189,36 @@ mod tests { .await; assert!(matches!(result, Err(ToolError::InvalidPattern(_)))); } + + #[tokio::test] + async fn truncates_long_lines_at_utf8_boundary() { + let dir = TempDir::new().unwrap(); + + // Create a line that's > MAX_LINE_LENGTH (2000) bytes with multibyte chars at the boundary. + // Use 1998 ASCII chars + "日本語" (9 bytes for 3 chars) = 2007 bytes total. + // Truncating at byte 2000 would land inside the multibyte sequence without floor_char_boundary. + let long_line = format!("match_me {}{}", "a".repeat(1989), "日本語"); + assert!( + long_line.len() > 2000, + "test setup: line must exceed MAX_LINE_LENGTH" + ); + + std::fs::write(dir.path().join("utf8_test.txt"), &long_line).unwrap(); + + let tool: GrepTool = GrepTool::new(); + let result = tool + .call(GrepArgs { + pattern: "match_me".to_string(), + path: dir.path().to_string_lossy().to_string(), + include: None, + limit: None, + }) + .await + .unwrap(); + + // Should not panic and output should be valid UTF-8 + assert!(result.content.contains("Found 1 matches")); + assert!(result.content.contains("L1:")); + // The output should be valid UTF-8 (this is implicitly tested by using .contains on a String) + } } diff --git a/src/llm-coding-tools-rig/src/allowed/grep.rs b/src/llm-coding-tools-rig/src/allowed/grep.rs index d2055a8e..0952b67b 100644 --- a/src/llm-coding-tools-rig/src/allowed/grep.rs +++ b/src/llm-coding-tools-rig/src/allowed/grep.rs @@ -112,8 +112,9 @@ impl Tool for GrepTool { for file in &result.files { let _ = writeln!(&mut output, "\n{}:", file.path); for m in &file.matches { + // Use floor_char_boundary to avoid panicking on UTF-8 multibyte boundaries let truncated_text = if m.line_text.len() > MAX_LINE_LENGTH { - &m.line_text[..MAX_LINE_LENGTH] + &m.line_text[..m.line_text.floor_char_boundary(MAX_LINE_LENGTH)] } else { &m.line_text }; @@ -198,4 +199,36 @@ mod tests { .await; assert!(matches!(result, Err(ToolError::InvalidPattern(_)))); } + + #[tokio::test] + async fn truncates_long_lines_at_utf8_boundary() { + let dir = TempDir::new().unwrap(); + + // Create a line that's > MAX_LINE_LENGTH (2000) bytes with multibyte chars at the boundary. + // Use 1998 ASCII chars + "日本語" (9 bytes for 3 chars) = 2007 bytes total. + // Truncating at byte 2000 would land inside the multibyte sequence without floor_char_boundary. + let long_line = format!("match_me {}{}", "a".repeat(1989), "日本語"); + assert!( + long_line.len() > 2000, + "test setup: line must exceed MAX_LINE_LENGTH" + ); + + std::fs::write(dir.path().join("utf8_test.txt"), &long_line).unwrap(); + + let tool: GrepTool = GrepTool::new([dir.path()]).unwrap(); + let result = tool + .call(GrepArgs { + pattern: "match_me".to_string(), + path: ".".to_string(), + include: None, + limit: None, + }) + .await + .unwrap(); + + // Should not panic and output should be valid UTF-8 + assert!(result.content.contains("Found 1 matches")); + assert!(result.content.contains("L1:")); + // The output should be valid UTF-8 (this is implicitly tested by using .contains on a String) + } } From a02aa536da80f8ebd582bef2a6b6dee821df10f5 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Thu, 8 Jan 2026 06:02:41 +0000 Subject: [PATCH 44/64] Fixed: Description intro/blurb in AGENTS.MD --- src/AGENTS.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/AGENTS.md b/src/AGENTS.md index 5c6bca28..55d98fba 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -1,6 +1,4 @@ -# llm-coding-tools - -Basic coding tools for rig based LLM agents +Basic coding oriented tools for LLM agents # Feature Flags (llm-coding-tools-core) From 976eb7a5e022759e2d5b73cd1e0ddb42ea594ec5 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Thu, 8 Jan 2026 11:05:44 +0000 Subject: [PATCH 45/64] Fixed: markdown nested list indentation in AGENTS.md Changed three-space indents to two-space indents for nested list items under 'llm-coding-tools-rig/' to ensure proper markdown rendering. --- src/AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AGENTS.md b/src/AGENTS.md index 55d98fba..204d5671 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -17,9 +17,9 @@ The `async` and `blocking` features are mutually exclusive - enabling both cause - `src/output.rs` - Tool output formatting - `src/util.rs` - Shared utilities - `llm-coding-tools-rig/` - Rig framework Tool implementations - - `src/absolute/` - Unrestricted file system tools - - `src/allowed/` - Sandboxed file system tools - - `src/bash.rs`, `src/task.rs`, etc. - Standalone tools + - `src/absolute/` - Unrestricted file system tools + - `src/allowed/` - Sandboxed file system tools + - `src/bash.rs`, `src/task.rs`, etc. - Standalone tools # Code & Performance Guidelines From 41f71fbde6e5e9111729eff4c05be419b01d6f61 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Thu, 8 Jan 2026 11:14:25 +0000 Subject: [PATCH 46/64] Changed: upgrade reqwest from 0.12 to 0.13.1 --- src/Cargo.lock | 337 ++++++++++++++++++++++++++- src/llm-coding-tools-core/Cargo.toml | 2 +- src/llm-coding-tools-rig/Cargo.toml | 2 +- 3 files changed, 328 insertions(+), 13 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index c20a181f..1093efef 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -87,6 +87,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base64" version = "0.22.1" @@ -129,9 +151,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -144,6 +174,25 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -154,6 +203,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -214,6 +273,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -298,6 +363,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futf" version = "0.1.5" @@ -547,7 +618,7 @@ dependencies = [ "regex", "serde", "serde_json", - "thiserror", + "thiserror 2.0.17", ] [[package]] @@ -821,6 +892,38 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.83" @@ -869,12 +972,12 @@ dependencies = [ "memchr", "parking_lot", "regex", - "reqwest", + "reqwest 0.13.1", "schemars", "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.17", "tokio", "wiremock", ] @@ -884,7 +987,7 @@ name = "llm-coding-tools-rig" version = "0.1.0" dependencies = [ "llm-coding-tools-core", - "reqwest", + "reqwest 0.13.1", "rig-core", "schemars", "serde", @@ -1052,6 +1155,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "openssl-probe" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" + [[package]] name = "ordered-float" version = "5.1.0" @@ -1208,7 +1317,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.17", "tokio", "tracing", "web-time", @@ -1220,6 +1329,7 @@ version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -1229,7 +1339,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -1360,7 +1470,6 @@ dependencies = [ "base64", "bytes", "encoding_rs", - "futures-channel", "futures-core", "futures-util", "h2", @@ -1397,6 +1506,44 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "rig-core" version = "0.27.0" @@ -1417,11 +1564,11 @@ dependencies = [ "mime_guess", "ordered-float", "pin-project-lite", - "reqwest", + "reqwest 0.12.28", "schemars", "serde", "serde_json", - "thiserror", + "thiserror 2.0.17", "tokio", "tracing", "tracing-futures", @@ -1467,6 +1614,7 @@ version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -1475,6 +1623,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.13.2" @@ -1485,12 +1645,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1517,6 +1705,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "1.2.0" @@ -1548,6 +1745,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.228" @@ -1733,7 +1953,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -1771,13 +1991,33 @@ dependencies = [ "utf-8", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2145,6 +2385,15 @@ dependencies = [ "string_cache_codegen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.5" @@ -2198,6 +2447,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2225,6 +2483,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -2258,6 +2531,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2270,6 +2549,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2282,6 +2567,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2306,6 +2597,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2318,6 +2615,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2330,6 +2633,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2342,6 +2651,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/src/llm-coding-tools-core/Cargo.toml b/src/llm-coding-tools-core/Cargo.toml index 769101cb..83f3914c 100644 --- a/src/llm-coding-tools-core/Cargo.toml +++ b/src/llm-coding-tools-core/Cargo.toml @@ -41,7 +41,7 @@ regex = "1.11" # ToolError includes regex::Error conversion # Webfetch tool converts HTML to markdown for LLM-friendly output html-to-markdown-rs = "2.16" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"], optional = true } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "rustls-native-certs"], optional = true } # Unifies async/sync code via procedural macros maybe-async = "0.2" diff --git a/src/llm-coding-tools-rig/Cargo.toml b/src/llm-coding-tools-rig/Cargo.toml index 70bcfbe6..cb2427c9 100644 --- a/src/llm-coding-tools-rig/Cargo.toml +++ b/src/llm-coding-tools-rig/Cargo.toml @@ -16,7 +16,7 @@ llm-coding-tools-core = { version = "0.1.0", path = "../llm-coding-tools-core", rig-core = { version = "0.27", default-features = false, features = ["reqwest-rustls"] } # WebFetchTool needs its own client instance -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "rustls-native-certs"] } # Tool::definition() returns JSON Schema for LLM parameter validation schemars = "1.0" From c58d28d001fee8196477d6ca21f8ae53806898c5 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Thu, 8 Jan 2026 11:17:39 +0000 Subject: [PATCH 47/64] Changed: upgrade dependencies to latest versions --- src/Cargo.lock | 56 ++++++++++++++-------------- src/llm-coding-tools-core/Cargo.toml | 12 +++--- src/llm-coding-tools-rig/Cargo.toml | 8 ++-- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index 1093efef..a6134173 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -559,9 +559,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -604,9 +604,9 @@ dependencies = [ [[package]] name = "html-to-markdown-rs" -version = "2.19.7" +version = "2.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "782ff8afa04f9390777430ee8d9acfd2452c707322ad264433594e9db8b33803" +checksum = "b44ff13ff909885d418b0c63d9a485382cdc1b3a3e016a100f8e79e5df934d21" dependencies = [ "astral-tl", "base64", @@ -862,9 +862,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown", @@ -1296,9 +1296,9 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "proc-macro2" -version = "1.0.104" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" dependencies = [ "unicode-ident", ] @@ -1361,9 +1361,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -1546,9 +1546,9 @@ dependencies = [ [[package]] name = "rig-core" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3799afd8ba38d90d9886be5bf596b0159043f88598b40e1f5aa08aad488f2223" +checksum = "5b1a48121c1ecd6f6ce59d64ec353c791aac6fc07bf4aa353380e8185659e6eb" dependencies = [ "as-any", "async-stream", @@ -1610,9 +1610,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "aws-lc-rs", "once_cell", @@ -1811,9 +1811,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.148" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", @@ -1917,9 +1917,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.113" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -2202,9 +2202,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "unicase" -version = "2.8.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" @@ -2220,9 +2220,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -2739,18 +2739,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", @@ -2819,6 +2819,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.10" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30e0d8dffbae3d840f64bda38e28391faef673a7b5a6017840f2a106c8145868" +checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" diff --git a/src/llm-coding-tools-core/Cargo.toml b/src/llm-coding-tools-core/Cargo.toml index 83f3914c..cce803b3 100644 --- a/src/llm-coding-tools-core/Cargo.toml +++ b/src/llm-coding-tools-core/Cargo.toml @@ -26,7 +26,7 @@ serde_json = "1.0" thiserror = "2.0" # Todo types derive JsonSchema for LLM tool parameter validation -schemars = "1.0" +schemars = "1.2" # Sync RwLock for TodoState (no tokio dependency) parking_lot = "0.12" @@ -37,10 +37,10 @@ grep-regex = "0.1" # Regex matcher for grep_search grep-searcher = "0.1" # File content searching for grep_search ignore = "0.4" # Respects .gitignore when walking directories memchr = "2.7" # Fast newline detection in read_file -regex = "1.11" # ToolError includes regex::Error conversion +regex = "1.12" # ToolError includes regex::Error conversion # Webfetch tool converts HTML to markdown for LLM-friendly output -html-to-markdown-rs = "2.16" +html-to-markdown-rs = "2.20" reqwest = { version = "0.13", default-features = false, features = ["rustls", "rustls-native-certs"], optional = true } # Unifies async/sync code via procedural macros @@ -50,10 +50,10 @@ maybe-async = "0.2" async-trait = { version = "0.1", optional = true } # Async file I/O, process execution, and timeouts -tokio = { version = "1.0", features = ["fs", "io-util", "process", "time"], optional = true } +tokio = { version = "1.49", features = ["fs", "io-util", "process", "time"], optional = true } [dev-dependencies] -tempfile = "3.10" +tempfile = "3.24" # For async tests (when async feature enabled) -tokio = { version = "1.0", features = ["rt", "macros"] } +tokio = { version = "1.49", features = ["rt", "macros"] } wiremock = "0.6" diff --git a/src/llm-coding-tools-rig/Cargo.toml b/src/llm-coding-tools-rig/Cargo.toml index cb2427c9..591ed339 100644 --- a/src/llm-coding-tools-rig/Cargo.toml +++ b/src/llm-coding-tools-rig/Cargo.toml @@ -13,16 +13,16 @@ readme = "README.md" llm-coding-tools-core = { version = "0.1.0", path = "../llm-coding-tools-core", features = ["tokio"] } # Implements rig_core::tool::Tool trait for each tool -rig-core = { version = "0.27", default-features = false, features = ["reqwest-rustls"] } +rig-core = { version = "0.28", default-features = false, features = ["reqwest-rustls"] } # WebFetchTool needs its own client instance reqwest = { version = "0.13", default-features = false, features = ["rustls", "rustls-native-certs"] } # Tool::definition() returns JSON Schema for LLM parameter validation -schemars = "1.0" +schemars = "1.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" [dev-dependencies] -tempfile = "3.10" -tokio = { version = "1.0", features = ["rt-multi-thread", "macros"] } +tempfile = "3.24" +tokio = { version = "1.49", features = ["rt-multi-thread", "macros"] } From 8c0975d47ead7a8d180b34f1acc3000197e7015e Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Thu, 8 Jan 2026 11:18:42 +0000 Subject: [PATCH 48/64] Fixed: markdown nested list indentation in llm-coding-tools-core README.md --- src/llm-coding-tools-core/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/llm-coding-tools-core/README.md b/src/llm-coding-tools-core/README.md index 4b8a6e8c..85d76062 100644 --- a/src/llm-coding-tools-core/README.md +++ b/src/llm-coding-tools-core/README.md @@ -60,6 +60,6 @@ Available context strings: ## Design Principles - No framework-specific dependencies, plug and play into any LLM framework/library - - See [llm-coding-tools-rig](https://crates.io/crates/llm-coding-tools-rig) for an integration example with [rig](https://crates.io/crates/rig) + - See [llm-coding-tools-rig](https://crates.io/crates/llm-coding-tools-rig) for an integration example with [rig](https://crates.io/crates/rig) - Minimal dependency footprint - Performance-oriented (optimized) with zero-cost abstractions From 354930eb46fcd1c001a627a1a4383b864ce8a760 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Thu, 8 Jan 2026 11:33:01 +0000 Subject: [PATCH 49/64] Changed: PathResolver trait contract to return absolute path (may or may not be canonical) --- src/llm-coding-tools-core/src/path/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/llm-coding-tools-core/src/path/mod.rs b/src/llm-coding-tools-core/src/path/mod.rs index 89b1ecb8..d9111c4b 100644 --- a/src/llm-coding-tools-core/src/path/mod.rs +++ b/src/llm-coding-tools-core/src/path/mod.rs @@ -20,6 +20,7 @@ use std::path::PathBuf; pub trait PathResolver: Send + Sync { /// Resolves and validates a path string. /// - /// Returns the canonical path if valid, or an error describing the issue. + /// Returns an absolute path (may or may not be canonical) if valid, + /// or an error describing the issue. fn resolve(&self, path: &str) -> ToolResult; } From 7e2314b6cf046badeadc953c23950aa23dc1e09f Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Thu, 8 Jan 2026 11:47:53 +0000 Subject: [PATCH 50/64] Security: Document that bash tool bypasses AllowedPathResolver restrictions Add documentation clarifying that when bash/shell tool is enabled, the path resolver's protections are advisory since arbitrary shell commands can access any file. Recommends disabling bash or using OS-level sandboxing for actual filesystem restrictions. --- src/llm-coding-tools-core/src/path/allowed.rs | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/llm-coding-tools-core/src/path/allowed.rs b/src/llm-coding-tools-core/src/path/allowed.rs index e0ce67ef..8b81898e 100644 --- a/src/llm-coding-tools-core/src/path/allowed.rs +++ b/src/llm-coding-tools-core/src/path/allowed.rs @@ -16,22 +16,19 @@ use std::path::PathBuf; /// 1. Canonicalizing the resolved path to eliminate `..` and symlinks /// 2. Verifying the result starts with an allowed base directory /// -/// # Example +/// ## Bash Tool Bypasses Path Restrictions /// -/// ```no_run -/// use llm_coding_tools_core::path::{PathResolver, AllowedPathResolver}; -/// use std::path::PathBuf; +/// **When the bash/shell tool is enabled, this resolver's protections are effectively +/// advisory.** The bash tool permits arbitrary shell commands, meaning an LLM can +/// directly read, write, or delete any file the process has OS-level permissions for +/// (e.g., `cat /etc/passwd`, `rm -rf /`, `curl ... | sh`). /// -/// let resolver = AllowedPathResolver::new(vec![ -/// PathBuf::from("/home/user/project"), -/// ]).unwrap(); +/// This resolver only restricts the structured file operations (`read`, `write`, `edit`, +/// `glob`, `grep`). If your threat model requires actual filesystem sandboxing, you must +/// either: /// -/// // Relative paths resolved against allowed directories -/// assert!(resolver.resolve("src/main.rs").is_ok()); -/// -/// // Path traversal is blocked -/// assert!(resolver.resolve("../secret.txt").is_err()); -/// ``` +/// - Disable the bash tool entirely, or +/// - Run the process in an OS-level sandbox (containers, seccomp, landlock, etc.) #[derive(Debug, Clone)] pub struct AllowedPathResolver { /// Canonicalized allowed base directories. From b0b9613f86edd1ea778cb4012ad000ff5e5bca29 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Thu, 8 Jan 2026 11:49:03 +0000 Subject: [PATCH 51/64] Fixed: UTF-8 panic in basic example string truncation Replace byte-index slicing with char-based truncation to prevent panics on multi-byte UTF-8 characters in description and preamble output. --- src/llm-coding-tools-rig/examples/basic.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/llm-coding-tools-rig/examples/basic.rs b/src/llm-coding-tools-rig/examples/basic.rs index 6b31e98d..f9654e5d 100644 --- a/src/llm-coding-tools-rig/examples/basic.rs +++ b/src/llm-coding-tools-rig/examples/basic.rs @@ -40,16 +40,14 @@ async fn main() { // === Print tool definitions from ToolSet === println!("=== Tools in ToolSet ==="); for def in toolset.get_tool_definitions().await.unwrap() { - println!( - " - {}: {}", - def.name, - &def.description[..60.min(def.description.len())] - ); + let truncated_desc: String = def.description.chars().take(60).collect(); + println!(" - {}: {}", def.name, truncated_desc); } // === Print generated preamble === println!("\n=== Generated Preamble ({} chars) ===\n", preamble.len()); - println!("{}", &preamble[..1000.min(preamble.len())]); + let truncated_preamble: String = preamble.chars().take(1000).collect(); + println!("{}", truncated_preamble); if preamble.len() > 1000 { println!("\n... ({} more chars)", preamble.len() - 1000); } From 29f6d4c2c8bce60ddf2dcb7301974f687cb63b95 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Thu, 8 Jan 2026 11:50:58 +0000 Subject: [PATCH 52/64] Changed: simplify README examples by using default constructors Remove redundant generic type annotations (ReadTool, GrepTool) since LINE_NUMBERS defaults to true. Add example showing how to opt out of line numbers using the explicit generic (ReadTool::). --- src/llm-coding-tools-rig/README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/llm-coding-tools-rig/README.md b/src/llm-coding-tools-rig/README.md index edbc2776..6d4d6cb6 100644 --- a/src/llm-coding-tools-rig/README.md +++ b/src/llm-coding-tools-rig/README.md @@ -44,11 +44,15 @@ File tools (Read, Write, Edit, Glob, Grep) come in two variants: ```rust use llm_coding_tools_rig::absolute::{ReadTool, WriteTool, EditTool, GlobTool, GrepTool}; -let read: ReadTool = ReadTool::new(); // enables line numbers +let read = ReadTool::new(); // LINE_NUMBERS defaults to true let write = WriteTool::new(); let edit = EditTool::new(); let glob = GlobTool::new(); -let grep: GrepTool = GrepTool::new(); +let grep = GrepTool::new(); + +// Disable line numbers with explicit generic: +let read_raw = ReadTool::::new(); +let grep_raw = GrepTool::::new(); ``` **`allowed::*`** - Sandboxed to configured directories: From 15848c071a9f89ff0e5fca71e87485fae2b108f7 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Thu, 8 Jan 2026 11:56:46 +0000 Subject: [PATCH 53/64] Fixed: enforce absolute path validation for workdir in execute_command The BashArgs.workdir documentation states it must be an absolute path, but this constraint was not enforced. Added is_absolute() check before is_dir() check to provide a clearer error message when a relative path is provided. --- src/llm-coding-tools-core/src/operations/bash/async_impl.rs | 6 ++++++ .../src/operations/bash/blocking_impl.rs | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/llm-coding-tools-core/src/operations/bash/async_impl.rs b/src/llm-coding-tools-core/src/operations/bash/async_impl.rs index 2828c633..283e5107 100644 --- a/src/llm-coding-tools-core/src/operations/bash/async_impl.rs +++ b/src/llm-coding-tools-core/src/operations/bash/async_impl.rs @@ -16,6 +16,12 @@ pub async fn execute_command( timeout: Duration, ) -> ToolResult { if let Some(dir) = workdir { + if !dir.is_absolute() { + return Err(ToolError::InvalidPath(format!( + "working directory must be an absolute path: {}", + dir.display() + ))); + } if !dir.is_dir() { return Err(ToolError::InvalidPath(format!( "working directory does not exist: {}", diff --git a/src/llm-coding-tools-core/src/operations/bash/blocking_impl.rs b/src/llm-coding-tools-core/src/operations/bash/blocking_impl.rs index c5545d93..a1da5ab9 100644 --- a/src/llm-coding-tools-core/src/operations/bash/blocking_impl.rs +++ b/src/llm-coding-tools-core/src/operations/bash/blocking_impl.rs @@ -16,6 +16,12 @@ pub fn execute_command( timeout: Duration, ) -> ToolResult { if let Some(dir) = workdir { + if !dir.is_absolute() { + return Err(ToolError::InvalidPath(format!( + "working directory must be an absolute path: {}", + dir.display() + ))); + } if !dir.is_dir() { return Err(ToolError::InvalidPath(format!( "working directory does not exist: {}", From e25496d48b43589bef5b6dc6d2188ea1a4789800 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Thu, 8 Jan 2026 13:04:14 +0000 Subject: [PATCH 54/64] Added: PreambleBuilder compile-time environment section and placeholder substitution - Introduce const generic ENV parameter to PreambleBuilder for compile-time elimination of environment section - Add working_directory() method (only available when ENV=true) accepting runtime string paths - Implement separate build() methods optimized for each ENV variant via impl specialization - Add Substitute extension trait providing substitute() and substitute_all() for string placeholder replacement - Update bash.txt context to reference Environment section instead of inline {directory} placeholder - Update examples with explicit PreambleBuilder:: type annotations for clarity - Add comprehensive tests for environment section rendering, working directory acceptance, and placeholder substitution - Maintain full backwards compatibility: default ENV=false preserves existing API --- .../src/context/bash.txt | 2 +- src/llm-coding-tools-core/src/lib.rs | 2 +- src/llm-coding-tools-core/src/preamble.rs | 324 ++++++++++++++++-- src/llm-coding-tools-rig/examples/basic.rs | 2 +- .../examples/full_agent.rs | 2 +- .../examples/sandboxed.rs | 2 +- src/llm-coding-tools-rig/src/lib.rs | 6 +- 7 files changed, 310 insertions(+), 30 deletions(-) diff --git a/src/llm-coding-tools-core/src/context/bash.txt b/src/llm-coding-tools-core/src/context/bash.txt index 49f0d3f2..1516915a 100644 --- a/src/llm-coding-tools-core/src/context/bash.txt +++ b/src/llm-coding-tools-core/src/context/bash.txt @@ -1,6 +1,6 @@ Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. -All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. +All commands run in the working directory shown in the Environment section above. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. diff --git a/src/llm-coding-tools-core/src/lib.rs b/src/llm-coding-tools-core/src/lib.rs index dd944e55..1cae7760 100644 --- a/src/llm-coding-tools-core/src/lib.rs +++ b/src/llm-coding-tools-core/src/lib.rs @@ -32,7 +32,7 @@ pub use context::ToolContext; pub use error::{ToolError, ToolResult}; pub use output::ToolOutput; pub use path::{AbsolutePathResolver, AllowedPathResolver, PathResolver}; -pub use preamble::PreambleBuilder; +pub use preamble::{PreambleBuilder, Substitute}; // Re-export operations (always available, sync or async based on runtime feature) pub use operations::{ diff --git a/src/llm-coding-tools-core/src/preamble.rs b/src/llm-coding-tools-core/src/preamble.rs index 8c298c65..254d8082 100644 --- a/src/llm-coding-tools-core/src/preamble.rs +++ b/src/llm-coding-tools-core/src/preamble.rs @@ -13,8 +13,10 @@ struct ContextEntry { /// Builder that tracks tools and generates formatted preambles. /// -/// Use `.track()` to record a tool's context while passing it through -/// to `ToolSet::builder()`. This gives full access to Rig's API. +/// # Generic Parameters +/// +/// - `ENV`: When `true`, includes an environment section with working directory +/// before tool listings. Defaults to `false` for backwards compatibility. /// /// # Example /// @@ -23,23 +25,36 @@ struct ContextEntry { /// use llm_coding_tools_rig::{BashTool, PreambleBuilder}; /// use rig::tool::ToolSet; /// +/// // Without environment section (default) /// let mut pb = PreambleBuilder::new(); /// +/// // With environment section +/// let mut pb = PreambleBuilder::::new() +/// .working_directory(std::env::current_dir().unwrap()); +/// /// let toolset = ToolSet::builder() /// .static_tool(pb.track(ReadTool::::new())) -/// .static_tool(pb.track(GlobTool::new())) /// .static_tool(pb.track(BashTool::new())) /// .build(); /// -/// let preamble = pb.build(); -/// // Pass preamble to agent builder via .preamble(&preamble) +/// let preamble = pb.build() +/// .substitute("agents", agent_list); /// ``` -#[derive(Default)] -pub struct PreambleBuilder { +pub struct PreambleBuilder { entries: Vec, + working_directory: Option, +} + +impl Default for PreambleBuilder { + fn default() -> Self { + Self { + entries: Vec::new(), + working_directory: None, + } + } } -impl PreambleBuilder { +impl PreambleBuilder { /// Creates a new preamble builder. #[inline] pub fn new() -> Self { @@ -62,28 +77,54 @@ impl PreambleBuilder { }); tool } +} - /// Generates the preamble string. +impl PreambleBuilder { + /// Sets the working directory to display in the environment section. + /// + /// Accepts any type that can be converted to String, including: + /// - `&str` + /// - `String` + /// - `PathBuf` or `&Path` (via `.display().to_string()`) /// - /// Call this after tracking all tools, then pass the result - /// to Rig's `.preamble()` method on the agent builder. + /// Only available when environment section is enabled (`PreambleBuilder`). + /// + /// # Example + /// + /// ```ignore + /// let pb = PreambleBuilder::::new() + /// .working_directory("/home/user/project"); + /// + /// // With runtime-computed path + /// let pb = PreambleBuilder::::new() + /// .working_directory(std::env::current_dir().unwrap().display().to_string()); + /// ``` + #[inline] + pub fn working_directory(mut self, path: impl Into) -> Self { + self.working_directory = Some(path.into()); + self + } +} + +impl PreambleBuilder { + /// Generates the preamble string without environment section. pub fn build(self) -> String { if self.entries.is_empty() { return String::new(); } - let mut output = String::with_capacity( - self.entries - .iter() - .map(|e| e.context.len() + e.name.len() + 20) - .sum(), - ); + let tools_size: usize = self + .entries + .iter() + .map(|e| e.context.len() + e.name.len() + 20) + .sum(); + + let mut output = String::with_capacity(tools_size + 30); output.push_str("# Tool Usage Guidelines\n\n"); for entry in self.entries { output.push_str("## "); - // Capitalize first letter let mut chars = entry.name.chars(); if let Some(first) = chars.next() { output.push(first.to_ascii_uppercase()); @@ -99,6 +140,117 @@ impl PreambleBuilder { } } +impl PreambleBuilder { + /// Generates the preamble string with environment section. + pub fn build(self) -> String { + // Environment section size: ~50 bytes header + path length + // "# Environment\n\nWorking directory: \n\n" = ~38 bytes + const ENV_HEADER_SIZE: usize = 50; + + let env_size = self + .working_directory + .as_ref() + .map_or(0, |d| d.len() + ENV_HEADER_SIZE); + + let tools_size: usize = self + .entries + .iter() + .map(|e| e.context.len() + e.name.len() + 20) + .sum(); + + let has_tools = !self.entries.is_empty(); + let has_env = self.working_directory.is_some(); + + // Return empty if nothing to output + if !has_tools && !has_env { + return String::new(); + } + + let total_size = env_size + tools_size + if has_tools { 30 } else { 0 }; + let mut output = String::with_capacity(total_size); + + // Environment section + if let Some(ref dir) = self.working_directory { + output.push_str("# Environment\n\n"); + output.push_str("Working directory: "); + output.push_str(dir); + output.push_str("\n\n"); + } + + // Tool section + if has_tools { + output.push_str("# Tool Usage Guidelines\n\n"); + + for entry in self.entries { + output.push_str("## "); + let mut chars = entry.name.chars(); + if let Some(first) = chars.next() { + output.push(first.to_ascii_uppercase()); + output.push_str(chars.as_str()); + } + output.push_str(" Tool\n\n"); + output.push_str(entry.context); + output.push_str("\n\n"); + } + } + + output.truncate(output.trim_end().len()); + output + } +} + +/// Extension trait for placeholder substitution on preamble strings. +/// +/// Provides simple `{key}` placeholder replacement after building a preamble. +/// Unmatched placeholders are left as-is. +/// +/// # Example +/// +/// ```rust +/// use llm_coding_tools_core::preamble::Substitute; +/// +/// let preamble = "Available agents: {agents}".to_string(); +/// let result = preamble +/// .substitute("agents", "code-review, research") +/// .substitute("missing", "ignored"); +/// +/// assert_eq!(result, "Available agents: code-review, research"); +/// ``` +pub trait Substitute { + /// Replaces `{key}` placeholder with the given value. + /// + /// Returns a new String with the substitution applied. + /// If the placeholder is not found, returns the string unchanged. + fn substitute(self, key: &str, value: &str) -> String; + + /// Replaces multiple `{key}` placeholders with their values. + /// + /// Accepts an iterator of (key, value) pairs. + fn substitute_all<'a>( + self, + substitutions: impl IntoIterator, + ) -> String; +} + +impl Substitute for String { + #[inline] + fn substitute(self, key: &str, value: &str) -> String { + let placeholder = format!("{{{}}}", key); + self.replace(&placeholder, value) + } + + fn substitute_all<'a>( + mut self, + substitutions: impl IntoIterator, + ) -> String { + for (key, value) in substitutions { + let placeholder = format!("{{{}}}", key); + self = self.replace(&placeholder, value); + } + self + } +} + #[cfg(test)] mod tests { use super::*; @@ -116,13 +268,13 @@ mod tests { #[test] fn empty_builder_returns_empty_string() { - let preamble = PreambleBuilder::new().build(); + let preamble = PreambleBuilder::::new().build(); assert!(preamble.is_empty()); } #[test] fn track_returns_tool_unchanged() { - let mut pb = PreambleBuilder::new(); + let mut pb = PreambleBuilder::::new(); let tool = MockTool { id: 42 }; let returned = pb.track(tool); assert_eq!(returned.id, 42); @@ -130,7 +282,7 @@ mod tests { #[test] fn single_tool_formats_correctly() { - let mut pb = PreambleBuilder::new(); + let mut pb = PreambleBuilder::::new(); let _ = pb.track(MockTool { id: 1 }); let preamble = pb.build(); @@ -149,7 +301,7 @@ mod tests { } } - let mut pb = PreambleBuilder::new(); + let mut pb = PreambleBuilder::::new(); let _ = pb.track(MockTool { id: 1 }); let _ = pb.track(OtherTool); let preamble = pb.build(); @@ -161,4 +313,132 @@ mod tests { "Tools should appear in insertion order" ); } + + #[test] + fn builder_without_env_omits_environment_section() { + let mut pb = PreambleBuilder::::new(); + let _ = pb.track(MockTool { id: 1 }); + let preamble = pb.build(); + + assert!(!preamble.contains("# Environment")); + assert!(!preamble.contains("Working directory")); + assert!(preamble.contains("# Tool Usage Guidelines")); + } + + #[test] + fn builder_with_env_includes_environment_section() { + let mut pb = PreambleBuilder::::new().working_directory("/home/user/project"); + let _ = pb.track(MockTool { id: 1 }); + let preamble = pb.build(); + + assert!(preamble.contains("# Environment")); + assert!(preamble.contains("Working directory: /home/user/project")); + // Environment should come before tools + let env_pos = preamble.find("# Environment").unwrap(); + let tools_pos = preamble.find("# Tool Usage Guidelines").unwrap(); + assert!(env_pos < tools_pos); + } + + #[test] + fn builder_with_env_no_working_dir_no_tools_returns_empty() { + let pb = PreambleBuilder::::new(); + let preamble = pb.build(); + assert!(preamble.is_empty()); + } + + #[test] + fn builder_with_env_and_working_dir_but_no_tools() { + // Environment section should render even without tools tracked + let pb = PreambleBuilder::::new().working_directory("/home/user/project"); + let preamble = pb.build(); + + assert!(preamble.contains("# Environment")); + assert!(preamble.contains("Working directory: /home/user/project")); + assert!(!preamble.contains("# Tool Usage Guidelines")); + } + + #[test] + fn working_directory_accepts_runtime_string() { + // Simulates std::env::current_dir().unwrap().display().to_string() + let runtime_path = String::from("/runtime/computed/path"); + let pb = PreambleBuilder::::new().working_directory(runtime_path); + let preamble = pb.build(); + + assert!(preamble.contains("Working directory: /runtime/computed/path")); + } + + #[test] + fn working_directory_accepts_str() { + let pb = PreambleBuilder::::new().working_directory("/static/path"); + let preamble = pb.build(); + + assert!(preamble.contains("Working directory: /static/path")); + } + + #[test] + fn substitute_replaces_single_placeholder() { + use super::Substitute; + + let text = "Hello {name}!".to_string(); + let result = text.substitute("name", "World"); + assert_eq!(result, "Hello World!"); + } + + #[test] + fn substitute_leaves_unmatched_placeholders() { + use super::Substitute; + + let text = "Hello {name}, welcome to {place}!".to_string(); + let result = text.substitute("name", "Alice"); + assert_eq!(result, "Hello Alice, welcome to {place}!"); + } + + #[test] + fn substitute_handles_empty_value() { + use super::Substitute; + + let text = "Prefix{middle}Suffix".to_string(); + let result = text.substitute("middle", ""); + assert_eq!(result, "PrefixSuffix"); + } + + #[test] + fn substitute_all_replaces_multiple() { + use super::Substitute; + + let text = "Hello {name}, welcome to {place}!".to_string(); + let result = text.substitute_all([("name", "Alice"), ("place", "Wonderland")]); + assert_eq!(result, "Hello Alice, welcome to Wonderland!"); + } + + #[test] + fn substitute_no_placeholder_returns_unchanged() { + use super::Substitute; + + let text = "No placeholders here".to_string(); + let result = text.substitute("missing", "value"); + assert_eq!(result, "No placeholders here"); + } + + #[test] + fn generic_flag_is_compile_time() { + // This test verifies the generic works at compile time + // If it compiles, the generic system works + let _pb_no_env: PreambleBuilder = PreambleBuilder::new(); + let _pb_with_env: PreambleBuilder = PreambleBuilder::new(); + + // Type inference defaults to false + let _pb_default: PreambleBuilder = PreambleBuilder::new(); + } + + #[test] + fn backwards_compatibility_existing_api() { + // Existing code should work unchanged + let mut pb = PreambleBuilder::::new(); + let _ = pb.track(MockTool { id: 1 }); + let preamble = pb.build(); + + assert!(preamble.contains("# Tool Usage Guidelines")); + assert!(preamble.contains("## Mock Tool")); + } } diff --git a/src/llm-coding-tools-rig/examples/basic.rs b/src/llm-coding-tools-rig/examples/basic.rs index f9654e5d..592ff633 100644 --- a/src/llm-coding-tools-rig/examples/basic.rs +++ b/src/llm-coding-tools-rig/examples/basic.rs @@ -20,7 +20,7 @@ async fn main() { let todos = TodoTools::new(); // === Create preamble builder to track tools === - let mut pb = PreambleBuilder::new(); + let mut pb = PreambleBuilder::::new(); // === Use ToolSet::builder() directly - full Rig API! === let toolset = ToolSet::builder() diff --git a/src/llm-coding-tools-rig/examples/full_agent.rs b/src/llm-coding-tools-rig/examples/full_agent.rs index 2e3ac85f..dc1a12fc 100644 --- a/src/llm-coding-tools-rig/examples/full_agent.rs +++ b/src/llm-coding-tools-rig/examples/full_agent.rs @@ -23,7 +23,7 @@ async fn main() -> Result<(), Box> { // PreambleBuilder tracks which tools are registered and generates // a combined context string for the system prompt. This gives the // LLM detailed guidance on how to use each tool effectively. - let mut pb = PreambleBuilder::new(); + let mut pb = PreambleBuilder::::new(); // === 3. Build toolset with all tools === // diff --git a/src/llm-coding-tools-rig/examples/sandboxed.rs b/src/llm-coding-tools-rig/examples/sandboxed.rs index 7e70d584..8b980a9c 100644 --- a/src/llm-coding-tools-rig/examples/sandboxed.rs +++ b/src/llm-coding-tools-rig/examples/sandboxed.rs @@ -55,7 +55,7 @@ async fn main() -> Result<(), Box> { let grep: GrepTool = GrepTool::with_resolver(resolver); // === Build toolset === - let mut pb = PreambleBuilder::new(); + let mut pb = PreambleBuilder::::new(); let toolset = ToolSet::builder() .static_tool(pb.track(read)) .static_tool(pb.track(write)) diff --git a/src/llm-coding-tools-rig/src/lib.rs b/src/llm-coding-tools-rig/src/lib.rs index 0913926c..ea017183 100644 --- a/src/llm-coding-tools-rig/src/lib.rs +++ b/src/llm-coding-tools-rig/src/lib.rs @@ -32,8 +32,8 @@ pub use llm_coding_tools_core::{ToolError, ToolOutput, ToolResult}; pub use llm_coding_tools_core::context; pub use llm_coding_tools_core::ToolContext; -// Re-export PreambleBuilder from core -pub use llm_coding_tools_core::PreambleBuilder; +// Re-export PreambleBuilder and Substitute from core +pub use llm_coding_tools_core::{PreambleBuilder, Substitute}; // Re-export path resolvers pub use llm_coding_tools_core::path::{AbsolutePathResolver, AllowedPathResolver, PathResolver}; @@ -71,7 +71,7 @@ mod tests { #[test] fn preamble_builder_with_real_tools() { - let mut pb = PreambleBuilder::new(); + let mut pb = PreambleBuilder::::new(); let read: absolute::ReadTool = pb.track(absolute::ReadTool::new()); let bash = pb.track(BashTool::new()); From bd6209229661f6cf97cbe44148ac4d51bcd06268 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Thu, 8 Jan 2026 13:40:55 +0000 Subject: [PATCH 55/64] Updated: Comprehensive Claude Code-quality guidance for all 14 tool context files - Enhanced all context files with structured sections: Description, Parameters, When to Use, When NOT to Use, Examples, Best Practices - Increased documentation depth from 10-20 lines to 40-130 lines per file - Added critical safety guidance to grep files: 'NEVER invoke grep or rg as a Bash command' - Expanded bash.txt with complete git workflows, PR creation protocols, and safety constraints - Standardized parameter names to snake_case across all context files - Improved usability and consistency for AI agent tool integration --- .../src/context/bash.txt | 80 +++++++++++- .../src/context/edit_absolute.txt | 76 +++++++++++- .../src/context/edit_allowed.txt | 75 +++++++++++- .../src/context/glob_absolute.txt | 64 +++++++++- .../src/context/glob_allowed.txt | 67 +++++++++- .../src/context/grep_absolute.txt | 80 +++++++++++- .../src/context/grep_allowed.txt | 83 ++++++++++++- .../src/context/read_absolute.txt | 49 +++++++- .../src/context/read_allowed.txt | 47 +++++++- .../src/context/task.txt | 75 ++++++------ .../src/context/todowrite.txt | 114 ++++++------------ .../src/context/webfetch.txt | 63 ++++++++-- .../src/context/write_absolute.txt | 45 ++++++- .../src/context/write_allowed.txt | 46 ++++++- 14 files changed, 787 insertions(+), 177 deletions(-) diff --git a/src/llm-coding-tools-core/src/context/bash.txt b/src/llm-coding-tools-core/src/context/bash.txt index 1516915a..4102ccbd 100644 --- a/src/llm-coding-tools-core/src/context/bash.txt +++ b/src/llm-coding-tools-core/src/context/bash.txt @@ -21,15 +21,14 @@ Before executing the command, please follow these steps: - Capture the output of the command. Usage notes: - - The command argument is required. - - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will time out after 120000ms (2 minutes). + - The `command` argument is required. + - You can specify an optional `timeout_ms` in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes). - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds 30000 characters, output will be truncated before being returned to you. - - You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter. - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - File search: Use Glob (NOT find or ls) - - Content search: Use Grep (NOT grep or rg) + - Content search: Use Grep (NOT grep) - Read files: Use Read (NOT cat/head/tail) - Edit files: Use Edit (NOT sed/awk) - Write files: Use Write (NOT echo >/cat < cd /foo/bar && pytest tests + +# Committing changes with git + +Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully: + +Git Safety Protocol: +- NEVER update the git config +- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them +- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it +- NEVER run force push to main/master, warn the user if they request it +- Avoid git commit --amend. ONLY use --amend when ALL conditions are met: + (1) User explicitly requested amend, OR commit SUCCEEDED but pre-commit hook auto-modified files that need including + (2) HEAD commit was created by you in this conversation (verify: git log -1 --format='%an %ae') + (3) Commit has NOT been pushed to remote (verify: git status shows "Your branch is ahead") +- CRITICAL: If commit FAILED or was REJECTED by hook, NEVER amend - fix the issue and create a NEW commit +- CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push) +- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. + +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool: + - Run a git status command to see all untracked files. + - Run a git diff command to see both staged and unstaged changes that will be committed. + - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. +2. Analyze all staged changes (both previously staged and newly added) and draft a commit message: + - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.). + - Do not commit files that likely contain secrets (.env, credentials.json, etc.). Warn the user if they specifically request to commit those files + - Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what" + - Ensure it accurately reflects the changes and their purpose +3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands: + - Add relevant untracked files to the staging area. + - Create the commit with a message + - Run git status after the commit completes to verify success. + Note: git status depends on the commit completing, so run it sequentially after the commit. +4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above) + +Important notes: +- NEVER run additional commands to read or explore code, besides git bash commands +- NEVER use the TodoWrite or Task tools +- DO NOT push to the remote repository unless the user explicitly asks you to do so +- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. +- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit + +# Creating pull requests +Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed. + +IMPORTANT: When the user asks you to create a pull request, follow these steps carefully: + +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch: + - Run a git status command to see all untracked files + - Run a git diff command to see both staged and unstaged changes that will be committed + - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote + - Run a git log command and `git diff [base-branch]...HEAD` to understand the full commit history for the current branch (from the time it diverged from the base branch) +2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request summary +3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel: + - Create new branch if needed + - Push to remote with -u flag if needed + - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting. + +gh pr create --title "the pr title" --body "$(cat <<'EOF' +## Summary +<1-3 bullet points> + +## Test plan +[Bulleted markdown checklist of TODOs for testing the pull request...] +EOF +)" + + +Important: +- DO NOT use the TodoWrite or Task tools +- Return the PR URL when you're done, so the user can see it + +# Other common operations +- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments diff --git a/src/llm-coding-tools-core/src/context/edit_absolute.txt b/src/llm-coding-tools-core/src/context/edit_absolute.txt index 26fae469..9b54d7d2 100644 --- a/src/llm-coding-tools-core/src/context/edit_absolute.txt +++ b/src/llm-coding-tools-core/src/context/edit_absolute.txt @@ -1,10 +1,74 @@ -Performs exact string replacements in files. +Performs exact string replacements in files. Usage: -- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. -- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the oldString or newString. +- You must use your Read tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. +- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is "L{n}: ". Everything after that prefix is the actual file content to match. Never include any part of the line number prefix in `old_string` or `new_string`. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. -- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content". -- The edit will FAIL if `oldString` is found multiple times in the file with an error "oldString found multiple times and requires more code context to uniquely identify the intended match". Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`. -- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. + +## Parameters + +- `file_path`: Absolute path to the file to modify (required) +- `old_string`: Exact text to find and replace (required) +- `new_string`: Replacement text (required) +- `replace_all`: Replace all occurrences when true, default false (optional) + +## Error Behavior + +- The edit will FAIL if `old_string` is not found in the file with an error "oldString not found in content". +- The edit will FAIL if `old_string` is found multiple times in the file with an error "oldString found multiple times and requires more code context to uniquely identify the intended match". Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`. + +## When to Use This Tool + +- Making targeted changes to existing files +- Fixing bugs in specific code sections +- Updating function implementations +- Renaming variables across a file (with `replace_all: true`) +- Adding new code to existing files + +## When NOT to Use This Tool + +- Creating new files - use Write tool instead +- When most of a file needs to change - use Write tool instead +- When you haven't read the file yet - read it first! + +## Examples + +Replacing a single occurrence: +``` +file_path: "/home/user/project/src/main.rs" +old_string: "fn old_name() {" +new_string: "fn new_name() {" +``` + +Renaming a variable everywhere in a file: +``` +file_path: "/home/user/project/src/main.rs" +old_string: "old_var" +new_string: "new_var" +replace_all: true +``` + +Adding code after an existing line: +``` +file_path: "/home/user/project/src/main.rs" +old_string: "use std::io;" +new_string: "use std::io;\nuse std::fs;" +``` + +## Best Practices + +1. Always read the file first using the Read tool +2. Copy the exact text from the Read output, preserving whitespace and indentation +3. Include enough context in `old_string` to make it unique +4. When adding new code, include the line before/after in `old_string` for context +5. Use `replace_all: true` when renaming variables or making consistent changes +6. Don't include line number prefixes (like "L42: ") in your old_string or new_string + +## Common Mistakes to Avoid + +- Forgetting to read the file first +- Including line number prefixes in old_string +- Not including enough context (causes "found multiple times" error) +- Changing indentation unintentionally +- Forgetting that old_string must match EXACTLY (including whitespace) diff --git a/src/llm-coding-tools-core/src/context/edit_allowed.txt b/src/llm-coding-tools-core/src/context/edit_allowed.txt index 90e27f40..615c42a0 100644 --- a/src/llm-coding-tools-core/src/context/edit_allowed.txt +++ b/src/llm-coding-tools-core/src/context/edit_allowed.txt @@ -1,12 +1,77 @@ Performs exact string replacements in files within allowed directories. Usage: -- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. +- You must use your Read tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. - Paths can be relative to configured allowed directories, or absolute paths within allowed directories - Paths outside allowed directories will be rejected -- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the oldString or newString. +- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is "L{n}: ". Everything after that prefix is the actual file content to match. Never include any part of the line number prefix in `old_string` or `new_string`. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. -- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content". -- The edit will FAIL if `oldString` is found multiple times in the file with an error "oldString found multiple times and requires more code context to uniquely identify the intended match". Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`. -- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. + +## Parameters + +- `file_path`: Path to the file to modify - can be relative or absolute within allowed directories (required) +- `old_string`: Exact text to find and replace (required) +- `new_string`: Replacement text (required) +- `replace_all`: Replace all occurrences when true, default false (optional) + +## Error Behavior + +- The edit will FAIL if `old_string` is not found in the file with an error "oldString not found in content". +- The edit will FAIL if `old_string` is found multiple times in the file with an error "oldString found multiple times and requires more code context to uniquely identify the intended match". Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`. + +## When to Use This Tool + +- Making targeted changes to existing files +- Fixing bugs in specific code sections +- Updating function implementations +- Renaming variables across a file (with `replace_all: true`) +- Adding new code to existing files + +## When NOT to Use This Tool + +- Creating new files - use Write tool instead +- When most of a file needs to change - use Write tool instead +- When you haven't read the file yet - read it first! + +## Examples + +Replacing a single occurrence: +``` +file_path: "src/main.rs" +old_string: "fn old_name() {" +new_string: "fn new_name() {" +``` + +Renaming a variable everywhere in a file: +``` +file_path: "src/main.rs" +old_string: "old_var" +new_string: "new_var" +replace_all: true +``` + +Adding code after an existing line: +``` +file_path: "src/main.rs" +old_string: "use std::io;" +new_string: "use std::io;\nuse std::fs;" +``` + +## Best Practices + +1. Always read the file first using the Read tool +2. Copy the exact text from the Read output, preserving whitespace and indentation +3. Include enough context in `old_string` to make it unique +4. When adding new code, include the line before/after in `old_string` for context +5. Use `replace_all: true` when renaming variables or making consistent changes +6. Don't include line number prefixes (like "L42: ") in your old_string or new_string +7. Relative paths are resolved against allowed directories + +## Common Mistakes to Avoid + +- Forgetting to read the file first +- Including line number prefixes in old_string +- Not including enough context (causes "found multiple times" error) +- Changing indentation unintentionally +- Forgetting that old_string must match EXACTLY (including whitespace) diff --git a/src/llm-coding-tools-core/src/context/glob_absolute.txt b/src/llm-coding-tools-core/src/context/glob_absolute.txt index 627da6ca..b8f9f3bc 100644 --- a/src/llm-coding-tools-core/src/context/glob_absolute.txt +++ b/src/llm-coding-tools-core/src/context/glob_absolute.txt @@ -1,6 +1,64 @@ -- Fast file pattern matching tool that works with any codebase size +Fast file pattern matching tool that works with any codebase size. + - Supports glob patterns like "**/*.js" or "src/**/*.ts" -- Returns matching file paths sorted by modification time +- Returns matching file paths sorted by modification time (newest first) +- Respects .gitignore rules - Use this tool when you need to find files by name patterns - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead -- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. + +## Parameters + +- `pattern`: Glob pattern to match files against (required) + - `*` matches any characters except path separators + - `**` matches any characters including path separators (recursive) + - `?` matches a single character + - `[abc]` matches any character in the brackets + - `{a,b}` matches either pattern +- `path`: Absolute directory path to search in (required) + +## When to Use This Tool + +- Finding files by extension: `**/*.rs`, `**/*.tsx` +- Finding files by name pattern: `**/test_*.py`, `**/*_spec.js` +- Locating configuration files: `**/Cargo.toml`, `**/package.json` +- Finding files in specific directories: `src/**/*.rs` + +## When NOT to Use This Tool + +- Searching for content inside files - use Grep instead +- Reading file contents - use Read instead +- Complex multi-step searches - use Task tool instead + +## Examples + +Find all Rust files: +``` +pattern: "**/*.rs" +path: "/home/user/project" +``` + +Find all test files: +``` +pattern: "**/test_*.py" +path: "/home/user/project" +``` + +Find TypeScript and TSX files: +``` +pattern: "**/*.{ts,tsx}" +path: "/home/user/project/src" +``` + +Find Cargo.toml files anywhere: +``` +pattern: "**/Cargo.toml" +path: "/home/user/project" +``` + +## Best Practices + +1. You can call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. +2. Start with broader patterns and narrow down if needed +3. Use `**` for recursive searches across all subdirectories +4. Combine with Read tool to examine found files +5. Results are sorted by modification time - most recently changed files appear first diff --git a/src/llm-coding-tools-core/src/context/glob_allowed.txt b/src/llm-coding-tools-core/src/context/glob_allowed.txt index e05a64eb..5fef3589 100644 --- a/src/llm-coding-tools-core/src/context/glob_allowed.txt +++ b/src/llm-coding-tools-core/src/context/glob_allowed.txt @@ -1,8 +1,67 @@ -- Fast file pattern matching tool that works with any codebase size +Fast file pattern matching tool that works with any codebase size. + - Searches within configured allowed directories only -- Supports glob patterns like "**/*.js" or "src/**/*.ts" - Paths can be relative to allowed directories; paths outside will be rejected -- Returns matching file paths sorted by modification time +- Supports glob patterns like "**/*.js" or "src/**/*.ts" +- Returns matching file paths sorted by modification time (newest first) +- Respects .gitignore rules - Use this tool when you need to find files by name patterns - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead -- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. + +## Parameters + +- `pattern`: Glob pattern to match files against (required) + - `*` matches any characters except path separators + - `**` matches any characters including path separators (recursive) + - `?` matches a single character + - `[abc]` matches any character in the brackets + - `{a,b}` matches either pattern +- `path`: Directory path to search in - can be relative or absolute within allowed directories (required) + +## When to Use This Tool + +- Finding files by extension: `**/*.rs`, `**/*.tsx` +- Finding files by name pattern: `**/test_*.py`, `**/*_spec.js` +- Locating configuration files: `**/Cargo.toml`, `**/package.json` +- Finding files in specific directories: `src/**/*.rs` + +## When NOT to Use This Tool + +- Searching for content inside files - use Grep instead +- Reading file contents - use Read instead +- Complex multi-step searches - use Task tool instead + +## Examples + +Find all Rust files: +``` +pattern: "**/*.rs" +path: "." +``` + +Find all test files: +``` +pattern: "**/test_*.py" +path: "." +``` + +Find TypeScript and TSX files: +``` +pattern: "**/*.{ts,tsx}" +path: "src" +``` + +Find Cargo.toml files anywhere: +``` +pattern: "**/Cargo.toml" +path: "." +``` + +## Best Practices + +1. You can call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. +2. Start with broader patterns and narrow down if needed +3. Use `**` for recursive searches across all subdirectories +4. Combine with Read tool to examine found files +5. Results are sorted by modification time - most recently changed files appear first +6. Paths outside allowed directories will be rejected diff --git a/src/llm-coding-tools-core/src/context/grep_absolute.txt b/src/llm-coding-tools-core/src/context/grep_absolute.txt index adf58369..c9684db8 100644 --- a/src/llm-coding-tools-core/src/context/grep_absolute.txt +++ b/src/llm-coding-tools-core/src/context/grep_absolute.txt @@ -1,8 +1,78 @@ -- Fast content search tool that works with any codebase size +Fast content search tool built on ripgrep. Works with any codebase size. + - Searches file contents using regular expressions -- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.) -- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}") -- Returns file paths and line numbers with at least one match sorted by modification time +- Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+") +- Filter files by pattern with the `include` parameter (e.g., "*.rs", "*.{ts,tsx}") +- Returns file paths, line numbers, and matching content sorted by modification time (newest first) - Use this tool when you need to find files containing specific patterns -- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`. - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead + +IMPORTANT: ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access. + +## Parameters + +- `pattern`: Regex pattern to search for in file contents (required) +- `path`: Absolute directory path to search in (required) +- `include`: Optional file glob filter (e.g., "*.rs", "*.{ts,tsx}") +- `limit`: Maximum number of matches to return (default: 100, max: 2000) + +## Pattern Syntax Notes (ripgrep-based) + +- Literal braces need escaping: use `interface\\{\\}` to find `interface{}` in Go code +- Use `\\b` for word boundaries: `\\bfoo\\b` matches "foo" but not "foobar" +- Use `\\s` for whitespace, `\\w` for word characters +- Use `.*` for any characters: `error.*failed` matches "error: connection failed" +- Use `|` for alternation: `TODO|FIXME` matches either +- Patterns match within single lines only; multiline patterns are not supported + +## When to Use This Tool + +- Finding function definitions: `fn\\s+process_` +- Finding usages of a variable or function: `\\bmy_function\\(` +- Finding TODO comments: `TODO|FIXME|HACK` +- Finding error messages: `error.*failed` +- Finding imports: `^use\\s+` + +## When NOT to Use This Tool + +- Finding files by name - use Glob instead +- Reading entire file contents - use Read instead +- Complex multi-step research - use Task tool instead + +## Examples + +Find all function definitions: +``` +pattern: "fn\\s+\\w+" +path: "/home/user/project" +include: "*.rs" +``` + +Find TODO comments: +``` +pattern: "TODO|FIXME" +path: "/home/user/project" +``` + +Find usage of a specific function: +``` +pattern: "\\bprocess_request\\(" +path: "/home/user/project/src" +``` + +Find error handling patterns: +``` +pattern: "Err\\(|Error::" +path: "/home/user/project" +include: "*.rs" +limit: 50 +``` + +## Best Practices + +1. You can call multiple tools in a single response. It is always better to speculatively perform multiple searches in parallel if they are potentially useful. +2. Use the `include` parameter to narrow searches to relevant file types +3. Use word boundaries (`\\b`) to avoid partial matches +4. Escape special regex characters when searching for literal text +5. Start with broader patterns and refine based on results +6. Use `limit` parameter if you expect many matches but only need a sample diff --git a/src/llm-coding-tools-core/src/context/grep_allowed.txt b/src/llm-coding-tools-core/src/context/grep_allowed.txt index fa3f84a1..9fe4edc4 100644 --- a/src/llm-coding-tools-core/src/context/grep_allowed.txt +++ b/src/llm-coding-tools-core/src/context/grep_allowed.txt @@ -1,10 +1,81 @@ -- Fast content search tool that works with any codebase size +Fast content search tool built on ripgrep. Works with any codebase size. + - Searches within configured allowed directories only -- Searches file contents using regular expressions -- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.) -- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}") - Paths can be relative to allowed directories; paths outside will be rejected -- Returns file paths and line numbers with at least one match sorted by modification time +- Searches file contents using regular expressions +- Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+") +- Filter files by pattern with the `include` parameter (e.g., "*.rs", "*.{ts,tsx}") +- Returns file paths, line numbers, and matching content sorted by modification time (newest first) - Use this tool when you need to find files containing specific patterns -- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`. - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead + +IMPORTANT: ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access. + +## Parameters + +- `pattern`: Regex pattern to search for in file contents (required) +- `path`: Directory path to search in - can be relative or absolute within allowed directories (required) +- `include`: Optional file glob filter (e.g., "*.rs", "*.{ts,tsx}") +- `limit`: Maximum number of matches to return (default: 100, max: 2000) + +## Pattern Syntax Notes (ripgrep-based) + +- Literal braces need escaping: use `interface\\{\\}` to find `interface{}` in Go code +- Use `\\b` for word boundaries: `\\bfoo\\b` matches "foo" but not "foobar" +- Use `\\s` for whitespace, `\\w` for word characters +- Use `.*` for any characters: `error.*failed` matches "error: connection failed" +- Use `|` for alternation: `TODO|FIXME` matches either +- Patterns match within single lines only; multiline patterns are not supported + +## When to Use This Tool + +- Finding function definitions: `fn\\s+process_` +- Finding usages of a variable or function: `\\bmy_function\\(` +- Finding TODO comments: `TODO|FIXME|HACK` +- Finding error messages: `error.*failed` +- Finding imports: `^use\\s+` + +## When NOT to Use This Tool + +- Finding files by name - use Glob instead +- Reading entire file contents - use Read instead +- Complex multi-step research - use Task tool instead + +## Examples + +Find all function definitions: +``` +pattern: "fn\\s+\\w+" +path: "." +include: "*.rs" +``` + +Find TODO comments: +``` +pattern: "TODO|FIXME" +path: "." +``` + +Find usage of a specific function: +``` +pattern: "\\bprocess_request\\(" +path: "src" +``` + +Find error handling patterns: +``` +pattern: "Err\\(|Error::" +path: "." +include: "*.rs" +limit: 50 +``` + +## Best Practices + +1. You can call multiple tools in a single response. It is always better to speculatively perform multiple searches in parallel if they are potentially useful. +2. Use the `include` parameter to narrow searches to relevant file types +3. Use word boundaries (`\\b`) to avoid partial matches +4. Escape special regex characters when searching for literal text +5. Start with broader patterns and refine based on results +6. Use `limit` parameter if you expect many matches but only need a sample +7. Paths outside allowed directories will be rejected diff --git a/src/llm-coding-tools-core/src/context/read_absolute.txt b/src/llm-coding-tools-core/src/context/read_absolute.txt index b5bffee2..b6fe01da 100644 --- a/src/llm-coding-tools-core/src/context/read_absolute.txt +++ b/src/llm-coding-tools-core/src/context/read_absolute.txt @@ -2,11 +2,48 @@ Reads a file from the local filesystem. You can access any file directly by usin Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. Usage: -- The filePath parameter must be an absolute path, not a relative path -- By default, it reads up to 2000 lines starting from the beginning of the file -- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters +- The `file_path` parameter must be an absolute path, not a relative path +- By default, it reads up to 2000 lines starting from line 1 +- You can optionally specify `offset` (1-indexed starting line) and `limit` (max lines), but it's recommended to read the whole file by not providing these parameters - Any lines longer than 2000 characters will be truncated -- Results are returned using cat -n format, with line numbers starting at 1 -- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful. +- Results are returned with line numbers prefixed in "L{n}: content" format (e.g., "L1: first line") +- This tool can read image files (eg PNG, JPG, etc). When reading an image file the contents are presented visually. +- This tool can only read files, not directories. To list directory contents, use bash with `ls`. - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents. -- You can read image files using this tool. + +You can call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful. + +## When to Use This Tool + +- Reading source code files to understand implementation +- Viewing configuration files +- Checking file contents before making edits +- Reading log files for debugging +- Viewing images and screenshots provided by the user + +## When NOT to Use This Tool + +- To list directory contents - use bash with `ls` instead +- To search for patterns across files - use Grep instead +- To find files by name - use Glob instead + +## Examples + +Reading a full file: +``` +file_path: "/home/user/project/src/main.rs" +``` + +Reading specific lines (lines 100-200): +``` +file_path: "/home/user/project/src/main.rs" +offset: 100 +limit: 100 +``` + +## Best Practices + +1. Read files before editing them - the Edit tool requires you to have read the file first +2. When exploring a codebase, read multiple related files in parallel to save time +3. For large files, consider reading specific sections using offset/limit if you know what you're looking for +4. Always use absolute paths - relative paths will be rejected diff --git a/src/llm-coding-tools-core/src/context/read_allowed.txt b/src/llm-coding-tools-core/src/context/read_allowed.txt index d295103c..ba55cba5 100644 --- a/src/llm-coding-tools-core/src/context/read_allowed.txt +++ b/src/llm-coding-tools-core/src/context/read_allowed.txt @@ -4,10 +4,47 @@ Assume this tool is able to read files within the configured allowed directories Usage: - Paths can be relative to configured allowed directories, or absolute paths within allowed directories - Paths outside allowed directories will be rejected -- By default, it reads up to 2000 lines starting from the beginning of the file -- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters +- By default, it reads up to 2000 lines starting from line 1 +- You can optionally specify `offset` (1-indexed starting line) and `limit` (max lines), but it's recommended to read the whole file by not providing these parameters - Any lines longer than 2000 characters will be truncated -- Results are returned using cat -n format, with line numbers starting at 1 -- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful. +- Results are returned with line numbers prefixed in "L{n}: content" format (e.g., "L1: first line") +- This tool can read image files (eg PNG, JPG, etc). When reading an image file the contents are presented visually. +- This tool can only read files, not directories. To list directory contents, use bash with `ls`. - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents. -- You can read image files using this tool. + +You can call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful. + +## When to Use This Tool + +- Reading source code files to understand implementation +- Viewing configuration files +- Checking file contents before making edits +- Reading log files for debugging +- Viewing images and screenshots provided by the user + +## When NOT to Use This Tool + +- To list directory contents - use bash with `ls` instead +- To search for patterns across files - use Grep instead +- To find files by name - use Glob instead + +## Examples + +Reading a full file: +``` +file_path: "src/main.rs" +``` + +Reading specific lines (lines 100-200): +``` +file_path: "src/main.rs" +offset: 100 +limit: 100 +``` + +## Best Practices + +1. Read files before editing them - the Edit tool requires you to have read the file first +2. When exploring a codebase, read multiple related files in parallel to save time +3. For large files, consider reading specific sections using offset/limit if you know what you're looking for +4. Relative paths are resolved against allowed directories diff --git a/src/llm-coding-tools-core/src/context/task.txt b/src/llm-coding-tools-core/src/context/task.txt index c940bc94..9c68820a 100644 --- a/src/llm-coding-tools-core/src/context/task.txt +++ b/src/llm-coding-tools-core/src/context/task.txt @@ -1,60 +1,53 @@ Launch a new agent to handle complex, multistep tasks autonomously. +The Task tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it. + Available agent types and the tools they have access to: {agents} -When using the Task tool, you must specify a subagent_type parameter to select which agent type to use. +When using the Task tool, you must specify a `subagent_type` parameter to select which agent type to use. -When to use the Task tool: -- When you are instructed to execute custom slash commands. Use the Task tool with the slash command invocation as the entire prompt. The slash command can take arguments. For example: Task(description="Check the file", prompt="/check-file path/to/file.py") +## When NOT to Use the Task Tool -When NOT to use the Task tool: -- If you want to read a specific file path, use the Read or Glob tool instead of the Task tool, to find the match more quickly +- If you want to read a specific file path, use the Read or Glob tool instead, to find the match more quickly - If you are searching for a specific class definition like "class Foo", use the Glob tool instead, to find the match more quickly -- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Task tool, to find the match more quickly +- If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead, to find the match more quickly - Other tasks that are not related to the agent descriptions above +## Usage Notes + +- Always include a short description (3-5 words) summarizing what the agent will do +- Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses +- When the agent is done, it will return a single message back to you. The result returned by the agent is NOT visible to the user. To show the user the result, you must send a text message with a concise summary of the result. +- Each agent invocation is stateless unless you provide a `session_id`. Your prompt should contain a highly detailed task description for the agent to perform autonomously, and you should specify exactly what information the agent should return back to you. +- The agent's outputs should generally be trusted +- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent +- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. +- Provide clear, detailed prompts so the agent can work autonomously and return exactly the information you need. -Usage notes: -1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses -2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. -3. Each agent invocation is stateless unless you provide a session_id. Your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. -4. The agent's outputs should generally be trusted -5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent -6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. +## Agent Guidelines -Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above): +When agents are running, they follow these principles: +- For file searches: Use Grep or Glob when searching broadly. Use Read when the specific file path is known. +- For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results. +- Be thorough: Check multiple locations, consider different naming conventions, look for related files. +- NEVER create files unless absolutely necessary for achieving the goal. ALWAYS prefer editing existing files. +- NEVER proactively create documentation files (*.md) or README files. +- Agent threads always have their cwd reset between bash calls - use absolute file paths. +- In final responses, share relevant file names and code snippets. All file paths must be absolute. - -"code-reviewer": use this agent after you are done writing a significant piece of code -"greeting-responder": use this agent when to respond to user greetings with a friendly joke - +## Examples -user: "Please write a function that checks if a number is prime" -assistant: Sure let me write a function that checks if a number is prime -assistant: First let me use the Write tool to write a function that checks if a number is prime -assistant: I'm going to use the Write tool to write the following code: - -function isPrime(n) { - if (n <= 1) return false - for (let i = 2; i * i <= n; i++) { - if (n % i === 0) return false - } - return true -} - - -Since a significant piece of code was written and the task was completed, now use the code-reviewer agent to review the code - -assistant: Now let me use the code-reviewer agent to review the code -assistant: Uses the Task tool to launch the code-reviewer agent +User: "Please write a function that checks if a number is prime" +Assistant: Sure, let me write that function. +*Writes the function using Edit/Write tools* +Since significant code was written, now use a test-runner agent to verify: +*Uses Task tool with: subagent_type="test-runner", prompt="Run tests for the prime checking function"* -user: "Hello" - -Since the user is greeting, use the greeting-responder agent to respond with a friendly joke - -assistant: "I'm going to use the Task tool to launch the with the greeting-responder agent" +User: "Find all places where we handle authentication errors" +*For a broad search across the codebase:* +*Uses Task tool with: subagent_type="researcher", prompt="Search for all authentication error handling in the codebase. Look for patterns like AuthError, authentication failed, login error, etc. Return file paths and relevant code snippets."* diff --git a/src/llm-coding-tools-core/src/context/todowrite.txt b/src/llm-coding-tools-core/src/context/todowrite.txt index d7a111a7..9204ec48 100644 --- a/src/llm-coding-tools-core/src/context/todowrite.txt +++ b/src/llm-coding-tools-core/src/context/todowrite.txt @@ -2,15 +2,16 @@ Use this tool to create and manage a structured task list for your current codin It also helps the user understand the progress of the task and overall progress of their requests. ## When to Use This Tool + Use this tool proactively in these scenarios: -1. Complex multistep tasks - When a task requires 3 or more distinct steps or actions +1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions 2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations 3. User explicitly requests todo list - When the user directly asks you to use the todo list 4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated) -5. After receiving new instructions - Immediately capture user requirements as todos. Feel free to edit the todo list based on new information. -6. After completing a task - Mark it complete and add any new follow-up tasks -7. When you start working on a new task, mark the todo as in_progress. Ideally you should only have one todo as in_progress at a time. Complete existing tasks before starting new ones. +5. After receiving new instructions - Immediately capture user requirements as todos +6. When you start working on a task - Mark it as in_progress BEFORE beginning work. Ideally you should only have one todo as in_progress at a time +7. After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation ## When NOT to Use This Tool @@ -28,11 +29,11 @@ NOTE that you should not use this tool if there is only one trivial task to do. User: I want to add a dark mode toggle to the application settings. Make sure you run the tests and build when you're done! Assistant: I'll help add a dark mode toggle to your application settings. Let me create a todo list to track this implementation. *Creates todo list with the following items:* -1. Create dark mode toggle component in Settings page -2. Add dark mode state management (context/store) -3. Implement CSS-in-JS styles for dark theme -4. Update existing components to support theme switching -5. Run tests and build process, addressing any failures or errors that occur +1. Creating dark mode toggle component in Settings page +2. Adding dark mode state management (context/store) +3. Implementing CSS-in-JS styles for dark theme +4. Updating existing components to support theme switching +5. Running tests and build process, addressing any failures or errors that occur *Begins working on the first task* @@ -59,87 +60,30 @@ The assistant used the todo list because: - - -User: I need to implement these features for my e-commerce site: user registration, product catalog, shopping cart, and checkout flow. -Assistant: I'll help implement these features. First, let's add all the features to the todo list. -*Creates a todo list breaking down each feature into specific tasks based on the project architecture* -Assistant: Let's start with user registration. This will involve creating database models, API endpoints, and frontend forms that integrate with your existing authentication system. - - -The assistant used the todo list because: -1. The user provided multiple complex features to implement in a comma separated list -2. The todo list helps organize these large features into manageable tasks -3. This approach allows for tracking progress across the entire implementation - - - - -User: Can you help optimize my React application? It's rendering slowly and has performance issues. -Assistant: I'll help optimize your React application. First, let me examine your codebase to identify potential performance bottlenecks. -*Reviews component structure, render patterns, state management, and data fetching* -Assistant: After analyzing your codebase, I've identified several performance issues. Let me create a todo list to track our optimization efforts. -*Creates todo list with items like: 1) Implement memoization for expensive calculations in ProductList, 2) Add virtualization for long lists in Dashboard, 3) Optimize image loading in Gallery component, 4) Fix state update loops in ShoppingCart, 5) Review bundle size and implement code splitting* -Let's start by implementing memoization for the expensive calculations in your ProductList component. - - -The assistant used the todo list because: -1. First, the assistant examined the codebase to identify specific performance issues -2. Based on this analysis, it identified multiple optimization opportunities -3. Performance optimization is a non-trivial task requiring multiple steps -4. The todo list helps methodically track improvements across different components -5. This systematic approach ensures all performance bottlenecks are addressed - - - ## Examples of When NOT to Use the Todo List User: How do I print 'Hello World' in Python? Assistant: In Python, you can print "Hello World" with this simple code: -python +```python print("Hello World") +``` -This will output the text "Hello World" to the console when executed. +This will output the text "Hello World" to the console when executed. -The assistant did not use the todo list because this is a single, trivial task that can be completed in one step. There's no need to track multiple tasks or steps for such a straightforward request. - - - - -User: What does the git status command do? -Assistant: The git status command shows the current state of your working directory and staging area. It displays which changes have been staged, which haven't, and which files aren't being tracked by Git. This is a fundamental command for understanding the current state of your repository before making commits. - - -The assistant did not use the todo list because this is an informational request with no actual coding task to complete. The user is simply asking for an explanation, not for the assistant to perform multiple steps or tasks. +The assistant did not use the todo list because this is a single, trivial task that can be completed in one step. User: Can you add a comment to the calculateTotal function to explain what it does? -Assistant: Sure, let me add a comment to the calculateTotal function to explain what it does. -* Uses the Edit tool to add a comment to the calculateTotal function * - - -The assistant did not use the todo list because this is a single, straightforward task confined to one location in the code. Adding a comment doesn't require tracking multiple steps or systematic organization. - - - - -User: Run npm install for me and tell me what happens. -Assistant: I'll run the npm install command for you. - -*Executes: npm install* - -The command completed successfully. Here's the output: -[Output of npm install command] - -All dependencies have been installed according to your package.json file. +Assistant: Sure, let me add a comment to the calculateTotal function. +*Uses the Edit tool to add a comment* -The assistant did not use the todo list because this is a single command execution with immediate results. There are no multiple steps to track or organize, making the todo list unnecessary for this straightforward task. +The assistant did not use the todo list because this is a single, straightforward task confined to one location in the code. @@ -149,16 +93,30 @@ The assistant did not use the todo list because this is a single command executi - pending: Task not yet started - in_progress: Currently working on (limit to ONE task at a time) - completed: Task finished successfully - - cancelled: Task no longer needed + + **IMPORTANT**: Task descriptions should use imperative form describing what needs to be done: + - "Run tests" (not "Running tests") + - "Build the project" (not "Building the project") + - "Fix authentication bug" (not "Fixing authentication bug") 2. **Task Management**: - Update task status in real-time as you work - Mark tasks complete IMMEDIATELY after finishing (don't batch completions) - - Only have ONE task in_progress at any time + - Exactly ONE task must be in_progress at any time (not less, not more) - Complete current tasks before starting new ones - - Cancel tasks that become irrelevant - -3. **Task Breakdown**: + - Remove tasks that are no longer relevant from the list entirely + +3. **Task Completion Requirements**: + - ONLY mark a task as completed when you have FULLY accomplished it + - If you encounter errors, blockers, or cannot finish, keep the task as in_progress + - When blocked, create a new task describing what needs to be resolved + - Never mark a task as completed if: + - Tests are failing + - Implementation is partial + - You encountered unresolved errors + - You couldn't find necessary files or dependencies + +4. **Task Breakdown**: - Create specific, actionable items - Break complex tasks into smaller, manageable steps - Use clear, descriptive task names diff --git a/src/llm-coding-tools-core/src/context/webfetch.txt b/src/llm-coding-tools-core/src/context/webfetch.txt index 169aadef..3375178f 100644 --- a/src/llm-coding-tools-core/src/context/webfetch.txt +++ b/src/llm-coding-tools-core/src/context/webfetch.txt @@ -1,13 +1,56 @@ -- Fetches content from a specified URL -- Takes a URL and optional format as input -- Fetches the URL content, converts to requested format (markdown by default) -- Returns the content in the specified format +Fetches content from a specified URL and processes it for analysis. + +- Takes a URL and optional `timeout_ms` as input +- HTML content is automatically converted to markdown for easier reading +- JSON content is automatically prettified +- Other content types are returned as-is - Use this tool when you need to retrieve and analyze web content -Usage notes: - - IMPORTANT: if another tool is present that offers better web fetching capabilities, is more targeted to the task, or has fewer restrictions, prefer using that tool instead of this one. - - The URL must be a fully-formed valid URL +## Parameters + +- `url`: The URL to fetch content from (required) + - Must be a fully-formed valid URL - HTTP URLs will be automatically upgraded to HTTPS - - Format options: "markdown" (default), "text", or "html" - - This tool is read-only and does not modify any files - - Results may be summarized if the content is very large +- `timeout_ms`: Optional timeout in milliseconds (default varies by implementation) + +## Usage Notes + +- IMPORTANT: If another tool is present that offers better web fetching capabilities, is more targeted to the task, or has fewer restrictions, prefer using that tool instead of this one. +- The URL must be a fully-formed valid URL (e.g., "https://example.com/page") +- HTTP URLs will be automatically upgraded to HTTPS for security +- Redirects are followed automatically +- This tool is read-only and does not modify any files +- Results may be summarized if the content is very large + +## When to Use This Tool + +- Fetching documentation from the web +- Reading API references or library documentation +- Retrieving content from URLs provided by the user +- Checking website content for analysis + +## When NOT to Use This Tool + +- For local file operations - use Read tool instead +- For searching the web - this only fetches specific URLs +- When another MCP tool offers better web capabilities + +## Examples + +Fetching a documentation page: +``` +url: "https://docs.rust-lang.org/book/ch01-00-getting-started.html" +``` + +Fetching with custom timeout: +``` +url: "https://api.example.com/large-response" +timeout_ms: 30000 +``` + +## Best Practices + +1. Provide complete URLs including the protocol (https://) +2. Use this tool for specific URLs, not for web searching +3. If content is very large, results may be summarized - ask for specific sections if needed +4. Consider timeout settings for slow-loading pages diff --git a/src/llm-coding-tools-core/src/context/write_absolute.txt b/src/llm-coding-tools-core/src/context/write_absolute.txt index d974350d..547d1e16 100644 --- a/src/llm-coding-tools-core/src/context/write_absolute.txt +++ b/src/llm-coding-tools-core/src/context/write_absolute.txt @@ -1,7 +1,48 @@ -Writes a file to the local filesystem. +Writes a file to the local filesystem. Creates parent directories if they don't exist. Usage: - This tool will overwrite the existing file if there is one at the provided path. -- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first. +- ALWAYS prefer editing existing files in the codebase using the Edit tool. NEVER write new files unless explicitly required. - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. - Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. + +## Parameters + +- `file_path`: Absolute path for the file to write (required) +- `content`: Content to write to the file (required) + +## When to Use This Tool + +- Creating new files that don't exist yet +- Completely rewriting a file when most content changes +- Writing generated output (build artifacts, reports, etc.) +- Creating new source files when explicitly requested + +## When NOT to Use This Tool + +- Modifying existing files - use Edit tool instead (more precise, less error-prone) +- Creating documentation unless explicitly requested +- Writing files you haven't read first (if they exist) + +## Examples + +Creating a new file: +``` +file_path: "/home/user/project/src/new_module.rs" +content: "//! New module\n\npub fn hello() {\n println!(\"Hello!\");\n}\n" +``` + +## Best Practices + +1. ALWAYS read existing files with Read tool before overwriting them +2. Prefer Edit tool for making changes to existing files - it's safer and more precise +3. When creating new files, ensure the content is complete and correct +4. Don't create files proactively - wait for explicit user requests +5. Use absolute paths only - relative paths will be rejected + +## Error Handling + +- If you try to overwrite a file you haven't read, the operation will fail +- Permission errors will be returned if you can't write to the location +- Parent directories are created automatically if they don't exist diff --git a/src/llm-coding-tools-core/src/context/write_allowed.txt b/src/llm-coding-tools-core/src/context/write_allowed.txt index f699dbe7..41d9b5c3 100644 --- a/src/llm-coding-tools-core/src/context/write_allowed.txt +++ b/src/llm-coding-tools-core/src/context/write_allowed.txt @@ -1,9 +1,51 @@ -Writes a file to the local filesystem within allowed directories. +Writes a file to the local filesystem within allowed directories. Creates parent directories if they don't exist. Usage: - This tool will overwrite the existing file if there is one at the provided path. - Paths can be relative to configured allowed directories, or absolute paths within allowed directories - Paths outside allowed directories will be rejected -- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first. +- ALWAYS prefer editing existing files in the codebase using the Edit tool. NEVER write new files unless explicitly required. - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. - Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. + +## Parameters + +- `file_path`: Path for the file to write - can be relative or absolute within allowed directories (required) +- `content`: Content to write to the file (required) + +## When to Use This Tool + +- Creating new files that don't exist yet +- Completely rewriting a file when most content changes +- Writing generated output (build artifacts, reports, etc.) +- Creating new source files when explicitly requested + +## When NOT to Use This Tool + +- Modifying existing files - use Edit tool instead (more precise, less error-prone) +- Creating documentation unless explicitly requested +- Writing files you haven't read first (if they exist) + +## Examples + +Creating a new file: +``` +file_path: "src/new_module.rs" +content: "//! New module\n\npub fn hello() {\n println!(\"Hello!\");\n}\n" +``` + +## Best Practices + +1. ALWAYS read existing files with Read tool before overwriting them +2. Prefer Edit tool for making changes to existing files - it's safer and more precise +3. When creating new files, ensure the content is complete and correct +4. Don't create files proactively - wait for explicit user requests +5. Relative paths are resolved against allowed directories + +## Error Handling + +- If you try to overwrite a file you haven't read, the operation will fail +- Paths outside allowed directories will be rejected +- Permission errors will be returned if you can't write to the location +- Parent directories are created automatically if they don't exist From d2ab79822ad15a4d66ced09a288a81139b741b86 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 14 Jan 2026 20:30:35 +0000 Subject: [PATCH 56/64] Changed: refresh README quick start Aligns examples with a runnable agent setup and trims usage details for clarity. --- src/llm-coding-tools-rig/README.md | 148 ++++++++++------------------- 1 file changed, 51 insertions(+), 97 deletions(-) diff --git a/src/llm-coding-tools-rig/README.md b/src/llm-coding-tools-rig/README.md index 6d4d6cb6..7b95923f 100644 --- a/src/llm-coding-tools-rig/README.md +++ b/src/llm-coding-tools-rig/README.md @@ -27,117 +27,71 @@ llm-coding-tools-rig = "0.1" ## Quick Start -Run the included example: - -```bash -cargo run --example basic -p llm-coding-tools-rig -``` - -## Usage - -### File Operation Tools - -File tools (Read, Write, Edit, Glob, Grep) come in two variants: - -**`absolute::*`** - Unrestricted filesystem access, requires absolute paths: +Minimal runnable agent (requires `OPENAI_API_KEY`): ```rust -use llm_coding_tools_rig::absolute::{ReadTool, WriteTool, EditTool, GlobTool, GrepTool}; - -let read = ReadTool::new(); // LINE_NUMBERS defaults to true -let write = WriteTool::new(); -let edit = EditTool::new(); -let glob = GlobTool::new(); -let grep = GrepTool::new(); - -// Disable line numbers with explicit generic: -let read_raw = ReadTool::::new(); -let grep_raw = GrepTool::::new(); -``` - -**`allowed::*`** - Sandboxed to configured directories: - -```rust -use llm_coding_tools_rig::allowed::{ReadTool, WriteTool}; -use llm_coding_tools_rig::AllowedPathResolver; -use std::path::PathBuf; +use llm_coding_tools_rig::absolute::{GlobTool, GrepTool, ReadTool}; +use llm_coding_tools_rig::{BashTool, PreambleBuilder, TodoTools}; +use rig::providers::openai; +use rig::tool::ToolSet; -// Option 1: Pass paths directly -let read: ReadTool = ReadTool::new([ - PathBuf::from("/home/user/project"), - PathBuf::from("/tmp/workspace"), -]).unwrap(); - -// Option 2: Share a resolver across tools (recommended) -let resolver = AllowedPathResolver::new([ - PathBuf::from("/home/user/project"), -]).unwrap(); -let read: ReadTool = ReadTool::with_resolver(resolver.clone()); -let write = WriteTool::with_resolver(resolver); +#[tokio::main] +async fn main() -> Result<(), Box> { + let todos = TodoTools::new(); + let mut pb = PreambleBuilder::::new(); + + let toolset = ToolSet::builder() + .static_tool(pb.track(ReadTool::::new())) + .static_tool(pb.track(GlobTool::new())) + .static_tool(pb.track(GrepTool::::new())) + .static_tool(pb.track(BashTool::new())) + .static_tool(pb.track(todos.read)) + .static_tool(pb.track(todos.write)) + .build(); + + let preamble = pb.build(); + + let client = openai::Client::from_env(); + let agent = client + .agent("gpt-4o") + .preamble(&preamble) + .tools(toolset) + .build(); + + let response = agent + .prompt("Search for TODO comments in src/") + .await?; + println!("{response}"); + + Ok(()) +} ``` -### Other Tools - -Tools that don't operate on files: - -```rust -use llm_coding_tools_rig::{BashTool, TaskTool, WebFetchTool, TodoTools}; +Run the full example app: -let bash = BashTool::new(); // Shell command execution -let webfetch = WebFetchTool::new(); // URL content fetching -let task = TaskTool::with_mock(); // Sub-agent delegation -let todos = TodoTools::new(); // Todo list (todos.read, todos.write) +```bash +OPENAI_API_KEY=... cargo run --example full_agent -p llm-coding-tools-rig ``` -### PreambleBuilder +## Usage -`PreambleBuilder` tracks registered tools and generates a combined context string -for the agent's system prompt. This provides LLM guidance on using each tool effectively. +File tools come in `absolute::*` (unrestricted) and `allowed::*` (sandboxed) variants: ```rust -use llm_coding_tools_rig::absolute::{ReadTool, GlobTool}; -use llm_coding_tools_rig::{BashTool, PreambleBuilder, TodoTools}; -use rig::tool::ToolSet; +use llm_coding_tools_rig::absolute::{ReadTool, WriteTool}; +use llm_coding_tools_rig::allowed::{ReadTool as AllowedReadTool, WriteTool as AllowedWriteTool}; +use llm_coding_tools_rig::AllowedPathResolver; +use std::path::PathBuf; -// Create preamble builder to track tools -let mut pb = PreambleBuilder::new(); - -// Create todo tools with shared state -let todos = TodoTools::new(); - -// Build toolset - pb.track() registers each tool and passes it through -let toolset = ToolSet::builder() - .static_tool(pb.track(ReadTool::::new())) - .static_tool(pb.track(GlobTool::new())) - .static_tool(pb.track(BashTool::new())) - .static_tool(pb.track(todos.read)) - .static_tool(pb.track(todos.write)) - .build(); - -// Generate preamble with usage instructions for all tracked tools -let preamble = pb.build(); - -// Use with rig agent: -// let agent = client.agent("gpt-4o") -// .preamble(&preamble) // <-- Pass preamble here -// .tools(toolset) -// .build(); +let read = ReadTool::::new(); +let resolver = AllowedPathResolver::new([PathBuf::from("/home/user/project")]).unwrap(); +let sandboxed_read: AllowedReadTool = AllowedReadTool::with_resolver(resolver.clone()); +let sandboxed_write = AllowedWriteTool::with_resolver(resolver); ``` -### Context Strings - -LLM guidance strings are re-exported from `llm_coding_tools_core`: - -```rust -use llm_coding_tools_rig::context::{BASH, READ_ABSOLUTE, READ_ALLOWED}; - -// Use context strings in system prompts or tool descriptions -println!("{}", BASH); - -// Path-based tools have absolute and allowed variants -println!("{}", READ_ABSOLUTE); // For absolute::ReadTool -println!("{}", READ_ALLOWED); // For allowed::ReadTool -``` +Other tools: `BashTool`, `WebFetchTool`, `TaskTool`, `TodoTools`. +Use `PreambleBuilder` to register tools and pass `pb.build()` to `.preamble()`. +Context strings are re-exported in `llm_coding_tools_rig::context` (e.g., `BASH`, `READ_ABSOLUTE`). ## Examples From 341d0745231dc599d121019b58fb46dd07c2a74d Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 14 Jan 2026 20:36:20 +0000 Subject: [PATCH 57/64] Added: document PreambleBuilder output examples Add rustdoc and README snippets showing the preamble format. --- src/llm-coding-tools-core/src/preamble.rs | 30 +++++++++++++++++++++++ src/llm-coding-tools-rig/README.md | 14 +++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/llm-coding-tools-core/src/preamble.rs b/src/llm-coding-tools-core/src/preamble.rs index 254d8082..b0a58fe9 100644 --- a/src/llm-coding-tools-core/src/preamble.rs +++ b/src/llm-coding-tools-core/src/preamble.rs @@ -40,6 +40,36 @@ struct ContextEntry { /// let preamble = pb.build() /// .substitute("agents", agent_list); /// ``` +/// +/// # Output +/// +/// The generated preamble is Markdown. For example, with two tools: +/// +/// ```text +/// # Tool Usage Guidelines +/// +/// ## Read Tool +/// +/// Reads files from disk. +/// +/// ## Bash Tool +/// +/// Executes shell commands. +/// ``` +/// +/// When the environment section is enabled and a working directory is provided: +/// +/// ```text +/// # Environment +/// +/// Working directory: /home/user/project +/// +/// # Tool Usage Guidelines +/// +/// ## Read Tool +/// +/// Reads files from disk. +/// ``` pub struct PreambleBuilder { entries: Vec, working_directory: Option, diff --git a/src/llm-coding-tools-rig/README.md b/src/llm-coding-tools-rig/README.md index 7b95923f..89c7ac3c 100644 --- a/src/llm-coding-tools-rig/README.md +++ b/src/llm-coding-tools-rig/README.md @@ -67,6 +67,20 @@ async fn main() -> Result<(), Box> { } ``` +Example preamble output (truncated): + +```text +# Tool Usage Guidelines + +## Read Tool + +Reads files from disk. + +## Bash Tool + +Executes shell commands. +``` + Run the full example app: ```bash From 4bdc85cef787d4debd933de6cf96f2efac84111a Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 14 Jan 2026 21:30:15 +0000 Subject: [PATCH 58/64] Added: verify glob results are mtime sorted Add a test that pins mtimes to validate ordering. --- .../src/operations/glob.rs | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/llm-coding-tools-core/src/operations/glob.rs b/src/llm-coding-tools-core/src/operations/glob.rs index 6384b409..776ad1b4 100644 --- a/src/llm-coding-tools-core/src/operations/glob.rs +++ b/src/llm-coding-tools-core/src/operations/glob.rs @@ -105,8 +105,9 @@ pub fn glob_files( mod tests { use super::*; use crate::path::AbsolutePathResolver; - use std::fs::{self, File}; + use std::fs::{self, File, FileTimes}; use std::io::Write; + use std::time::{Duration, SystemTime}; use tempfile::TempDir; fn create_test_tree() -> TempDir { @@ -139,6 +140,46 @@ mod tests { assert!(!result.files.iter().any(|f| f.contains("target"))); } + #[test] + fn glob_sorts_by_mtime_desc() { + let dir = TempDir::new().unwrap(); + let base = dir.path(); + let resolver = AbsolutePathResolver; + + let older_path = base.join("older.txt"); + let newer_path = base.join("newer.txt"); + let older_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1); + let newer_time = SystemTime::UNIX_EPOCH + Duration::from_secs(2); + + let older_file = File::create(&older_path).unwrap(); + older_file + .set_times(FileTimes::new().set_modified(older_time)) + .unwrap(); + let newer_file = File::create(&newer_path).unwrap(); + newer_file + .set_times(FileTimes::new().set_modified(newer_time)) + .unwrap(); + + let result = glob_files(&resolver, "**/*.txt", base.to_str().unwrap()).unwrap(); + + let newer_index = result + .files + .iter() + .position(|path| path.ends_with("newer.txt")) + .unwrap(); + let older_index = result + .files + .iter() + .position(|path| path.ends_with("older.txt")) + .unwrap(); + + assert!( + newer_index < older_index, + "expected newer file before older: {:?}", + result.files + ); + } + #[test] fn glob_returns_forward_slash_paths() { // Patterns and returned paths use forward slashes on all platforms From b18380331a64c3bd8b2625760a49fae47b93efaa Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 14 Jan 2026 21:30:19 +0000 Subject: [PATCH 59/64] Changed: avoid zeroed buffer in blocking webfetch Read into an uninitialized buffer to reduce overhead. --- .../src/operations/webfetch/blocking_impl.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/llm-coding-tools-core/src/operations/webfetch/blocking_impl.rs b/src/llm-coding-tools-core/src/operations/webfetch/blocking_impl.rs index aa5fdd66..e2662963 100644 --- a/src/llm-coding-tools-core/src/operations/webfetch/blocking_impl.rs +++ b/src/llm-coding-tools-core/src/operations/webfetch/blocking_impl.rs @@ -3,6 +3,7 @@ use super::{categorize_reqwest_error, check_size, process_content, WebFetchOutput}; use crate::error::{ToolError, ToolResult}; use std::io::Read; +use std::mem::MaybeUninit; use std::time::Duration; /// Fetches content from a URL and returns processed content. @@ -42,18 +43,24 @@ pub fn fetch_url( // Stream response body with incremental size checks to avoid memory exhaustion let mut bytes = content_length.map_or_else(Vec::new, Vec::with_capacity); let mut total_len: usize = 0; - let mut buffer = [0u8; 8192]; + let mut buffer = [MaybeUninit::::uninit(); 8192]; + let buffer_ptr = buffer.as_mut_ptr() as *mut u8; + let buffer_len = buffer.len(); loop { - let n = response - .read(&mut buffer) - .map_err(|e| ToolError::Http(e.to_string()))?; + let n = { + let buf = unsafe { std::slice::from_raw_parts_mut(buffer_ptr, buffer_len) }; + response + .read(buf) + .map_err(|e| ToolError::Http(e.to_string()))? + }; if n == 0 { break; } total_len += n; check_size(total_len, url)?; - bytes.extend_from_slice(&buffer[..n]); + let initialized = unsafe { std::slice::from_raw_parts(buffer_ptr, n) }; + bytes.extend_from_slice(initialized); } let byte_length = total_len; From ced9557a5d68d517168b96ecef00cdaa432f488e Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 14 Jan 2026 21:43:39 +0000 Subject: [PATCH 60/64] Changed: annotate grep search flow and inline helper Adds sparse comments and marks helper inline for readability. --- src/llm-coding-tools-core/src/operations/grep.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/llm-coding-tools-core/src/operations/grep.rs b/src/llm-coding-tools-core/src/operations/grep.rs index 346a09e1..d126c364 100644 --- a/src/llm-coding-tools-core/src/operations/grep.rs +++ b/src/llm-coding-tools-core/src/operations/grep.rs @@ -58,6 +58,7 @@ pub fn grep_search( let matcher = RegexMatcher::new(pattern).map_err(|e| ToolError::InvalidPattern(e.to_string()))?; + // Optional filename filter via glob. let glob_pattern = include .map(|g| Pattern::new(g).map_err(|e| ToolError::InvalidPattern(e.to_string()))) .transpose()?; @@ -81,6 +82,7 @@ pub fn grep_search( Err(_) => continue, }; + // Skip directories and non-regular files. match entry.file_type() { Some(ft) if ft.is_file() => {} _ => continue, @@ -88,6 +90,7 @@ pub fn grep_search( let entry_path = entry.path(); + // Apply include glob to basename when requested. if let Some(ref glob) = glob_pattern { let file_name = match entry_path.file_name().and_then(|n| n.to_str()) { Some(name) => name, @@ -116,12 +119,14 @@ pub fn grep_search( }); } + // Sort newest files first. files.sort_by(|a, b| b.mtime.cmp(&a.mtime)); let mut match_count = 0; let mut truncate_at = files.len(); let mut truncated = false; + // Enforce overall match limit across files. for (x, file) in files.iter_mut().enumerate() { let remaining = limit - match_count; if file.matches.len() > remaining { @@ -143,6 +148,7 @@ pub fn grep_search( }) } +#[inline] fn collect_file_matches( matcher: &RegexMatcher, searcher: &mut Searcher, From 3f3a6cb7d68acd4fae264cc5ea7211589e545ba1 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 14 Jan 2026 22:45:44 +0000 Subject: [PATCH 61/64] Changed: clarify preamble usage and read flow comments Add a generic PreambleBuilder example alongside the rig ToolSet snippet, and annotate the read operation's buffering flow for easier maintenance. --- src/llm-coding-tools-core/src/operations/read.rs | 7 +++++++ src/llm-coding-tools-core/src/preamble.rs | 11 +++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/llm-coding-tools-core/src/operations/read.rs b/src/llm-coding-tools-core/src/operations/read.rs index 973f0267..14b95eae 100644 --- a/src/llm-coding-tools-core/src/operations/read.rs +++ b/src/llm-coding-tools-core/src/operations/read.rs @@ -74,12 +74,15 @@ pub async fn read_file( let estimated_capacity = limit * ESTIMATED_CHARS_PER_LINE; let mut output = String::with_capacity(estimated_capacity); + // Holds a partial line that spans multiple buffers. let mut overflow: Vec = Vec::new(); let mut line_number = 0usize; let mut lines_output = 0usize; + // Stream buffered chunks, splitting into lines as we go. loop { let buf = reader.fill_buf().await?; + // Flush any trailing partial line at EOF. if buf.is_empty() { if !overflow.is_empty() { line_number += 1; @@ -97,12 +100,15 @@ pub async fn read_file( let mut pos = 0; while pos < buf.len() { + // Fast newline search to delimit lines. if let Some(newline_offset) = memchr(b'\n', &buf[pos..]) { let newline_pos = pos + newline_offset; line_number += 1; + // Only emit lines within the requested window. if line_number >= offset && lines_output < limit { if overflow.is_empty() { + // Fast path: line is fully in this buffer. process_line::( &buf[pos..newline_pos], line_number, @@ -110,6 +116,7 @@ pub async fn read_file( &mut lines_output, ); } else { + // Slow path: prepend buffered fragment. overflow.extend_from_slice(&buf[pos..newline_pos]); process_line::( &overflow, diff --git a/src/llm-coding-tools-core/src/preamble.rs b/src/llm-coding-tools-core/src/preamble.rs index b0a58fe9..53a4f8dd 100644 --- a/src/llm-coding-tools-core/src/preamble.rs +++ b/src/llm-coding-tools-core/src/preamble.rs @@ -91,9 +91,16 @@ impl PreambleBuilder { Self::default() } - /// Records context and returns tool unchanged for ToolSet. + /// Records context and returns tool unchanged. /// - /// Use this to wrap tools when adding to `ToolSet::builder()`: + /// Use this to wrap tools before registering them with your tool collection: + /// ```ignore + /// let mut pb = PreambleBuilder::new(); + /// let my_tool = pb.track(MyTool::new()); + /// // register my_tool with your tool collection + /// ``` + /// + /// For example, if working with rig's ToolSet builder: /// ```ignore /// let mut pb = PreambleBuilder::new(); /// let toolset = ToolSet::builder() From 374b7d0bbaf62224a984227d3c85e976f3197a7c Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 14 Jan 2026 22:52:42 +0000 Subject: [PATCH 62/64] Changed: make preamble examples compile Switch preamble examples to core-only types with no_run blocks and explicit generics so doctests compile cleanly. --- src/llm-coding-tools-core/src/preamble.rs | 59 +++++++++++++++-------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/src/llm-coding-tools-core/src/preamble.rs b/src/llm-coding-tools-core/src/preamble.rs index 53a4f8dd..3b761514 100644 --- a/src/llm-coding-tools-core/src/preamble.rs +++ b/src/llm-coding-tools-core/src/preamble.rs @@ -20,25 +20,31 @@ struct ContextEntry { /// /// # Example /// -/// ```ignore -/// use llm_coding_tools_rig::absolute::{ReadTool, GlobTool}; -/// use llm_coding_tools_rig::{BashTool, PreambleBuilder}; -/// use rig::tool::ToolSet; +/// ```no_run +/// use llm_coding_tools_core::context::{ToolContext, READ_ABSOLUTE}; +/// use llm_coding_tools_core::PreambleBuilder; +/// +/// struct ReadTool; +/// +/// impl ToolContext for ReadTool { +/// const NAME: &'static str = "read"; +/// +/// fn context(&self) -> &'static str { +/// READ_ABSOLUTE +/// } +/// } /// /// // Without environment section (default) -/// let mut pb = PreambleBuilder::new(); +/// let mut pb = PreambleBuilder::::new(); +/// let _preamble = pb.build(); /// /// // With environment section /// let mut pb = PreambleBuilder::::new() -/// .working_directory(std::env::current_dir().unwrap()); +/// .working_directory(std::env::current_dir().unwrap().display().to_string()); /// -/// let toolset = ToolSet::builder() -/// .static_tool(pb.track(ReadTool::::new())) -/// .static_tool(pb.track(BashTool::new())) -/// .build(); +/// pb.track(ReadTool); /// -/// let preamble = pb.build() -/// .substitute("agents", agent_list); +/// let _preamble = pb.build(); /// ``` /// /// # Output @@ -94,10 +100,23 @@ impl PreambleBuilder { /// Records context and returns tool unchanged. /// /// Use this to wrap tools before registering them with your tool collection: - /// ```ignore - /// let mut pb = PreambleBuilder::new(); - /// let my_tool = pb.track(MyTool::new()); - /// // register my_tool with your tool collection + /// ```no_run + /// use llm_coding_tools_core::context::{ToolContext, READ_ABSOLUTE}; + /// use llm_coding_tools_core::PreambleBuilder; + /// + /// struct MyTool; + /// + /// impl ToolContext for MyTool { + /// const NAME: &'static str = "read"; + /// + /// fn context(&self) -> &'static str { + /// READ_ABSOLUTE + /// } + /// } + /// + /// let mut pb = PreambleBuilder::::new(); + /// let _my_tool = pb.track(MyTool); + /// // register _my_tool with your tool collection /// ``` /// /// For example, if working with rig's ToolSet builder: @@ -128,12 +147,14 @@ impl PreambleBuilder { /// /// # Example /// - /// ```ignore - /// let pb = PreambleBuilder::::new() + /// ```no_run + /// use llm_coding_tools_core::PreambleBuilder; + /// + /// let _pb = PreambleBuilder::::new() /// .working_directory("/home/user/project"); /// /// // With runtime-computed path - /// let pb = PreambleBuilder::::new() + /// let _pb = PreambleBuilder::::new() /// .working_directory(std::env::current_dir().unwrap().display().to_string()); /// ``` #[inline] From 475bbaefb867df880bbc4dca91b9d3f5bff742ad Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 14 Jan 2026 22:57:30 +0000 Subject: [PATCH 63/64] Format: cargo.toml files --- src/llm-coding-tools-core/Cargo.toml | 17 ++++++++++------- src/llm-coding-tools-rig/Cargo.toml | 9 +++++++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/llm-coding-tools-core/Cargo.toml b/src/llm-coding-tools-core/Cargo.toml index cce803b3..b8e6a449 100644 --- a/src/llm-coding-tools-core/Cargo.toml +++ b/src/llm-coding-tools-core/Cargo.toml @@ -32,16 +32,19 @@ schemars = "1.2" parking_lot = "0.12" # Glob and grep tool implementations -glob = "0.3" # Pattern matching for glob_files -grep-regex = "0.1" # Regex matcher for grep_search -grep-searcher = "0.1" # File content searching for grep_search -ignore = "0.4" # Respects .gitignore when walking directories -memchr = "2.7" # Fast newline detection in read_file -regex = "1.12" # ToolError includes regex::Error conversion +glob = "0.3" # Pattern matching for glob_files +grep-regex = "0.1" # Regex matcher for grep_search +grep-searcher = "0.1" # File content searching for grep_search +ignore = "0.4" # Respects .gitignore when walking directories +memchr = "2.7" # Fast newline detection in read_file +regex = "1.12" # ToolError includes regex::Error conversion # Webfetch tool converts HTML to markdown for LLM-friendly output html-to-markdown-rs = "2.20" -reqwest = { version = "0.13", default-features = false, features = ["rustls", "rustls-native-certs"], optional = true } +reqwest = { version = "0.13", default-features = false, features = [ + "rustls", + "rustls-native-certs", +], optional = true } # Unifies async/sync code via procedural macros maybe-async = "0.2" diff --git a/src/llm-coding-tools-rig/Cargo.toml b/src/llm-coding-tools-rig/Cargo.toml index 591ed339..7782b23d 100644 --- a/src/llm-coding-tools-rig/Cargo.toml +++ b/src/llm-coding-tools-rig/Cargo.toml @@ -10,13 +10,18 @@ readme = "README.md" [dependencies] # Core tool operations (file read/write/edit, glob, grep, bash, etc.) -llm-coding-tools-core = { version = "0.1.0", path = "../llm-coding-tools-core", features = ["tokio"] } +llm-coding-tools-core = { version = "0.1.0", path = "../llm-coding-tools-core", features = [ + "tokio", +] } # Implements rig_core::tool::Tool trait for each tool rig-core = { version = "0.28", default-features = false, features = ["reqwest-rustls"] } # WebFetchTool needs its own client instance -reqwest = { version = "0.13", default-features = false, features = ["rustls", "rustls-native-certs"] } +reqwest = { version = "0.13", default-features = false, features = [ + "rustls", + "rustls-native-certs", +] } # Tool::definition() returns JSON Schema for LLM parameter validation schemars = "1.2" From 397a9a48b7d023837b7c3b773c548414297a5a71 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Wed, 14 Jan 2026 23:21:27 +0000 Subject: [PATCH 64/64] Changed: align globbing with ripgrep deps Swap to globset matchers, drop regex error handling, and pin ripgrep-aligned crate versions. --- src/Cargo.lock | 3 +-- src/llm-coding-tools-core/Cargo.toml | 13 ++++++------- src/llm-coding-tools-core/src/error.rs | 16 +++------------- src/llm-coding-tools-core/src/operations/glob.rs | 7 +++---- src/llm-coding-tools-core/src/operations/grep.rs | 10 +++++----- 5 files changed, 18 insertions(+), 31 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index a6134173..8eebfa82 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -963,7 +963,7 @@ name = "llm-coding-tools-core" version = "0.1.0" dependencies = [ "async-trait", - "glob", + "globset", "grep-regex", "grep-searcher", "html-to-markdown-rs", @@ -971,7 +971,6 @@ dependencies = [ "maybe-async", "memchr", "parking_lot", - "regex", "reqwest 0.13.1", "schemars", "serde", diff --git a/src/llm-coding-tools-core/Cargo.toml b/src/llm-coding-tools-core/Cargo.toml index b8e6a449..b80e4dcd 100644 --- a/src/llm-coding-tools-core/Cargo.toml +++ b/src/llm-coding-tools-core/Cargo.toml @@ -31,13 +31,12 @@ schemars = "1.2" # Sync RwLock for TodoState (no tokio dependency) parking_lot = "0.12" -# Glob and grep tool implementations -glob = "0.3" # Pattern matching for glob_files -grep-regex = "0.1" # Regex matcher for grep_search -grep-searcher = "0.1" # File content searching for grep_search -ignore = "0.4" # Respects .gitignore when walking directories -memchr = "2.7" # Fast newline detection in read_file -regex = "1.12" # ToolError includes regex::Error conversion +# Glob and grep tool implementations (aligned with ripgrep) +globset = "0.4.18" # Glob matching with ripgrep-optimized engine +grep-regex = "0.1.14" # Regex matcher for grep_search +grep-searcher = "0.1.16" # File content searching for grep_search +ignore = "0.4.25" # Respects .gitignore when walking directories +memchr = "2.6.3" # Fast newline detection in read_file # Webfetch tool converts HTML to markdown for LLM-friendly output html-to-markdown-rs = "2.20" diff --git a/src/llm-coding-tools-core/src/error.rs b/src/llm-coding-tools-core/src/error.rs index 79719644..ebf25292 100644 --- a/src/llm-coding-tools-core/src/error.rs +++ b/src/llm-coding-tools-core/src/error.rs @@ -40,27 +40,17 @@ pub enum ToolError { /// JSON serialization/deserialization failed. #[error("JSON error: {0}")] Json(#[from] serde_json::Error), - - /// Regex compilation or matching failed. - #[error("regex error: {0}")] - Regex(#[from] regex::Error), } /// Result type alias for tool operations. pub type ToolResult = Result; -impl From for ToolError { - fn from(e: glob::PatternError) -> Self { +impl From for ToolError { + fn from(e: globset::Error) -> Self { ToolError::InvalidPattern(e.to_string()) } } -impl From for ToolError { - fn from(e: glob::GlobError) -> Self { - ToolError::Io(e.into_error()) - } -} - #[cfg(test)] mod tests { use super::*; @@ -80,7 +70,7 @@ mod tests { #[test] fn tool_error_from_glob_pattern_error() { - let glob_err = glob::Pattern::new("[invalid").unwrap_err(); + let glob_err = globset::Glob::new("[invalid").unwrap_err(); let err: ToolError = glob_err.into(); assert!(matches!(err, ToolError::InvalidPattern(_))); } diff --git a/src/llm-coding-tools-core/src/operations/glob.rs b/src/llm-coding-tools-core/src/operations/glob.rs index 776ad1b4..25d331ef 100644 --- a/src/llm-coding-tools-core/src/operations/glob.rs +++ b/src/llm-coding-tools-core/src/operations/glob.rs @@ -2,7 +2,7 @@ use crate::error::{ToolError, ToolResult}; use crate::path::PathResolver; -use glob::Pattern; +use globset::Glob; use ignore::WalkBuilder; use serde::Serialize; use std::time::SystemTime; @@ -36,8 +36,7 @@ pub fn glob_files( ))); } - let compiled_pattern = - Pattern::new(pattern).map_err(|e| ToolError::InvalidPattern(e.to_string()))?; + let matcher = Glob::new(pattern)?.compile_matcher(); let mut files_with_mtime: Vec<(String, SystemTime)> = Vec::new(); @@ -75,7 +74,7 @@ pub fn glob_files( continue; } - if !compiled_pattern.matches(&rel_path) { + if !matcher.is_match(&rel_path) { continue; } diff --git a/src/llm-coding-tools-core/src/operations/grep.rs b/src/llm-coding-tools-core/src/operations/grep.rs index d126c364..052c2466 100644 --- a/src/llm-coding-tools-core/src/operations/grep.rs +++ b/src/llm-coding-tools-core/src/operations/grep.rs @@ -2,7 +2,7 @@ use crate::error::{ToolError, ToolResult}; use crate::path::PathResolver; -use glob::Pattern; +use globset::Glob; use grep_regex::RegexMatcher; use grep_searcher::sinks::UTF8; use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder}; @@ -59,8 +59,8 @@ pub fn grep_search( RegexMatcher::new(pattern).map_err(|e| ToolError::InvalidPattern(e.to_string()))?; // Optional filename filter via glob. - let glob_pattern = include - .map(|g| Pattern::new(g).map_err(|e| ToolError::InvalidPattern(e.to_string()))) + let glob_matcher = include + .map(|pattern| Glob::new(pattern).map(|glob| glob.compile_matcher())) .transpose()?; let mut searcher = SearcherBuilder::new() @@ -91,12 +91,12 @@ pub fn grep_search( let entry_path = entry.path(); // Apply include glob to basename when requested. - if let Some(ref glob) = glob_pattern { + if let Some(ref matcher) = glob_matcher { let file_name = match entry_path.file_name().and_then(|n| n.to_str()) { Some(name) => name, None => continue, }; - if !glob.matches(file_name) { + if !matcher.is_match(file_name) { continue; } }