Add agent tool-calling support with file and directory tools
- Add src/tools.rs with definitions and execution for read_file, list_directory, search_files, create_file, and edit_file tools - Integrate tool-calling agent loop in chat and main - Switch onde dependency to local path for tool API - Update crossterm to 0.28.1 in Cargo.lock - Add detailed SKILL.md documentation for tool calling architecture and usage
Seto Elkahfi committed
Apr 13, 2026 at 22:58 UTC
221b03cd011b6f11f9bb2af667f84175f32b809a
6 files changed
+1485
-120
.agents/skills/tool-calling/SKILL.md
+283
new file mode 100644
index 0000000..448286b
--- /dev/null
+++ b/.agents/skills/tool-calling/SKILL.md
@@ -0,0 +1,283 @@
+# Skill: Tool Calling in siGit Code
+
+## Overview
+
+siGit Code supports **agentic tool calling** — the LLM can invoke tools (read files, list directories, search code) to ground its answers in the actual codebase. This works in both **interactive TUI mode** and **ACP server mode** (Zed editor).
+
+Tool calling spans three layers:
+
+```
+siGit (agent loop + tool execution)
+ → onde (ChatEngine with tool-aware API)
+ → mistral.rs (model inference + tool call parsing)
+```
+
+---
+
+## Model Requirement
+
+**Only Qwen 3 supports tool calling.** Qwen 2.5 does NOT — mistral.rs only has a parser for Qwen 3's `<tool_call>...</tool_call>` XML format.
+
+| Model | Constructor | Size | Tool calling |
+|-------|-----------|------|:---:|
+| Qwen 3 4B (Q4_K_M) | `GgufModelConfig::qwen3_4b()` | ~2.6 GB | ✅ |
+| Qwen 3 1.7B (Q4_K_M) | `GgufModelConfig::qwen3_1_7b()` | ~1.2 GB | ✅ |
+| Qwen 2.5 Coder 3B | `GgufModelConfig::qwen25_coder_3b()` | ~1.9 GB | ❌ |
+| Qwen 2.5 1.5B | `GgufModelConfig::qwen25_1_5b()` | ~0.9 GB | ❌ |
+
+siGit uses **Qwen 3 4B** by default (set in `main.rs` via `GgufModelConfig::qwen3_4b()`).
+
+### bartowski GGUF naming convention
+
+bartowski's repos use the publisher name as a prefix with an underscore:
+
+| Constant | Value |
+|----------|-------|
+| `BARTOWSKI_QWEN3_4B_GGUF` | `"bartowski/Qwen_Qwen3-4B-GGUF"` |
+| `QWEN3_4B_GGUF_FILE` | `"Qwen_Qwen3-4B-Q4_K_M.gguf"` |
+| `BARTOWSKI_QWEN3_1_7B_GGUF` | `"bartowski/Qwen_Qwen3-1.7B-GGUF"` |
+| `QWEN3_1_7B_GGUF_FILE` | `"Qwen_Qwen3-1.7B-Q4_K_M.gguf"` |
+
+These constants live in `onde/src/inference/models.rs`.
+
+---
+
+## Architecture
+
+### Layer 1: mistral.rs (model-level)
+
+mistral.rs handles the low-level tool calling protocol:
+
+- **`RequestBuilder::set_tools(Vec<Tool>)`** — attach tool definitions (JSON Schema) to a request
+- **`RequestBuilder::set_tool_choice(ToolChoice::Auto)`** — let the model decide whether to call tools
+- **`RequestBuilder::add_message_with_tool_call(role, content, tool_calls)`** — replay an assistant message that contained tool calls
+- **`RequestBuilder::add_tool_message(content, tool_call_id)`** — send a tool execution result back
+
+Key types (all re-exported from `mistralrs` crate, accessible via `onde::mistralrs::*`):
+
+| Type | Purpose |
+|------|---------|
+| `Tool` | A tool definition: `{ tp: ToolType::Function, function: Function }` |
+| `Function` | Name, description, JSON Schema parameters, `strict` flag |
+| `ToolChoice` | `None`, `Auto`, or `Tool(...)` |
+| `ToolCallResponse` | Model's tool call: `{ id, function: CalledFunction }` |
+| `CalledFunction` | `{ name, arguments }` where arguments is a JSON string |
+| `ToolCallType` | Currently only `Function` |
+
+### Layer 2: onde (engine-level)
+
+onde's `ChatEngine` wraps mistral.rs with conversation history management. The tool calling API is **Rust-only** (no UniFFI annotations — Swift/Kotlin bindings are not affected).
+
+#### Types (`onde/src/inference/types.rs`)
+
+| Type | Purpose |
+|------|---------|
+| `ToolDefinition` | `{ name, description, parameters_schema: String }` — tool schema for the model |
+| `ToolCallRequest` | `{ id, function_name, arguments: String }` — parsed tool call from model response |
+| `ToolResult` | `{ tool_call_id, content: String }` — execution result to feed back |
+| `ToolAwareResult` | `{ text, tool_calls: Vec<ToolCallRequest>, duration_secs, ... }` — inference result that may contain tool calls |
+
+#### Methods (`onde/src/inference/engine.rs`)
+
+| Method | Purpose |
+|--------|---------|
+| `send_message_with_tools(&self, msg, &[ToolDefinition])` | Send user message with tools available. Returns `ToolAwareResult`. If `tool_calls` is non-empty, the model wants to call tools. |
+| `send_tool_results(&self, Vec<ToolResult>, Option<&[ToolDefinition]>)` | Feed tool execution results back. Pass tools to allow further rounds, or `None` to force a text response. |
+| `stream_tool_results(&self, Vec<ToolResult>, Option<Vec<ToolDefinition>>)` | Streaming variant for the final text response after tool rounds. |
+
+#### Internal history
+
+`LoadedModel.history` uses `Vec<HistoryEntry>` (not `Vec<ChatMessage>`) to support tool-related messages:
+
+```rust
+enum HistoryEntry {
+ Text(ChatMessage), // regular user/assistant/system
+ AssistantToolCall { content, tool_calls }, // assistant response with tool calls
+ ToolResult { tool_call_id, content }, // tool execution result
+}
+```
+
+The existing `history()` public method converts back to `Vec<ChatMessage>` for backward compatibility. All existing methods (`send_message`, `stream_message`, etc.) work unchanged — they use `HistoryEntry::Text` internally.
+
+When replaying history in requests:
+- `build_request()` (no tools) — `AssistantToolCall` replays as plain assistant text, `ToolResult` is skipped
+- `build_request_with_tools()` — uses `add_message_with_tool_call()` and `add_tool_message()` for full fidelity
+
+### Layer 3: siGit (agent-level)
+
+#### Tool definitions (`sigit/src/tools.rs`)
+
+Three coding tools with JSON Schema definitions and execution functions:
+
+| Tool | Parameters | Behavior |
+|------|-----------|----------|
+| `read_file` | `path` (required) | Reads file contents, truncates at 10,000 chars |
+| `list_directory` | `path` (required) | Lists entries with `[DIR]`/`[FILE]` prefix, dirs first, sorted |
+| `search_files` | `pattern` (required), `path` (optional) | Recursive regex search, max 50 matches, skips hidden dirs |
+
+Public API:
+- `all_tools() -> Vec<AgentTool>` — returns tool schemas (name, description, JSON Schema)
+- `execute_tool(name: &str, arguments: &str) -> String` — dispatches by name, returns result string
+
+#### Conversion to onde types
+
+In both `main.rs` and `chat.rs`, `AgentTool` is converted to `ToolDefinition`:
+
+```rust
+let onde_tools: Vec<ToolDefinition> = tools::all_tools()
+ .into_iter()
+ .map(|t| ToolDefinition {
+ name: t.name.to_string(),
+ description: t.description.to_string(),
+ parameters_schema: t.parameters_schema.to_string(),
+ })
+ .collect();
+```
+
+---
+
+## The Agentic Loop
+
+Both ACP mode (`main.rs` → `SiGitAgent::prompt()`) and TUI mode (`chat.rs` → event loop) implement the same pattern:
+
+```
+1. engine.send_message_with_tools(user_text, &tools) → ToolAwareResult
+2. while result.tool_calls is non-empty AND round < MAX_TOOL_ROUNDS (10):
+ a. For each tool_call:
+ - Show status to user (🔧 tool_name)
+ - Execute: tools::execute_tool(name, arguments)
+ - Collect ToolResult { tool_call_id, content }
+ b. Decide next_tools:
+ - If round < MAX_TOOL_ROUNDS → Some(&tools) (allow more calls)
+ - Else → None (force text response)
+ c. engine.send_tool_results(results, next_tools) → ToolAwareResult
+3. Send final result.text to user
+```
+
+### ACP mode specifics (`main.rs`)
+
+- Tool status is sent as `SessionUpdate::AgentMessageChunk` with a 🔧 prefix
+- Final text is sent as a single `AgentMessageChunk`
+- Returns `StopReason::EndTurn`
+
+### TUI mode specifics (`chat.rs`)
+
+- Tool status shown as `ChatMessage::system("🔧 tool_name")`
+- Forces a `terminal.draw()` after each tool call for visual feedback
+- Final text added as `ChatMessage::assistant(result.text)`
+- The tool loop is **blocking** (non-streaming) — the TUI doesn't accept input during tool execution
+
+---
+
+## System Prompt
+
+The system prompt in `main.rs` (`SYSTEM_PROMPT`) includes tool-awareness instructions:
+
+```
+You have access to tools that let you read files, list directories, and search
+code. Use them proactively to understand the codebase before answering questions
+or writing code. Always ground your answers in the actual code.
+```
+
+This is critical — without it, the model may not use the tools even when they're available.
+
+---
+
+## Adding a New Tool
+
+1. **Define the schema** in `sigit/src/tools.rs`:
+ - Add an `AgentTool` entry to `all_tools()` with name, description, and JSON Schema
+ - The `parameters_schema` must be a valid JSON Schema object with `type`, `properties`, and `required`
+
+2. **Implement execution** in `sigit/src/tools.rs`:
+ - Add a case to `execute_tool()` match
+ - Write `exec_your_tool(arguments: &str) -> String`
+ - Parse arguments with `serde_json::from_str::<Value>(arguments)`
+ - Return results as a string, handle errors gracefully (never panic)
+
+3. **No changes needed** in onde or mistral.rs — the tool definitions are passed dynamically via `send_message_with_tools()`.
+
+### Example: adding a `write_file` tool
+
+```rust
+// In all_tools():
+AgentTool {
+ name: "write_file",
+ description: "Create or overwrite a file with the given content.",
+ parameters_schema: json!({
+ "type": "object",
+ "properties": {
+ "path": { "type": "string", "description": "File path to write" },
+ "content": { "type": "string", "description": "File content" }
+ },
+ "required": ["path", "content"]
+ }),
+}
+
+// In execute_tool():
+"write_file" => exec_write_file(arguments),
+
+// Implementation:
+fn exec_write_file(arguments: &str) -> String {
+ let args: Value = serde_json::from_str(arguments).unwrap_or_default();
+ let path = args["path"].as_str().unwrap_or("");
+ let content = args["content"].as_str().unwrap_or("");
+ match std::fs::write(path, content) {
+ Ok(()) => format!("Successfully wrote {} bytes to {}", content.len(), path),
+ Err(e) => format!("Error writing {}: {}", path, e),
+ }
+}
+```
+
+---
+
+## Dependencies
+
+### onde (`Cargo.toml`)
+
+- `serde_json = "1.0"` — parsing `parameters_schema` JSON strings into `HashMap<String, Value>` for mistral.rs `Function.parameters`
+
+### siGit (`Cargo.toml`)
+
+- `onde = { path = "../onde" }` — local path dep (required during development for the tool calling API)
+- `serde_json = "1"` — parsing tool call arguments
+- `regex = "1"` — used by the `search_files` tool
+
+---
+
+## Debugging Tool Calling
+
+### Model doesn't call tools
+
+- Verify the model is Qwen 3 (check `GgufModelConfig::qwen3_4b()` in both `main.rs` load sites)
+- Check the system prompt includes tool-awareness instructions
+- Check logs: `ChatEngine: tool inference END — tool_calls: 0` means the model chose not to use tools
+- Try a more explicit prompt: *"Use the read_file tool to read src/main.rs"*
+
+### Tool calls fail / wrong arguments
+
+- Check `ToolDefinition.parameters_schema` is valid JSON Schema
+- Check `strict: Some(true)` is set in onde's `attach_tools()` (it is by default) — this enables constrained decoding
+- Check logs for the raw arguments: `→ read_file({"path":"..."})`
+
+### History replay issues
+
+- Tool call history is replayed via `replay_history_with_tools()` which uses `add_message_with_tool_call()` and `add_tool_message()`
+- If history gets corrupted, `/clear` in the TUI or `engine.clear_history()` resets it
+- The non-tool `build_request()` gracefully degrades: `AssistantToolCall` replays as plain text, `ToolResult` entries are skipped
+
+---
+
+## File Map
+
+| File | What it does |
+|------|-------------|
+| `onde/src/inference/types.rs` | `ToolDefinition`, `ToolCallRequest`, `ToolResult`, `ToolAwareResult` types |
+| `onde/src/inference/engine.rs` | `HistoryEntry` enum, `send_message_with_tools()`, `send_tool_results()`, `stream_tool_results()`, helper functions (`build_request_with_tools`, `replay_history_with_tools`, `attach_tools`, `parse_tool_calls`) |
+| `onde/src/inference/models.rs` | Qwen 3 model constants (`BARTOWSKI_QWEN3_4B_GGUF`, `QWEN3_4B_GGUF_FILE`, etc.) and `GgufModelConfig::qwen3_4b()` / `qwen3_1_7b()` constructors |
+| `onde/src/inference/mod.rs` | Re-exports `ToolAwareResult`, `ToolCallRequest`, `ToolDefinition`, `ToolResult` |
+| `sigit/src/tools.rs` | `AgentTool` struct, `all_tools()`, `execute_tool()`, implementations for `read_file`, `list_directory`, `search_files` |
+| `sigit/src/main.rs` | `agent_tools_as_onde()` converter, agentic loop in `SiGitAgent::prompt()`, `MAX_TOOL_ROUNDS` constant, Qwen 3 4B model selection |
+| `sigit/src/chat.rs` | TUI agentic loop (same pattern as ACP), tool status display as system messages |
+| `mistral.rs/docs/TOOL_CALLING.md` | Upstream docs for supported models and API |
+| `mistral.rs/mistralrs/examples/advanced/tools/main.rs` | Reference example for mistral.rs tool calling |
\ No newline at end of file
Cargo.lock
+43
-56
index 2c24312..65475f9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1056,15 +1056,16 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
-version = "0.25.0"
+version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67"
+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
- "bitflags 1.3.2",
+ "bitflags 2.11.0",
"crossterm_winapi",
- "libc",
- "mio 0.8.11",
+ "futures-core",
+ "mio",
"parking_lot",
+ "rustix 0.38.44",
"signal-hook",
"signal-hook-mio",
"winapi",
@@ -1072,16 +1073,17 @@ dependencies = [
[[package]]
name = "crossterm"
-version = "0.28.1"
+version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
+checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags 2.11.0",
"crossterm_winapi",
- "futures-core",
- "mio 1.2.0",
+ "derive_more",
+ "document-features",
+ "mio",
"parking_lot",
- "rustix 0.38.44",
+ "rustix 1.1.4",
"signal-hook",
"signal-hook-mio",
"winapi",
@@ -1435,6 +1437,15 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359"
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
[[package]]
name = "dtoa"
version = "1.0.11"
@@ -3016,6 +3027,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
[[package]]
name = "llguidance"
version = "1.7.2"
@@ -3270,18 +3287,6 @@ dependencies = [
"simd-adler32",
]
-[[package]]
-name = "mio"
-version = "0.8.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
-dependencies = [
- "libc",
- "log",
- "wasi",
- "windows-sys 0.48.0",
-]
-
[[package]]
name = "mio"
version = "1.2.0"
@@ -3297,8 +3302,7 @@ dependencies = [
[[package]]
name = "mistralrs"
version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9bb0a83340b4492ebba9760ba6845364de369c7e10c1f91af8733d574c7405fa"
+source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
dependencies = [
"anyhow",
"candle-core",
@@ -3325,8 +3329,7 @@ dependencies = [
[[package]]
name = "mistralrs-audio"
version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ac5d36f634c7c20c45845bc995be3b6387ab01048410386a5b180cfeaf89c72"
+source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
dependencies = [
"anyhow",
"apodize",
@@ -3337,8 +3340,7 @@ dependencies = [
[[package]]
name = "mistralrs-core"
version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2b8b9e5c94491d9ceeded3a30291cb6af6ae74810a5776dd55e3bbb8b3429d4"
+source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
dependencies = [
"ahash",
"akin",
@@ -3435,8 +3437,7 @@ dependencies = [
[[package]]
name = "mistralrs-macros"
version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5aa9b4794322d3f89fe61d21f33f67be494c16d5bd869604b111218f32544d32"
+source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
dependencies = [
"darling 0.23.0",
"proc-macro2",
@@ -3447,8 +3448,7 @@ dependencies = [
[[package]]
name = "mistralrs-mcp"
version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fa97d4e3189ed80ebbc730b7da5b2356df0ae004ab489066249340c33c089ab"
+source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
dependencies = [
"anyhow",
"async-trait",
@@ -3468,8 +3468,7 @@ dependencies = [
[[package]]
name = "mistralrs-paged-attn"
version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e53ddf1537426997b46abdadbe6ca8a7ce7668004ed2b7cf00115016558bae8"
+source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
dependencies = [
"anyhow",
"candle-core",
@@ -3485,8 +3484,7 @@ dependencies = [
[[package]]
name = "mistralrs-quant"
version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "980715493d252e9aaf0779c4c101576d67ef4dd9812bfd77a54987f6f17526f4"
+source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
dependencies = [
"byteorder",
"candle-core",
@@ -3515,8 +3513,7 @@ dependencies = [
[[package]]
name = "mistralrs-vision"
version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "10046a3da2de5b702d3e829b5ddef7aa829ad454819dcc4876dea481a6cb5456"
+source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
dependencies = [
"candle-core",
"image",
@@ -3830,9 +3827,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "onde"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5436633863ffc31311f529439353ba06a436dd661d2a5d93c8793c3500123a13"
+version = "0.1.3"
dependencies = [
"anyhow",
"cc",
@@ -3842,6 +3837,7 @@ dependencies = [
"mistralrs",
"mistralrs-core",
"serde",
+ "serde_json",
"thiserror 2.0.18",
"tokio",
"tsync",
@@ -5308,6 +5304,8 @@ dependencies = [
"log",
"onde",
"ratatui",
+ "regex",
+ "serde_json",
"tokio",
"tokio-util",
"uuid 1.23.0",
@@ -5330,8 +5328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
- "mio 0.8.11",
- "mio 1.2.0",
+ "mio",
"signal-hook",
]
@@ -6112,7 +6109,7 @@ checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
dependencies = [
"bytes",
"libc",
- "mio 1.2.0",
+ "mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
@@ -6292,11 +6289,10 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tqdm"
version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b316d5c2ac649ca856dacd487d0ebb94f3b746bada51355d93dd2c007ab62a2e"
+source = "git+https://github.com/setoelkahfi/tqdm?branch=deps%2Fbump-crossterm#41e4182829136e6dadbdd1c36af824d19219147a"
dependencies = [
"anyhow",
- "crossterm 0.25.0",
+ "crossterm 0.29.0",
"once_cell",
]
@@ -7273,15 +7269,6 @@ dependencies = [
"windows-targets 0.42.2",
]
-[[package]]
-name = "windows-sys"
-version = "0.48.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
-dependencies = [
- "windows-targets 0.48.5",
-]
-
[[package]]
name = "windows-sys"
version = "0.52.0"
Cargo.toml
+3
-1
index 550dc65..16d2304 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,7 +15,7 @@ path = "src/main.rs"
agent-client-protocol = "0.10.4"
# Onde Inference engine (local LLM)
-onde = "0.1.1"
+onde = { path = "../onde" }
# Async runtime
async-trait = "0.1"
@@ -31,4 +31,6 @@ ratatui = { version = "0.29", default-features = false, features = ["crossterm"]
anyhow = "1"
log = "0.4"
env_logger = "0.11"
+serde_json = "1"
+regex = "1"
uuid = { version = "1", features = ["v4"] }
src/chat.rs
+251
-35
index c7bab80..4e4d1ad 100644
--- a/src/chat.rs
+++ b/src/chat.rs
@@ -2,13 +2,19 @@
//!
//! Takes over the alternate screen and multiplexes terminal events with
//! streaming LLM tokens via `tokio::select!`.
+//!
+//! Inference runs on a background `tokio::spawn` task so the event loop
+//! keeps redrawing while the model thinks. Results flow back through an
+//! `mpsc` channel as `InferenceUpdate` variants.
use std::future::pending;
+use std::sync::Arc;
+use std::time::Duration;
use anyhow::Result;
use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use futures::StreamExt;
-use onde::inference::{ChatEngine, StreamChunk};
+use onde::inference::{ChatEngine, StreamChunk, ToolDefinition, ToolResult};
use ratatui::{
Frame,
layout::{Constraint, Layout, Position},
@@ -55,6 +61,18 @@ impl ChatMessage {
}
}
+// ── Inference updates from background task ───────────────────────────────────
+
+/// Messages sent from the spawned inference task back to the event loop.
+enum InferenceUpdate {
+ /// The model is calling a tool — show its name in the chat.
+ ToolUse(String),
+ /// The model produced a final text response.
+ Response(String),
+ /// Something went wrong during inference.
+ Error(String),
+}
+
// ── App state ────────────────────────────────────────────────────────────────
struct App {
@@ -64,6 +82,12 @@ struct App {
scroll_offset: u16,
stream_rx: Option<mpsc::Receiver<StreamChunk>>,
stream_buf: String,
+ /// Channel for receiving results from the background inference task.
+ inference_rx: Option<mpsc::Receiver<InferenceUpdate>>,
+ /// True while waiting for inference to finish.
+ thinking: bool,
+ /// Counter driving the thinking spinner animation.
+ thinking_tick: u8,
quit: bool,
/// Toggled every other tick while streaming — drives the blinking cursor.
blink_on: bool,
@@ -85,6 +109,9 @@ const BANNER_ART: &str = "\
55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555
88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888";
+/// Spinner frames for the "thinking" animation.
+const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
+
impl App {
fn new() -> Self {
let mut messages = Vec::new();
@@ -108,12 +135,20 @@ impl App {
scroll_offset: 0,
stream_rx: None,
stream_buf: String::new(),
+ inference_rx: None,
+ thinking: false,
+ thinking_tick: 0,
quit: false,
blink_on: true,
blink_counter: 0,
}
}
+ /// True when either streaming tokens or waiting for inference.
+ fn is_busy(&self) -> bool {
+ self.is_streaming() || self.thinking
+ }
+
fn is_streaming(&self) -> bool {
self.stream_rx.is_some()
}
@@ -134,6 +169,25 @@ impl App {
self.blink_on = self.blink_counter % 4 < 2;
}
+ fn start_thinking(&mut self) {
+ self.thinking = true;
+ self.thinking_tick = 0;
+ }
+
+ fn stop_thinking(&mut self) {
+ self.thinking = false;
+ self.inference_rx = None;
+ }
+
+ fn tick_thinking(&mut self) {
+ self.thinking_tick = self.thinking_tick.wrapping_add(1);
+ }
+
+ fn thinking_frame(&self) -> &'static str {
+ let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len();
+ THINKING_FRAMES[idx]
+ }
+
/// Total lines the messages area would need (rough estimate for scrolling).
fn total_message_lines(&self, width: u16) -> u16 {
if width == 0 {
@@ -148,6 +202,10 @@ impl App {
if !self.stream_buf.is_empty() {
lines += wrapped_line_count(&self.stream_buf, Role::Assistant, w);
}
+ // thinking indicator
+ if self.thinking {
+ lines += 1;
+ }
lines
}
@@ -165,7 +223,7 @@ impl App {
fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 {
let prefix_len = match role {
Role::User => 6, // "you > "
- Role::Assistant => 7, // "siGit > " — wait, that's 8. Let's just use 7 for "siGit> "
+ Role::Assistant => 8, // "siGit > "
Role::System => 0,
};
let effective = if width > prefix_len {
@@ -270,7 +328,7 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect
let buf_lines: Vec<&str> = app.stream_buf.split('\n').collect();
for (i, segment) in buf_lines.iter().enumerate() {
if i > 0 {
- lines.push(Line::from(spans.drain(..).collect::<Vec<_>>()));
+ lines.push(Line::from(std::mem::take(&mut spans)));
// continuation lines get no prefix
}
spans.push(Span::raw(segment.to_string()));
@@ -284,6 +342,25 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect
lines.push(Line::from(spans));
}
+ // thinking indicator (animated spinner)
+ if app.thinking {
+ let frame_char = app.thinking_frame();
+ lines.push(Line::from(vec![
+ Span::styled(
+ "siGit > ",
+ Style::default()
+ .fg(Color::Green)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(
+ format!("{frame_char} thinking…"),
+ Style::default()
+ .fg(Color::Yellow)
+ .add_modifier(Modifier::DIM),
+ ),
+ ]));
+ }
+
// auto-scroll
app.auto_scroll(area.height, area.width);
@@ -350,19 +427,21 @@ fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::DarkGray))
- .title(if app.is_streaming() {
+ .title(if app.thinking {
+ " thinking… "
+ } else if app.is_streaming() {
" streaming… "
} else {
" message "
})
- .title_style(Style::default().fg(if app.is_streaming() {
+ .title_style(Style::default().fg(if app.is_busy() {
Color::Yellow
} else {
Color::DarkGray
}));
let input_text = Paragraph::new(app.input.as_str())
- .style(Style::default().fg(if app.is_streaming() {
+ .style(Style::default().fg(if app.is_busy() {
Color::DarkGray
} else {
Color::White
@@ -372,7 +451,7 @@ fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
frame.render_widget(input_text, area);
// place cursor inside the input block (1 for border padding)
- if !app.is_streaming() {
+ if !app.is_busy() {
let x = area.x + app.cursor as u16 + 1;
let y = area.y + 1;
frame.set_cursor_position(Position::new(x.min(area.right().saturating_sub(1)), y));
@@ -380,7 +459,7 @@ fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
}
fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
- let hints: &[(&str, &str)] = if app.is_streaming() {
+ let hints: &[(&str, &str)] = if app.is_busy() {
&[("Ctrl+C", "cancel")]
} else {
&[("Enter", "send"), ("/help", "commands"), ("Ctrl+C", "quit")]
@@ -395,12 +474,12 @@ fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
format!(" {key} "),
Style::default()
.fg(Color::Black)
- .bg(Color::DarkGray)
+ .bg(Color::Gray)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
format!(" {label}"),
- Style::default().fg(Color::DarkGray),
+ Style::default().fg(Color::Gray),
));
}
@@ -513,19 +592,114 @@ async fn exec_slash(app: &mut App, cmd: SlashCommand, engine: &ChatEngine) {
}
}
+// ── Background inference task ────────────────────────────────────────────────
+
+/// Maximum number of tool-calling rounds before forcing a text response.
+const MAX_TOOL_ROUNDS: usize = 10;
+
+/// Build onde `ToolDefinition`s from our agent tools.
+fn build_onde_tools() -> Vec<ToolDefinition> {
+ crate::tools::all_tools()
+ .into_iter()
+ .map(|t| ToolDefinition {
+ name: t.name.to_string(),
+ description: t.description.to_string(),
+ parameters_schema: t.parameters_schema.to_string(),
+ })
+ .collect()
+}
+
+/// Runs the agentic tool-calling loop on a background task and sends
+/// progress updates back through `tx`.
+///
+/// The sender is dropped when the task finishes, which the event loop
+/// detects as `None` from `rx.recv()`.
+async fn run_inference_task(
+ engine: Arc<ChatEngine>,
+ text: String,
+ tx: mpsc::Sender<InferenceUpdate>,
+) {
+ let onde_tools = build_onde_tools();
+
+ let mut result = match engine.send_message_with_tools(&text, &onde_tools).await {
+ Ok(r) => r,
+ Err(err) => {
+ let _ = tx.send(InferenceUpdate::Error(err.to_string())).await;
+ return;
+ }
+ };
+
+ let mut round = 0;
+
+ while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
+ round += 1;
+ log::info!("tool round {} — {} call(s)", round, result.tool_calls.len());
+
+ let mut tool_results = Vec::new();
+
+ for tc in &result.tool_calls {
+ log::info!(
+ " → {}({})",
+ tc.function_name,
+ tc.arguments.chars().take(120).collect::<String>()
+ );
+
+ // Notify the UI about the tool call.
+ let _ = tx
+ .send(InferenceUpdate::ToolUse(tc.function_name.clone()))
+ .await;
+
+ // Execute the tool (synchronous / blocking-ok for file I/O).
+ let output = crate::tools::execute_tool(&tc.function_name, &tc.arguments);
+ log::info!(" ← {} chars", output.len());
+
+ tool_results.push(ToolResult {
+ tool_call_id: tc.id.clone(),
+ content: output,
+ });
+ }
+
+ // Allow further tool calls unless we've hit the limit.
+ let next_tools = if round < MAX_TOOL_ROUNDS {
+ Some(onde_tools.as_slice())
+ } else {
+ None // force a text response on the last round
+ };
+
+ match engine.send_tool_results(tool_results, next_tools).await {
+ Ok(r) => result = r,
+ Err(err) => {
+ let _ = tx.send(InferenceUpdate::Error(err.to_string())).await;
+ return;
+ }
+ }
+ }
+
+ // Send the final text response.
+ if !result.text.is_empty() && result.tool_calls.is_empty() {
+ let _ = tx.send(InferenceUpdate::Response(result.text)).await;
+ }
+
+ log::info!("inference complete — {} tool round(s)", round);
+ // Sender drops here → event loop sees `None`.
+}
+
// ── Main loop ────────────────────────────────────────────────────────────────
/// Run the interactive chat UI. Blocks until the user quits.
///
/// The caller must have already loaded a model into `engine`.
-pub async fn run(engine: &ChatEngine) -> Result<()> {
+pub async fn run(engine: Arc<ChatEngine>) -> Result<()> {
let mut terminal = ratatui::init();
let result = event_loop(&mut terminal, engine).await;
ratatui::restore();
result
}
-async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine) -> Result<()> {
+async fn event_loop(
+ terminal: &mut ratatui::DefaultTerminal,
+ engine: Arc<ChatEngine>,
+) -> Result<()> {
let mut app = App::new();
let mut event_stream = EventStream::new();
@@ -537,11 +711,12 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine
break;
}
- // multiplex terminal events and streaming tokens
+ // multiplex terminal events, streaming tokens, inference updates,
+ // and the thinking-spinner timer.
tokio::select! {
biased;
- // streaming chunks — only active when we have a receiver
+ // ── streaming chunks ─────────────────────────────────────────
chunk = async {
match app.stream_rx.as_mut() {
Some(rx) => rx.recv().await,
@@ -564,7 +739,45 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine
}
}
- // terminal events
+ // ── inference updates from background task ───────────────────
+ update = async {
+ match app.inference_rx.as_mut() {
+ Some(rx) => rx.recv().await,
+ None => pending().await,
+ }
+ } => {
+ match update {
+ Some(InferenceUpdate::ToolUse(name)) => {
+ app.messages.push(ChatMessage::system(format!("🔧 {name}")));
+ }
+ Some(InferenceUpdate::Response(text)) => {
+ app.stop_thinking();
+ app.messages.push(ChatMessage::assistant(text));
+ }
+ Some(InferenceUpdate::Error(msg)) => {
+ app.stop_thinking();
+ app.messages.push(ChatMessage::system(format!("error: {msg}")));
+ }
+ None => {
+ // Sender dropped — task finished (possibly with no
+ // text response, e.g. all tool calls with empty final).
+ app.stop_thinking();
+ }
+ }
+ }
+
+ // ── thinking spinner tick (100ms) ────────────────────────────
+ _ = async {
+ if app.thinking {
+ tokio::time::sleep(Duration::from_millis(100)).await
+ } else {
+ pending().await
+ }
+ } => {
+ app.tick_thinking();
+ }
+
+ // ── terminal events ──────────────────────────────────────────
maybe_event = event_stream.next() => {
let Some(Ok(event)) = maybe_event else {
// stream ended or error — bail
@@ -572,14 +785,21 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine
};
if let Event::Key(key) = event {
- // while streaming, only ctrl+c/d work
- if app.is_streaming() {
+ // While busy (streaming or thinking), only Ctrl+C/D work.
+ if app.is_busy() {
if key.kind == KeyEventKind::Press {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) {
- // drop the receiver to stop reading
- app.finalize_stream();
- app.messages.push(ChatMessage::system("(cancelled)"));
+ if app.is_streaming() {
+ app.finalize_stream();
+ app.messages.push(ChatMessage::system("(cancelled)"));
+ }
+ if app.thinking {
+ // Drop the receiver — the background task
+ // will see a closed channel and stop.
+ app.stop_thinking();
+ app.messages.push(ChatMessage::system("(cancelled)"));
+ }
}
}
continue;
@@ -588,26 +808,22 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine
if let Some(text) = handle_key(&mut app, key) {
// check for slash command first
if let Some(cmd) = parse_slash(&text) {
- exec_slash(&mut app, cmd, engine).await;
+ exec_slash(&mut app, cmd, &engine).await;
continue;
}
- // regular message — send to engine
+ // ── Spawn inference on a background task ─────────
app.messages.push(ChatMessage::user(&text));
+ app.start_thinking();
- match engine.stream_message(text).await {
- Ok(rx) => {
- app.stream_rx = Some(rx);
- app.stream_buf.clear();
- app.blink_counter = 0;
- app.blink_on = true;
- }
- Err(err) => {
- app.messages.push(ChatMessage::system(format!(
- "error: {err}"
- )));
- }
- }
+ let (tx, rx) = mpsc::channel::<InferenceUpdate>(64);
+ app.inference_rx = Some(rx);
+
+ let engine_handle = Arc::clone(&engine);
+ let user_text = text.clone();
+ tokio::spawn(async move {
+ run_inference_task(engine_handle, user_text, tx).await;
+ });
}
}
}
src/main.rs
+124
-28
index b2e6711..45ff787 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -24,7 +24,9 @@
mod chat;
mod setup;
+mod tools;
+use std::fs::File;
use std::io::IsTerminal;
use std::sync::Arc;
@@ -35,7 +37,7 @@ use agent_client_protocol::{
SessionId, SessionNotification, SessionUpdate, StopReason,
};
use futures::future::LocalBoxFuture;
-use onde::inference::{ChatEngine, GgufModelConfig};
+use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult};
use tokio::sync::{Mutex, mpsc};
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
@@ -53,9 +55,28 @@ You help with:
- Software architecture and design patterns
- Code review
+You have access to tools that let you read files, list directories, and search \
+code. Use them proactively to understand the codebase before answering questions \
+or writing code. Always ground your answers in the actual code.
+
Be direct and brief. Write clean, idiomatic code. When debugging, go for the \
root cause, not the symptom. Correct beats clever.";
+/// Maximum number of tool-calling rounds before forcing a text response.
+const MAX_TOOL_ROUNDS: usize = 10;
+
+/// Convert the agent tool definitions into onde's `ToolDefinition` type.
+fn agent_tools_as_onde() -> Vec<ToolDefinition> {
+ tools::all_tools()
+ .into_iter()
+ .map(|t| ToolDefinition {
+ name: t.name.to_string(),
+ description: t.description.to_string(),
+ parameters_schema: t.parameters_schema.to_string(),
+ })
+ .collect()
+}
+
// ── Per-session state ────────────────────────────────────────────────────────
/// One active session at a time. We store the `SessionId` directly (not as a
@@ -121,8 +142,9 @@ impl Agent for SiGitAgent {
self.engine.clear_history().await;
} else {
// First session — pull the model (if needed) and load it.
- log::info!("loading default model (this may take a minute on first run)...");
- let config = GgufModelConfig::platform_default();
+ // Qwen 3 4B is required for tool calling support.
+ log::info!("loading Qwen 3 4B model (this may take a minute on first run)...");
+ let config = GgufModelConfig::qwen3_4b();
self.engine
.load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), None)
.await
@@ -176,32 +198,93 @@ impl Agent for SiGitAgent {
user_text.chars().take(80).collect::<String>()
);
- let mut rx = self
+ // ── Agentic tool-calling loop ────────────────────────────────────
+ //
+ // 1. Send the user message with tool definitions (non-streaming).
+ // 2. If the model responds with tool calls, execute them, feed
+ // results back, and repeat (up to MAX_TOOL_ROUNDS).
+ // 3. Once the model produces a text response (no tool calls),
+ // stream it to the editor.
+
+ let onde_tools = agent_tools_as_onde();
+
+ let mut result = self
.engine
- .stream_message(user_text)
+ .send_message_with_tools(&user_text, &onde_tools)
.await
.map_err(|e| agent_client_protocol::Error::new(-32603, e.to_string()))?;
- while let Some(chunk) = rx.recv().await {
- if !chunk.delta.is_empty() {
- let notification = SessionNotification::new(
- session_id.clone(),
- SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(
- chunk.delta,
- ))),
+ let mut round = 0;
+
+ while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
+ round += 1;
+ log::info!(
+ "prompt({}) tool round {} — {} call(s)",
+ session_id,
+ round,
+ result.tool_calls.len()
+ );
+
+ let mut tool_results = Vec::new();
+
+ for tc in &result.tool_calls {
+ log::info!(
+ " → {}({})",
+ tc.function_name,
+ tc.arguments.chars().take(120).collect::<String>()
);
- // Forwarder gone (client disconnected?) — stop.
- if self.notification_tx.send(notification).await.is_err() {
- log::warn!("notification channel closed — stopping stream");
- break;
- }
+
+ // Notify the editor that we're calling a tool.
+ let status_text = format!("🔧 `{}`\n", tc.function_name,);
+ let _ = self
+ .notification_tx
+ .send(SessionNotification::new(
+ session_id.clone(),
+ SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(
+ status_text,
+ ))),
+ ))
+ .await;
+
+ // Execute the tool.
+ let output = tools::execute_tool(&tc.function_name, &tc.arguments);
+
+ log::info!(" ← {} chars", output.len());
+
+ tool_results.push(ToolResult {
+ tool_call_id: tc.id.clone(),
+ content: output,
+ });
}
- if chunk.done {
- break;
+
+ // Decide whether to allow further tool calls.
+ let next_tools = if round < MAX_TOOL_ROUNDS {
+ Some(onde_tools.as_slice())
+ } else {
+ None // force a text response on the last round
+ };
+
+ result = self
+ .engine
+ .send_tool_results(tool_results, next_tools)
+ .await
+ .map_err(|e| agent_client_protocol::Error::new(-32603, e.to_string()))?;
+ }
+
+ // ── Send the final text response ─────────────────────────────────
+ if !result.text.is_empty() {
+ let notification = SessionNotification::new(
+ session_id.clone(),
+ SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(
+ result.text,
+ ))),
+ );
+ if self.notification_tx.send(notification).await.is_err() {
+ log::warn!("notification channel closed");
}
}
- log::info!("prompt({}) complete", session_id);
+ log::info!("prompt({}) complete — {} tool round(s)", session_id, round);
Ok(PromptResponse::new(StopReason::EndTurn))
}
@@ -244,8 +327,8 @@ fn print_banner() {
async fn run_interactive() -> anyhow::Result<()> {
println!(" Loading model...");
- let engine = ChatEngine::new();
- let config = GgufModelConfig::platform_default();
+ let engine = Arc::new(ChatEngine::new());
+ let config = GgufModelConfig::qwen3_4b();
engine
.load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), None)
.await
@@ -258,7 +341,7 @@ async fn run_interactive() -> anyhow::Result<()> {
info.approx_memory.as_deref().unwrap_or("?"),
);
- chat::run(&engine).await
+ chat::run(engine).await
}
// ── ACP server mode ──────────────────────────────────────────────────────────
@@ -312,16 +395,29 @@ async fn run_acp_server() -> anyhow::Result<()> {
#[tokio::main]
async fn main() -> anyhow::Result<()> {
- // Logs always go to stderr (stdout is either the TUI or the ACP wire).
- env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
- .target(env_logger::Target::Stderr)
- .init();
+ let interactive = std::io::stdin().is_terminal();
+
+ // In interactive/TUI mode, logs go to a file so they don't scribble over
+ // the alternate screen buffer. In ACP mode they go to stderr as usual.
+ if interactive {
+ let log_file = File::create("sigit.log").unwrap_or_else(|_| {
+ // Fall back to /tmp if cwd is not writable.
+ File::create(std::env::temp_dir().join("sigit.log")).expect("cannot open any log file")
+ });
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
+ .target(env_logger::Target::Pipe(Box::new(log_file)))
+ .init();
+ } else {
+ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
+ .target(env_logger::Target::Stderr)
+ .init();
+ }
// Shared model cache (macOS App Group) — must run before anything
// touches hf-hub or ChatEngine.
setup::setup_shared_model_cache();
- if std::io::stdin().is_terminal() {
+ if interactive {
// Interactive mode — full-screen chat TUI.
print_banner();
run_interactive().await
src/tools.rs
+781
new file mode 100644
index 0000000..241521d
--- /dev/null
+++ b/src/tools.rs
@@ -0,0 +1,781 @@
+//! Tool definitions and execution for the siGit coding agent.
+//!
+//! Each tool has:
+//! - A schema (JSON Schema) that describes its parameters for the LLM
+//! - An execution function that runs the tool and returns a string result
+//!
+//! # Dependencies
+//!
+//! This module requires `serde_json` and `regex` crates in `Cargo.toml`:
+//! ```toml
+//! serde_json = "1"
+//! regex = "1"
+//! ```
+//!
+//! # Write Tools
+//!
+//! - `create_file` — create a new file (fails if it already exists)
+//! - `edit_file` — replace an exact old-text span with new text in an existing file
+
+use regex::Regex;
+use serde_json::{Value, json};
+use std::fs;
+use std::path::Path;
+
+/// Maximum characters returned from `read_file` before truncation.
+const READ_FILE_CHAR_LIMIT: usize = 10_000;
+
+/// Maximum number of matching lines returned from `search_files`.
+const SEARCH_FILES_MATCH_LIMIT: usize = 50;
+
+// ── Tool schemas ─────────────────────────────────────────────────────────────
+
+/// A tool definition with its JSON Schema and metadata for the LLM.
+pub struct AgentTool {
+ /// Machine-readable tool name (e.g. `"read_file"`).
+ pub name: &'static str,
+ /// Human-readable description shown to the LLM.
+ pub description: &'static str,
+ /// JSON Schema describing the tool's parameters.
+ pub parameters_schema: Value,
+}
+
+/// Return all available agent tools.
+pub fn all_tools() -> Vec<AgentTool> {
+ vec![
+ AgentTool {
+ name: "read_file",
+ description: "Read the contents of a file at the given path. \
+ Returns the file text, or an error message if the file cannot be read. \
+ Output is truncated to 10 000 characters.",
+ parameters_schema: json!({
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Absolute or relative path to the file to read."
+ }
+ },
+ "required": ["path"],
+ "additionalProperties": false
+ }),
+ },
+ AgentTool {
+ name: "list_directory",
+ description: "List files and directories at the given path. \
+ Each entry is prefixed with [DIR] or [FILE]. \
+ Directories are listed first, sorted alphabetically.",
+ parameters_schema: json!({
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Absolute or relative path to the directory to list."
+ }
+ },
+ "required": ["path"],
+ "additionalProperties": false
+ }),
+ },
+ AgentTool {
+ name: "search_files",
+ description: "Search for a regex pattern across files in a directory tree. \
+ Returns matching lines in `file:line_number: content` format. \
+ Skips binary files and hidden directories. \
+ Limited to the first 50 matches.",
+ parameters_schema: json!({
+ "type": "object",
+ "properties": {
+ "pattern": {
+ "type": "string",
+ "description": "Regular expression pattern to search for."
+ },
+ "path": {
+ "type": "string",
+ "description": "Root directory to search in. Defaults to \".\" (current directory)."
+ }
+ },
+ "required": ["pattern"],
+ "additionalProperties": false
+ }),
+ },
+ AgentTool {
+ name: "create_file",
+ description: "Create a new file at the given path with the provided content. \
+ Parent directories are created automatically if they do not exist. \
+ Fails if the file already exists — use edit_file to modify existing files.",
+ parameters_schema: json!({
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Absolute or relative path for the new file."
+ },
+ "content": {
+ "type": "string",
+ "description": "The full text content to write into the new file."
+ }
+ },
+ "required": ["path", "content"],
+ "additionalProperties": false
+ }),
+ },
+ AgentTool {
+ name: "edit_file",
+ description: "Edit an existing file by replacing an exact substring (old_text) with \
+ new text (new_text). The old_text must appear exactly once in the file. \
+ Use read_file first to see the current content and identify the exact \
+ text to replace. To append to a file, match the last few lines as \
+ old_text and include them plus the new content as new_text.",
+ parameters_schema: json!({
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Path to the existing file to edit."
+ },
+ "old_text": {
+ "type": "string",
+ "description": "The exact text span to find and replace. Must match exactly once."
+ },
+ "new_text": {
+ "type": "string",
+ "description": "The replacement text that will take the place of old_text."
+ }
+ },
+ "required": ["path", "old_text", "new_text"],
+ "additionalProperties": false
+ }),
+ },
+ ]
+}
+
+// ── Tool execution ───────────────────────────────────────────────────────────
+
+/// Execute a tool by name with the given JSON arguments string.
+///
+/// Returns the tool output as a human-readable string. Errors are returned as
+/// descriptive strings rather than panicking.
+pub fn execute_tool(name: &str, arguments: &str) -> String {
+ match name {
+ "read_file" => exec_read_file(arguments),
+ "list_directory" => exec_list_directory(arguments),
+ "search_files" => exec_search_files(arguments),
+ "create_file" => exec_create_file(arguments),
+ "edit_file" => exec_edit_file(arguments),
+ _ => format!("Unknown tool: {name}"),
+ }
+}
+
+// ── read_file ────────────────────────────────────────────────────────────────
+
+/// Read the contents of a single file, truncating at [`READ_FILE_CHAR_LIMIT`].
+fn exec_read_file(arguments: &str) -> String {
+ let args: Value = match serde_json::from_str(arguments) {
+ Ok(v) => v,
+ Err(err) => return format!("Error: failed to parse arguments: {err}"),
+ };
+
+ let path_str = match args.get("path").and_then(Value::as_str) {
+ Some(p) => p,
+ None => return "Error: missing required parameter \"path\"".to_string(),
+ };
+
+ let path = Path::new(path_str);
+
+ if !path.exists() {
+ return format!("Error: path does not exist: {path_str}");
+ }
+
+ if !path.is_file() {
+ return format!("Error: path is not a file: {path_str}");
+ }
+
+ match fs::read_to_string(path) {
+ Ok(contents) => {
+ if contents.len() > READ_FILE_CHAR_LIMIT {
+ let truncated: String = contents.chars().take(READ_FILE_CHAR_LIMIT).collect();
+ format!(
+ "{truncated}\n\n--- truncated (showing {READ_FILE_CHAR_LIMIT} of {} characters) ---",
+ contents.len()
+ )
+ } else {
+ contents
+ }
+ }
+ Err(err) => format!("Error: could not read file: {err}"),
+ }
+}
+
+// ── list_directory ───────────────────────────────────────────────────────────
+
+/// List directory entries, directories first, sorted alphabetically.
+fn exec_list_directory(arguments: &str) -> String {
+ let args: Value = match serde_json::from_str(arguments) {
+ Ok(v) => v,
+ Err(err) => return format!("Error: failed to parse arguments: {err}"),
+ };
+
+ let path_str = match args.get("path").and_then(Value::as_str) {
+ Some(p) => p,
+ None => return "Error: missing required parameter \"path\"".to_string(),
+ };
+
+ let path = Path::new(path_str);
+
+ if !path.exists() {
+ return format!("Error: path does not exist: {path_str}");
+ }
+
+ if !path.is_dir() {
+ return format!("Error: path is not a directory: {path_str}");
+ }
+
+ let entries = match fs::read_dir(path) {
+ Ok(rd) => rd,
+ Err(err) => return format!("Error: could not read directory: {err}"),
+ };
+
+ let mut dirs: Vec<String> = Vec::new();
+ let mut files: Vec<String> = Vec::new();
+
+ for entry in entries {
+ let entry = match entry {
+ Ok(e) => e,
+ Err(err) => {
+ files.push(format!("[ERR] {err}"));
+ continue;
+ }
+ };
+
+ let name = entry.file_name().to_string_lossy().to_string();
+
+ let is_dir = match entry.file_type() {
+ Ok(ft) => ft.is_dir(),
+ Err(_) => false,
+ };
+
+ if is_dir {
+ dirs.push(format!("[DIR] {name}"));
+ } else {
+ files.push(format!("[FILE] {name}"));
+ }
+ }
+
+ dirs.sort();
+ files.sort();
+
+ // Directories first, then files.
+ dirs.extend(files);
+
+ if dirs.is_empty() {
+ return format!("(empty directory: {path_str})");
+ }
+
+ dirs.join("\n")
+}
+
+// ── search_files ─────────────────────────────────────────────────────────────
+
+/// Recursively search files for a regex pattern, returning matching lines.
+fn exec_search_files(arguments: &str) -> String {
+ let args: Value = match serde_json::from_str(arguments) {
+ Ok(v) => v,
+ Err(err) => return format!("Error: failed to parse arguments: {err}"),
+ };
+
+ let pattern_str = match args.get("pattern").and_then(Value::as_str) {
+ Some(p) => p,
+ None => return "Error: missing required parameter \"pattern\"".to_string(),
+ };
+
+ let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
+
+ let re = match Regex::new(pattern_str) {
+ Ok(r) => r,
+ Err(err) => return format!("Error: invalid regex pattern: {err}"),
+ };
+
+ let root = Path::new(root_str);
+
+ if !root.exists() {
+ return format!("Error: path does not exist: {root_str}");
+ }
+
+ if !root.is_dir() {
+ return format!("Error: path is not a directory: {root_str}");
+ }
+
+ let mut matches: Vec<String> = Vec::new();
+ walk_and_search(root, &re, &mut matches);
+
+ if matches.is_empty() {
+ return format!("No matches found for pattern: {pattern_str}");
+ }
+
+ let total = matches.len();
+ if total > SEARCH_FILES_MATCH_LIMIT {
+ matches.truncate(SEARCH_FILES_MATCH_LIMIT);
+ matches.push(format!(
+ "\n--- truncated (showing {SEARCH_FILES_MATCH_LIMIT} of {total} matches) ---"
+ ));
+ }
+
+ matches.join("\n")
+}
+
+/// Recursively walk a directory and collect regex matches.
+///
+/// Skips hidden directories (names starting with `.`) and binary files.
+/// Stops collecting once the match list reaches a generous internal cap (2×
+/// the public limit) to avoid unbounded work.
+fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) {
+ // Internal cap to avoid scanning the entire filesystem.
+ const WALK_CAP: usize = SEARCH_FILES_MATCH_LIMIT * 2;
+
+ let entries = match fs::read_dir(dir) {
+ Ok(rd) => rd,
+ Err(_) => return,
+ };
+
+ // Collect and sort for deterministic output.
+ let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
+ sorted.sort_by_key(|e| e.file_name());
+
+ for entry in sorted {
+ if matches.len() >= WALK_CAP {
+ return;
+ }
+
+ let path = entry.path();
+ let name = entry.file_name();
+ let name_str = name.to_string_lossy();
+
+ // Skip hidden entries.
+ if name_str.starts_with('.') {
+ continue;
+ }
+
+ if path.is_dir() {
+ walk_and_search(&path, re, matches);
+ } else if path.is_file() {
+ search_file(&path, re, matches);
+ }
+ }
+}
+
+/// Search a single file line-by-line for the regex pattern.
+///
+/// Skips files that cannot be read as UTF-8 (assumed binary).
+fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) {
+ let contents = match fs::read_to_string(path) {
+ Ok(c) => c,
+ // Skip binary / unreadable files silently.
+ Err(_) => return,
+ };
+
+ let display_path = path.display();
+
+ for (line_idx, line) in contents.lines().enumerate() {
+ if re.is_match(line) {
+ let line_number = line_idx + 1;
+ matches.push(format!("{display_path}:{line_number}: {line}"));
+ }
+ }
+}
+
+// ── create_file ──────────────────────────────────────────────────────────────
+
+/// Create a new file with the provided content.
+///
+/// Parent directories are created automatically. Fails if the file already
+/// exists to prevent accidental overwrites — the LLM should use `edit_file`
+/// for existing files.
+fn exec_create_file(arguments: &str) -> String {
+ let args: Value = match serde_json::from_str(arguments) {
+ Ok(v) => v,
+ Err(err) => return format!("Error: failed to parse arguments: {err}"),
+ };
+
+ let path_str = match args.get("path").and_then(Value::as_str) {
+ Some(p) => p,
+ None => return "Error: missing required parameter \"path\"".to_string(),
+ };
+
+ let content = match args.get("content").and_then(Value::as_str) {
+ Some(c) => c,
+ None => return "Error: missing required parameter \"content\"".to_string(),
+ };
+
+ let path = Path::new(path_str);
+
+ if path.exists() {
+ return format!(
+ "Error: file already exists: {path_str} — use edit_file to modify existing files"
+ );
+ }
+
+ // Create parent directories if needed.
+ if let Some(parent) = path.parent()
+ && !parent.as_os_str().is_empty()
+ && !parent.exists()
+ && let Err(err) = fs::create_dir_all(parent)
+ {
+ return format!("Error: could not create parent directories: {err}");
+ }
+
+ match fs::write(path, content) {
+ Ok(()) => format!("Created file: {path_str} ({} bytes)", content.len()),
+ Err(err) => format!("Error: could not write file: {err}"),
+ }
+}
+
+// ── edit_file ────────────────────────────────────────────────────────────────
+
+/// Edit an existing file by replacing an exact occurrence of `old_text` with
+/// `new_text`.
+///
+/// The `old_text` must appear **exactly once** in the file. This prevents
+/// ambiguous edits and forces the LLM to read the file first to get the exact
+/// text span.
+fn exec_edit_file(arguments: &str) -> String {
+ let args: Value = match serde_json::from_str(arguments) {
+ Ok(v) => v,
+ Err(err) => return format!("Error: failed to parse arguments: {err}"),
+ };
+
+ let path_str = match args.get("path").and_then(Value::as_str) {
+ Some(p) => p,
+ None => return "Error: missing required parameter \"path\"".to_string(),
+ };
+
+ let old_text = match args.get("old_text").and_then(Value::as_str) {
+ Some(t) => t,
+ None => return "Error: missing required parameter \"old_text\"".to_string(),
+ };
+
+ let new_text = match args.get("new_text").and_then(Value::as_str) {
+ Some(t) => t,
+ None => return "Error: missing required parameter \"new_text\"".to_string(),
+ };
+
+ let path = Path::new(path_str);
+
+ if !path.exists() {
+ return format!("Error: file does not exist: {path_str} — use create_file for new files");
+ }
+
+ if !path.is_file() {
+ return format!("Error: path is not a file: {path_str}");
+ }
+
+ let contents = match fs::read_to_string(path) {
+ Ok(c) => c,
+ Err(err) => return format!("Error: could not read file: {err}"),
+ };
+
+ // Count occurrences to give a clear error message.
+ let occurrences = contents.matches(old_text).count();
+
+ if occurrences == 0 {
+ return format!(
+ "Error: old_text not found in {path_str}. \
+ Use read_file to see the current content and copy the exact text to replace."
+ );
+ }
+
+ if occurrences > 1 {
+ return format!(
+ "Error: old_text appears {occurrences} times in {path_str}. \
+ Include more surrounding context in old_text so it matches exactly once."
+ );
+ }
+
+ let updated = contents.replacen(old_text, new_text, 1);
+
+ match fs::write(path, &updated) {
+ Ok(()) => format!("Edited file: {path_str} ({} bytes written)", updated.len()),
+ Err(err) => format!("Error: could not write file: {err}"),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+
+ #[test]
+ fn test_execute_unknown_tool() {
+ let result = execute_tool("nonexistent", "{}");
+ assert!(result.starts_with("Unknown tool:"));
+ }
+
+ #[test]
+ fn test_read_file_missing_path_param() {
+ let result = exec_read_file("{}");
+ assert!(result.contains("missing required parameter"));
+ }
+
+ #[test]
+ fn test_read_file_nonexistent() {
+ let result = exec_read_file(r#"{"path": "/tmp/__sigit_no_such_file_42__"}"#);
+ assert!(result.contains("does not exist"));
+ }
+
+ #[test]
+ fn test_read_file_success() {
+ let dir = std::env::temp_dir().join("sigit_test_read_file");
+ let _ = fs::create_dir_all(&dir);
+ let file_path = dir.join("hello.txt");
+ fs::write(&file_path, "hello world").unwrap();
+
+ let args = format!(r#"{{"path": "{}"}}"#, file_path.display());
+ let result = exec_read_file(&args);
+ assert_eq!(result, "hello world");
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn test_list_directory_missing_path_param() {
+ let result = exec_list_directory("{}");
+ assert!(result.contains("missing required parameter"));
+ }
+
+ #[test]
+ fn test_list_directory_success() {
+ let dir = std::env::temp_dir().join("sigit_test_list_dir");
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(dir.join("subdir")).unwrap();
+ fs::write(dir.join("aaa.txt"), "").unwrap();
+ fs::write(dir.join("bbb.rs"), "").unwrap();
+
+ let args = format!(r#"{{"path": "{}"}}"#, dir.display());
+ let result = exec_list_directory(&args);
+
+ assert!(result.contains("[DIR] subdir"));
+ assert!(result.contains("[FILE] aaa.txt"));
+ assert!(result.contains("[FILE] bbb.rs"));
+
+ // Directories should appear before files.
+ let dir_pos = result.find("[DIR]").unwrap();
+ let file_pos = result.find("[FILE]").unwrap();
+ assert!(dir_pos < file_pos);
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn test_search_files_invalid_regex() {
+ let result = exec_search_files(r#"{"pattern": "[invalid", "path": "."}"#);
+ assert!(result.contains("invalid regex"));
+ }
+
+ #[test]
+ fn test_search_files_success() {
+ let dir = std::env::temp_dir().join("sigit_test_search");
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).unwrap();
+ fs::write(
+ dir.join("code.rs"),
+ "fn main() {\n println!(\"hello\");\n}\n",
+ )
+ .unwrap();
+ fs::write(dir.join("other.txt"), "no match here\n").unwrap();
+
+ let args = format!(r#"{{"pattern": "println", "path": "{}"}}"#, dir.display());
+ let result = exec_search_files(&args);
+
+ assert!(result.contains("code.rs:2:"));
+ assert!(result.contains("println"));
+ assert!(!result.contains("other.txt"));
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn test_search_files_no_matches() {
+ let dir = std::env::temp_dir().join("sigit_test_search_none");
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).unwrap();
+ fs::write(dir.join("empty.txt"), "nothing special").unwrap();
+
+ let args = format!(
+ r#"{{"pattern": "zzz_will_not_match_42", "path": "{}"}}"#,
+ dir.display()
+ );
+ let result = exec_search_files(&args);
+ assert!(result.contains("No matches found"));
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn test_all_tools_count() {
+ let tools = all_tools();
+ assert_eq!(tools.len(), 5);
+ assert_eq!(tools[0].name, "read_file");
+ assert_eq!(tools[1].name, "list_directory");
+ assert_eq!(tools[2].name, "search_files");
+ assert_eq!(tools[3].name, "create_file");
+ assert_eq!(tools[4].name, "edit_file");
+ }
+
+ #[test]
+ fn test_all_tools_schemas_are_valid_json_objects() {
+ for tool in all_tools() {
+ assert!(
+ tool.parameters_schema.is_object(),
+ "schema for {} is not an object",
+ tool.name
+ );
+ let obj = tool.parameters_schema.as_object().unwrap();
+ assert!(obj.contains_key("type"));
+ assert!(obj.contains_key("properties"));
+ assert!(obj.contains_key("required"));
+ }
+ }
+
+ // ── create_file tests ────────────────────────────────────────────────
+
+ #[test]
+ fn test_create_file_missing_path() {
+ let result = exec_create_file(r#"{"content": "hello"}"#);
+ assert!(result.contains("missing required parameter"));
+ }
+
+ #[test]
+ fn test_create_file_missing_content() {
+ let result = exec_create_file(r#"{"path": "/tmp/sigit_test_nope.txt"}"#);
+ assert!(result.contains("missing required parameter"));
+ }
+
+ #[test]
+ fn test_create_file_success() {
+ let dir = std::env::temp_dir().join("sigit_test_create_file");
+ let _ = fs::remove_dir_all(&dir);
+
+ let file_path = dir.join("sub").join("new_file.txt");
+ let args = format!(
+ r#"{{"path": "{}", "content": "hello world"}}"#,
+ file_path.display()
+ );
+
+ let result = exec_create_file(&args);
+ assert!(result.starts_with("Created file:"), "got: {result}");
+ assert!(file_path.exists());
+ assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello world");
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn test_create_file_already_exists() {
+ let dir = std::env::temp_dir().join("sigit_test_create_exists");
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).unwrap();
+
+ let file_path = dir.join("existing.txt");
+ fs::write(&file_path, "original").unwrap();
+
+ let args = format!(
+ r#"{{"path": "{}", "content": "overwrite attempt"}}"#,
+ file_path.display()
+ );
+
+ let result = exec_create_file(&args);
+ assert!(result.contains("already exists"), "got: {result}");
+ // Original content untouched.
+ assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ // ── edit_file tests ──────────────────────────────────────────────────
+
+ #[test]
+ fn test_edit_file_missing_params() {
+ let result = exec_edit_file(r#"{"path": "x"}"#);
+ assert!(result.contains("missing required parameter"));
+
+ let result = exec_edit_file(r#"{"path": "x", "old_text": "a"}"#);
+ assert!(result.contains("missing required parameter"));
+ }
+
+ #[test]
+ fn test_edit_file_nonexistent() {
+ let result = exec_edit_file(
+ r#"{"path": "/tmp/__sigit_no_such__", "old_text": "a", "new_text": "b"}"#,
+ );
+ assert!(result.contains("does not exist"));
+ }
+
+ #[test]
+ fn test_edit_file_success() {
+ let dir = std::env::temp_dir().join("sigit_test_edit_file");
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).unwrap();
+
+ let file_path = dir.join("code.rs");
+ fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
+
+ let args = format!(
+ r#"{{"path": "{}", "old_text": "println!(\"hello\")", "new_text": "println!(\"world\")"}}"#,
+ file_path.display()
+ );
+
+ let result = exec_edit_file(&args);
+ assert!(result.starts_with("Edited file:"), "got: {result}");
+
+ let updated = fs::read_to_string(&file_path).unwrap();
+ assert!(updated.contains("println!(\"world\")"));
+ assert!(!updated.contains("println!(\"hello\")"));
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn test_edit_file_old_text_not_found() {
+ let dir = std::env::temp_dir().join("sigit_test_edit_notfound");
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).unwrap();
+
+ let file_path = dir.join("data.txt");
+ fs::write(&file_path, "aaa bbb ccc").unwrap();
+
+ let args = format!(
+ r#"{{"path": "{}", "old_text": "zzz", "new_text": "yyy"}}"#,
+ file_path.display()
+ );
+
+ let result = exec_edit_file(&args);
+ assert!(result.contains("old_text not found"), "got: {result}");
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn test_edit_file_ambiguous_match() {
+ let dir = std::env::temp_dir().join("sigit_test_edit_ambiguous");
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).unwrap();
+
+ let file_path = dir.join("repeat.txt");
+ fs::write(&file_path, "foo bar foo bar foo").unwrap();
+
+ let args = format!(
+ r#"{{"path": "{}", "old_text": "foo", "new_text": "baz"}}"#,
+ file_path.display()
+ );
+
+ let result = exec_edit_file(&args);
+ assert!(result.contains("appears 3 times"), "got: {result}");
+ // File should be unchanged.
+ assert_eq!(
+ fs::read_to_string(&file_path).unwrap(),
+ "foo bar foo bar foo"
+ );
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+}