From 58938dc5751c8b4e50ff4b237f4b9ad353a76227 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Thu, 15 Jan 2026 22:13:07 +0000 Subject: [PATCH 01/13] Docs: chain verification commands with && for single-line copy-paste --- src/AGENTS.md | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/AGENTS.md b/src/AGENTS.md index 204d5671..271c722e 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -71,16 +71,7 @@ This is a high-performance library. Optimize aggressively. All must pass without warnings: ```bash -# Test async mode (default) -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 (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 +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 && cargo test -p llm-coding-tools-core --no-default-features --features blocking --quiet && cargo doc --workspace --no-deps --quiet && cargo fmt --all --quiet ``` Note: `llm-coding-tools-rig` is async-only (implements rig's async `Tool` trait). @@ -88,6 +79,5 @@ The `blocking` feature only applies to `llm-coding-tools-core`. For individual crates: ```bash -cargo publish --dry-run -p llm-coding-tools-core --quiet -cargo publish --dry-run -p llm-coding-tools-rig --quiet +cargo publish --dry-run -p llm-coding-tools-core --quiet && cargo publish --dry-run -p llm-coding-tools-rig --quiet ``` From 3d4b6ef8a9fa8841158d2e2db937664d58f95250 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Thu, 15 Jan 2026 22:18:34 +0000 Subject: [PATCH 02/13] Docs: treat clippy warnings as errors for both crates --- src/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AGENTS.md b/src/AGENTS.md index 271c722e..c6573cc6 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -71,7 +71,7 @@ This is a high-performance library. Optimize aggressively. All must pass without warnings: ```bash -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 && cargo test -p llm-coding-tools-core --no-default-features --features blocking --quiet && cargo doc --workspace --no-deps --quiet && cargo fmt --all --quiet +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 -- -D warnings && cargo clippy -p llm-coding-tools-rig --quiet -- -D warnings && cargo test -p llm-coding-tools-core --no-default-features --features blocking --quiet && cargo doc --workspace --no-deps --quiet && cargo fmt --all ``` Note: `llm-coding-tools-rig` is async-only (implements rig's async `Tool` trait). From a988885541ada664a60b6cc086eacf3cd05584f5 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 16 Jan 2026 08:30:43 +0000 Subject: [PATCH 03/13] Added: serdesAI tool crate and core conversion support Introduce llm-coding-tools-serdesai with absolute/allowed tools plus bash/task/todo/webfetch integrations backed by schema builders and conversion helpers. Extend core ToolOutput with overflow metadata, and add README, examples, tests, and workspace wiring for the new crate. Renamed tools to uppercase --- src/AGENTS.md | 12 +- src/Cargo.lock | 546 +++++++++++++++++- src/Cargo.toml | 2 +- src/llm-coding-tools-core/Cargo.toml | 4 +- src/llm-coding-tools-core/src/preamble.rs | 2 +- src/llm-coding-tools-rig/README.md | 9 - src/llm-coding-tools-rig/examples/basic.rs | 31 +- .../examples/full_agent.rs | 105 ---- .../examples/sandboxed.rs | 44 +- src/llm-coding-tools-rig/src/absolute/edit.rs | 4 +- src/llm-coding-tools-rig/src/absolute/glob.rs | 4 +- src/llm-coding-tools-rig/src/absolute/grep.rs | 4 +- src/llm-coding-tools-rig/src/absolute/read.rs | 4 +- .../src/absolute/write.rs | 4 +- src/llm-coding-tools-rig/src/allowed/edit.rs | 4 +- src/llm-coding-tools-rig/src/allowed/glob.rs | 4 +- src/llm-coding-tools-rig/src/allowed/grep.rs | 4 +- src/llm-coding-tools-rig/src/allowed/read.rs | 4 +- src/llm-coding-tools-rig/src/allowed/write.rs | 4 +- src/llm-coding-tools-rig/src/bash.rs | 4 +- src/llm-coding-tools-rig/src/lib.rs | 4 +- src/llm-coding-tools-rig/src/task.rs | 4 +- src/llm-coding-tools-rig/src/todo.rs | 8 +- src/llm-coding-tools-rig/src/webfetch.rs | 4 +- src/llm-coding-tools-serdesai/Cargo.toml | 36 ++ src/llm-coding-tools-serdesai/README.md | 93 +++ .../examples/basic.rs | 64 ++ .../examples/sandboxed.rs | 54 ++ .../src/absolute/edit.rs | 184 ++++++ .../src/absolute/glob.rs | 124 ++++ .../src/absolute/grep.rs | 310 ++++++++++ .../src/absolute/mod.rs | 24 + .../src/absolute/read.rs | 139 +++++ .../src/absolute/write.rs | 103 ++++ .../src/allowed/edit.rs | 147 +++++ .../src/allowed/glob.rs | 143 +++++ .../src/allowed/grep.rs | 232 ++++++++ .../src/allowed/mod.rs | 24 + .../src/allowed/read.rs | 160 +++++ .../src/allowed/write.rs | 121 ++++ src/llm-coding-tools-serdesai/src/bash.rs | 247 ++++++++ src/llm-coding-tools-serdesai/src/convert.rs | 215 +++++++ src/llm-coding-tools-serdesai/src/lib.rs | 73 +++ src/llm-coding-tools-serdesai/src/schema.rs | 187 ++++++ src/llm-coding-tools-serdesai/src/task.rs | 175 ++++++ src/llm-coding-tools-serdesai/src/todo.rs | 169 ++++++ src/llm-coding-tools-serdesai/src/webfetch.rs | 151 +++++ 47 files changed, 3768 insertions(+), 226 deletions(-) delete mode 100644 src/llm-coding-tools-rig/examples/full_agent.rs create mode 100644 src/llm-coding-tools-serdesai/Cargo.toml create mode 100644 src/llm-coding-tools-serdesai/README.md create mode 100644 src/llm-coding-tools-serdesai/examples/basic.rs create mode 100644 src/llm-coding-tools-serdesai/examples/sandboxed.rs create mode 100644 src/llm-coding-tools-serdesai/src/absolute/edit.rs create mode 100644 src/llm-coding-tools-serdesai/src/absolute/glob.rs create mode 100644 src/llm-coding-tools-serdesai/src/absolute/grep.rs create mode 100644 src/llm-coding-tools-serdesai/src/absolute/mod.rs create mode 100644 src/llm-coding-tools-serdesai/src/absolute/read.rs create mode 100644 src/llm-coding-tools-serdesai/src/absolute/write.rs create mode 100644 src/llm-coding-tools-serdesai/src/allowed/edit.rs create mode 100644 src/llm-coding-tools-serdesai/src/allowed/glob.rs create mode 100644 src/llm-coding-tools-serdesai/src/allowed/grep.rs create mode 100644 src/llm-coding-tools-serdesai/src/allowed/mod.rs create mode 100644 src/llm-coding-tools-serdesai/src/allowed/read.rs create mode 100644 src/llm-coding-tools-serdesai/src/allowed/write.rs create mode 100644 src/llm-coding-tools-serdesai/src/bash.rs create mode 100644 src/llm-coding-tools-serdesai/src/convert.rs create mode 100644 src/llm-coding-tools-serdesai/src/lib.rs create mode 100644 src/llm-coding-tools-serdesai/src/schema.rs create mode 100644 src/llm-coding-tools-serdesai/src/task.rs create mode 100644 src/llm-coding-tools-serdesai/src/todo.rs create mode 100644 src/llm-coding-tools-serdesai/src/webfetch.rs diff --git a/src/AGENTS.md b/src/AGENTS.md index c6573cc6..0ee8505b 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -20,6 +20,11 @@ The `async` and `blocking` features are mutually exclusive - enabling both cause - `src/absolute/` - Unrestricted file system tools - `src/allowed/` - Sandboxed file system tools - `src/bash.rs`, `src/task.rs`, etc. - Standalone tools +- `llm-coding-tools-serdesai/` - serdesAI framework Tool implementations + - `src/absolute/` - Unrestricted file system tools + - `src/allowed/` - Sandboxed file system tools + - `src/schema.rs` - Schema building utilities + - `src/convert.rs` - Type conversions between core and serdesAI # Code & Performance Guidelines @@ -31,7 +36,6 @@ This is a high-performance library. Optimize aggressively. - `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 @@ -71,13 +75,13 @@ This is a high-performance library. Optimize aggressively. All must pass without warnings: ```bash -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 -- -D warnings && cargo clippy -p llm-coding-tools-rig --quiet -- -D warnings && cargo test -p llm-coding-tools-core --no-default-features --features blocking --quiet && cargo doc --workspace --no-deps --quiet && cargo fmt --all +cargo build -p llm-coding-tools-core && cargo build -p llm-coding-tools-rig --quiet && cargo build -p llm-coding-tools-serdesai --quiet && cargo test -p llm-coding-tools-core && cargo test -p llm-coding-tools-rig --quiet && cargo test -p llm-coding-tools-serdesai --quiet && cargo clippy -p llm-coding-tools-core -- -D warnings && cargo clippy -p llm-coding-tools-rig --quiet -- -D warnings && cargo clippy -p llm-coding-tools-serdesai --quiet -- -D warnings && cargo test -p llm-coding-tools-core --no-default-features --features blocking --quiet && cargo doc --workspace --no-deps --quiet && cargo fmt --all ``` -Note: `llm-coding-tools-rig` is async-only (implements rig's async `Tool` trait). +Note: `llm-coding-tools-rig` and `llm-coding-tools-serdesai` are async-only (implement async `Tool` traits). The `blocking` feature only applies to `llm-coding-tools-core`. For individual crates: ```bash -cargo publish --dry-run -p llm-coding-tools-core --quiet && cargo publish --dry-run -p llm-coding-tools-rig --quiet +cargo publish --dry-run -p llm-coding-tools-core --quiet && cargo publish --dry-run -p llm-coding-tools-rig --quiet && cargo publish --dry-run -p llm-coding-tools-serdesai --quiet ``` diff --git a/src/Cargo.lock b/src/Cargo.lock index 8eebfa82..a76a4ec8 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -17,6 +17,21 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "as-any" version = "0.3.2" @@ -121,6 +136,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -174,6 +198,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cmake" version = "0.1.57" @@ -219,6 +257,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -244,6 +291,51 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -262,6 +354,47 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -474,6 +607,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -604,9 +747,9 @@ dependencies = [ [[package]] name = "html-to-markdown-rs" -version = "2.20.0" +version = "2.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b44ff13ff909885d418b0c63d9a485382cdc1b3a3e016a100f8e79e5df934d21" +checksum = "bc035a13874f15114115e664e096596b9ac54c9938befb5bfc2f2e35a5492e7d" dependencies = [ "astral-tl", "base64", @@ -742,6 +885,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -823,6 +990,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -868,6 +1041,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown", + "serde", + "serde_core", ] [[package]] @@ -995,6 +1170,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "llm-coding-tools-serdesai" +version = "0.1.0" +dependencies = [ + "async-trait", + "llm-coding-tools-core", + "reqwest 0.13.1", + "serde", + "serde_json", + "serdes-ai", + "tempfile", + "tokio", + "wiremock", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -1332,7 +1522,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", @@ -1373,14 +1563,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1390,7 +1601,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", ] [[package]] @@ -1833,6 +2053,236 @@ dependencies = [ "serde", ] +[[package]] +name = "serdes-ai" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8569bee40f2a987f3475e3d6782d8a77f06de16c39673028039c6243b2a0ecf9" +dependencies = [ + "futures", + "serdes-ai-agent", + "serdes-ai-core", + "serdes-ai-macros", + "serdes-ai-models", + "serdes-ai-output", + "serdes-ai-providers", + "serdes-ai-retries", + "serdes-ai-streaming", + "serdes-ai-tools", + "serdes-ai-toolsets", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "serdes-ai-agent" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed7bd40c9d7926475b223a077d5bf21bddba5181f52037ece4f92fd637ac5e7" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures", + "parking_lot", + "pin-project-lite", + "serde", + "serde_json", + "serdes-ai-core", + "serdes-ai-models", + "serdes-ai-tools", + "thiserror 1.0.69", + "tokio", + "uuid", +] + +[[package]] +name = "serdes-ai-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "676c99ca60334ed24283a4468273ceada2756c0fb7de4b013c6d6d95fd4047ef" +dependencies = [ + "anyhow", + "base64", + "bytes", + "chrono", + "derive_builder", + "indexmap", + "mime", + "serde", + "serde_json", + "thiserror 1.0.69", + "url", + "uuid", +] + +[[package]] +name = "serdes-ai-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18800715aeadd544510c8f9c201ccd39386767ebc6bf39676f6fa9c71363942f" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serdes-ai-models" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6509a072a0853207cb80849fdafc6c3840b41e6a8c03b8613d1148158673356f" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "bytes", + "chrono", + "derive_builder", + "futures", + "pin-project-lite", + "reqwest 0.12.28", + "serde", + "serde_json", + "serdes-ai-core", + "serdes-ai-output", + "serdes-ai-tools", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "serdes-ai-output" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c382cd2cc061d118c9390e5445f8655acc8cacb430eae1c24a09927f23c767a9" +dependencies = [ + "anyhow", + "async-trait", + "parking_lot", + "regex", + "serde", + "serde_json", + "serdes-ai-core", + "serdes-ai-macros", + "serdes-ai-tools", + "serdes-ai-toolsets", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "serdes-ai-providers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d421ede16430bd11fc3539aea7e55d7b8491157b18ae5430262cfaa0f64a4e5" +dependencies = [ + "async-trait", + "base64", + "getrandom 0.2.16", + "parking_lot", + "reqwest 0.12.28", + "serde", + "serde_json", + "serdes-ai-core", + "serdes-ai-models", + "sha2", + "thiserror 1.0.69", + "tokio", + "tracing", + "urlencoding", +] + +[[package]] +name = "serdes-ai-retries" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d1f7a5278e683e094981fbb235b8b4633dea5eb2143ae24cc82f50685b0b73e" +dependencies = [ + "anyhow", + "async-trait", + "rand 0.8.5", + "reqwest 0.12.28", + "serde", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "serdes-ai-streaming" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804ecff9861822dce45e4b11d6317a9afa41413f0c0e009bd66f92dcf2bbe7dc" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures", + "parking_lot", + "pin-project-lite", + "serde", + "serde_json", + "serdes-ai-core", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "serdes-ai-tools" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1548b8e44d2320944c741ff51b08c728776ab92d2f145a9175d6657fe9ec19f2" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "indexmap", + "parking_lot", + "serde", + "serde_json", + "serdes-ai-core", + "serdes-ai-macros", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "serdes-ai-toolsets" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d62f4cd04af6b4907bbd8ce28161a18e17223d631f18e20adb266bcec034e56" +dependencies = [ + "async-trait", + "indexmap", + "parking_lot", + "serde", + "serde_json", + "serdes-ai-core", + "serdes-ai-tools", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1908,6 +2358,12 @@ dependencies = [ "quote", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -2064,6 +2520,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -2092,6 +2549,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2199,6 +2667,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicase" version = "2.9.0" @@ -2227,8 +2701,15 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -2247,6 +2728,24 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -2411,6 +2910,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/src/Cargo.toml b/src/Cargo.toml index 6584a8a7..30e02aef 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" -members = ["llm-coding-tools-core", "llm-coding-tools-rig"] +members = ["llm-coding-tools-core", "llm-coding-tools-rig", "llm-coding-tools-serdesai"] # Profile Build [profile.profile] diff --git a/src/llm-coding-tools-core/Cargo.toml b/src/llm-coding-tools-core/Cargo.toml index b80e4dcd..253a7215 100644 --- a/src/llm-coding-tools-core/Cargo.toml +++ b/src/llm-coding-tools-core/Cargo.toml @@ -36,10 +36,10 @@ 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 +memchr = "2.7.6" # Fast newline detection in read_file # Webfetch tool converts HTML to markdown for LLM-friendly output -html-to-markdown-rs = "2.20" +html-to-markdown-rs = "2.22" reqwest = { version = "0.13", default-features = false, features = [ "rustls", "rustls-native-certs", diff --git a/src/llm-coding-tools-core/src/preamble.rs b/src/llm-coding-tools-core/src/preamble.rs index 3b761514..b5fbd699 100644 --- a/src/llm-coding-tools-core/src/preamble.rs +++ b/src/llm-coding-tools-core/src/preamble.rs @@ -120,7 +120,7 @@ impl PreambleBuilder { /// ``` /// /// For example, if working with rig's ToolSet builder: - /// ```ignore + /// ```text /// let mut pb = PreambleBuilder::new(); /// let toolset = ToolSet::builder() /// .static_tool(pb.track(ReadTool::new())) diff --git a/src/llm-coding-tools-rig/README.md b/src/llm-coding-tools-rig/README.md index 89c7ac3c..0b9ac470 100644 --- a/src/llm-coding-tools-rig/README.md +++ b/src/llm-coding-tools-rig/README.md @@ -81,12 +81,6 @@ Reads files from disk. Executes shell commands. ``` -Run the full example app: - -```bash -OPENAI_API_KEY=... cargo run --example full_agent -p llm-coding-tools-rig -``` - ## Usage File tools come in `absolute::*` (unrestricted) and `allowed::*` (sandboxed) variants: @@ -113,9 +107,6 @@ Context strings are re-exported in `llm_coding_tools_rig::context` (e.g., `BASH` # Basic toolset setup with PreambleBuilder cargo run --example basic -p llm-coding-tools-rig -# Complete agent configuration (recommended starting point) -cargo run --example full_agent -p llm-coding-tools-rig - # Sandboxed file access with allowed::* tools cargo run --example sandboxed -p llm-coding-tools-rig ``` diff --git a/src/llm-coding-tools-rig/examples/basic.rs b/src/llm-coding-tools-rig/examples/basic.rs index 592ff633..c79b17dc 100644 --- a/src/llm-coding-tools-rig/examples/basic.rs +++ b/src/llm-coding-tools-rig/examples/basic.rs @@ -7,8 +7,6 @@ //! - Generating and using the preamble string //! //! Run: cargo run --example basic -p llm-coding-tools-rig -//! -//! For a complete agent setup, see: cargo run --example full_agent -p llm-coding-tools-rig use llm_coding_tools_rig::absolute::{GlobTool, GrepTool, ReadTool}; use llm_coding_tools_rig::{BashTool, PreambleBuilder, TodoTools}; @@ -23,7 +21,7 @@ async fn main() { let mut pb = PreambleBuilder::::new(); // === Use ToolSet::builder() directly - full Rig API! === - let toolset = ToolSet::builder() + let _toolset = ToolSet::builder() .static_tool(pb.track(ReadTool::::new())) .static_tool(pb.track(GlobTool::new())) .static_tool(pb.track(GrepTool::::new())) @@ -37,29 +35,6 @@ async fn main() { // === 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() { - 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()); - let truncated_preamble: String = preamble.chars().take(1000).collect(); - println!("{}", truncated_preamble); - 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?; + // Print the preamble + println!("{preamble}"); } diff --git a/src/llm-coding-tools-rig/examples/full_agent.rs b/src/llm-coding-tools-rig/examples/full_agent.rs deleted file mode 100644 index dc1a12fc..00000000 --- a/src/llm-coding-tools-rig/examples/full_agent.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! 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 llm-coding-tools-rig - -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] -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/llm-coding-tools-rig/examples/sandboxed.rs b/src/llm-coding-tools-rig/examples/sandboxed.rs index 8b980a9c..96225ab9 100644 --- a/src/llm-coding-tools-rig/examples/sandboxed.rs +++ b/src/llm-coding-tools-rig/examples/sandboxed.rs @@ -23,26 +23,12 @@ async fn main() -> Result<(), Box> { // // 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 + std::env::current_dir()?, // 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 resolver and tools === // // Create one resolver and share it across tools. // More efficient and ensures consistency. @@ -56,7 +42,7 @@ async fn main() -> Result<(), Box> { // === Build toolset === let mut pb = PreambleBuilder::::new(); - let toolset = ToolSet::builder() + let _toolset = ToolSet::builder() .static_tool(pb.track(read)) .static_tool(pb.track(write)) .static_tool(pb.track(edit)) @@ -66,26 +52,8 @@ async fn main() -> Result<(), Box> { 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."); + // Print the preamble + println!("{preamble}"); Ok(()) } diff --git a/src/llm-coding-tools-rig/src/absolute/edit.rs b/src/llm-coding-tools-rig/src/absolute/edit.rs index fcd9e803..7ebbef9b 100644 --- a/src/llm-coding-tools-rig/src/absolute/edit.rs +++ b/src/llm-coding-tools-rig/src/absolute/edit.rs @@ -36,7 +36,7 @@ impl EditTool { } impl Tool for EditTool { - const NAME: &'static str = "edit"; + const NAME: &'static str = "Edit"; type Error = EditError; type Args = EditArgs; @@ -67,7 +67,7 @@ impl Tool for EditTool { } impl ToolContext for EditTool { - const NAME: &'static str = "edit"; + const NAME: &'static str = "Edit"; fn context(&self) -> &'static str { llm_coding_tools_core::context::EDIT_ABSOLUTE diff --git a/src/llm-coding-tools-rig/src/absolute/glob.rs b/src/llm-coding-tools-rig/src/absolute/glob.rs index b3b34fe8..4bd11cff 100644 --- a/src/llm-coding-tools-rig/src/absolute/glob.rs +++ b/src/llm-coding-tools-rig/src/absolute/glob.rs @@ -30,7 +30,7 @@ impl GlobTool { } impl Tool for GlobTool { - const NAME: &'static str = "glob"; + const NAME: &'static str = "Glob"; type Error = ToolError; type Args = GlobArgs; @@ -54,7 +54,7 @@ impl Tool for GlobTool { } impl ToolContext for GlobTool { - const NAME: &'static str = "glob"; + const NAME: &'static str = "Glob"; fn context(&self) -> &'static str { llm_coding_tools_core::context::GLOB_ABSOLUTE diff --git a/src/llm-coding-tools-rig/src/absolute/grep.rs b/src/llm-coding-tools-rig/src/absolute/grep.rs index b273164a..1e0d4a2d 100644 --- a/src/llm-coding-tools-rig/src/absolute/grep.rs +++ b/src/llm-coding-tools-rig/src/absolute/grep.rs @@ -45,7 +45,7 @@ impl GrepTool { } impl Tool for GrepTool { - const NAME: &'static str = "grep"; + const NAME: &'static str = "Grep"; type Error = ToolError; type Args = GrepArgs; @@ -132,7 +132,7 @@ impl Tool for GrepTool { } impl ToolContext for GrepTool { - const NAME: &'static str = "grep"; + const NAME: &'static str = "Grep"; fn context(&self) -> &'static str { llm_coding_tools_core::context::GREP_ABSOLUTE diff --git a/src/llm-coding-tools-rig/src/absolute/read.rs b/src/llm-coding-tools-rig/src/absolute/read.rs index b11aadc3..c60e80e6 100644 --- a/src/llm-coding-tools-rig/src/absolute/read.rs +++ b/src/llm-coding-tools-rig/src/absolute/read.rs @@ -45,7 +45,7 @@ impl ReadTool { } impl Tool for ReadTool { - const NAME: &'static str = "read"; + const NAME: &'static str = "Read"; type Error = ToolError; type Args = ReadArgs; @@ -72,7 +72,7 @@ impl Tool for ReadTool { } impl ToolContext for ReadTool { - const NAME: &'static str = "read"; + const NAME: &'static str = "Read"; fn context(&self) -> &'static str { llm_coding_tools_core::context::READ_ABSOLUTE diff --git a/src/llm-coding-tools-rig/src/absolute/write.rs b/src/llm-coding-tools-rig/src/absolute/write.rs index 93cdafa7..2eb1f6bc 100644 --- a/src/llm-coding-tools-rig/src/absolute/write.rs +++ b/src/llm-coding-tools-rig/src/absolute/write.rs @@ -30,7 +30,7 @@ impl WriteTool { } impl Tool for WriteTool { - const NAME: &'static str = "write"; + const NAME: &'static str = "Write"; type Error = ToolError; type Args = WriteToolArgs; @@ -54,7 +54,7 @@ impl Tool for WriteTool { } impl ToolContext for WriteTool { - const NAME: &'static str = "write"; + const NAME: &'static str = "Write"; fn context(&self) -> &'static str { llm_coding_tools_core::context::WRITE_ABSOLUTE diff --git a/src/llm-coding-tools-rig/src/allowed/edit.rs b/src/llm-coding-tools-rig/src/allowed/edit.rs index 82ba6902..621f55b5 100644 --- a/src/llm-coding-tools-rig/src/allowed/edit.rs +++ b/src/llm-coding-tools-rig/src/allowed/edit.rs @@ -45,7 +45,7 @@ impl EditTool { } impl Tool for EditTool { - const NAME: &'static str = "edit"; + const NAME: &'static str = "Edit"; type Error = EditError; type Args = EditArgs; @@ -75,7 +75,7 @@ impl Tool for EditTool { } impl ToolContext for EditTool { - const NAME: &'static str = "edit"; + const NAME: &'static str = "Edit"; fn context(&self) -> &'static str { llm_coding_tools_core::context::EDIT_ALLOWED diff --git a/src/llm-coding-tools-rig/src/allowed/glob.rs b/src/llm-coding-tools-rig/src/allowed/glob.rs index 5dfdad8d..061ff787 100644 --- a/src/llm-coding-tools-rig/src/allowed/glob.rs +++ b/src/llm-coding-tools-rig/src/allowed/glob.rs @@ -39,7 +39,7 @@ impl GlobTool { } impl Tool for GlobTool { - const NAME: &'static str = "glob"; + const NAME: &'static str = "Glob"; type Error = ToolError; type Args = GlobArgs; @@ -62,7 +62,7 @@ impl Tool for GlobTool { } impl ToolContext for GlobTool { - const NAME: &'static str = "glob"; + const NAME: &'static str = "Glob"; fn context(&self) -> &'static str { llm_coding_tools_core::context::GLOB_ALLOWED diff --git a/src/llm-coding-tools-rig/src/allowed/grep.rs b/src/llm-coding-tools-rig/src/allowed/grep.rs index decda513..a9c7501b 100644 --- a/src/llm-coding-tools-rig/src/allowed/grep.rs +++ b/src/llm-coding-tools-rig/src/allowed/grep.rs @@ -54,7 +54,7 @@ impl GrepTool { } impl Tool for GrepTool { - const NAME: &'static str = "grep"; + const NAME: &'static str = "Grep"; type Error = ToolError; type Args = GrepArgs; @@ -135,7 +135,7 @@ impl Tool for GrepTool { } impl ToolContext for GrepTool { - const NAME: &'static str = "grep"; + const NAME: &'static str = "Grep"; fn context(&self) -> &'static str { llm_coding_tools_core::context::GREP_ALLOWED diff --git a/src/llm-coding-tools-rig/src/allowed/read.rs b/src/llm-coding-tools-rig/src/allowed/read.rs index 63134b63..7a6b2644 100644 --- a/src/llm-coding-tools-rig/src/allowed/read.rs +++ b/src/llm-coding-tools-rig/src/allowed/read.rs @@ -56,7 +56,7 @@ impl ReadTool { } impl Tool for ReadTool { - const NAME: &'static str = "read"; + const NAME: &'static str = "Read"; type Error = ToolError; type Args = ReadArgs; @@ -84,7 +84,7 @@ impl Tool for ReadTool { } impl ToolContext for ReadTool { - const NAME: &'static str = "read"; + const NAME: &'static str = "Read"; fn context(&self) -> &'static str { llm_coding_tools_core::context::READ_ALLOWED diff --git a/src/llm-coding-tools-rig/src/allowed/write.rs b/src/llm-coding-tools-rig/src/allowed/write.rs index 47cf7228..d457771b 100644 --- a/src/llm-coding-tools-rig/src/allowed/write.rs +++ b/src/llm-coding-tools-rig/src/allowed/write.rs @@ -39,7 +39,7 @@ impl WriteTool { } impl Tool for WriteTool { - const NAME: &'static str = "write"; + const NAME: &'static str = "Write"; type Error = ToolError; type Args = WriteToolArgs; @@ -62,7 +62,7 @@ impl Tool for WriteTool { } impl ToolContext for WriteTool { - const NAME: &'static str = "write"; + const NAME: &'static str = "Write"; fn context(&self) -> &'static str { llm_coding_tools_core::context::WRITE_ALLOWED diff --git a/src/llm-coding-tools-rig/src/bash.rs b/src/llm-coding-tools-rig/src/bash.rs index 89671eaa..83c0a1c8 100644 --- a/src/llm-coding-tools-rig/src/bash.rs +++ b/src/llm-coding-tools-rig/src/bash.rs @@ -45,7 +45,7 @@ impl BashTool { } impl Tool for BashTool { - const NAME: &'static str = "bash"; + const NAME: &'static str = "Bash"; type Error = ToolError; type Args = BashArgs; @@ -71,7 +71,7 @@ impl Tool for BashTool { } impl ToolContext for BashTool { - const NAME: &'static str = "bash"; + const NAME: &'static str = "Bash"; fn context(&self) -> &'static str { llm_coding_tools_core::context::BASH diff --git a/src/llm-coding-tools-rig/src/lib.rs b/src/llm-coding-tools-rig/src/lib.rs index ea017183..da50ca23 100644 --- a/src/llm-coding-tools-rig/src/lib.rs +++ b/src/llm-coding-tools-rig/src/lib.rs @@ -11,7 +11,7 @@ //! //! # Example //! -//! ```ignore +//! ```no_run //! use llm_coding_tools_rig::absolute::ReadTool; //! use llm_coding_tools_rig::BashTool; //! ``` @@ -82,7 +82,7 @@ mod tests { assert!(preamble.contains("absolute path")); // From READ_ABSOLUTE // Tools are returned unchanged - assert_eq!( as rig::tool::Tool>::NAME, "read"); + assert_eq!( as rig::tool::Tool>::NAME, "Read"); let _ = read; let _ = bash; } diff --git a/src/llm-coding-tools-rig/src/task.rs b/src/llm-coding-tools-rig/src/task.rs index f2932cfa..031199fc 100644 --- a/src/llm-coding-tools-rig/src/task.rs +++ b/src/llm-coding-tools-rig/src/task.rs @@ -63,7 +63,7 @@ impl TaskTool { } impl Tool for TaskTool { - const NAME: &'static str = "task"; + const NAME: &'static str = "Task"; type Error = ToolError; type Args = TaskArgs; @@ -86,7 +86,7 @@ impl Tool for TaskTool { } impl ToolContext for TaskTool { - const NAME: &'static str = "task"; + const NAME: &'static str = "Task"; fn context(&self) -> &'static str { llm_coding_tools_core::context::TASK diff --git a/src/llm-coding-tools-rig/src/todo.rs b/src/llm-coding-tools-rig/src/todo.rs index e3e66b14..0a889cab 100644 --- a/src/llm-coding-tools-rig/src/todo.rs +++ b/src/llm-coding-tools-rig/src/todo.rs @@ -37,7 +37,7 @@ impl TodoWriteTool { } impl Tool for TodoWriteTool { - const NAME: &'static str = "todowrite"; + const NAME: &'static str = "TodoWrite"; type Error = ToolError; type Args = TodoWriteArgs; @@ -72,7 +72,7 @@ impl TodoReadTool { } impl Tool for TodoReadTool { - const NAME: &'static str = "todoread"; + const NAME: &'static str = "TodoRead"; type Error = ToolError; type Args = TodoReadArgs; @@ -94,7 +94,7 @@ impl Tool for TodoReadTool { } impl ToolContext for TodoWriteTool { - const NAME: &'static str = "todowrite"; + const NAME: &'static str = "TodoWrite"; fn context(&self) -> &'static str { llm_coding_tools_core::context::TODO_WRITE @@ -102,7 +102,7 @@ impl ToolContext for TodoWriteTool { } impl ToolContext for TodoReadTool { - const NAME: &'static str = "todoread"; + const NAME: &'static str = "TodoRead"; fn context(&self) -> &'static str { llm_coding_tools_core::context::TODO_READ diff --git a/src/llm-coding-tools-rig/src/webfetch.rs b/src/llm-coding-tools-rig/src/webfetch.rs index 824fc7e2..7e037834 100644 --- a/src/llm-coding-tools-rig/src/webfetch.rs +++ b/src/llm-coding-tools-rig/src/webfetch.rs @@ -58,7 +58,7 @@ impl WebFetchTool { } impl Tool for WebFetchTool { - const NAME: &'static str = "webfetch"; + const NAME: &'static str = "WebFetch"; type Error = ToolError; type Args = WebFetchArgs; @@ -88,7 +88,7 @@ impl Tool for WebFetchTool { } impl ToolContext for WebFetchTool { - const NAME: &'static str = "webfetch"; + const NAME: &'static str = "WebFetch"; fn context(&self) -> &'static str { llm_coding_tools_core::context::WEBFETCH diff --git a/src/llm-coding-tools-serdesai/Cargo.toml b/src/llm-coding-tools-serdesai/Cargo.toml new file mode 100644 index 00000000..174cb413 --- /dev/null +++ b/src/llm-coding-tools-serdesai/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "llm-coding-tools-serdesai" +version = "0.1.0" +edition = "2024" +description = "Lightweight, high-performance serdesAI framework Tool implementations for coding tools" +repository = "https://github.com/Sewer56/llm-coding-tools" +license = "Apache-2.0" +readme = "README.md" +include = ["src/**/*", "examples/**/*", "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", +] } + +# serdes-ai provides Tool trait, ToolDefinition, RunContext +serdes-ai = "0.1" + +# Tool trait is async - async-trait is NOT re-exported from serdes-ai +async-trait = "0.1" + +# Tool parameters use serde for deserialization, serde_json for schema +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# HTTP client for webfetch tool +reqwest = { version = "0.13", default-features = false, features = [ + "rustls", + "rustls-native-certs", +] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tempfile = "3" +wiremock = "0.6" diff --git a/src/llm-coding-tools-serdesai/README.md b/src/llm-coding-tools-serdesai/README.md new file mode 100644 index 00000000..016134c9 --- /dev/null +++ b/src/llm-coding-tools-serdesai/README.md @@ -0,0 +1,93 @@ +# llm-coding-tools-serdesai + +[![Crates.io](https://img.shields.io/crates/v/llm-coding-tools-serdesai.svg)](https://crates.io/crates/llm-coding-tools-serdesai) +[![Docs.rs](https://docs.rs/llm-coding-tools-serdesai/badge.svg)](https://docs.rs/llm-coding-tools-serdesai) + +Lightweight, high-performance serdesAI framework Tool implementations for coding tools. + +## Features + +- **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** - Shared-state todo list tracking +- **Context strings** - LLM guidance text for tool usage (re-exported from core) +- **Schema builders** - Composable helpers for custom tool definitions + +## Installation + +Add to your `Cargo.toml`: + +```toml +[dependencies] +llm-coding-tools-serdesai = "0.1" +``` + +## Quick Start + +```rust +use llm_coding_tools_serdesai::absolute::{GlobTool, GrepTool, ReadTool}; +use llm_coding_tools_serdesai::{BashTool, PreambleBuilder, create_todo_tools}; +use serdes_ai::tools::ToolRegistry; + +#[tokio::main] +async fn main() { + let mut pb = PreambleBuilder::::new(); + let mut registry = ToolRegistry::<()>::new(); + + registry.register(pb.track(ReadTool::::new())); + registry.register(pb.track(GlobTool::new())); + registry.register(pb.track(GrepTool::::new())); + registry.register(pb.track(BashTool::new())); + + let (todo_read, todo_write, _state) = create_todo_tools(); + registry.register(pb.track(todo_read)); + registry.register(pb.track(todo_write)); + + let preamble = pb.build(); + + // Pass `preamble` to your agent's system prompt + // Pass `registry` to your agent's tools +} +``` + +See the [basic example](examples/basic.rs) for a complete working setup. + +## Usage + +File tools come in `absolute::*` (unrestricted) and `allowed::*` (sandboxed) variants: + +```rust +use llm_coding_tools_serdesai::absolute::{ReadTool, WriteTool}; +use llm_coding_tools_serdesai::allowed::{ReadTool as AllowedReadTool, WriteTool as AllowedWriteTool}; +use std::path::PathBuf; + +// Unrestricted access (absolute paths) +let read = ReadTool::::new(); + +// Sandboxed access (paths relative to allowed directories) +let allowed_paths = vec![PathBuf::from("/home/user/project"), PathBuf::from("/tmp")]; +let sandboxed_read: AllowedReadTool = AllowedReadTool::new(allowed_paths.clone()); +let sandboxed_write = AllowedWriteTool::new(allowed_paths); +``` + +Other tools: `BashTool`, `WebFetchTool`, `TaskTool`, `TodoReadTool`, `TodoWriteTool`. +Use `PreambleBuilder` to register tools and pass `pb.build()` to your agent's system prompt. +Context strings are re-exported in `llm_coding_tools_serdesai::context` (e.g., `BASH`, `READ_ABSOLUTE`). + +## Examples + +```bash +# Basic toolset setup with PreambleBuilder +cargo run --example basic -p llm-coding-tools-serdesai + +# Sandboxed file access with allowed::* tools +cargo run --example sandboxed -p llm-coding-tools-serdesai +``` + +## License + +Apache 2.0 diff --git a/src/llm-coding-tools-serdesai/examples/basic.rs b/src/llm-coding-tools-serdesai/examples/basic.rs new file mode 100644 index 00000000..ef180888 --- /dev/null +++ b/src/llm-coding-tools-serdesai/examples/basic.rs @@ -0,0 +1,64 @@ +//! Basic tools example - demonstrates tool setup with serdesAI. +//! +//! Shows: +//! - Creating tools individually +//! - Using [`PreambleBuilder`] for context generation +//! - Registering tools with [`ToolRegistry`] +//! +//! Run: cargo run --example basic -p llm-coding-tools-serdesai + +use llm_coding_tools_serdesai::absolute::{GlobTool, GrepTool, ReadTool}; +use llm_coding_tools_serdesai::{BashTool, PreambleBuilder, WebFetchTool, create_todo_tools}; +use serdes_ai::tools::ToolRegistry; + +#[tokio::main] +async fn main() { + // === Create preamble builder to track tools === + let mut pb = PreambleBuilder::::new(); + + // === Create and register tools with ToolRegistry === + let mut registry = ToolRegistry::<()>::new(); + + // File operations + registry.register(pb.track(ReadTool::::new())); + registry.register(pb.track(GlobTool::new())); + registry.register(pb.track(GrepTool::::new())); + + // Shell execution + registry.register(pb.track(BashTool::new())); + + // Web content fetching + registry.register(pb.track(WebFetchTool::new())); + + // Todo tools with shared state + let (todo_read, todo_write, _state) = create_todo_tools(); + registry.register(pb.track(todo_read)); + registry.register(pb.track(todo_write)); + + // === Generate preamble string === + let preamble = pb.build(); + + // === Print tool definitions from registry === + println!("=== Tools in Registry ({}) ===", registry.len()); + for def in registry.definitions() { + println!(" - {}: {}", def.name, def.description); + } + + // === Print generated preamble === + println!( + "\n=== Generated Preamble ({} chars) ===\n", + preamble.chars().count() + ); + println!("{}", preamble); + + // === Integration with serdesAI Agent === + // IMPORTANT: Pass the preamble to your agent's system prompt! + // + // let agent = Agent::builder() + // .model("openai:gpt-4o") + // .system_prompt(&preamble) + // .tools(registry) + // .build()?; + // + // let response = agent.run("Read Cargo.toml", ()).await?; +} diff --git a/src/llm-coding-tools-serdesai/examples/sandboxed.rs b/src/llm-coding-tools-serdesai/examples/sandboxed.rs new file mode 100644 index 00000000..69e5eeee --- /dev/null +++ b/src/llm-coding-tools-serdesai/examples/sandboxed.rs @@ -0,0 +1,54 @@ +//! 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 llm-coding-tools-serdesai + +use llm_coding_tools_serdesai::PreambleBuilder; +use llm_coding_tools_serdesai::allowed::{EditTool, GlobTool, GrepTool, ReadTool, WriteTool}; +use serdes_ai::tools::ToolRegistry; + +#[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. + let allowed_paths = vec![ + std::env::current_dir()?, // Current working directory + std::env::temp_dir(), // Temp directory (cross-platform) + ]; + + // === Create tools with allowed paths === + // + // Each tool is initialized with the same set of allowed directories. + // The `allowed` module tools use `AllowedPathResolver` internally. + let read: ReadTool = + ReadTool::new(allowed_paths.clone()).expect("allowed paths should be valid"); + let write = WriteTool::new(allowed_paths.clone()); + let edit = EditTool::new(allowed_paths.clone()); + let glob = GlobTool::new(allowed_paths.clone()); + let grep: GrepTool = GrepTool::new(allowed_paths); + + // === Build registry with preamble tracking === + let mut pb = PreambleBuilder::::new(); + let mut registry = ToolRegistry::<()>::new(); + + registry.register(pb.track(read)); + registry.register(pb.track(write)); + registry.register(pb.track(edit)); + registry.register(pb.track(glob)); + registry.register(pb.track(grep)); + + let preamble = pb.build(); + + // Print the preamble + println!("{preamble}"); + + Ok(()) +} diff --git a/src/llm-coding-tools-serdesai/src/absolute/edit.rs b/src/llm-coding-tools-serdesai/src/absolute/edit.rs new file mode 100644 index 00000000..9f80930a --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/absolute/edit.rs @@ -0,0 +1,184 @@ +//! Edit file tool using [`AbsolutePathResolver`]. + +use async_trait::async_trait; +use llm_coding_tools_core::ToolContext; +use llm_coding_tools_core::operations::edit_file; +use llm_coding_tools_core::path::AbsolutePathResolver; +use serde::Deserialize; +use serdes_ai::tools::{ + RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult, ToolReturn, +}; + +use crate::convert::edit_error_to_serdes; + +/// Internal args for JSON deserialization. +#[derive(Debug, Deserialize)] +struct EditArgs { + /// Absolute path to the file. + file_path: String, + /// The exact text to find and replace. + old_string: String, + /// The text to replace with. + new_string: String, + /// Replace all occurrences instead of just the first. Defaults to false. + #[serde(default)] + 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 + } +} + +#[async_trait] +impl Tool for EditTool { + fn definition(&self) -> ToolDefinition { + let schema = SchemaBuilder::new() + .string("file_path", "Absolute path to the file", true) + .string("old_string", "The exact text to find and replace", true) + .string("new_string", "The text to replace with", true) + .boolean( + "replace_all", + "Replace all occurrences instead of just the first. Defaults to false.", + false, + ) + .build() + .expect("schema build should not fail"); + + ToolDefinition::new( + "Edit", + "Makes exact string replacements in files. Use replace_all=true to replace all occurrences.", + ) + .with_parameters(schema) + } + + async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { + let args: EditArgs = serde_json::from_value(args) + .map_err(|e| ToolError::validation_error("Edit", None, e.to_string()))?; + + let resolver = AbsolutePathResolver; + let result = edit_file( + &resolver, + &args.file_path, + &args.old_string, + &args.new_string, + args.replace_all, + ) + .await; + + result.map(ToolReturn::text).map_err(edit_error_to_serdes) + } +} + +impl ToolContext for EditTool { + const NAME: &'static str = "Edit"; + + fn context(&self) -> &'static str { + llm_coding_tools_core::context::EDIT_ABSOLUTE + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use serdes_ai::tools::RunContext; + use std::io::Write as _; + use tempfile::NamedTempFile; + + fn mock_ctx() -> RunContext<()> { + RunContext::new((), "test-model") + } + + #[tokio::test] + async fn edit_success() { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b"hello world").unwrap(); + file.flush().unwrap(); + + let tool = EditTool::new(); + let result = tool + .call( + &mock_ctx(), + json!({ + "file_path": file.path().to_string_lossy(), + "old_string": "world", + "new_string": "rust" + }), + ) + .await + .unwrap(); + + let text = result.as_text().unwrap(); + assert!(text.contains("1 occurrence")); + assert_eq!(std::fs::read_to_string(file.path()).unwrap(), "hello rust"); + } + + #[tokio::test] + async fn edit_not_found_error() { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b"hello world").unwrap(); + file.flush().unwrap(); + + let tool = EditTool::new(); + let result = tool + .call( + &mock_ctx(), + json!({ + "file_path": file.path().to_string_lossy(), + "old_string": "not_found", + "new_string": "replacement" + }), + ) + .await; + + let err = result.unwrap_err(); + assert!(matches!(err, ToolError::ValidationFailed { .. })); + // Check the error contains the validation message + match err { + ToolError::ValidationFailed { errors, .. } => { + assert!(!errors.is_empty()); + assert!(errors[0].message.contains("not found")); + } + _ => panic!("Expected ValidationFailed"), + } + } + + #[tokio::test] + async fn edit_ambiguous_match_error() { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(b"hello hello hello").unwrap(); + file.flush().unwrap(); + + let tool = EditTool::new(); + let result = tool + .call( + &mock_ctx(), + json!({ + "file_path": file.path().to_string_lossy(), + "old_string": "hello", + "new_string": "world", + "replace_all": false + }), + ) + .await; + + let err = result.unwrap_err(); + assert!(matches!(err, ToolError::ValidationFailed { .. })); + // Check the error contains the validation message + match err { + ToolError::ValidationFailed { errors, .. } => { + assert!(!errors.is_empty()); + assert!(errors[0].message.contains("3 times")); + } + _ => panic!("Expected ValidationFailed"), + } + } +} diff --git a/src/llm-coding-tools-serdesai/src/absolute/glob.rs b/src/llm-coding-tools-serdesai/src/absolute/glob.rs new file mode 100644 index 00000000..f3eb778d --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/absolute/glob.rs @@ -0,0 +1,124 @@ +//! Glob pattern file finding tool using [`AbsolutePathResolver`]. + +use async_trait::async_trait; +use llm_coding_tools_core::operations::glob_files; +use llm_coding_tools_core::path::AbsolutePathResolver; +use llm_coding_tools_core::{ToolContext, ToolOutput}; +use serde::Deserialize; +use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult}; + +use crate::convert::to_serdes_result; + +/// Internal args for JSON deserialization. +#[derive(Debug, Deserialize)] +struct GlobArgs { + /// Glob pattern to match files (e.g., "**/*.rs", "src/**/*.ts"). + pattern: String, + /// Absolute directory path to search in. + path: String, +} + +/// Tool for finding files matching glob patterns. +/// +/// Respects `.gitignore` and returns paths sorted by modification time (newest first). +#[derive(Debug, Clone, Default)] +pub struct GlobTool; + +impl GlobTool { + /// Creates a new glob tool instance. + #[inline] + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for GlobTool { + fn definition(&self) -> ToolDefinition { + let schema = SchemaBuilder::new() + .string( + "pattern", + "Glob pattern to match files (e.g., \"**/*.rs\", \"src/**/*.ts\")", + true, + ) + .string("path", "Absolute directory path to search in", true) + .build() + .expect("schema build should not fail"); + + ToolDefinition::new( + "Glob", + "Find files matching a glob pattern. Respects .gitignore and returns paths sorted by modification time (newest first).", + ) + .with_parameters(schema) + } + + async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { + let args: GlobArgs = serde_json::from_value(args) + .map_err(|e| ToolError::validation_error("Glob", None, e.to_string()))?; + + let resolver = AbsolutePathResolver; + let result = glob_files(&resolver, &args.pattern, &args.path); + + // Convert GlobOutput to ToolOutput for consistent error handling + to_serdes_result( + "Glob", + result.map(|output| { + let content = if output.files.is_empty() { + "No files found matching the pattern.".to_string() + } else { + output.files.join("\n") + }; + if output.truncated { + ToolOutput::truncated(content) + } else { + ToolOutput::new(content) + } + }), + ) + } +} + +impl ToolContext for GlobTool { + const NAME: &'static str = "Glob"; + + fn context(&self) -> &'static str { + llm_coding_tools_core::context::GLOB_ABSOLUTE + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use serdes_ai::tools::RunContext; + use std::fs::{self, File}; + use tempfile::TempDir; + + fn mock_ctx() -> RunContext<()> { + RunContext::new((), "test-model") + } + + #[tokio::test] + async fn finds_files_with_required_path() { + let dir = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + File::create(dir.path().join("src/lib.rs")).unwrap(); + File::create(dir.path().join("src/main.rs")).unwrap(); + + let tool = GlobTool::new(); + let result = tool + .call( + &mock_ctx(), + json!({ + "pattern": "**/*.rs", + "path": dir.path().to_string_lossy() + }), + ) + .await + .unwrap(); + + let text = result.as_text().unwrap(); + assert!(text.contains("lib.rs")); + assert!(text.contains("main.rs")); + } +} diff --git a/src/llm-coding-tools-serdesai/src/absolute/grep.rs b/src/llm-coding-tools-serdesai/src/absolute/grep.rs new file mode 100644 index 00000000..e04c54eb --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/absolute/grep.rs @@ -0,0 +1,310 @@ +//! Grep content search tool using [`AbsolutePathResolver`]. + +use async_trait::async_trait; +use llm_coding_tools_core::ToolContext; +use llm_coding_tools_core::operations::grep_search; +use llm_coding_tools_core::path::AbsolutePathResolver; +use serde::Deserialize; +use serdes_ai::tools::{ + RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult, ToolReturn, +}; +use std::fmt::Write; + +use crate::convert::to_serdes_result; + +const DEFAULT_LIMIT: usize = 100; +const MAX_LIMIT: usize = 2000; +const MAX_LINE_LENGTH: usize = 2000; + +/// Internal args for JSON deserialization. +#[derive(Debug, Deserialize)] +struct GrepArgs { + /// Regular expression pattern to search for in file contents. + pattern: String, + /// Absolute directory path to search in. + path: String, + /// File pattern to filter search results (e.g., "*.rs", "*.{ts,tsx}"). + #[serde(default)] + include: Option, + /// Maximum number of matches to return (default: 100, max: 2000). + #[serde(default)] + limit: Option, +} + +/// Tool for searching file contents using regex patterns. +/// +/// The `LINE_NUMBERS` const generic controls output format: +/// - `true` (default): Lines prefixed with `L{number}: ` +/// - `false`: Raw matching lines +#[derive(Debug, Clone, Default)] +pub struct GrepTool; + +impl GrepTool { + /// Creates a new grep tool instance. + #[inline] + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for GrepTool { + fn definition(&self) -> ToolDefinition { + // Description matches rig exactly + 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." + }; + let schema = SchemaBuilder::new() + .string( + "pattern", + "Regular expression pattern to search for in file contents", + true, + ) + .string("path", "Absolute directory path to search in", true) + .string( + "include", + "File pattern to filter search results (e.g., \"*.rs\", \"*.{ts,tsx}\")", + false, + ) + .integer_constrained( + "limit", + "Maximum number of matches to return (default: 100, max: 2000)", + false, + Some(1), + Some(2000), + ) + .build() + .expect("schema build should not fail"); + + ToolDefinition::new("Grep", description).with_parameters(schema) + } + + async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { + let args: GrepArgs = serde_json::from_value(args) + .map_err(|e| ToolError::validation_error("Grep", None, e.to_string()))?; + + let pattern = args.pattern.trim(); + if pattern.is_empty() { + return Err(ToolError::validation_error( + "Grep", + Some("pattern".to_string()), + "pattern must not be empty".to_string(), + )); + } + + let limit = args.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + if limit == 0 { + return Err(ToolError::validation_error( + "Grep", + Some("limit".to_string()), + "limit must be greater than zero".to_string(), + )); + } + + 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); + + match result { + Err(e) => to_serdes_result("Grep", Err(e)), + Ok(grep_output) => { + if grep_output.files.is_empty() { + return Ok(ToolReturn::text("No matches found.")); + } + + // Format output grouped by file + let mut output = String::with_capacity(4096); + let _ = writeln!(&mut output, "Found {} matches", grep_output.match_count); + + for file in &grep_output.files { + let _ = writeln!(&mut output, "\n{}:", file.path); + for m in &file.matches { + // Truncate at UTF-8 boundary + let truncated_text = if m.line_text.len() > MAX_LINE_LENGTH { + &m.line_text[..m.line_text.floor_char_boundary(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 grep_output.truncated { + let _ = write!(&mut output, "\n(Results truncated at {} matches)", limit); + } + + Ok(ToolReturn::text(output)) + } + } + } +} + +impl ToolContext for GrepTool { + const NAME: &'static str = "Grep"; + + fn context(&self) -> &'static str { + llm_coding_tools_core::context::GREP_ABSOLUTE + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use serdes_ai::tools::RunContext; + use tempfile::TempDir; + + fn mock_ctx() -> RunContext<()> { + RunContext::new((), "test-model") + } + + #[tokio::test] + async fn finds_content_with_required_path() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("test.txt"), "hello world\nfoo bar").unwrap(); + + let tool: GrepTool = GrepTool::new(); + let result = tool + .call( + &mock_ctx(), + json!({ + "pattern": "hello", + "path": dir.path().to_string_lossy() + }), + ) + .await + .unwrap(); + + let text = result.as_text().unwrap(); + assert!(text.contains("Found 1 matches")); + assert!(text.contains("L1: hello world")); + } + + #[tokio::test] + async fn validates_limit() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("test.txt"), "hello").unwrap(); + + let tool: GrepTool = GrepTool::new(); + let result = tool + .call( + &mock_ctx(), + json!({ + "pattern": "hello", + "path": dir.path().to_string_lossy(), + "limit": 0 + }), + ) + .await; + + let err = result.unwrap_err(); + assert!(matches!(err, ToolError::ValidationFailed { .. })); + // Check the error contains the validation message + match err { + ToolError::ValidationFailed { errors, .. } => { + assert!(!errors.is_empty()); + assert!(errors[0].message.contains("limit")); + } + _ => panic!("Expected ValidationFailed"), + } + } + + #[tokio::test] + async fn returns_no_matches_message_when_empty() { + 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( + &mock_ctx(), + json!({ + "pattern": "nonexistent_pattern_xyz", + "path": dir.path().to_string_lossy() + }), + ) + .await + .unwrap(); + + let text = result.as_text().unwrap(); + assert_eq!(text, "No matches found."); + } + + #[tokio::test] + async fn include_filter_restricts_to_matching_files() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("code.rs"), "fn hello() {}").unwrap(); + std::fs::write(dir.path().join("code.py"), "def hello(): pass").unwrap(); + std::fs::write(dir.path().join("readme.txt"), "hello world").unwrap(); + + let tool: GrepTool = GrepTool::new(); + + // Search only .rs files + let result = tool + .call( + &mock_ctx(), + json!({ + "pattern": "hello", + "path": dir.path().to_string_lossy(), + "include": "*.rs" + }), + ) + .await + .unwrap(); + + let text = result.as_text().unwrap(); + assert!(text.contains("Found 1 matches")); + assert!(text.contains("code.rs")); + assert!(!text.contains("code.py")); + assert!(!text.contains("readme.txt")); + } + + #[tokio::test] + async fn truncates_long_lines_at_max_length() { + let dir = TempDir::new().unwrap(); + // Create a line longer than MAX_LINE_LENGTH (2000 chars) + let long_line = format!("prefix_{}_suffix", "x".repeat(2500)); + std::fs::write(dir.path().join("long.txt"), &long_line).unwrap(); + + let tool: GrepTool = GrepTool::new(); + let result = tool + .call( + &mock_ctx(), + json!({ + "pattern": "prefix", + "path": dir.path().to_string_lossy() + }), + ) + .await + .unwrap(); + + let text = result.as_text().unwrap(); + assert!(text.contains("Found 1 matches")); + // The line should be truncated - it should contain prefix but not suffix + assert!(text.contains("prefix_")); + assert!(!text.contains("_suffix")); + // Verify the match line doesn't exceed MAX_LINE_LENGTH + for line in text.lines() { + if line.contains("prefix_") { + // Line format is " L1: content", so actual content is line.len() - prefix + let content_start = line.find("prefix_").unwrap(); + let content = &line[content_start..]; + assert!(content.len() <= MAX_LINE_LENGTH); + } + } + } +} diff --git a/src/llm-coding-tools-serdesai/src/absolute/mod.rs b/src/llm-coding-tools-serdesai/src/absolute/mod.rs new file mode 100644 index 00000000..a1f592e4 --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/absolute/mod.rs @@ -0,0 +1,24 @@ +//! Tools using [`llm_coding_tools_core::path::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::EditTool; +pub use glob::GlobTool; +pub use grep::GrepTool; +pub use read::ReadTool; +pub use write::WriteTool; diff --git a/src/llm-coding-tools-serdesai/src/absolute/read.rs b/src/llm-coding-tools-serdesai/src/absolute/read.rs new file mode 100644 index 00000000..d2ace97b --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/absolute/read.rs @@ -0,0 +1,139 @@ +//! Read file tool using [`AbsolutePathResolver`]. + +use async_trait::async_trait; +use llm_coding_tools_core::ToolContext; +use llm_coding_tools_core::operations::read_file; +use llm_coding_tools_core::path::AbsolutePathResolver; +use serde::Deserialize; +use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult}; + +use crate::convert::to_serdes_result; + +const DEFAULT_OFFSET: usize = 1; +const DEFAULT_LIMIT: usize = 2000; + +fn default_offset() -> usize { + DEFAULT_OFFSET +} + +fn default_limit() -> usize { + DEFAULT_LIMIT +} + +/// Internal args for JSON deserialization. +#[derive(Debug, Deserialize)] +struct ReadArgs { + /// Absolute path to the file. + file_path: String, + /// Line offset to start reading from (1-based). Defaults to 1. + #[serde(default = "default_offset")] + offset: usize, + /// Maximum number of lines to return. Defaults to 2000. + #[serde(default = "default_limit")] + limit: usize, +} + +/// Tool for reading file contents with optional line numbers. +/// +/// The `LINE_NUMBERS` const generic controls output format: +/// - `true` (default): Lines prefixed with `L{number}: ` +/// - `false`: Raw file content +#[derive(Debug, Clone, Default)] +pub struct ReadTool; + +impl ReadTool { + /// Creates a new read tool instance. + #[inline] + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for ReadTool { + fn definition(&self) -> ToolDefinition { + // Description matches rig exactly + 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." + }; + let schema = SchemaBuilder::new() + .string("file_path", "Absolute path to the file", true) + .integer_constrained( + "offset", + "Line offset to start reading from (1-based). Defaults to 1.", + false, + Some(1), + None, + ) + .integer_constrained( + "limit", + "Maximum number of lines to return. Defaults to 2000.", + false, + Some(1), + None, + ) + .build() + .expect("schema build should not fail"); + + ToolDefinition::new("Read", description).with_parameters(schema) + } + + async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { + let args: ReadArgs = serde_json::from_value(args) + .map_err(|e| ToolError::validation_error("Read", None, e.to_string()))?; + + let resolver = AbsolutePathResolver; + // Core uses 1-indexed offset directly; args.offset defaults to 1 + let result = + read_file::<_, LINE_NUMBERS>(&resolver, &args.file_path, args.offset, args.limit).await; + to_serdes_result("Read", result) + } +} + +impl ToolContext for ReadTool { + const NAME: &'static str = "Read"; + + fn context(&self) -> &'static str { + llm_coding_tools_core::context::READ_ABSOLUTE + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use serdes_ai::tools::RunContext; + use std::io::Write as _; + use tempfile::NamedTempFile; + + fn mock_ctx() -> RunContext<()> { + RunContext::new((), "test-model") + } + + #[tokio::test] + async fn reads_file_with_offset_and_limit() { + let mut temp = NamedTempFile::new().unwrap(); + temp.write_all(b"line1\nline2\nline3\nline4\n").unwrap(); + let tool: ReadTool = ReadTool::new(); + + let result = tool + .call( + &mock_ctx(), + json!({ + "file_path": temp.path().to_string_lossy(), + "offset": 2, + "limit": 2 + }), + ) + .await + .unwrap(); + + let text = result.as_text().unwrap(); + assert!(text.contains("L2: line2")); + assert!(text.contains("L3: line3")); + assert!(!text.contains("L1:")); + assert!(!text.contains("L4:")); + } +} diff --git a/src/llm-coding-tools-serdesai/src/absolute/write.rs b/src/llm-coding-tools-serdesai/src/absolute/write.rs new file mode 100644 index 00000000..ebb81b6b --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/absolute/write.rs @@ -0,0 +1,103 @@ +//! Write file tool using [`AbsolutePathResolver`]. + +use async_trait::async_trait; +use llm_coding_tools_core::operations::write_file; +use llm_coding_tools_core::path::AbsolutePathResolver; +use llm_coding_tools_core::{ToolContext, ToolOutput}; +use serde::Deserialize; +use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult}; + +use crate::convert::to_serdes_result; + +/// Internal args for JSON deserialization. +#[derive(Debug, Deserialize)] +struct WriteArgs { + /// Absolute path to the file. + file_path: String, + /// Content to write to the file. + content: String, +} + +/// Tool for writing content to files. +/// +/// Creates parent directories if needed and overwrites existing files. +#[derive(Debug, Clone, Default)] +pub struct WriteTool; + +impl WriteTool { + /// Creates a new write tool instance. + #[inline] + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for WriteTool { + fn definition(&self) -> ToolDefinition { + let schema = SchemaBuilder::new() + .string("file_path", "Absolute path to the file", true) + .string("content", "Content to write to the file", true) + .build() + .expect("schema build should not fail"); + + ToolDefinition::new( + "Write", + "Write content to a file, creating parent directories if needed. Overwrites existing files.", + ) + .with_parameters(schema) + } + + async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { + let args: WriteArgs = serde_json::from_value(args) + .map_err(|e| ToolError::validation_error("Write", None, e.to_string()))?; + + let resolver = AbsolutePathResolver; + let result = write_file(&resolver, &args.file_path, &args.content).await; + + // Convert String result to ToolOutput for consistent error handling + to_serdes_result("Write", result.map(ToolOutput::new)) + } +} + +impl ToolContext for WriteTool { + const NAME: &'static str = "Write"; + + fn context(&self) -> &'static str { + llm_coding_tools_core::context::WRITE_ABSOLUTE + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use serdes_ai::tools::RunContext; + use tempfile::TempDir; + + fn mock_ctx() -> RunContext<()> { + RunContext::new((), "test-model") + } + + #[tokio::test] + async fn writes_file() { + let temp = TempDir::new().unwrap(); + let file_path = temp.path().join("new.txt"); + let tool = WriteTool::new(); + + let result = tool + .call( + &mock_ctx(), + json!({ + "file_path": file_path.to_string_lossy(), + "content": "hello world" + }), + ) + .await + .unwrap(); + + let text = result.as_text().unwrap(); + assert!(text.contains("11 bytes")); + assert_eq!(std::fs::read_to_string(&file_path).unwrap(), "hello world"); + } +} diff --git a/src/llm-coding-tools-serdesai/src/allowed/edit.rs b/src/llm-coding-tools-serdesai/src/allowed/edit.rs new file mode 100644 index 00000000..e93676d9 --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/allowed/edit.rs @@ -0,0 +1,147 @@ +//! Edit file tool using [`AllowedPathResolver`]. + +use async_trait::async_trait; +use llm_coding_tools_core::ToolContext; +use llm_coding_tools_core::operations::edit_file; +use llm_coding_tools_core::path::AllowedPathResolver; +use serde::Deserialize; +use serdes_ai::tools::{ + RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult, ToolReturn, +}; +use std::path::PathBuf; + +use crate::convert::edit_error_to_serdes; + +/// Internal args for JSON deserialization. +#[derive(Debug, Deserialize)] +struct EditArgs { + /// Path to the file (relative to allowed directories). + file_path: String, + /// The exact text to find and replace. + old_string: String, + /// The text to replace with. + new_string: String, + /// Replace all occurrences instead of just the first. Defaults to false. + #[serde(default)] + 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 canonical directories. + /// `allowed_directories` should already be canonicalized (see [`AllowedPathResolver::from_canonical`]). + pub fn new(allowed_directories: Vec) -> Self { + Self { + resolver: AllowedPathResolver::from_canonical(allowed_directories), + } + } +} + +#[async_trait] +impl Tool for EditTool { + fn definition(&self) -> ToolDefinition { + let schema = SchemaBuilder::new() + .string( + "file_path", + "Path to the file (relative to allowed directories)", + true, + ) + .string("old_string", "The exact text to find and replace", true) + .string("new_string", "The text to replace with", true) + .boolean( + "replace_all", + "Replace all occurrences instead of just the first. Defaults to false.", + false, + ) + .build() + .expect("schema build should not fail"); + + ToolDefinition::new( + "Edit", + "Make exact string replacements in files within allowed directories. \ + Paths are relative to configured base directories.", + ) + .with_parameters(schema) + } + + async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { + let args: EditArgs = serde_json::from_value(args) + .map_err(|e| ToolError::validation_error("Edit", None, e.to_string()))?; + + let result = edit_file( + &self.resolver, + &args.file_path, + &args.old_string, + &args.new_string, + args.replace_all, + ) + .await; + + result.map(ToolReturn::text).map_err(edit_error_to_serdes) + } +} + +impl ToolContext for EditTool { + const NAME: &'static str = "Edit"; + + fn context(&self) -> &'static str { + llm_coding_tools_core::context::EDIT_ALLOWED + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use serdes_ai::tools::RunContext; + use tempfile::TempDir; + + fn mock_ctx() -> RunContext<()> { + RunContext::new((), "test-model") + } + + #[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(vec![dir.path().to_path_buf()]); + let result = tool + .call( + &mock_ctx(), + json!({ + "file_path": "test.txt", + "old_string": "world", + "new_string": "rust" + }), + ) + .await + .unwrap(); + + let text = result.as_text().unwrap(); + assert!(text.contains("1 occurrence")); + } + + #[tokio::test] + async fn rejects_path_traversal() { + let dir = TempDir::new().unwrap(); + let tool = EditTool::new(vec![dir.path().to_path_buf()]); + let result = tool + .call( + &mock_ctx(), + json!({ + "file_path": "../../../etc/passwd", + "old_string": "old", + "new_string": "new" + }), + ) + .await; + + assert!(result.is_err()); + } +} diff --git a/src/llm-coding-tools-serdesai/src/allowed/glob.rs b/src/llm-coding-tools-serdesai/src/allowed/glob.rs new file mode 100644 index 00000000..80074b28 --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/allowed/glob.rs @@ -0,0 +1,143 @@ +//! Glob pattern file finding tool using [`AllowedPathResolver`]. + +use async_trait::async_trait; +use llm_coding_tools_core::operations::glob_files; +use llm_coding_tools_core::path::AllowedPathResolver; +use llm_coding_tools_core::{ToolContext, ToolOutput}; +use serde::Deserialize; +use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult}; +use std::path::PathBuf; + +use crate::convert::to_serdes_result; + +/// Internal args for JSON deserialization. +#[derive(Debug, Deserialize)] +struct GlobArgs { + /// Glob pattern to match files (e.g., "**/*.rs", "src/**/*.ts"). + pattern: String, + /// Directory path to search in (relative to allowed directories). + 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_directories: Vec) -> Self { + Self { + resolver: AllowedPathResolver::from_canonical(allowed_directories), + } + } +} + +#[async_trait] +impl Tool for GlobTool { + fn definition(&self) -> ToolDefinition { + let schema = SchemaBuilder::new() + .string( + "pattern", + "Glob pattern to match files (e.g., \"**/*.rs\", \"src/**/*.ts\")", + true, + ) + .string( + "path", + "Directory path to search in (relative to allowed directories)", + true, + ) + .build() + .expect("schema build should not fail"); + + ToolDefinition::new( + "Glob", + "Find files matching a glob pattern within allowed directories. \ + Paths are relative to configured base directories.", + ) + .with_parameters(schema) + } + + async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { + let args: GlobArgs = serde_json::from_value(args) + .map_err(|e| ToolError::validation_error("Glob", None, e.to_string()))?; + + let result = glob_files(&self.resolver, &args.pattern, &args.path); + to_serdes_result( + "Glob", + result.map(|output| { + let content = if output.files.is_empty() { + "No files found matching the pattern.".to_string() + } else { + output.files.join("\n") + }; + if output.truncated { + ToolOutput::truncated(content) + } else { + ToolOutput::new(content) + } + }), + ) + } +} + +impl ToolContext for GlobTool { + const NAME: &'static str = "Glob"; + + fn context(&self) -> &'static str { + llm_coding_tools_core::context::GLOB_ALLOWED + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use serdes_ai::tools::RunContext; + use std::fs::{self, File}; + use tempfile::TempDir; + + fn mock_ctx() -> RunContext<()> { + RunContext::new((), "test-model") + } + + #[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(vec![dir.path().to_path_buf()]); + let result = tool + .call( + &mock_ctx(), + json!({ + "pattern": "**/*.rs", + "path": "." + }), + ) + .await + .unwrap(); + + let text = result.as_text().unwrap(); + assert!(text.contains("lib.rs")); + } + + #[tokio::test] + async fn rejects_path_traversal() { + let dir = TempDir::new().unwrap(); + let tool = GlobTool::new(vec![dir.path().to_path_buf()]); + let result = tool + .call( + &mock_ctx(), + json!({ + "pattern": "*.rs", + "path": "../../../etc" + }), + ) + .await; + + assert!(result.is_err()); + } +} diff --git a/src/llm-coding-tools-serdesai/src/allowed/grep.rs b/src/llm-coding-tools-serdesai/src/allowed/grep.rs new file mode 100644 index 00000000..a63d75a8 --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/allowed/grep.rs @@ -0,0 +1,232 @@ +//! Grep content search tool using [`AllowedPathResolver`]. + +use async_trait::async_trait; +use llm_coding_tools_core::ToolContext; +use llm_coding_tools_core::operations::grep_search; +use llm_coding_tools_core::path::AllowedPathResolver; +use serde::Deserialize; +use serdes_ai::tools::{ + RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult, ToolReturn, +}; +use std::fmt::Write; +use std::path::PathBuf; + +use crate::convert::to_serdes_result; + +const DEFAULT_LIMIT: usize = 100; +const MAX_LIMIT: usize = 2000; +const MAX_LINE_LENGTH: usize = 2000; + +/// Internal args for JSON deserialization. +#[derive(Debug, Deserialize)] +struct GrepArgs { + /// Regular expression pattern to search for in file contents. + pattern: String, + /// Directory path to search in (relative to allowed directories). + path: String, + /// File pattern to filter search results (e.g., "*.rs", "*.{ts,tsx}"). + #[serde(default)] + include: Option, + /// Maximum number of matches to return (default: 100, max: 2000). + #[serde(default)] + 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_directories: Vec) -> Self { + Self { + resolver: AllowedPathResolver::from_canonical(allowed_directories), + } + } +} + +#[async_trait] +impl Tool for GrepTool { + fn definition(&self) -> ToolDefinition { + let description = if LINE_NUMBERS { + "Search file contents using regex patterns within allowed directories. \ + Returns matches with line numbers. Paths are relative to configured base directories." + } else { + "Search file contents using regex patterns within allowed directories. \ + Paths are relative to configured base directories." + }; + let schema = SchemaBuilder::new() + .string( + "pattern", + "Regular expression pattern to search for in file contents", + true, + ) + .string( + "path", + "Directory path to search in (relative to allowed directories)", + true, + ) + .string( + "include", + "File pattern to filter search results (e.g., \"*.rs\", \"*.{ts,tsx}\")", + false, + ) + .integer_constrained( + "limit", + "Maximum number of matches to return (default: 100, max: 2000)", + false, + Some(1), + Some(2000), + ) + .build() + .expect("schema build should not fail"); + + ToolDefinition::new("Grep", description).with_parameters(schema) + } + + async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { + let args: GrepArgs = serde_json::from_value(args) + .map_err(|e| ToolError::validation_error("Grep", None, e.to_string()))?; + + let pattern = args.pattern.trim(); + if pattern.is_empty() { + return Err(ToolError::validation_error( + "Grep", + Some("pattern".to_string()), + "pattern must not be empty".to_string(), + )); + } + + let limit = args.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + if limit == 0 { + return Err(ToolError::validation_error( + "Grep", + Some("limit".to_string()), + "limit must be greater than zero".to_string(), + )); + } + + 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); + + match result { + Err(e) => to_serdes_result("Grep", Err(e)), + Ok(grep_output) => { + if grep_output.files.is_empty() { + return Ok(ToolReturn::text("No matches found.")); + } + + let mut output = String::with_capacity(4096); + let _ = writeln!(&mut output, "Found {} matches", grep_output.match_count); + + for file in &grep_output.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[..m.line_text.floor_char_boundary(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 grep_output.truncated { + let _ = write!(&mut output, "\n(Results truncated at {} matches)", limit); + } + + Ok(ToolReturn::text(output)) + } + } + } +} + +impl ToolContext for GrepTool { + const NAME: &'static str = "Grep"; + + fn context(&self) -> &'static str { + llm_coding_tools_core::context::GREP_ALLOWED + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use serdes_ai::tools::RunContext; + use tempfile::TempDir; + + fn mock_ctx() -> RunContext<()> { + RunContext::new((), "test-model") + } + + #[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(vec![dir.path().to_path_buf()]); + let result = tool + .call( + &mock_ctx(), + json!({ + "pattern": "hello", + "path": "." + }), + ) + .await + .unwrap(); + + let text = result.as_text().unwrap(); + assert!(text.contains("Found 1 matches")); + assert!(text.contains("L1: hello world")); + } + + #[tokio::test] + async fn rejects_path_traversal() { + let dir = TempDir::new().unwrap(); + let tool: GrepTool = GrepTool::new(vec![dir.path().to_path_buf()]); + let result = tool + .call( + &mock_ctx(), + json!({ + "pattern": "test", + "path": "../../../etc" + }), + ) + .await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn rejects_empty_pattern() { + let dir = TempDir::new().unwrap(); + let tool: GrepTool = GrepTool::new(vec![dir.path().to_path_buf()]); + let result = tool + .call( + &mock_ctx(), + json!({ + "pattern": " ", + "path": "." + }), + ) + .await; + + assert!(result.is_err()); + } +} diff --git a/src/llm-coding-tools-serdesai/src/allowed/mod.rs b/src/llm-coding-tools-serdesai/src/allowed/mod.rs new file mode 100644 index 00000000..f4dd4ee5 --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/allowed/mod.rs @@ -0,0 +1,24 @@ +//! Tools using [`llm_coding_tools_core::path::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::EditTool; +pub use glob::GlobTool; +pub use grep::GrepTool; +pub use read::ReadTool; +pub use write::WriteTool; diff --git a/src/llm-coding-tools-serdesai/src/allowed/read.rs b/src/llm-coding-tools-serdesai/src/allowed/read.rs new file mode 100644 index 00000000..48da4ec9 --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/allowed/read.rs @@ -0,0 +1,160 @@ +//! Read file tool using [`AllowedPathResolver`]. + +use async_trait::async_trait; +use llm_coding_tools_core::operations::read_file; +use llm_coding_tools_core::path::AllowedPathResolver; +use llm_coding_tools_core::{ToolContext, ToolResult as CoreToolResult}; +use serde::Deserialize; +use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult}; +use std::path::PathBuf; + +use crate::convert::to_serdes_result; + +const DEFAULT_OFFSET: usize = 1; +const DEFAULT_LIMIT: usize = 2000; + +fn default_offset() -> usize { + DEFAULT_OFFSET +} + +fn default_limit() -> usize { + DEFAULT_LIMIT +} + +/// Internal args for JSON deserialization. +#[derive(Debug, Deserialize)] +struct ReadArgs { + /// Path to the file (relative to allowed directories). + file_path: String, + /// Line offset to start reading from (1-based). Defaults to 1. + #[serde(default = "default_offset")] + offset: usize, + /// Maximum number of lines to return. Defaults to 2000. + #[serde(default = "default_limit")] + 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. + /// + /// Returns an error if any directory doesn't exist or can't be canonicalized. + pub fn new(allowed_directories: Vec) -> CoreToolResult { + Ok(Self { + resolver: AllowedPathResolver::new(allowed_directories)?, + }) + } +} + +#[async_trait] +impl Tool for ReadTool { + fn definition(&self) -> 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." + }; + let schema = SchemaBuilder::new() + .string( + "file_path", + "Path to the file (relative to allowed directories)", + true, + ) + .integer_constrained( + "offset", + "Line offset to start reading from (1-based). Defaults to 1.", + false, + Some(1), + None, + ) + .integer_constrained( + "limit", + "Maximum number of lines to return. Defaults to 2000.", + false, + Some(1), + None, + ) + .build() + .expect("schema build should not fail"); + + ToolDefinition::new("Read", description).with_parameters(schema) + } + + async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { + let args: ReadArgs = serde_json::from_value(args) + .map_err(|e| ToolError::validation_error("Read", None, e.to_string()))?; + + let result = + read_file::<_, LINE_NUMBERS>(&self.resolver, &args.file_path, args.offset, args.limit) + .await; + to_serdes_result("Read", result) + } +} + +impl ToolContext for ReadTool { + const NAME: &'static str = "Read"; + + fn context(&self) -> &'static str { + llm_coding_tools_core::context::READ_ALLOWED + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use serdes_ai::tools::RunContext; + use tempfile::TempDir; + + fn mock_ctx() -> RunContext<()> { + RunContext::new((), "test-model") + } + + #[tokio::test] + async fn reads_file_with_line_numbers() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("test.txt"), "hello\nworld\n").unwrap(); + + let tool: ReadTool = ReadTool::new(vec![dir.path().to_path_buf()]).unwrap(); + let result = tool + .call( + &mock_ctx(), + json!({ + "file_path": "test.txt", + "offset": 1, + "limit": 2000 + }), + ) + .await + .unwrap(); + + let text = result.as_text().unwrap(); + assert!(text.contains("L1: hello")); + assert!(text.contains("L2: world")); + } + + #[tokio::test] + async fn rejects_path_traversal() { + let dir = TempDir::new().unwrap(); + let tool: ReadTool = ReadTool::new(vec![dir.path().to_path_buf()]).unwrap(); + let result = tool + .call( + &mock_ctx(), + json!({ + "file_path": "../../../etc/passwd" + }), + ) + .await; + + assert!(result.is_err()); + } +} diff --git a/src/llm-coding-tools-serdesai/src/allowed/write.rs b/src/llm-coding-tools-serdesai/src/allowed/write.rs new file mode 100644 index 00000000..662cd28e --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/allowed/write.rs @@ -0,0 +1,121 @@ +//! Write file tool using [`AllowedPathResolver`]. + +use async_trait::async_trait; +use llm_coding_tools_core::operations::write_file; +use llm_coding_tools_core::path::AllowedPathResolver; +use llm_coding_tools_core::{ToolContext, ToolOutput}; +use serde::Deserialize; +use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult}; +use std::path::PathBuf; + +use crate::convert::to_serdes_result; + +/// Internal args for JSON deserialization. +#[derive(Debug, Deserialize)] +struct WriteArgs { + /// Path to the file (relative to allowed directories). + file_path: String, + 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_directories: Vec) -> Self { + Self { + resolver: AllowedPathResolver::from_canonical(allowed_directories), + } + } +} + +#[async_trait] +impl Tool for WriteTool { + fn definition(&self) -> ToolDefinition { + let schema = SchemaBuilder::new() + .string( + "file_path", + "Path to the file (relative to allowed directories)", + true, + ) + .string("content", "Content to write to the file", true) + .build() + .expect("schema build should not fail"); + + ToolDefinition::new( + "Write", + "Write content to a file within allowed directories. \ + Paths are relative to configured base directories.", + ) + .with_parameters(schema) + } + + async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { + let args: WriteArgs = serde_json::from_value(args) + .map_err(|e| ToolError::validation_error("Write", None, e.to_string()))?; + + let result = write_file(&self.resolver, &args.file_path, &args.content).await; + to_serdes_result("Write", result.map(ToolOutput::new)) + } +} + +impl ToolContext for WriteTool { + const NAME: &'static str = "Write"; + + fn context(&self) -> &'static str { + llm_coding_tools_core::context::WRITE_ALLOWED + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use serdes_ai::tools::RunContext; + use tempfile::TempDir; + + fn mock_ctx() -> RunContext<()> { + RunContext::new((), "test-model") + } + + #[tokio::test] + async fn writes_new_file() { + let dir = TempDir::new().unwrap(); + let tool = WriteTool::new(vec![dir.path().to_path_buf()]); + let result = tool + .call( + &mock_ctx(), + json!({ + "file_path": "new.txt", + "content": "hello" + }), + ) + .await + .unwrap(); + + let text = result.as_text().unwrap(); + assert!(text.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(vec![dir.path().to_path_buf()]); + let result = tool + .call( + &mock_ctx(), + json!({ + "file_path": "../../../tmp/escape.txt", + "content": "content" + }), + ) + .await; + + assert!(result.is_err()); + } +} diff --git a/src/llm-coding-tools-serdesai/src/bash.rs b/src/llm-coding-tools-serdesai/src/bash.rs new file mode 100644 index 00000000..03babc92 --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/bash.rs @@ -0,0 +1,247 @@ +//! Shell command execution tool. +//! +//! Provides cross-platform shell command execution with timeout support. + +use crate::convert::to_serdes_result; +use crate::schema::bash_schema; +use async_trait::async_trait; +use llm_coding_tools_core::ToolOutput; +use llm_coding_tools_core::context::ToolContext; +use llm_coding_tools_core::operations::execute_command; +use serde::Deserialize; +use serdes_ai::tools::{RunContext, Tool, ToolDefinition, ToolError, ToolResult}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// Default timeout: 2 minutes. +const DEFAULT_TIMEOUT_MS: u64 = 120_000; + +/// Arguments for the bash tool. +#[derive(Debug, Clone, Deserialize)] +struct BashArgs { + /// The shell command to execute. + command: String, + /// Optional working directory (must be absolute path). + workdir: Option, + /// Timeout in milliseconds. Optional - falls back to constructor default or 120000ms. + timeout_ms: Option, +} + +/// Tool for executing shell commands. +/// +/// Uses bash on Unix, cmd on Windows. +#[derive(Debug, Clone, Default)] +pub struct BashTool { + /// Default timeout for commands when not specified in args. + default_timeout: Option, + /// Default working directory when not specified in args. + default_workdir: Option, +} + +impl BashTool { + /// Creates a new bash tool instance with default settings. + #[inline] + pub fn new() -> Self { + Self::default() + } + + /// Sets the default timeout for commands. + /// + /// This timeout is used when `timeout_ms` is not provided in the tool arguments. + pub fn with_default_timeout(mut self, timeout: Duration) -> Self { + self.default_timeout = Some(timeout); + self + } + + /// Sets the default working directory. + /// + /// This directory is used when `workdir` is not provided in the tool arguments. + pub fn with_default_workdir(mut self, workdir: impl Into) -> Self { + self.default_workdir = Some(workdir.into()); + self + } +} + +#[async_trait] +impl Tool for BashTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition::new( + "Bash", + "Execute a shell command with optional working directory and timeout.", + ) + .with_parameters(bash_schema().expect("schema serialization should never fail")) + } + + async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { + let args: BashArgs = serde_json::from_value(args) + .map_err(|e| ToolError::validation_error("Bash", None, e.to_string()))?; + + // Use arg workdir, falling back to default_workdir + let workdir: Option<&Path> = args + .workdir + .as_ref() + .map(|s| Path::new(s.as_str())) + .or(self.default_workdir.as_deref()); + + // Priority: args.timeout_ms > self.default_timeout > DEFAULT_TIMEOUT_MS + let timeout = args + .timeout_ms + .map(Duration::from_millis) + .or(self.default_timeout) + .unwrap_or(Duration::from_millis(DEFAULT_TIMEOUT_MS)); + + let result = execute_command(&args.command, workdir, timeout).await; + + // Inline format_bash_output - only used here + to_serdes_result( + "Bash", + result.map(|output| { + // Pre-allocate capacity based on known stdout/stderr sizes plus overhead for labels + let estimated = output.stdout.len() + output.stderr.len() + 32; + let mut content = String::with_capacity(estimated); + + 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 + && code != 0 + { + if !content.is_empty() { + content.push('\n'); + } + content.push_str(&format!("[exit code: {}]", code)); + } + + ToolOutput::new(content) + }), + ) + } +} + +impl ToolContext for BashTool { + const NAME: &'static str = "Bash"; + + fn context(&self) -> &'static str { + llm_coding_tools_core::context::BASH + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_ctx() -> RunContext<()> { + RunContext::minimal("test-model") + } + + #[tokio::test] + async fn executes_echo() { + let tool = BashTool::new(); + let args = serde_json::json!({ + "command": "echo hello", + "timeout_ms": 5000 + }); + let result = tool.call(&mock_ctx(), args).await.unwrap(); + assert!(result.as_text().unwrap().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 = serde_json::json!({ + "command": cmd, + "timeout_ms": 100 + }); + let result = tool.call(&mock_ctx(), args).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn workdir_parameter_changes_directory() { + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.to_string_lossy(); + let cmd = if cfg!(target_os = "windows") { + "cd" + } else { + "pwd" + }; + let tool = BashTool::new(); + let args = serde_json::json!({ + "command": cmd, + "workdir": temp_path, + "timeout_ms": 5000 + }); + let result = tool.call(&mock_ctx(), args).await.unwrap(); + let output = result.as_text().unwrap(); + // Canonicalize both paths for comparison (handles symlinks like /tmp -> /private/tmp on macOS) + let expected = temp_dir.canonicalize().unwrap_or(temp_dir); + assert!(output.contains(expected.to_string_lossy().as_ref())); + } + + #[tokio::test] + async fn default_workdir_is_used() { + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.to_string_lossy(); + let cmd = if cfg!(target_os = "windows") { + "cd" + } else { + "pwd" + }; + let tool = BashTool::new().with_default_workdir(&*temp_path); + let args = serde_json::json!({ + "command": cmd + }); + let result = tool.call(&mock_ctx(), args).await.unwrap(); + let output = result.as_text().unwrap(); + // Canonicalize both paths for comparison (handles symlinks like /tmp -> /private/tmp on macOS) + let expected = temp_dir.canonicalize().unwrap_or(temp_dir); + assert!(output.contains(expected.to_string_lossy().as_ref())); + } + + #[tokio::test] + async fn per_call_timeout_overrides_default() { + // Constructor sets 10s default, but per-call arg specifies 100ms + let tool = BashTool::new().with_default_timeout(Duration::from_secs(10)); + let cmd = if cfg!(target_os = "windows") { + "ping -n 10 127.0.0.1" + } else { + "sleep 10" + }; + let args = serde_json::json!({ + "command": cmd, + "timeout_ms": 100 // Should override the 10s default + }); + let result = tool.call(&mock_ctx(), args).await; + // Should timeout with the 100ms, not wait 10s + assert!(result.is_err()); + } + + #[tokio::test] + async fn default_timeout_used_when_arg_omitted() { + let tool = BashTool::new().with_default_timeout(Duration::from_millis(100)); + let cmd = if cfg!(target_os = "windows") { + "ping -n 10 127.0.0.1" + } else { + "sleep 10" + }; + // No timeout_ms in args - should use constructor default + let args = serde_json::json!({ + "command": cmd + }); + let result = tool.call(&mock_ctx(), args).await; + assert!(result.is_err()); + } +} diff --git a/src/llm-coding-tools-serdesai/src/convert.rs b/src/llm-coding-tools-serdesai/src/convert.rs new file mode 100644 index 00000000..2d014c9c --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/convert.rs @@ -0,0 +1,215 @@ +//! Type conversions between core types and serdesAI types. +//! +//! Provides [`From`] implementations and helper functions to bridge +//! [`llm_coding_tools_core`] types with serdesAI's tool system. + +use llm_coding_tools_core::operations::EditError; +use llm_coding_tools_core::{ToolError as CoreError, ToolOutput, ToolResult as CoreResult}; +use serde_json::json; +use serdes_ai::tools::{ToolError as SerdesError, ToolReturn}; + +/// Convert [`ToolOutput`] to [`ToolReturn`]. +/// +/// - Non-truncated output: `ToolReturn::text(content)` +/// - Truncated output: `ToolReturn::json({ "content": ..., "truncated": true })` +#[inline] +pub fn output_to_return(output: ToolOutput) -> ToolReturn { + if output.truncated { + ToolReturn::json(json!({ + "content": output.content, + "truncated": true + })) + } else { + ToolReturn::text(output.content) + } +} + +/// Convert core `ToolResult` to serdesAI `ToolResult`. +/// +/// This is the primary conversion function for tool implementations. +/// Requires tool_name for proper error context in validation errors. +/// +/// # Example +/// +/// ```no_run +/// use llm_coding_tools_serdesai::convert::to_serdes_result; +/// use llm_coding_tools_core::{ToolOutput, ToolResult}; +/// +/// // In a tool implementation: +/// fn convert_result(core_result: ToolResult) -> serdes_ai::tools::ToolResult { +/// to_serdes_result("my_tool", core_result) +/// } +/// ``` +#[inline] +pub fn to_serdes_result( + tool_name: &str, + result: CoreResult, +) -> Result { + result + .map(output_to_return) + .map_err(|err| core_error_to_serdes(tool_name, err)) +} + +/// Convert [`EditError`] to serdesAI error. +/// +/// Maps edit-specific errors to appropriate error types: +/// - Validation errors: `NotFound`, `AmbiguousMatch`, `EmptyOldString`, `IdenticalStrings` +/// - Execution errors: `Tool(ToolError)` (IO, path errors) +pub fn edit_error_to_serdes(err: EditError) -> SerdesError { + match err { + EditError::NotFound => SerdesError::validation_error( + "edit", + Some("old_string".to_string()), + "old_string not found in file content".to_string(), + ), + EditError::AmbiguousMatch(count) => SerdesError::validation_error( + "edit", + Some("old_string".to_string()), + format!( + "oldString found {count} times and requires more code context to uniquely identify the intended match" + ), + ), + EditError::EmptyOldString => SerdesError::validation_error( + "edit", + Some("old_string".to_string()), + "old_string must not be empty".to_string(), + ), + EditError::IdenticalStrings => SerdesError::validation_error( + "edit", + None, + "old_string and new_string must be different".to_string(), + ), + EditError::Tool(tool_err) => core_error_to_serdes("edit", tool_err), + } +} + +/// Convert core [`ToolError`] to serdesAI [`ToolError`] with tool name context. +pub(crate) fn core_error_to_serdes(tool_name: &str, err: CoreError) -> SerdesError { + match &err { + // Validation errors - input/parameter issues + CoreError::InvalidPath(msg) => { + SerdesError::validation_error(tool_name, Some("path".to_string()), msg.clone()) + } + CoreError::InvalidPattern(msg) => { + SerdesError::validation_error(tool_name, Some("pattern".to_string()), msg.clone()) + } + CoreError::OutOfBounds(msg) => { + SerdesError::validation_error(tool_name, Some("offset".to_string()), msg.clone()) + } + CoreError::Validation(msg) => SerdesError::validation_error(tool_name, None, msg.clone()), + // Execution errors - runtime failures + CoreError::Io(_) + | CoreError::Http(_) + | CoreError::Execution(_) + | CoreError::Timeout(_) + | CoreError::Json(_) => SerdesError::execution_failed(err.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use llm_coding_tools_core::{ToolError as CoreError, ToolOutput}; + + #[test] + fn tool_output_converts_to_text_when_not_truncated() { + let output = ToolOutput::new("hello world"); + let ret = output_to_return(output); + assert_eq!(ret.as_text(), Some("hello world")); + } + + #[test] + fn tool_output_converts_to_json_when_truncated() { + let output = ToolOutput::truncated("partial content"); + let ret = output_to_return(output); + let json = ret.as_json().expect("should be json"); + assert_eq!(json["content"], "partial content"); + assert_eq!(json["truncated"], true); + } + + #[test] + fn invalid_path_error_maps_to_validation_error() { + let core_err = CoreError::InvalidPath("not absolute".into()); + let result: Result = + Err(core_error_to_serdes("test_tool", core_err)); + let serdes_err = result.unwrap_err(); + // Use pattern matching - is_validation_error() doesn't exist + assert!(matches!(serdes_err, SerdesError::ValidationFailed { .. })); + } + + #[test] + fn invalid_pattern_error_maps_to_validation_error() { + let core_err = CoreError::InvalidPattern("bad regex".into()); + let result: Result = + Err(core_error_to_serdes("test_tool", core_err)); + let serdes_err = result.unwrap_err(); + assert!(matches!(serdes_err, SerdesError::ValidationFailed { .. })); + } + + #[test] + fn out_of_bounds_error_maps_to_validation_error() { + let core_err = CoreError::OutOfBounds("offset too large".into()); + let result: Result = + Err(core_error_to_serdes("test_tool", core_err)); + let serdes_err = result.unwrap_err(); + assert!(matches!(serdes_err, SerdesError::ValidationFailed { .. })); + } + + #[test] + fn io_error_maps_to_execution_error() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"); + let core_err: CoreError = io_err.into(); + let serdes_err = core_error_to_serdes("test_tool", core_err); + // ExecutionFailed is not a ValidationFailed + assert!(!matches!(serdes_err, SerdesError::ValidationFailed { .. })); + } + + #[test] + fn execution_error_maps_to_execution_failed() { + let core_err = CoreError::Execution("command failed".into()); + let serdes_err = core_error_to_serdes("test_tool", core_err); + assert!(!matches!(serdes_err, SerdesError::ValidationFailed { .. })); + // Use message() which exists, and check the error content + assert!(serdes_err.message().contains("execution error")); + } + + #[test] + fn timeout_error_maps_to_execution_failed() { + let core_err = CoreError::Timeout("timed out".into()); + let serdes_err = core_error_to_serdes("test_tool", core_err); + assert!(!matches!(serdes_err, SerdesError::ValidationFailed { .. })); + } + + #[test] + fn to_serdes_result_maps_success() { + let core_result: CoreResult = Ok(ToolOutput::new("success")); + let serdes_result = to_serdes_result("test_tool", core_result); + assert!(serdes_result.is_ok()); + assert_eq!(serdes_result.unwrap().as_text(), Some("success")); + } + + #[test] + fn to_serdes_result_maps_error() { + let core_result: CoreResult = + Err(CoreError::Execution("command failed".into())); + let serdes_result = to_serdes_result("test_tool", core_result); + assert!(serdes_result.is_err()); + } + + #[test] + fn to_serdes_result_includes_tool_name_in_validation_error() { + let core_result: CoreResult = Err(CoreError::InvalidPath("bad path".into())); + let serdes_result = to_serdes_result("read_file", core_result); + let err = serdes_result.unwrap_err(); + assert!(matches!(err, SerdesError::ValidationFailed { .. })); + // Validation error should include the error details + match err { + SerdesError::ValidationFailed { tool_name, errors } => { + assert_eq!(tool_name, "read_file"); + assert!(!errors.is_empty()); + assert!(errors[0].message.contains("bad path")); + } + _ => panic!("Expected ValidationFailed"), + } + } +} diff --git a/src/llm-coding-tools-serdesai/src/lib.rs b/src/llm-coding-tools-serdesai/src/lib.rs new file mode 100644 index 00000000..a4a288c1 --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/lib.rs @@ -0,0 +1,73 @@ +//! serdesAI framework Tool implementations for coding tools. +//! +//! This crate provides `serdes_ai::Tool` implementations wrapping +//! the core operations from [`llm_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 +//! +//! ```no_run +//! use llm_coding_tools_serdesai::absolute::ReadTool; +//! use llm_coding_tools_serdesai::BashTool; +//! ``` + +#![warn(missing_docs)] + +pub mod absolute; +pub mod allowed; +pub mod bash; +pub mod convert; +pub mod task; +pub mod todo; +pub mod webfetch; + +pub(crate) mod schema; + +/// Re-export core types for convenience. +pub use llm_coding_tools_core::{ToolError, ToolOutput, ToolResult}; + +/// Re-export context module and [`ToolContext`] trait for convenience. +pub use llm_coding_tools_core::ToolContext; +pub use llm_coding_tools_core::context; + +/// Re-export [`PreambleBuilder`] and [`Substitute`] from core. +pub use llm_coding_tools_core::{PreambleBuilder, Substitute}; + +/// Re-export path resolvers from core. +pub use llm_coding_tools_core::path::{AbsolutePathResolver, AllowedPathResolver, PathResolver}; + +// Re-export absolute path tools +pub use absolute::{EditTool, GlobTool, GrepTool, ReadTool, WriteTool}; + +/// Re-export allowed module tool types (namespaced to avoid conflicts). +/// +/// Use this module when you need both absolute and allowed tools: +/// +/// ```no_run +/// use llm_coding_tools_serdesai::{ReadTool, WriteTool}; // absolute +/// use llm_coding_tools_serdesai::allowed_tools::{ReadTool as SandboxedReadTool}; +/// ``` +pub mod allowed_tools { + pub use crate::allowed::{EditTool, GlobTool, GrepTool, ReadTool, WriteTool}; +} + +// Re-export core operation types used by tools +pub use llm_coding_tools_core::{ + BashOutput, EditError, GlobOutput, GrepFileMatches, GrepLineMatch, GrepOutput, + MockTaskExecutor, TaskExecutor, TaskResult, Todo, TodoPriority, TodoState, TodoStatus, + WebFetchOutput, +}; + +// Re-export conversion utilities +pub use convert::{edit_error_to_serdes, output_to_return, to_serdes_result}; + +// Re-export standalone tools +pub use bash::BashTool; +pub use task::TaskTool; +pub use todo::{TodoReadTool, TodoWriteTool, create_todo_tools}; +pub use webfetch::WebFetchTool; diff --git a/src/llm-coding-tools-serdesai/src/schema.rs b/src/llm-coding-tools-serdesai/src/schema.rs new file mode 100644 index 00000000..cee02543 --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/schema.rs @@ -0,0 +1,187 @@ +//! Schema building utilities for tool parameter definitions. +//! +//! Provides composable helper functions and complete parameter schemas +//! using serdesAI's [`SchemaBuilder`]. This module is internal to the crate. + +use serde_json::Value; +use serdes_ai::tools::SchemaBuilder; + +// ============================================================================ +// Composable Schema Helpers +// ============================================================================ + +/// Add required command property with minimum length constraint. +#[inline] +pub fn add_command(builder: SchemaBuilder) -> SchemaBuilder { + builder.string_constrained( + "command", + "The shell command to execute", + true, + Some(1), + None, + None, + ) +} + +/// Add optional workdir property. +#[inline] +pub fn add_workdir(builder: SchemaBuilder) -> SchemaBuilder { + builder.string( + "workdir", + "Working directory for command execution (must be absolute path)", + false, + ) +} + +/// Add optional timeout_ms property with constraints. +#[inline] +pub fn add_timeout(builder: SchemaBuilder) -> SchemaBuilder { + builder.integer_constrained( + "timeout_ms", + "Timeout in milliseconds. Defaults to 120000 (2 minutes).", + false, + Some(1), + Some(600_000), + ) +} + +/// Add required todos array property. +#[inline] +pub fn add_todos(builder: SchemaBuilder) -> SchemaBuilder { + builder.raw( + "todos", + serde_json::json!({ + "type": "array", + "description": "The complete list of todos to set", + "items": { + "type": "object", + "required": ["id", "content", "status", "priority"], + "properties": { + "id": { "type": "string", "description": "Unique identifier" }, + "content": { "type": "string", "description": "Task description" }, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed", "cancelled"], + "description": "Current status" + }, + "priority": { + "type": "string", + "enum": ["high", "medium", "low"], + "description": "Priority level" + } + } + } + }), + true, + ) +} + +/// Add required url property. +#[inline] +pub fn add_url(builder: SchemaBuilder) -> SchemaBuilder { + builder.string("url", "The URL to fetch", true) +} + +/// Add required description property for task. +#[inline] +pub fn add_description(builder: SchemaBuilder) -> SchemaBuilder { + builder.string("description", "Short 3-5 word task description", true) +} + +/// Add required prompt property for task. +#[inline] +pub fn add_prompt(builder: SchemaBuilder) -> SchemaBuilder { + builder.string("prompt", "Detailed instructions for the sub-agent", true) +} + +/// Add required subagent_type property. +#[inline] +pub fn add_subagent_type(builder: SchemaBuilder) -> SchemaBuilder { + builder.string( + "subagent_type", + "Type of agent to use (e.g., \"general\", \"coder\")", + true, + ) +} + +/// Add optional session_id property. +#[inline] +pub fn add_session_id(builder: SchemaBuilder) -> SchemaBuilder { + builder.string("session_id", "Existing session to continue", false) +} + +// ============================================================================ +// Complete Tool Schemas +// ============================================================================ + +/// Build a complete schema for the bash tool. +pub fn bash_schema() -> Result { + add_timeout(add_workdir(add_command(SchemaBuilder::new()))).build() +} + +/// Build a complete schema for the todo write tool. +pub fn todo_write_schema() -> Result { + add_todos(SchemaBuilder::new()).build() +} + +/// Build a complete schema for the todo read tool (empty object). +pub fn todo_read_schema() -> Result { + SchemaBuilder::new().build() +} + +/// Build a complete schema for the webfetch tool. +pub fn webfetch_schema() -> Result { + add_timeout(add_url(SchemaBuilder::new())).build() +} + +/// Build a complete schema for the task tool. +pub fn task_schema() -> Result { + add_session_id(add_subagent_type(add_prompt(add_description( + SchemaBuilder::new(), + )))) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bash_schema_has_command_constraints() { + let schema = bash_schema().unwrap(); + let props = schema["properties"].as_object().unwrap(); + let command = props.get("command").unwrap(); + assert_eq!(command["minLength"], 1); + } + + #[test] + fn todo_write_schema_has_todos_required() { + let schema = todo_write_schema().unwrap(); + let required = schema["required"].as_array().unwrap(); + assert!(required.iter().any(|v| v == "todos")); + } + + #[test] + fn todo_read_schema_is_empty() { + let schema = todo_read_schema().unwrap(); + let required = schema.get("required").and_then(|v| v.as_array()); + assert!(required.is_none() || required.unwrap().is_empty()); + } + + #[test] + fn webfetch_schema_has_url_required() { + let schema = webfetch_schema().unwrap(); + let required = schema["required"].as_array().unwrap(); + assert!(required.iter().any(|v| v == "url")); + } + + #[test] + fn task_schema_has_required_fields() { + let schema = task_schema().unwrap(); + let required = schema["required"].as_array().unwrap(); + assert!(required.iter().any(|v| v == "description")); + assert!(required.iter().any(|v| v == "prompt")); + assert!(required.iter().any(|v| v == "subagent_type")); + assert!(!required.iter().any(|v| v == "session_id")); + } +} diff --git a/src/llm-coding-tools-serdesai/src/task.rs b/src/llm-coding-tools-serdesai/src/task.rs new file mode 100644 index 00000000..bf6f9ca1 --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/task.rs @@ -0,0 +1,175 @@ +//! Task tool for launching autonomous sub-agents. +//! +//! Provides [`TaskTool`] for spawning sub-agents to handle complex tasks. + +use crate::convert::to_serdes_result; +use crate::schema::task_schema; +use async_trait::async_trait; +use llm_coding_tools_core::ToolOutput; +use llm_coding_tools_core::context::ToolContext; +use serde::Deserialize; +use serdes_ai::tools::{RunContext, Tool, ToolDefinition, ToolError, ToolResult}; +use std::sync::Arc; + +/// Convenience re-exports from [`llm_coding_tools_core`] for users of this crate. +/// +/// Re-exports: +/// - [`MockTaskExecutor`]: Mock implementation for testing. +/// - [`TaskExecutor`]: Trait for executing sub-agent tasks. +/// - `CoreTaskArgs` (aliased from `TaskArgs`): Arguments for task execution. +/// - `CoreTaskResult` (aliased from `TaskResult`): Result of task execution. +pub use llm_coding_tools_core::{ + MockTaskExecutor, TaskArgs as CoreTaskArgs, TaskExecutor, TaskResult as CoreTaskResult, +}; + +/// Arguments for the task tool. +#[derive(Debug, Clone, Deserialize)] +struct TaskArgs { + /// Short 3-5 word task description. + description: String, + /// Detailed instructions for the sub-agent. + prompt: String, + /// Type of agent to use (e.g., "general", "coder"). + subagent_type: String, + /// Existing session to continue. + #[serde(default)] + 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. The executor must implement +/// [`TaskExecutor`] which requires `Send + Sync`. +#[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) + } +} + +// Note: TaskExecutor already requires Send + Sync in its trait definition. +// The 'static bound is needed for type erasure in async contexts. +#[async_trait] +impl Tool for TaskTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition::new("Task", "Delegate a task to a specialized sub-agent.") + .with_parameters(task_schema().expect("schema serialization should never fail")) + } + + async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { + let args: TaskArgs = serde_json::from_value(args) + .map_err(|e| ToolError::validation_error("Task", None, e.to_string()))?; + let core_args = CoreTaskArgs::from(args); + let result = self.executor.execute(&core_args).await; + to_serdes_result("Task", result.map(|r| ToolOutput::new(r.format()))) + } +} + +impl ToolContext for TaskTool { + const NAME: &'static str = "Task"; + + fn context(&self) -> &'static str { + llm_coding_tools_core::context::TASK + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_ctx() -> RunContext<()> { + RunContext::minimal("test-model") + } + + #[tokio::test] + async fn mock_executor_works() { + let (tool, _executor) = TaskTool::with_mock(); + let args = serde_json::json!({ + "description": "test task", + "prompt": "do something", + "subagent_type": "general" + }); + let result = tool.call(&mock_ctx(), args).await.unwrap(); + let text = result.as_text().unwrap(); + assert!(text.contains("test task")); + assert!(text.contains("completed")); + } + + #[tokio::test] + async fn custom_mock_response() { + let (tool, executor) = TaskTool::with_mock(); + executor.set_response("custom", "Custom result!"); + + let args = serde_json::json!({ + "description": "custom", + "prompt": "details", + "subagent_type": "coder" + }); + let result = tool.call(&mock_ctx(), args).await.unwrap(); + assert!(result.as_text().unwrap().contains("Custom result!")); + } + + /// Test executor that returns errors for testing error propagation. + #[derive(Debug)] + struct ErrorExecutor; + + #[async_trait] + impl TaskExecutor for ErrorExecutor { + async fn execute( + &self, + _args: &CoreTaskArgs, + ) -> Result { + Err(llm_coding_tools_core::ToolError::Execution( + "simulated executor failure".into(), + )) + } + } + + #[tokio::test] + async fn error_propagation_through_to_serdes_result() { + let executor = Arc::new(ErrorExecutor); + let tool = TaskTool::new(executor); + + let args = serde_json::json!({ + "description": "failing task", + "prompt": "this will fail", + "subagent_type": "general" + }); + + let result = tool.call(&mock_ctx(), args).await; + assert!(result.is_err()); + + let err = result.unwrap_err(); + // Execution errors should map to ExecutionFailed, not ValidationFailed + assert!(!matches!( + err, + serdes_ai::tools::ToolError::ValidationFailed { .. } + )); + assert!(err.message().contains("execution error")); + assert!(err.message().contains("simulated executor failure")); + } +} diff --git a/src/llm-coding-tools-serdesai/src/todo.rs b/src/llm-coding-tools-serdesai/src/todo.rs new file mode 100644 index 00000000..3c55cc06 --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/todo.rs @@ -0,0 +1,169 @@ +//! Todo list management tools. +//! +//! Provides tools for reading and writing todo items. + +use crate::convert::to_serdes_result; +use crate::schema::{todo_read_schema, todo_write_schema}; +use async_trait::async_trait; +use llm_coding_tools_core::ToolOutput; +use llm_coding_tools_core::context::ToolContext; +use llm_coding_tools_core::operations::{read_todos, write_todos}; +use serde::Deserialize; +use serdes_ai::tools::{RunContext, Tool, ToolDefinition, ToolError, ToolResult}; +use std::sync::Arc; + +// Re-export core types +pub use llm_coding_tools_core::{Todo, TodoPriority, TodoState, TodoStatus}; + +/// Arguments for writing todos. +#[derive(Debug, Clone, Deserialize)] +struct TodoWriteArgs { + /// The complete list of todos to set. + todos: Vec, +} + +/// Arguments for reading todos. +/// +/// Empty struct required for consistent JSON validation via `serde_json::from_value`. +/// Ensures the input is a valid JSON object even when no parameters are needed. +#[derive(Debug, Clone, Deserialize)] +struct TodoReadArgs {} + +/// Tool for writing/replacing the todo list. +#[derive(Debug, Clone)] +pub struct TodoWriteTool { + state: Arc, +} + +impl TodoWriteTool { + /// Creates a new todo write tool with the given shared state. + pub fn new(state: Arc) -> Self { + Self { state } + } +} + +#[async_trait] +impl Tool for TodoWriteTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition::new("TodoWrite", "Replace the todo list with new items.") + .with_parameters(todo_write_schema().expect("schema serialization should never fail")) + } + + async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { + let args: TodoWriteArgs = serde_json::from_value(args) + .map_err(|e| ToolError::validation_error("TodoWrite", None, e.to_string()))?; + let result = write_todos(&self.state, args.todos); + to_serdes_result("TodoWrite", result.map(ToolOutput::new)) + } +} + +impl ToolContext for TodoWriteTool { + const NAME: &'static str = "TodoWrite"; + + fn context(&self) -> &'static str { + llm_coding_tools_core::context::TODO_WRITE + } +} + +/// Tool for reading the current todo list. +#[derive(Debug, Clone)] +pub struct TodoReadTool { + state: Arc, +} + +impl TodoReadTool { + /// Creates a new todo read tool with the given shared state. + pub fn new(state: Arc) -> Self { + Self { state } + } +} + +#[async_trait] +impl Tool for TodoReadTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition::new("TodoRead", "Read the current todo list.") + .with_parameters(todo_read_schema().expect("schema serialization should never fail")) + } + + async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { + // Validate JSON is a proper object (empty struct validates this) + let _args: TodoReadArgs = serde_json::from_value(args) + .map_err(|e| ToolError::validation_error("TodoRead", None, e.to_string()))?; + let content = read_todos(&self.state); + Ok(crate::convert::output_to_return(ToolOutput::new(content))) + } +} + +impl ToolContext for TodoReadTool { + const NAME: &'static str = "TodoRead"; + + fn context(&self) -> &'static str { + llm_coding_tools_core::context::TODO_READ + } +} + +/// Creates a pair of todo tools with shared state. +/// +/// Returns `(TodoReadTool, TodoWriteTool, Arc)` for cases where +/// the caller needs access to the underlying state. +pub fn create_todo_tools() -> (TodoReadTool, TodoWriteTool, Arc) { + let state = Arc::new(TodoState::new()); + ( + TodoReadTool::new(Arc::clone(&state)), + TodoWriteTool::new(Arc::clone(&state)), + state, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_ctx() -> RunContext<()> { + RunContext::minimal("test-model") + } + + #[tokio::test] + async fn write_and_read_todos() { + let (read, write, _state) = create_todo_tools(); + + let write_args = serde_json::json!({ + "todos": [ + { "id": "1", "content": "Task 1", "status": "pending", "priority": "medium" }, + { "id": "2", "content": "Task 2", "status": "completed", "priority": "high" } + ] + }); + let write_result = write.call(&mock_ctx(), write_args).await.unwrap(); + assert!(write_result.as_text().unwrap().contains("2 task(s)")); + + let read_result = read.call(&mock_ctx(), serde_json::json!({})).await.unwrap(); + let text = read_result.as_text().unwrap(); + assert!(text.contains("Task 1")); + assert!(text.contains("Task 2")); + } + + #[tokio::test] + async fn shared_state_works() { + let state = Arc::new(TodoState::new()); + let write_tool = TodoWriteTool::new(Arc::clone(&state)); + let read_tool = TodoReadTool::new(Arc::clone(&state)); + + let write_args = serde_json::json!({ + "todos": [{ "id": "shared", "content": "Shared task", "status": "in_progress", "priority": "low" }] + }); + write_tool.call(&mock_ctx(), write_args).await.unwrap(); + + let read_result = read_tool + .call(&mock_ctx(), serde_json::json!({})) + .await + .unwrap(); + assert!(read_result.as_text().unwrap().contains("shared")); + } + + #[tokio::test] + async fn empty_list_returns_no_tasks() { + let (read, _write, _state) = create_todo_tools(); + let result = read.call(&mock_ctx(), serde_json::json!({})).await.unwrap(); + assert_eq!(result.as_text().unwrap(), "No tasks."); + } +} diff --git a/src/llm-coding-tools-serdesai/src/webfetch.rs b/src/llm-coding-tools-serdesai/src/webfetch.rs new file mode 100644 index 00000000..e889356b --- /dev/null +++ b/src/llm-coding-tools-serdesai/src/webfetch.rs @@ -0,0 +1,151 @@ +//! Web content fetching tool. +//! +//! Provides URL fetching with format conversion support. + +use crate::convert::to_serdes_result; +use crate::schema::webfetch_schema; +use async_trait::async_trait; +use llm_coding_tools_core::ToolOutput; +use llm_coding_tools_core::context::ToolContext; +use llm_coding_tools_core::operations::fetch_url; +use serde::Deserialize; +use serdes_ai::tools::{RunContext, Tool, ToolDefinition, ToolError, ToolResult}; +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)] +struct WebFetchArgs { + /// The URL to fetch. + url: String, + /// Timeout in milliseconds (default: 30000). + #[serde(default = "default_timeout_ms")] + 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 } + } +} + +#[async_trait] +impl Tool for WebFetchTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition::new( + "WebFetch", + "Fetch content from a URL. HTML is converted to markdown, JSON is prettified.", + ) + .with_parameters(webfetch_schema().expect("schema serialization should never fail")) + } + + async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { + let args: WebFetchArgs = serde_json::from_value(args) + .map_err(|e| ToolError::validation_error("WebFetch", None, e.to_string()))?; + let timeout = Duration::from_millis(args.timeout_ms); + let result = fetch_url(&self.client, &args.url, timeout).await; + + to_serdes_result( + "WebFetch", + result.map(|output| { + let content = format!( + "[{} - {} bytes]\n\n{}", + output.content_type, output.byte_length, output.content + ); + ToolOutput::new(content) + }), + ) + } +} + +impl ToolContext for WebFetchTool { + const NAME: &'static str = "WebFetch"; + + fn context(&self) -> &'static str { + llm_coding_tools_core::context::WEBFETCH + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_ctx() -> RunContext<()> { + RunContext::minimal("test-model") + } + + #[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); + } + + #[tokio::test] + async fn fetches_url_with_wiremock() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/test")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes("

Hello

") + .insert_header("content-type", "text/html"), + ) + .mount(&mock_server) + .await; + + let tool = WebFetchTool::new(); + let args = serde_json::json!({ + "url": format!("{}/test", mock_server.uri()), + "timeout_ms": 5000 + }); + + let result = tool.call(&mock_ctx(), args).await.unwrap(); + let text = result.as_text().unwrap(); + + // Should contain content type info and converted content + assert!(text.contains("text/html")); + assert!(text.contains("Hello")); + } +} From 7a70a527329631c209b28b18a4196021b7c696a6 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 16 Jan 2026 18:16:23 +0000 Subject: [PATCH 04/13] Changed: serdesAI allowed tools constructor signatures for better ergonomics and error handling - Refactored all allowed tool constructors (ReadTool, EditTool, WriteTool, GrepTool, GlobTool) to accept flexible iterator inputs via 'impl IntoIterator>' instead of 'Vec' - All constructors now return 'ToolResult' instead of 'Self' to provide proper error handling for canonicalization failures - Replaced 'AllowedPathResolver::from_canonical()' with 'AllowedPathResolver::new()' for consistent error propagation - Updated test fixtures to use array literal syntax '[dir.path()]' instead of vector syntax 'vec![dir.path().to_path_buf()]' for improved readability - Aligned serdesAI tool signatures with corresponding rig framework implementations for consistency --- .../examples/sandboxed.rs | 11 +++++----- .../src/allowed/edit.rs | 21 +++++++++++-------- .../src/allowed/glob.rs | 16 +++++++------- .../src/allowed/grep.rs | 18 +++++++++------- .../src/allowed/read.rs | 10 ++++----- .../src/allowed/write.rs | 16 +++++++------- 6 files changed, 50 insertions(+), 42 deletions(-) diff --git a/src/llm-coding-tools-serdesai/examples/sandboxed.rs b/src/llm-coding-tools-serdesai/examples/sandboxed.rs index 69e5eeee..154fe251 100644 --- a/src/llm-coding-tools-serdesai/examples/sandboxed.rs +++ b/src/llm-coding-tools-serdesai/examples/sandboxed.rs @@ -28,12 +28,11 @@ async fn main() -> Result<(), Box> { // // Each tool is initialized with the same set of allowed directories. // The `allowed` module tools use `AllowedPathResolver` internally. - let read: ReadTool = - ReadTool::new(allowed_paths.clone()).expect("allowed paths should be valid"); - let write = WriteTool::new(allowed_paths.clone()); - let edit = EditTool::new(allowed_paths.clone()); - let glob = GlobTool::new(allowed_paths.clone()); - let grep: GrepTool = GrepTool::new(allowed_paths); + let read: ReadTool = ReadTool::new(allowed_paths.clone())?; + let write = WriteTool::new(allowed_paths.clone())?; + let edit = EditTool::new(allowed_paths.clone())?; + let glob = GlobTool::new(allowed_paths.clone())?; + let grep: GrepTool = GrepTool::new(allowed_paths)?; // === Build registry with preamble tracking === let mut pb = PreambleBuilder::::new(); diff --git a/src/llm-coding-tools-serdesai/src/allowed/edit.rs b/src/llm-coding-tools-serdesai/src/allowed/edit.rs index e93676d9..a821e8ee 100644 --- a/src/llm-coding-tools-serdesai/src/allowed/edit.rs +++ b/src/llm-coding-tools-serdesai/src/allowed/edit.rs @@ -8,7 +8,7 @@ use serde::Deserialize; use serdes_ai::tools::{ RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult, ToolReturn, }; -use std::path::PathBuf; +use std::path::Path; use crate::convert::edit_error_to_serdes; @@ -33,12 +33,15 @@ pub struct EditTool { } impl EditTool { - /// Creates a new edit tool restricted to the given canonical directories. - /// `allowed_directories` should already be canonicalized (see [`AllowedPathResolver::from_canonical`]). - pub fn new(allowed_directories: Vec) -> Self { - Self { - resolver: AllowedPathResolver::from_canonical(allowed_directories), - } + /// Creates a new edit tool restricted to the given directories. + /// + /// Returns an error if any directory doesn't exist or can't be canonicalized. + pub fn new( + allowed_paths: impl IntoIterator>, + ) -> llm_coding_tools_core::ToolResult { + Ok(Self { + resolver: AllowedPathResolver::new(allowed_paths)?, + }) } } @@ -110,7 +113,7 @@ mod tests { let dir = TempDir::new().unwrap(); std::fs::write(dir.path().join("test.txt"), "hello world").unwrap(); - let tool = EditTool::new(vec![dir.path().to_path_buf()]); + let tool = EditTool::new([dir.path()]).unwrap(); let result = tool .call( &mock_ctx(), @@ -130,7 +133,7 @@ mod tests { #[tokio::test] async fn rejects_path_traversal() { let dir = TempDir::new().unwrap(); - let tool = EditTool::new(vec![dir.path().to_path_buf()]); + let tool = EditTool::new([dir.path()]).unwrap(); let result = tool .call( &mock_ctx(), diff --git a/src/llm-coding-tools-serdesai/src/allowed/glob.rs b/src/llm-coding-tools-serdesai/src/allowed/glob.rs index 80074b28..7d29d771 100644 --- a/src/llm-coding-tools-serdesai/src/allowed/glob.rs +++ b/src/llm-coding-tools-serdesai/src/allowed/glob.rs @@ -6,7 +6,7 @@ use llm_coding_tools_core::path::AllowedPathResolver; use llm_coding_tools_core::{ToolContext, ToolOutput}; use serde::Deserialize; use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult}; -use std::path::PathBuf; +use std::path::Path; use crate::convert::to_serdes_result; @@ -27,10 +27,12 @@ pub struct GlobTool { impl GlobTool { /// Creates a new glob tool restricted to the given directories. - pub fn new(allowed_directories: Vec) -> Self { - Self { - resolver: AllowedPathResolver::from_canonical(allowed_directories), - } + pub fn new( + allowed_paths: impl IntoIterator>, + ) -> llm_coding_tools_core::ToolResult { + Ok(Self { + resolver: AllowedPathResolver::new(allowed_paths)?, + }) } } @@ -108,7 +110,7 @@ mod tests { fs::create_dir_all(dir.path().join("src")).unwrap(); File::create(dir.path().join("src/lib.rs")).unwrap(); - let tool = GlobTool::new(vec![dir.path().to_path_buf()]); + let tool = GlobTool::new([dir.path()]).unwrap(); let result = tool .call( &mock_ctx(), @@ -127,7 +129,7 @@ mod tests { #[tokio::test] async fn rejects_path_traversal() { let dir = TempDir::new().unwrap(); - let tool = GlobTool::new(vec![dir.path().to_path_buf()]); + let tool = GlobTool::new([dir.path()]).unwrap(); let result = tool .call( &mock_ctx(), diff --git a/src/llm-coding-tools-serdesai/src/allowed/grep.rs b/src/llm-coding-tools-serdesai/src/allowed/grep.rs index a63d75a8..80ae821d 100644 --- a/src/llm-coding-tools-serdesai/src/allowed/grep.rs +++ b/src/llm-coding-tools-serdesai/src/allowed/grep.rs @@ -9,7 +9,7 @@ use serdes_ai::tools::{ RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult, ToolReturn, }; use std::fmt::Write; -use std::path::PathBuf; +use std::path::Path; use crate::convert::to_serdes_result; @@ -40,10 +40,12 @@ pub struct GrepTool { impl GrepTool { /// Creates a new grep tool restricted to the given directories. - pub fn new(allowed_directories: Vec) -> Self { - Self { - resolver: AllowedPathResolver::from_canonical(allowed_directories), - } + pub fn new( + allowed_paths: impl IntoIterator>, + ) -> llm_coding_tools_core::ToolResult { + Ok(Self { + resolver: AllowedPathResolver::new(allowed_paths)?, + }) } } @@ -179,7 +181,7 @@ mod tests { let dir = TempDir::new().unwrap(); std::fs::write(dir.path().join("test.txt"), "hello world").unwrap(); - let tool: GrepTool = GrepTool::new(vec![dir.path().to_path_buf()]); + let tool: GrepTool = GrepTool::new([dir.path()]).unwrap(); let result = tool .call( &mock_ctx(), @@ -199,7 +201,7 @@ mod tests { #[tokio::test] async fn rejects_path_traversal() { let dir = TempDir::new().unwrap(); - let tool: GrepTool = GrepTool::new(vec![dir.path().to_path_buf()]); + let tool: GrepTool = GrepTool::new([dir.path()]).unwrap(); let result = tool .call( &mock_ctx(), @@ -216,7 +218,7 @@ mod tests { #[tokio::test] async fn rejects_empty_pattern() { let dir = TempDir::new().unwrap(); - let tool: GrepTool = GrepTool::new(vec![dir.path().to_path_buf()]); + let tool: GrepTool = GrepTool::new([dir.path()]).unwrap(); let result = tool .call( &mock_ctx(), diff --git a/src/llm-coding-tools-serdesai/src/allowed/read.rs b/src/llm-coding-tools-serdesai/src/allowed/read.rs index 48da4ec9..dd344457 100644 --- a/src/llm-coding-tools-serdesai/src/allowed/read.rs +++ b/src/llm-coding-tools-serdesai/src/allowed/read.rs @@ -6,7 +6,7 @@ use llm_coding_tools_core::path::AllowedPathResolver; use llm_coding_tools_core::{ToolContext, ToolResult as CoreToolResult}; use serde::Deserialize; use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult}; -use std::path::PathBuf; +use std::path::Path; use crate::convert::to_serdes_result; @@ -46,9 +46,9 @@ impl ReadTool { /// Creates a new read tool restricted to the given directories. /// /// Returns an error if any directory doesn't exist or can't be canonicalized. - pub fn new(allowed_directories: Vec) -> CoreToolResult { + pub fn new(allowed_paths: impl IntoIterator>) -> CoreToolResult { Ok(Self { - resolver: AllowedPathResolver::new(allowed_directories)?, + resolver: AllowedPathResolver::new(allowed_paths)?, }) } } @@ -124,7 +124,7 @@ mod tests { let dir = TempDir::new().unwrap(); std::fs::write(dir.path().join("test.txt"), "hello\nworld\n").unwrap(); - let tool: ReadTool = ReadTool::new(vec![dir.path().to_path_buf()]).unwrap(); + let tool: ReadTool = ReadTool::new([dir.path()]).unwrap(); let result = tool .call( &mock_ctx(), @@ -145,7 +145,7 @@ mod tests { #[tokio::test] async fn rejects_path_traversal() { let dir = TempDir::new().unwrap(); - let tool: ReadTool = ReadTool::new(vec![dir.path().to_path_buf()]).unwrap(); + let tool: ReadTool = ReadTool::new([dir.path()]).unwrap(); let result = tool .call( &mock_ctx(), diff --git a/src/llm-coding-tools-serdesai/src/allowed/write.rs b/src/llm-coding-tools-serdesai/src/allowed/write.rs index 662cd28e..d798991b 100644 --- a/src/llm-coding-tools-serdesai/src/allowed/write.rs +++ b/src/llm-coding-tools-serdesai/src/allowed/write.rs @@ -6,7 +6,7 @@ use llm_coding_tools_core::path::AllowedPathResolver; use llm_coding_tools_core::{ToolContext, ToolOutput}; use serde::Deserialize; use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult}; -use std::path::PathBuf; +use std::path::Path; use crate::convert::to_serdes_result; @@ -26,10 +26,12 @@ pub struct WriteTool { impl WriteTool { /// Creates a new write tool restricted to the given directories. - pub fn new(allowed_directories: Vec) -> Self { - Self { - resolver: AllowedPathResolver::from_canonical(allowed_directories), - } + pub fn new( + allowed_paths: impl IntoIterator>, + ) -> llm_coding_tools_core::ToolResult { + Ok(Self { + resolver: AllowedPathResolver::new(allowed_paths)?, + }) } } @@ -85,7 +87,7 @@ mod tests { #[tokio::test] async fn writes_new_file() { let dir = TempDir::new().unwrap(); - let tool = WriteTool::new(vec![dir.path().to_path_buf()]); + let tool = WriteTool::new([dir.path()]).unwrap(); let result = tool .call( &mock_ctx(), @@ -105,7 +107,7 @@ mod tests { #[tokio::test] async fn rejects_path_traversal() { let dir = TempDir::new().unwrap(); - let tool = WriteTool::new(vec![dir.path().to_path_buf()]); + let tool = WriteTool::new([dir.path()]).unwrap(); let result = tool .call( &mock_ctx(), From 392ca06e140ff613533501dfffc7ac9d40dfd87b Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 16 Jan 2026 18:48:43 +0000 Subject: [PATCH 05/13] Changed: deduplicate grep output formatting into GrepOutput::format() Move duplicate formatting logic from 4 grep tool implementations into a single format() method on GrepOutput in the core crate. - Add GrepOutput::format() with capacity based on match count - Add DEFAULT_MAX_LINE_LENGTH constant (2000 bytes) - Update rig and serdesai grep tools to use the new method - Reduces ~80 lines of duplicate code to a single 30-line method --- .../src/operations/edit.rs | 4 +- .../src/operations/grep.rs | 48 +++++++++++++++++++ .../src/operations/mod.rs | 2 +- src/llm-coding-tools-rig/src/absolute/grep.rs | 29 +---------- src/llm-coding-tools-rig/src/allowed/grep.rs | 29 +---------- .../src/absolute/grep.rs | 34 ++----------- .../src/allowed/grep.rs | 28 +---------- 7 files changed, 62 insertions(+), 112 deletions(-) diff --git a/src/llm-coding-tools-core/src/operations/edit.rs b/src/llm-coding-tools-core/src/operations/edit.rs index 08464115..4f5d1dbd 100644 --- a/src/llm-coding-tools-core/src/operations/edit.rs +++ b/src/llm-coding-tools-core/src/operations/edit.rs @@ -21,7 +21,9 @@ pub enum EditError { #[error("old_string not found in file content")] NotFound, /// Multiple matches found when replace_all is false. - #[error("oldString found {0} times and requires more code context to uniquely identify the intended match")] + #[error( + "oldString found {0} times and requires more code context to uniquely identify the intended match" + )] AmbiguousMatch(usize), } diff --git a/src/llm-coding-tools-core/src/operations/grep.rs b/src/llm-coding-tools-core/src/operations/grep.rs index 052c2466..a2d9107e 100644 --- a/src/llm-coding-tools-core/src/operations/grep.rs +++ b/src/llm-coding-tools-core/src/operations/grep.rs @@ -8,9 +8,16 @@ use grep_searcher::sinks::UTF8; use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder}; use ignore::WalkBuilder; use serde::Serialize; +use std::fmt::Write; use std::path::Path; use std::time::SystemTime; +/// Default maximum line length (in bytes) for formatted grep output. +pub const DEFAULT_MAX_LINE_LENGTH: usize = 2000; + +/// Above average length of a file path. +const ESTIMATED_CHARS_PER_LINE: usize = 128; + /// A single line match within a file. #[derive(Debug, Clone, Serialize)] pub struct GrepLineMatch { @@ -42,6 +49,47 @@ pub struct GrepOutput { pub truncated: bool, } +impl GrepOutput { + /// Formats grep results as human-readable text. + /// + /// # Type Parameters + /// + /// * `LINE_NUMBERS` - When `true`, prefixes each match with `L{num}: ` + /// + /// # Arguments + /// + /// * `limit` - The original match limit (used in truncation message) + /// * `max_line_len` - Truncate lines exceeding this byte length at UTF-8 boundary + pub fn format(&self, limit: usize, max_line_len: usize) -> String { + let estimated_capacity = self.match_count * ESTIMATED_CHARS_PER_LINE; + let mut output = String::with_capacity(estimated_capacity); + + let _ = writeln!(&mut output, "Found {} matches", self.match_count); + + for file in &self.files { + let _ = writeln!(&mut output, "\n{}:", file.path); + for m in &file.matches { + let truncated_text = if m.line_text.len() > max_line_len { + &m.line_text[..m.line_text.floor_char_boundary(max_line_len)] + } 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 self.truncated { + let _ = write!(&mut output, "\n(Results truncated at {} matches)", limit); + } + + output + } +} + /// Searches for content matching a regex pattern. /// /// Results are sorted by modification time (newest first). diff --git a/src/llm-coding-tools-core/src/operations/mod.rs b/src/llm-coding-tools-core/src/operations/mod.rs index ace8e5d7..ee6ef998 100644 --- a/src/llm-coding-tools-core/src/operations/mod.rs +++ b/src/llm-coding-tools-core/src/operations/mod.rs @@ -17,7 +17,7 @@ pub mod write; pub use bash::{execute_command, BashOutput}; pub use edit::{edit_file, EditError}; pub use glob::{glob_files, GlobOutput}; -pub use grep::{grep_search, GrepFileMatches, GrepLineMatch, GrepOutput}; +pub use grep::{grep_search, GrepFileMatches, GrepLineMatch, GrepOutput, DEFAULT_MAX_LINE_LENGTH}; pub use read::read_file; pub use todo::{read_todos, write_todos, Todo, TodoPriority, TodoState, TodoStatus}; pub use write::write_file; diff --git a/src/llm-coding-tools-rig/src/absolute/grep.rs b/src/llm-coding-tools-rig/src/absolute/grep.rs index 1e0d4a2d..68a9d1a2 100644 --- a/src/llm-coding-tools-rig/src/absolute/grep.rs +++ b/src/llm-coding-tools-rig/src/absolute/grep.rs @@ -1,17 +1,15 @@ //! Grep content search tool using [`AbsolutePathResolver`]. -use llm_coding_tools_core::operations::grep_search; +use llm_coding_tools_core::operations::{grep_search, DEFAULT_MAX_LINE_LENGTH}; 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}; 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) @@ -98,30 +96,7 @@ impl Tool for GrepTool { 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 { - // 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[..m.line_text.floor_char_boundary(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); - } + let output = result.format::(limit, DEFAULT_MAX_LINE_LENGTH); Ok(if result.truncated { ToolOutput::truncated(output) diff --git a/src/llm-coding-tools-rig/src/allowed/grep.rs b/src/llm-coding-tools-rig/src/allowed/grep.rs index a9c7501b..4dfb160c 100644 --- a/src/llm-coding-tools-rig/src/allowed/grep.rs +++ b/src/llm-coding-tools-rig/src/allowed/grep.rs @@ -1,18 +1,16 @@ //! Grep content search tool using [`AllowedPathResolver`]. -use llm_coding_tools_core::operations::grep_search; +use llm_coding_tools_core::operations::{grep_search, DEFAULT_MAX_LINE_LENGTH}; 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}; use serde::Deserialize; -use std::fmt::Write; use std::path::Path; const DEFAULT_LIMIT: usize = 100; const MAX_LIMIT: usize = 2000; -const MAX_LINE_LENGTH: usize = 2000; fn default_limit() -> Option { Some(DEFAULT_LIMIT) @@ -101,30 +99,7 @@ impl Tool for GrepTool { 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 { - // 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[..m.line_text.floor_char_boundary(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); - } + let output = result.format::(limit, DEFAULT_MAX_LINE_LENGTH); Ok(if result.truncated { ToolOutput::truncated(output) diff --git a/src/llm-coding-tools-serdesai/src/absolute/grep.rs b/src/llm-coding-tools-serdesai/src/absolute/grep.rs index e04c54eb..17a02cff 100644 --- a/src/llm-coding-tools-serdesai/src/absolute/grep.rs +++ b/src/llm-coding-tools-serdesai/src/absolute/grep.rs @@ -2,19 +2,17 @@ use async_trait::async_trait; use llm_coding_tools_core::ToolContext; -use llm_coding_tools_core::operations::grep_search; +use llm_coding_tools_core::operations::{DEFAULT_MAX_LINE_LENGTH, grep_search}; use llm_coding_tools_core::path::AbsolutePathResolver; use serde::Deserialize; use serdes_ai::tools::{ RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult, ToolReturn, }; -use std::fmt::Write; use crate::convert::to_serdes_result; const DEFAULT_LIMIT: usize = 100; const MAX_LIMIT: usize = 2000; -const MAX_LINE_LENGTH: usize = 2000; /// Internal args for JSON deserialization. #[derive(Debug, Deserialize)] @@ -122,31 +120,7 @@ impl Tool for GrepTool MAX_LINE_LENGTH { - &m.line_text[..m.line_text.floor_char_boundary(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 grep_output.truncated { - let _ = write!(&mut output, "\n(Results truncated at {} matches)", limit); - } - + let output = grep_output.format::(limit, DEFAULT_MAX_LINE_LENGTH); Ok(ToolReturn::text(output)) } } @@ -297,13 +271,13 @@ mod tests { // The line should be truncated - it should contain prefix but not suffix assert!(text.contains("prefix_")); assert!(!text.contains("_suffix")); - // Verify the match line doesn't exceed MAX_LINE_LENGTH + // Verify the match line doesn't exceed DEFAULT_MAX_LINE_LENGTH for line in text.lines() { if line.contains("prefix_") { // Line format is " L1: content", so actual content is line.len() - prefix let content_start = line.find("prefix_").unwrap(); let content = &line[content_start..]; - assert!(content.len() <= MAX_LINE_LENGTH); + assert!(content.len() <= DEFAULT_MAX_LINE_LENGTH); } } } diff --git a/src/llm-coding-tools-serdesai/src/allowed/grep.rs b/src/llm-coding-tools-serdesai/src/allowed/grep.rs index 80ae821d..b523a22f 100644 --- a/src/llm-coding-tools-serdesai/src/allowed/grep.rs +++ b/src/llm-coding-tools-serdesai/src/allowed/grep.rs @@ -2,20 +2,18 @@ use async_trait::async_trait; use llm_coding_tools_core::ToolContext; -use llm_coding_tools_core::operations::grep_search; +use llm_coding_tools_core::operations::{DEFAULT_MAX_LINE_LENGTH, grep_search}; use llm_coding_tools_core::path::AllowedPathResolver; use serde::Deserialize; use serdes_ai::tools::{ RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult, ToolReturn, }; -use std::fmt::Write; use std::path::Path; use crate::convert::to_serdes_result; const DEFAULT_LIMIT: usize = 100; const MAX_LIMIT: usize = 2000; -const MAX_LINE_LENGTH: usize = 2000; /// Internal args for JSON deserialization. #[derive(Debug, Deserialize)] @@ -128,29 +126,7 @@ impl Tool for GrepTool MAX_LINE_LENGTH { - &m.line_text[..m.line_text.floor_char_boundary(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 grep_output.truncated { - let _ = write!(&mut output, "\n(Results truncated at {} matches)", limit); - } - + let output = grep_output.format::(limit, DEFAULT_MAX_LINE_LENGTH); Ok(ToolReturn::text(output)) } } From ce916df700db9d116b93fa5c1fa73dcb2b67de9d Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 16 Jan 2026 19:03:35 +0000 Subject: [PATCH 06/13] Changed: deduplicate bash output formatting into BashOutput::format_output() --- .../src/operations/bash/mod.rs | 39 +++++++++++++++++++ src/llm-coding-tools-rig/src/bash.rs | 30 +------------- src/llm-coding-tools-serdesai/src/bash.rs | 33 +--------------- 3 files changed, 42 insertions(+), 60 deletions(-) diff --git a/src/llm-coding-tools-core/src/operations/bash/mod.rs b/src/llm-coding-tools-core/src/operations/bash/mod.rs index fb550bd2..4ead22a5 100644 --- a/src/llm-coding-tools-core/src/operations/bash/mod.rs +++ b/src/llm-coding-tools-core/src/operations/bash/mod.rs @@ -1,5 +1,7 @@ //! Shell command execution operation. +use crate::ToolOutput; +use core::fmt::Write; use serde::Serialize; /// Result of shell command execution. @@ -13,6 +15,43 @@ pub struct BashOutput { pub stderr: String, } +impl BashOutput { + /// Formats the bash output into a [`ToolOutput`] for LLM consumption. + /// + /// Combines stdout, stderr (with `[stderr]` label), and non-zero exit codes + /// into a single formatted string. + pub fn format_output(&self) -> ToolOutput { + // Pre-allocate: stdout + stderr + labels overhead (~34 bytes) + // 34 bytes assumes the exit code is up to 10 digits, i.e. int32 range. + let estimated = self.stdout.len() + self.stderr.len() + 34; + let mut content = String::with_capacity(estimated); + + if !self.stdout.is_empty() { + content.push_str(&self.stdout); + } + + if !self.stderr.is_empty() { + if !content.is_empty() { + content.push('\n'); + } + content.push_str("[stderr]\n"); + content.push_str(&self.stderr); + } + + if let Some(code) = self.exit_code { + if code != 0 { + if !content.is_empty() { + content.push('\n'); + } + // Use write! to avoid format! allocation + let _ = write!(content, "[exit code: {code}]"); + } + } + + ToolOutput::new(content) + } +} + #[cfg(not(feature = "blocking"))] mod async_impl; #[cfg(not(feature = "blocking"))] diff --git a/src/llm-coding-tools-rig/src/bash.rs b/src/llm-coding-tools-rig/src/bash.rs index 83c0a1c8..87455e3b 100644 --- a/src/llm-coding-tools-rig/src/bash.rs +++ b/src/llm-coding-tools-rig/src/bash.rs @@ -3,7 +3,7 @@ //! Provides cross-platform shell command execution with timeout support. use llm_coding_tools_core::operations::execute_command; -use llm_coding_tools_core::{BashOutput, ToolContext, ToolError, ToolOutput}; +use llm_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 BashTool { let timeout = Duration::from_millis(args.timeout_ms); let result = execute_command(&args.command, workdir, timeout).await?; - Ok(format_bash_output(&result)) + Ok(result.format_output()) } } @@ -78,32 +78,6 @@ impl ToolContext for BashTool { } } -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::*; diff --git a/src/llm-coding-tools-serdesai/src/bash.rs b/src/llm-coding-tools-serdesai/src/bash.rs index 03babc92..1d7e4039 100644 --- a/src/llm-coding-tools-serdesai/src/bash.rs +++ b/src/llm-coding-tools-serdesai/src/bash.rs @@ -5,7 +5,6 @@ use crate::convert::to_serdes_result; use crate::schema::bash_schema; use async_trait::async_trait; -use llm_coding_tools_core::ToolOutput; use llm_coding_tools_core::context::ToolContext; use llm_coding_tools_core::operations::execute_command; use serde::Deserialize; @@ -92,37 +91,7 @@ impl Tool for BashTool { let result = execute_command(&args.command, workdir, timeout).await; - // Inline format_bash_output - only used here - to_serdes_result( - "Bash", - result.map(|output| { - // Pre-allocate capacity based on known stdout/stderr sizes plus overhead for labels - let estimated = output.stdout.len() + output.stderr.len() + 32; - let mut content = String::with_capacity(estimated); - - 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 - && code != 0 - { - if !content.is_empty() { - content.push('\n'); - } - content.push_str(&format!("[exit code: {}]", code)); - } - - ToolOutput::new(content) - }), - ) + to_serdes_result("Bash", result.map(|output| output.format_output())) } } From 3b3d3236f234f99067aa6261f653735f96a72796 Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 16 Jan 2026 19:14:52 +0000 Subject: [PATCH 07/13] Changed: remove redundant re-export of convert utilities from crate root Users can access conversion functions via the public convert module: llm_coding_tools_serdesai::convert::to_serdes_result --- src/llm-coding-tools-serdesai/src/lib.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/llm-coding-tools-serdesai/src/lib.rs b/src/llm-coding-tools-serdesai/src/lib.rs index a4a288c1..afb92e5c 100644 --- a/src/llm-coding-tools-serdesai/src/lib.rs +++ b/src/llm-coding-tools-serdesai/src/lib.rs @@ -63,9 +63,6 @@ pub use llm_coding_tools_core::{ WebFetchOutput, }; -// Re-export conversion utilities -pub use convert::{edit_error_to_serdes, output_to_return, to_serdes_result}; - // Re-export standalone tools pub use bash::BashTool; pub use task::TaskTool; From 88757dce185d90cf5a7272acf7ec27264d20fa4e Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 16 Jan 2026 19:22:34 +0000 Subject: [PATCH 08/13] Fixed: broken rustdoc links in convert.rs --- src/llm-coding-tools-serdesai/src/convert.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/llm-coding-tools-serdesai/src/convert.rs b/src/llm-coding-tools-serdesai/src/convert.rs index 2d014c9c..e733d101 100644 --- a/src/llm-coding-tools-serdesai/src/convert.rs +++ b/src/llm-coding-tools-serdesai/src/convert.rs @@ -2,16 +2,21 @@ //! //! Provides [`From`] implementations and helper functions to bridge //! [`llm_coding_tools_core`] types with serdesAI's tool system. +//! +//! [`llm_coding_tools_core`]: llm_coding_tools_core use llm_coding_tools_core::operations::EditError; use llm_coding_tools_core::{ToolError as CoreError, ToolOutput, ToolResult as CoreResult}; use serde_json::json; use serdes_ai::tools::{ToolError as SerdesError, ToolReturn}; -/// Convert [`ToolOutput`] to [`ToolReturn`]. +/// Convert [`ToolOutput`] to [`ToolReturn`] (serdesAI). /// /// - Non-truncated output: `ToolReturn::text(content)` /// - Truncated output: `ToolReturn::json({ "content": ..., "truncated": true })` +/// +/// [`ToolOutput`]: llm_coding_tools_core::ToolOutput +/// [`ToolReturn`]: serdes_ai::tools::ToolReturn #[inline] pub fn output_to_return(output: ToolOutput) -> ToolReturn { if output.truncated { @@ -24,7 +29,7 @@ pub fn output_to_return(output: ToolOutput) -> ToolReturn { } } -/// Convert core `ToolResult` to serdesAI `ToolResult`. +/// Convert core [`ToolResult`] to serdesAI [`ToolResult`]. /// /// This is the primary conversion function for tool implementations. /// Requires tool_name for proper error context in validation errors. @@ -40,6 +45,9 @@ pub fn output_to_return(output: ToolOutput) -> ToolReturn { /// to_serdes_result("my_tool", core_result) /// } /// ``` +/// +/// [`ToolResult`]: llm_coding_tools_core::ToolResult +/// [`ToolResult`]: serdes_ai::tools::ToolResult #[inline] pub fn to_serdes_result( tool_name: &str, @@ -55,6 +63,8 @@ pub fn to_serdes_result( /// Maps edit-specific errors to appropriate error types: /// - Validation errors: `NotFound`, `AmbiguousMatch`, `EmptyOldString`, `IdenticalStrings` /// - Execution errors: `Tool(ToolError)` (IO, path errors) +/// +/// [`EditError`]: llm_coding_tools_core::operations::EditError pub fn edit_error_to_serdes(err: EditError) -> SerdesError { match err { EditError::NotFound => SerdesError::validation_error( @@ -83,7 +93,10 @@ pub fn edit_error_to_serdes(err: EditError) -> SerdesError { } } -/// Convert core [`ToolError`] to serdesAI [`ToolError`] with tool name context. +/// Convert core [`ToolError`][core] to serdesAI [`ToolError`][serdes] with tool name context. +/// +/// [core]: llm_coding_tools_core::ToolError +/// [serdes]: serdes_ai::tools::ToolError pub(crate) fn core_error_to_serdes(tool_name: &str, err: CoreError) -> SerdesError { match &err { // Validation errors - input/parameter issues From 7452085d6bfaa02b677648665dbdff694ddb809b Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 16 Jan 2026 19:37:41 +0000 Subject: [PATCH 09/13] Changed: deduplicate webfetch output formatting into From for ToolOutput --- src/llm-coding-tools-core/src/output.rs | 22 +++++++++++++++++++ src/llm-coding-tools-rig/src/webfetch.rs | 7 +----- src/llm-coding-tools-serdesai/src/webfetch.rs | 11 +--------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/llm-coding-tools-core/src/output.rs b/src/llm-coding-tools-core/src/output.rs index a1c3c067..61a4ed42 100644 --- a/src/llm-coding-tools-core/src/output.rs +++ b/src/llm-coding-tools-core/src/output.rs @@ -1,5 +1,6 @@ //! Common output types for tool responses. +use crate::operations::WebFetchOutput; use serde::Serialize; /// Wrapper for tool output with truncation metadata. @@ -44,6 +45,15 @@ impl From<&str> for ToolOutput { } } +impl From for ToolOutput { + fn from(output: WebFetchOutput) -> Self { + Self::new(format!( + "[{} - {} bytes]\n\n{}", + output.content_type, output.byte_length, output.content + )) + } +} + #[cfg(test)] mod tests { use super::*; @@ -80,4 +90,16 @@ mod tests { let json = serde_json::to_string(&output).unwrap(); assert!(json.contains("truncated")); } + + #[test] + fn tool_output_from_webfetch_output() { + let webfetch = WebFetchOutput { + content: "Hello, world!".to_string(), + content_type: "text/plain".to_string(), + byte_length: 13, + }; + let output: ToolOutput = webfetch.into(); + assert_eq!(output.content, "[text/plain - 13 bytes]\n\nHello, world!"); + assert!(!output.truncated); + } } diff --git a/src/llm-coding-tools-rig/src/webfetch.rs b/src/llm-coding-tools-rig/src/webfetch.rs index 7e037834..cc4bf409 100644 --- a/src/llm-coding-tools-rig/src/webfetch.rs +++ b/src/llm-coding-tools-rig/src/webfetch.rs @@ -78,12 +78,7 @@ impl Tool for WebFetchTool { 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)) + Ok(result.into()) } } diff --git a/src/llm-coding-tools-serdesai/src/webfetch.rs b/src/llm-coding-tools-serdesai/src/webfetch.rs index e889356b..fd094dde 100644 --- a/src/llm-coding-tools-serdesai/src/webfetch.rs +++ b/src/llm-coding-tools-serdesai/src/webfetch.rs @@ -75,16 +75,7 @@ impl Tool for WebFetchTool { let timeout = Duration::from_millis(args.timeout_ms); let result = fetch_url(&self.client, &args.url, timeout).await; - to_serdes_result( - "WebFetch", - result.map(|output| { - let content = format!( - "[{} - {} bytes]\n\n{}", - output.content_type, output.byte_length, output.content - ); - ToolOutput::new(content) - }), - ) + to_serdes_result("WebFetch", result.map(ToolOutput::from)) } } From 48a1483be0d087e73d7100582ae531704b8a23ab Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 16 Jan 2026 19:39:13 +0000 Subject: [PATCH 10/13] Fixed: inconsistent field name in AmbiguousMatch error message Changed 'oldString' to 'old_string' in the error message to match the field identifier passed to SerdesError::validation_error. --- src/llm-coding-tools-serdesai/src/convert.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/llm-coding-tools-serdesai/src/convert.rs b/src/llm-coding-tools-serdesai/src/convert.rs index e733d101..0d5d7347 100644 --- a/src/llm-coding-tools-serdesai/src/convert.rs +++ b/src/llm-coding-tools-serdesai/src/convert.rs @@ -76,7 +76,7 @@ pub fn edit_error_to_serdes(err: EditError) -> SerdesError { "edit", Some("old_string".to_string()), format!( - "oldString found {count} times and requires more code context to uniquely identify the intended match" + "old_string found {count} times and requires more code context to uniquely identify the intended match" ), ), EditError::EmptyOldString => SerdesError::validation_error( From 104f9d8c3ff5889bab7819db98f1c3045767821b Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 16 Jan 2026 19:56:23 +0000 Subject: [PATCH 11/13] Fixed: remove redundant Arc wrapper around TodoState in serdesai todo tools TodoState already contains Arc>> internally, making it cheaply cloneable. The extra Arc wrapper was unnecessary indirection. This now matches the pattern used in the rig implementation. --- src/llm-coding-tools-serdesai/src/todo.rs | 29 +++++++++++------------ 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/llm-coding-tools-serdesai/src/todo.rs b/src/llm-coding-tools-serdesai/src/todo.rs index 3c55cc06..674d1309 100644 --- a/src/llm-coding-tools-serdesai/src/todo.rs +++ b/src/llm-coding-tools-serdesai/src/todo.rs @@ -10,7 +10,6 @@ use llm_coding_tools_core::context::ToolContext; use llm_coding_tools_core::operations::{read_todos, write_todos}; use serde::Deserialize; use serdes_ai::tools::{RunContext, Tool, ToolDefinition, ToolError, ToolResult}; -use std::sync::Arc; // Re-export core types pub use llm_coding_tools_core::{Todo, TodoPriority, TodoState, TodoStatus}; @@ -32,12 +31,12 @@ struct TodoReadArgs {} /// Tool for writing/replacing the todo list. #[derive(Debug, Clone)] pub struct TodoWriteTool { - state: Arc, + state: TodoState, } impl TodoWriteTool { - /// Creates a new todo write tool with the given shared state. - pub fn new(state: Arc) -> Self { + /// Creates a new todo write tool with the given state. + pub fn new(state: TodoState) -> Self { Self { state } } } @@ -68,12 +67,12 @@ impl ToolContext for TodoWriteTool { /// Tool for reading the current todo list. #[derive(Debug, Clone)] pub struct TodoReadTool { - state: Arc, + state: TodoState, } impl TodoReadTool { - /// Creates a new todo read tool with the given shared state. - pub fn new(state: Arc) -> Self { + /// Creates a new todo read tool with the given state. + pub fn new(state: TodoState) -> Self { Self { state } } } @@ -104,13 +103,13 @@ impl ToolContext for TodoReadTool { /// Creates a pair of todo tools with shared state. /// -/// Returns `(TodoReadTool, TodoWriteTool, Arc)` for cases where +/// Returns `(TodoReadTool, TodoWriteTool, TodoState)` for cases where /// the caller needs access to the underlying state. -pub fn create_todo_tools() -> (TodoReadTool, TodoWriteTool, Arc) { - let state = Arc::new(TodoState::new()); +pub fn create_todo_tools() -> (TodoReadTool, TodoWriteTool, TodoState) { + let state = TodoState::new(); ( - TodoReadTool::new(Arc::clone(&state)), - TodoWriteTool::new(Arc::clone(&state)), + TodoReadTool::new(state.clone()), + TodoWriteTool::new(state.clone()), state, ) } @@ -144,9 +143,9 @@ mod tests { #[tokio::test] async fn shared_state_works() { - let state = Arc::new(TodoState::new()); - let write_tool = TodoWriteTool::new(Arc::clone(&state)); - let read_tool = TodoReadTool::new(Arc::clone(&state)); + let state = TodoState::new(); + let write_tool = TodoWriteTool::new(state.clone()); + let read_tool = TodoReadTool::new(state); let write_args = serde_json::json!({ "todos": [{ "id": "shared", "content": "Shared task", "status": "in_progress", "priority": "low" }] From acce1161bd984b2c3dcfc032d095b7bb4664992f Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 16 Jan 2026 19:58:41 +0000 Subject: [PATCH 12/13] Fixed: broken rustdoc link in todo.rs --- src/llm-coding-tools-serdesai/src/todo.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/llm-coding-tools-serdesai/src/todo.rs b/src/llm-coding-tools-serdesai/src/todo.rs index 674d1309..0db4d696 100644 --- a/src/llm-coding-tools-serdesai/src/todo.rs +++ b/src/llm-coding-tools-serdesai/src/todo.rs @@ -23,7 +23,7 @@ struct TodoWriteArgs { /// Arguments for reading todos. /// -/// Empty struct required for consistent JSON validation via `serde_json::from_value`. +/// Empty struct required for consistent JSON validation via [`serde_json::from_value`]. /// Ensures the input is a valid JSON object even when no parameters are needed. #[derive(Debug, Clone, Deserialize)] struct TodoReadArgs {} From 1be5c5b2b1dcb5ed97556178bc4bb2fa9f1bd7ed Mon Sep 17 00:00:00 2001 From: Sewer56 Date: Fri, 16 Jan 2026 20:05:19 +0000 Subject: [PATCH 13/13] Changed: inline SchemaBuilder calls in each tool, remove centralized schema.rs Move schema building from schema.rs helper functions directly into each tool's definition() method. This makes each tool self-contained with its own schema definition using raw SchemaBuilder calls. Affected tools: BashTool, TodoWriteTool, TodoReadTool, WebFetchTool, TaskTool --- src/llm-coding-tools-serdesai/src/bash.rs | 29 ++- src/llm-coding-tools-serdesai/src/lib.rs | 2 - src/llm-coding-tools-serdesai/src/schema.rs | 187 ------------------ src/llm-coding-tools-serdesai/src/task.rs | 18 +- src/llm-coding-tools-serdesai/src/todo.rs | 43 +++- src/llm-coding-tools-serdesai/src/webfetch.rs | 17 +- 6 files changed, 91 insertions(+), 205 deletions(-) delete mode 100644 src/llm-coding-tools-serdesai/src/schema.rs diff --git a/src/llm-coding-tools-serdesai/src/bash.rs b/src/llm-coding-tools-serdesai/src/bash.rs index 1d7e4039..a719536b 100644 --- a/src/llm-coding-tools-serdesai/src/bash.rs +++ b/src/llm-coding-tools-serdesai/src/bash.rs @@ -3,12 +3,11 @@ //! Provides cross-platform shell command execution with timeout support. use crate::convert::to_serdes_result; -use crate::schema::bash_schema; use async_trait::async_trait; use llm_coding_tools_core::context::ToolContext; use llm_coding_tools_core::operations::execute_command; use serde::Deserialize; -use serdes_ai::tools::{RunContext, Tool, ToolDefinition, ToolError, ToolResult}; +use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -68,7 +67,31 @@ impl Tool for BashTool { "Bash", "Execute a shell command with optional working directory and timeout.", ) - .with_parameters(bash_schema().expect("schema serialization should never fail")) + .with_parameters( + SchemaBuilder::new() + .string_constrained( + "command", + "The shell command to execute", + true, + Some(1), + None, + None, + ) + .string( + "workdir", + "Working directory for command execution (must be absolute path)", + false, + ) + .integer_constrained( + "timeout_ms", + "Timeout in milliseconds. Defaults to 120000 (2 minutes).", + false, + Some(1), + Some(600_000), + ) + .build() + .expect("schema serialization should never fail"), + ) } async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { diff --git a/src/llm-coding-tools-serdesai/src/lib.rs b/src/llm-coding-tools-serdesai/src/lib.rs index afb92e5c..6cbcc3d2 100644 --- a/src/llm-coding-tools-serdesai/src/lib.rs +++ b/src/llm-coding-tools-serdesai/src/lib.rs @@ -26,8 +26,6 @@ pub mod task; pub mod todo; pub mod webfetch; -pub(crate) mod schema; - /// Re-export core types for convenience. pub use llm_coding_tools_core::{ToolError, ToolOutput, ToolResult}; diff --git a/src/llm-coding-tools-serdesai/src/schema.rs b/src/llm-coding-tools-serdesai/src/schema.rs deleted file mode 100644 index cee02543..00000000 --- a/src/llm-coding-tools-serdesai/src/schema.rs +++ /dev/null @@ -1,187 +0,0 @@ -//! Schema building utilities for tool parameter definitions. -//! -//! Provides composable helper functions and complete parameter schemas -//! using serdesAI's [`SchemaBuilder`]. This module is internal to the crate. - -use serde_json::Value; -use serdes_ai::tools::SchemaBuilder; - -// ============================================================================ -// Composable Schema Helpers -// ============================================================================ - -/// Add required command property with minimum length constraint. -#[inline] -pub fn add_command(builder: SchemaBuilder) -> SchemaBuilder { - builder.string_constrained( - "command", - "The shell command to execute", - true, - Some(1), - None, - None, - ) -} - -/// Add optional workdir property. -#[inline] -pub fn add_workdir(builder: SchemaBuilder) -> SchemaBuilder { - builder.string( - "workdir", - "Working directory for command execution (must be absolute path)", - false, - ) -} - -/// Add optional timeout_ms property with constraints. -#[inline] -pub fn add_timeout(builder: SchemaBuilder) -> SchemaBuilder { - builder.integer_constrained( - "timeout_ms", - "Timeout in milliseconds. Defaults to 120000 (2 minutes).", - false, - Some(1), - Some(600_000), - ) -} - -/// Add required todos array property. -#[inline] -pub fn add_todos(builder: SchemaBuilder) -> SchemaBuilder { - builder.raw( - "todos", - serde_json::json!({ - "type": "array", - "description": "The complete list of todos to set", - "items": { - "type": "object", - "required": ["id", "content", "status", "priority"], - "properties": { - "id": { "type": "string", "description": "Unique identifier" }, - "content": { "type": "string", "description": "Task description" }, - "status": { - "type": "string", - "enum": ["pending", "in_progress", "completed", "cancelled"], - "description": "Current status" - }, - "priority": { - "type": "string", - "enum": ["high", "medium", "low"], - "description": "Priority level" - } - } - } - }), - true, - ) -} - -/// Add required url property. -#[inline] -pub fn add_url(builder: SchemaBuilder) -> SchemaBuilder { - builder.string("url", "The URL to fetch", true) -} - -/// Add required description property for task. -#[inline] -pub fn add_description(builder: SchemaBuilder) -> SchemaBuilder { - builder.string("description", "Short 3-5 word task description", true) -} - -/// Add required prompt property for task. -#[inline] -pub fn add_prompt(builder: SchemaBuilder) -> SchemaBuilder { - builder.string("prompt", "Detailed instructions for the sub-agent", true) -} - -/// Add required subagent_type property. -#[inline] -pub fn add_subagent_type(builder: SchemaBuilder) -> SchemaBuilder { - builder.string( - "subagent_type", - "Type of agent to use (e.g., \"general\", \"coder\")", - true, - ) -} - -/// Add optional session_id property. -#[inline] -pub fn add_session_id(builder: SchemaBuilder) -> SchemaBuilder { - builder.string("session_id", "Existing session to continue", false) -} - -// ============================================================================ -// Complete Tool Schemas -// ============================================================================ - -/// Build a complete schema for the bash tool. -pub fn bash_schema() -> Result { - add_timeout(add_workdir(add_command(SchemaBuilder::new()))).build() -} - -/// Build a complete schema for the todo write tool. -pub fn todo_write_schema() -> Result { - add_todos(SchemaBuilder::new()).build() -} - -/// Build a complete schema for the todo read tool (empty object). -pub fn todo_read_schema() -> Result { - SchemaBuilder::new().build() -} - -/// Build a complete schema for the webfetch tool. -pub fn webfetch_schema() -> Result { - add_timeout(add_url(SchemaBuilder::new())).build() -} - -/// Build a complete schema for the task tool. -pub fn task_schema() -> Result { - add_session_id(add_subagent_type(add_prompt(add_description( - SchemaBuilder::new(), - )))) - .build() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn bash_schema_has_command_constraints() { - let schema = bash_schema().unwrap(); - let props = schema["properties"].as_object().unwrap(); - let command = props.get("command").unwrap(); - assert_eq!(command["minLength"], 1); - } - - #[test] - fn todo_write_schema_has_todos_required() { - let schema = todo_write_schema().unwrap(); - let required = schema["required"].as_array().unwrap(); - assert!(required.iter().any(|v| v == "todos")); - } - - #[test] - fn todo_read_schema_is_empty() { - let schema = todo_read_schema().unwrap(); - let required = schema.get("required").and_then(|v| v.as_array()); - assert!(required.is_none() || required.unwrap().is_empty()); - } - - #[test] - fn webfetch_schema_has_url_required() { - let schema = webfetch_schema().unwrap(); - let required = schema["required"].as_array().unwrap(); - assert!(required.iter().any(|v| v == "url")); - } - - #[test] - fn task_schema_has_required_fields() { - let schema = task_schema().unwrap(); - let required = schema["required"].as_array().unwrap(); - assert!(required.iter().any(|v| v == "description")); - assert!(required.iter().any(|v| v == "prompt")); - assert!(required.iter().any(|v| v == "subagent_type")); - assert!(!required.iter().any(|v| v == "session_id")); - } -} diff --git a/src/llm-coding-tools-serdesai/src/task.rs b/src/llm-coding-tools-serdesai/src/task.rs index bf6f9ca1..3c63efc5 100644 --- a/src/llm-coding-tools-serdesai/src/task.rs +++ b/src/llm-coding-tools-serdesai/src/task.rs @@ -3,12 +3,11 @@ //! Provides [`TaskTool`] for spawning sub-agents to handle complex tasks. use crate::convert::to_serdes_result; -use crate::schema::task_schema; use async_trait::async_trait; use llm_coding_tools_core::ToolOutput; use llm_coding_tools_core::context::ToolContext; use serde::Deserialize; -use serdes_ai::tools::{RunContext, Tool, ToolDefinition, ToolError, ToolResult}; +use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult}; use std::sync::Arc; /// Convenience re-exports from [`llm_coding_tools_core`] for users of this crate. @@ -76,8 +75,19 @@ impl TaskTool { #[async_trait] impl Tool for TaskTool { fn definition(&self) -> ToolDefinition { - ToolDefinition::new("Task", "Delegate a task to a specialized sub-agent.") - .with_parameters(task_schema().expect("schema serialization should never fail")) + ToolDefinition::new("Task", "Delegate a task to a specialized sub-agent.").with_parameters( + SchemaBuilder::new() + .string("description", "Short 3-5 word task description", true) + .string("prompt", "Detailed instructions for the sub-agent", true) + .string( + "subagent_type", + "Type of agent to use (e.g., \"general\", \"coder\")", + true, + ) + .string("session_id", "Existing session to continue", false) + .build() + .expect("schema serialization should never fail"), + ) } async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { diff --git a/src/llm-coding-tools-serdesai/src/todo.rs b/src/llm-coding-tools-serdesai/src/todo.rs index 0db4d696..0e447953 100644 --- a/src/llm-coding-tools-serdesai/src/todo.rs +++ b/src/llm-coding-tools-serdesai/src/todo.rs @@ -3,13 +3,12 @@ //! Provides tools for reading and writing todo items. use crate::convert::to_serdes_result; -use crate::schema::{todo_read_schema, todo_write_schema}; use async_trait::async_trait; use llm_coding_tools_core::ToolOutput; use llm_coding_tools_core::context::ToolContext; use llm_coding_tools_core::operations::{read_todos, write_todos}; use serde::Deserialize; -use serdes_ai::tools::{RunContext, Tool, ToolDefinition, ToolError, ToolResult}; +use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult}; // Re-export core types pub use llm_coding_tools_core::{Todo, TodoPriority, TodoState, TodoStatus}; @@ -44,8 +43,37 @@ impl TodoWriteTool { #[async_trait] impl Tool for TodoWriteTool { fn definition(&self) -> ToolDefinition { - ToolDefinition::new("TodoWrite", "Replace the todo list with new items.") - .with_parameters(todo_write_schema().expect("schema serialization should never fail")) + ToolDefinition::new("TodoWrite", "Replace the todo list with new items.").with_parameters( + SchemaBuilder::new() + .raw( + "todos", + serde_json::json!({ + "type": "array", + "description": "The complete list of todos to set", + "items": { + "type": "object", + "required": ["id", "content", "status", "priority"], + "properties": { + "id": { "type": "string", "description": "Unique identifier" }, + "content": { "type": "string", "description": "Task description" }, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed", "cancelled"], + "description": "Current status" + }, + "priority": { + "type": "string", + "enum": ["high", "medium", "low"], + "description": "Priority level" + } + } + } + }), + true, + ) + .build() + .expect("schema serialization should never fail"), + ) } async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { @@ -80,8 +108,11 @@ impl TodoReadTool { #[async_trait] impl Tool for TodoReadTool { fn definition(&self) -> ToolDefinition { - ToolDefinition::new("TodoRead", "Read the current todo list.") - .with_parameters(todo_read_schema().expect("schema serialization should never fail")) + ToolDefinition::new("TodoRead", "Read the current todo list.").with_parameters( + SchemaBuilder::new() + .build() + .expect("schema serialization should never fail"), + ) } async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult { diff --git a/src/llm-coding-tools-serdesai/src/webfetch.rs b/src/llm-coding-tools-serdesai/src/webfetch.rs index fd094dde..2baa0ec0 100644 --- a/src/llm-coding-tools-serdesai/src/webfetch.rs +++ b/src/llm-coding-tools-serdesai/src/webfetch.rs @@ -3,13 +3,12 @@ //! Provides URL fetching with format conversion support. use crate::convert::to_serdes_result; -use crate::schema::webfetch_schema; use async_trait::async_trait; use llm_coding_tools_core::ToolOutput; use llm_coding_tools_core::context::ToolContext; use llm_coding_tools_core::operations::fetch_url; use serde::Deserialize; -use serdes_ai::tools::{RunContext, Tool, ToolDefinition, ToolError, ToolResult}; +use serdes_ai::tools::{RunContext, SchemaBuilder, Tool, ToolDefinition, ToolError, ToolResult}; use std::time::Duration; /// Default timeout: 30 seconds. @@ -66,7 +65,19 @@ impl Tool for WebFetchTool { "WebFetch", "Fetch content from a URL. HTML is converted to markdown, JSON is prettified.", ) - .with_parameters(webfetch_schema().expect("schema serialization should never fail")) + .with_parameters( + SchemaBuilder::new() + .string("url", "The URL to fetch", true) + .integer_constrained( + "timeout_ms", + "Timeout in milliseconds. Defaults to 30000 (30 seconds).", + false, + Some(1), + Some(600_000), + ) + .build() + .expect("schema serialization should never fail"), + ) } async fn call(&self, _ctx: &RunContext, args: serde_json::Value) -> ToolResult {