@hej / sigit / commits / 221b03c

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 new
+283
@@ -0,0 +1,283 @@
1 +# Skill: Tool Calling in siGit Code
2 +
3 +## Overview
4 +
5 +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).
6 +
7 +Tool calling spans three layers:
8 +
9 +```
10 +siGit (agent loop + tool execution)
11 + → onde (ChatEngine with tool-aware API)
12 + → mistral.rs (model inference + tool call parsing)
13 +```
14 +
15 +---
16 +
17 +## Model Requirement
18 +
19 +**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.
20 +
21 +| Model | Constructor | Size | Tool calling |
22 +|-------|-----------|------|:---:|
23 +| Qwen 3 4B (Q4_K_M) | `GgufModelConfig::qwen3_4b()` | ~2.6 GB | ✅ |
24 +| Qwen 3 1.7B (Q4_K_M) | `GgufModelConfig::qwen3_1_7b()` | ~1.2 GB | ✅ |
25 +| Qwen 2.5 Coder 3B | `GgufModelConfig::qwen25_coder_3b()` | ~1.9 GB | ❌ |
26 +| Qwen 2.5 1.5B | `GgufModelConfig::qwen25_1_5b()` | ~0.9 GB | ❌ |
27 +
28 +siGit uses **Qwen 3 4B** by default (set in `main.rs` via `GgufModelConfig::qwen3_4b()`).
29 +
30 +### bartowski GGUF naming convention
31 +
32 +bartowski's repos use the publisher name as a prefix with an underscore:
33 +
34 +| Constant | Value |
35 +|----------|-------|
36 +| `BARTOWSKI_QWEN3_4B_GGUF` | `"bartowski/Qwen_Qwen3-4B-GGUF"` |
37 +| `QWEN3_4B_GGUF_FILE` | `"Qwen_Qwen3-4B-Q4_K_M.gguf"` |
38 +| `BARTOWSKI_QWEN3_1_7B_GGUF` | `"bartowski/Qwen_Qwen3-1.7B-GGUF"` |
39 +| `QWEN3_1_7B_GGUF_FILE` | `"Qwen_Qwen3-1.7B-Q4_K_M.gguf"` |
40 +
41 +These constants live in `onde/src/inference/models.rs`.
42 +
43 +---
44 +
45 +## Architecture
46 +
47 +### Layer 1: mistral.rs (model-level)
48 +
49 +mistral.rs handles the low-level tool calling protocol:
50 +
51 +- **`RequestBuilder::set_tools(Vec<Tool>)`** — attach tool definitions (JSON Schema) to a request
52 +- **`RequestBuilder::set_tool_choice(ToolChoice::Auto)`** — let the model decide whether to call tools
53 +- **`RequestBuilder::add_message_with_tool_call(role, content, tool_calls)`** — replay an assistant message that contained tool calls
54 +- **`RequestBuilder::add_tool_message(content, tool_call_id)`** — send a tool execution result back
55 +
56 +Key types (all re-exported from `mistralrs` crate, accessible via `onde::mistralrs::*`):
57 +
58 +| Type | Purpose |
59 +|------|---------|
60 +| `Tool` | A tool definition: `{ tp: ToolType::Function, function: Function }` |
61 +| `Function` | Name, description, JSON Schema parameters, `strict` flag |
62 +| `ToolChoice` | `None`, `Auto`, or `Tool(...)` |
63 +| `ToolCallResponse` | Model's tool call: `{ id, function: CalledFunction }` |
64 +| `CalledFunction` | `{ name, arguments }` where arguments is a JSON string |
65 +| `ToolCallType` | Currently only `Function` |
66 +
67 +### Layer 2: onde (engine-level)
68 +
69 +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).
70 +
71 +#### Types (`onde/src/inference/types.rs`)
72 +
73 +| Type | Purpose |
74 +|------|---------|
75 +| `ToolDefinition` | `{ name, description, parameters_schema: String }` — tool schema for the model |
76 +| `ToolCallRequest` | `{ id, function_name, arguments: String }` — parsed tool call from model response |
77 +| `ToolResult` | `{ tool_call_id, content: String }` — execution result to feed back |
78 +| `ToolAwareResult` | `{ text, tool_calls: Vec<ToolCallRequest>, duration_secs, ... }` — inference result that may contain tool calls |
79 +
80 +#### Methods (`onde/src/inference/engine.rs`)
81 +
82 +| Method | Purpose |
83 +|--------|---------|
84 +| `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. |
85 +| `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. |
86 +| `stream_tool_results(&self, Vec<ToolResult>, Option<Vec<ToolDefinition>>)` | Streaming variant for the final text response after tool rounds. |
87 +
88 +#### Internal history
89 +
90 +`LoadedModel.history` uses `Vec<HistoryEntry>` (not `Vec<ChatMessage>`) to support tool-related messages:
91 +
92 +```rust
93 +enum HistoryEntry {
94 + Text(ChatMessage), // regular user/assistant/system
95 + AssistantToolCall { content, tool_calls }, // assistant response with tool calls
96 + ToolResult { tool_call_id, content }, // tool execution result
97 +}
98 +```
99 +
100 +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.
101 +
102 +When replaying history in requests:
103 +- `build_request()` (no tools) — `AssistantToolCall` replays as plain assistant text, `ToolResult` is skipped
104 +- `build_request_with_tools()` — uses `add_message_with_tool_call()` and `add_tool_message()` for full fidelity
105 +
106 +### Layer 3: siGit (agent-level)
107 +
108 +#### Tool definitions (`sigit/src/tools.rs`)
109 +
110 +Three coding tools with JSON Schema definitions and execution functions:
111 +
112 +| Tool | Parameters | Behavior |
113 +|------|-----------|----------|
114 +| `read_file` | `path` (required) | Reads file contents, truncates at 10,000 chars |
115 +| `list_directory` | `path` (required) | Lists entries with `[DIR]`/`[FILE]` prefix, dirs first, sorted |
116 +| `search_files` | `pattern` (required), `path` (optional) | Recursive regex search, max 50 matches, skips hidden dirs |
117 +
118 +Public API:
119 +- `all_tools() -> Vec<AgentTool>` — returns tool schemas (name, description, JSON Schema)
120 +- `execute_tool(name: &str, arguments: &str) -> String` — dispatches by name, returns result string
121 +
122 +#### Conversion to onde types
123 +
124 +In both `main.rs` and `chat.rs`, `AgentTool` is converted to `ToolDefinition`:
125 +
126 +```rust
127 +let onde_tools: Vec<ToolDefinition> = tools::all_tools()
128 + .into_iter()
129 + .map(|t| ToolDefinition {
130 + name: t.name.to_string(),
131 + description: t.description.to_string(),
132 + parameters_schema: t.parameters_schema.to_string(),
133 + })
134 + .collect();
135 +```
136 +
137 +---
138 +
139 +## The Agentic Loop
140 +
141 +Both ACP mode (`main.rs` → `SiGitAgent::prompt()`) and TUI mode (`chat.rs` → event loop) implement the same pattern:
142 +
143 +```
144 +1. engine.send_message_with_tools(user_text, &tools) → ToolAwareResult
145 +2. while result.tool_calls is non-empty AND round < MAX_TOOL_ROUNDS (10):
146 + a. For each tool_call:
147 + - Show status to user (🔧 tool_name)
148 + - Execute: tools::execute_tool(name, arguments)
149 + - Collect ToolResult { tool_call_id, content }
150 + b. Decide next_tools:
151 + - If round < MAX_TOOL_ROUNDS → Some(&tools) (allow more calls)
152 + - Else → None (force text response)
153 + c. engine.send_tool_results(results, next_tools) → ToolAwareResult
154 +3. Send final result.text to user
155 +```
156 +
157 +### ACP mode specifics (`main.rs`)
158 +
159 +- Tool status is sent as `SessionUpdate::AgentMessageChunk` with a 🔧 prefix
160 +- Final text is sent as a single `AgentMessageChunk`
161 +- Returns `StopReason::EndTurn`
162 +
163 +### TUI mode specifics (`chat.rs`)
164 +
165 +- Tool status shown as `ChatMessage::system("🔧 tool_name")`
166 +- Forces a `terminal.draw()` after each tool call for visual feedback
167 +- Final text added as `ChatMessage::assistant(result.text)`
168 +- The tool loop is **blocking** (non-streaming) — the TUI doesn't accept input during tool execution
169 +
170 +---
171 +
172 +## System Prompt
173 +
174 +The system prompt in `main.rs` (`SYSTEM_PROMPT`) includes tool-awareness instructions:
175 +
176 +```
177 +You have access to tools that let you read files, list directories, and search
178 +code. Use them proactively to understand the codebase before answering questions
179 +or writing code. Always ground your answers in the actual code.
180 +```
181 +
182 +This is critical — without it, the model may not use the tools even when they're available.
183 +
184 +---
185 +
186 +## Adding a New Tool
187 +
188 +1. **Define the schema** in `sigit/src/tools.rs`:
189 + - Add an `AgentTool` entry to `all_tools()` with name, description, and JSON Schema
190 + - The `parameters_schema` must be a valid JSON Schema object with `type`, `properties`, and `required`
191 +
192 +2. **Implement execution** in `sigit/src/tools.rs`:
193 + - Add a case to `execute_tool()` match
194 + - Write `exec_your_tool(arguments: &str) -> String`
195 + - Parse arguments with `serde_json::from_str::<Value>(arguments)`
196 + - Return results as a string, handle errors gracefully (never panic)
197 +
198 +3. **No changes needed** in onde or mistral.rs — the tool definitions are passed dynamically via `send_message_with_tools()`.
199 +
200 +### Example: adding a `write_file` tool
201 +
202 +```rust
203 +// In all_tools():
204 +AgentTool {
205 + name: "write_file",
206 + description: "Create or overwrite a file with the given content.",
207 + parameters_schema: json!({
208 + "type": "object",
209 + "properties": {
210 + "path": { "type": "string", "description": "File path to write" },
211 + "content": { "type": "string", "description": "File content" }
212 + },
213 + "required": ["path", "content"]
214 + }),
215 +}
216 +
217 +// In execute_tool():
218 +"write_file" => exec_write_file(arguments),
219 +
220 +// Implementation:
221 +fn exec_write_file(arguments: &str) -> String {
222 + let args: Value = serde_json::from_str(arguments).unwrap_or_default();
223 + let path = args["path"].as_str().unwrap_or("");
224 + let content = args["content"].as_str().unwrap_or("");
225 + match std::fs::write(path, content) {
226 + Ok(()) => format!("Successfully wrote {} bytes to {}", content.len(), path),
227 + Err(e) => format!("Error writing {}: {}", path, e),
228 + }
229 +}
230 +```
231 +
232 +---
233 +
234 +## Dependencies
235 +
236 +### onde (`Cargo.toml`)
237 +
238 +- `serde_json = "1.0"` — parsing `parameters_schema` JSON strings into `HashMap<String, Value>` for mistral.rs `Function.parameters`
239 +
240 +### siGit (`Cargo.toml`)
241 +
242 +- `onde = { path = "../onde" }` — local path dep (required during development for the tool calling API)
243 +- `serde_json = "1"` — parsing tool call arguments
244 +- `regex = "1"` — used by the `search_files` tool
245 +
246 +---
247 +
248 +## Debugging Tool Calling
249 +
250 +### Model doesn't call tools
251 +
252 +- Verify the model is Qwen 3 (check `GgufModelConfig::qwen3_4b()` in both `main.rs` load sites)
253 +- Check the system prompt includes tool-awareness instructions
254 +- Check logs: `ChatEngine: tool inference END — tool_calls: 0` means the model chose not to use tools
255 +- Try a more explicit prompt: *"Use the read_file tool to read src/main.rs"*
256 +
257 +### Tool calls fail / wrong arguments
258 +
259 +- Check `ToolDefinition.parameters_schema` is valid JSON Schema
260 +- Check `strict: Some(true)` is set in onde's `attach_tools()` (it is by default) — this enables constrained decoding
261 +- Check logs for the raw arguments: `→ read_file({"path":"..."})`
262 +
263 +### History replay issues
264 +
265 +- Tool call history is replayed via `replay_history_with_tools()` which uses `add_message_with_tool_call()` and `add_tool_message()`
266 +- If history gets corrupted, `/clear` in the TUI or `engine.clear_history()` resets it
267 +- The non-tool `build_request()` gracefully degrades: `AssistantToolCall` replays as plain text, `ToolResult` entries are skipped
268 +
269 +---
270 +
271 +## File Map
272 +
273 +| File | What it does |
274 +|------|-------------|
275 +| `onde/src/inference/types.rs` | `ToolDefinition`, `ToolCallRequest`, `ToolResult`, `ToolAwareResult` types |
276 +| `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`) |
277 +| `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 |
278 +| `onde/src/inference/mod.rs` | Re-exports `ToolAwareResult`, `ToolCallRequest`, `ToolDefinition`, `ToolResult` |
279 +| `sigit/src/tools.rs` | `AgentTool` struct, `all_tools()`, `execute_tool()`, implementations for `read_file`, `list_directory`, `search_files` |
280 +| `sigit/src/main.rs` | `agent_tools_as_onde()` converter, agentic loop in `SiGitAgent::prompt()`, `MAX_TOOL_ROUNDS` constant, Qwen 3 4B model selection |
281 +| `sigit/src/chat.rs` | TUI agentic loop (same pattern as ACP), tool status display as system messages |
282 +| `mistral.rs/docs/TOOL_CALLING.md` | Upstream docs for supported models and API |
283 +| `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
@@ -1056,15 +1056,16 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
1056
1057 [[package]]
1058 name = "crossterm"
1059 -version = "0.25.0"
1059 +version = "0.28.1"
1060 source = "registry+https://github.com/rust-lang/crates.io-index"
1061 -checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67"
1061 +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
1062 dependencies = [
1063 - "bitflags 1.3.2",
1063 + "bitflags 2.11.0",
1064 "crossterm_winapi",
1065 - "libc",
1066 - "mio 0.8.11",
1065 + "futures-core",
1066 + "mio",
1067 "parking_lot",
1068 + "rustix 0.38.44",
1069 "signal-hook",
1070 "signal-hook-mio",
1071 "winapi",
@@ -1072,16 +1073,17 @@ dependencies = [
1073
1074 [[package]]
1075 name = "crossterm"
1075 -version = "0.28.1"
1076 +version = "0.29.0"
1077 source = "registry+https://github.com/rust-lang/crates.io-index"
1077 -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
1078 +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
1079 dependencies = [
1080 "bitflags 2.11.0",
1081 "crossterm_winapi",
1081 - "futures-core",
1082 - "mio 1.2.0",
1082 + "derive_more",
1083 + "document-features",
1084 + "mio",
1085 "parking_lot",
1084 - "rustix 0.38.44",
1086 + "rustix 1.1.4",
1087 "signal-hook",
1088 "signal-hook-mio",
1089 "winapi",
@@ -1435,6 +1437,15 @@ version = "1.1.1"
1437 source = "registry+https://github.com/rust-lang/crates.io-index"
1438 checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359"
1439
1440 +[[package]]
1441 +name = "document-features"
1442 +version = "0.2.12"
1443 +source = "registry+https://github.com/rust-lang/crates.io-index"
1444 +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
1445 +dependencies = [
1446 + "litrs",
1447 +]
1448 +
1449 [[package]]
1450 name = "dtoa"
1451 version = "1.0.11"
@@ -3016,6 +3027,12 @@ version = "0.8.2"
3027 source = "registry+https://github.com/rust-lang/crates.io-index"
3028 checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
3029
3030 +[[package]]
3031 +name = "litrs"
3032 +version = "1.0.0"
3033 +source = "registry+https://github.com/rust-lang/crates.io-index"
3034 +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
3035 +
3036 [[package]]
3037 name = "llguidance"
3038 version = "1.7.2"
@@ -3270,18 +3287,6 @@ dependencies = [
3287 "simd-adler32",
3288 ]
3289
3273 -[[package]]
3274 -name = "mio"
3275 -version = "0.8.11"
3276 -source = "registry+https://github.com/rust-lang/crates.io-index"
3277 -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
3278 -dependencies = [
3279 - "libc",
3280 - "log",
3281 - "wasi",
3282 - "windows-sys 0.48.0",
3283 -]
3284 -
3290 [[package]]
3291 name = "mio"
3292 version = "1.2.0"
@@ -3297,8 +3302,7 @@ dependencies = [
3302 [[package]]
3303 name = "mistralrs"
3304 version = "0.8.1"
3300 -source = "registry+https://github.com/rust-lang/crates.io-index"
3301 -checksum = "9bb0a83340b4492ebba9760ba6845364de369c7e10c1f91af8733d574c7405fa"
3305 +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
3306 dependencies = [
3307 "anyhow",
3308 "candle-core",
@@ -3325,8 +3329,7 @@ dependencies = [
3329 [[package]]
3330 name = "mistralrs-audio"
3331 version = "0.8.1"
3328 -source = "registry+https://github.com/rust-lang/crates.io-index"
3329 -checksum = "5ac5d36f634c7c20c45845bc995be3b6387ab01048410386a5b180cfeaf89c72"
3332 +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
3333 dependencies = [
3334 "anyhow",
3335 "apodize",
@@ -3337,8 +3340,7 @@ dependencies = [
3340 [[package]]
3341 name = "mistralrs-core"
3342 version = "0.8.1"
3340 -source = "registry+https://github.com/rust-lang/crates.io-index"
3341 -checksum = "a2b8b9e5c94491d9ceeded3a30291cb6af6ae74810a5776dd55e3bbb8b3429d4"
3343 +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
3344 dependencies = [
3345 "ahash",
3346 "akin",
@@ -3435,8 +3437,7 @@ dependencies = [
3437 [[package]]
3438 name = "mistralrs-macros"
3439 version = "0.8.1"
3438 -source = "registry+https://github.com/rust-lang/crates.io-index"
3439 -checksum = "5aa9b4794322d3f89fe61d21f33f67be494c16d5bd869604b111218f32544d32"
3440 +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
3441 dependencies = [
3442 "darling 0.23.0",
3443 "proc-macro2",
@@ -3447,8 +3448,7 @@ dependencies = [
3448 [[package]]
3449 name = "mistralrs-mcp"
3450 version = "0.8.1"
3450 -source = "registry+https://github.com/rust-lang/crates.io-index"
3451 -checksum = "4fa97d4e3189ed80ebbc730b7da5b2356df0ae004ab489066249340c33c089ab"
3451 +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
3452 dependencies = [
3453 "anyhow",
3454 "async-trait",
@@ -3468,8 +3468,7 @@ dependencies = [
3468 [[package]]
3469 name = "mistralrs-paged-attn"
3470 version = "0.8.1"
3471 -source = "registry+https://github.com/rust-lang/crates.io-index"
3472 -checksum = "6e53ddf1537426997b46abdadbe6ca8a7ce7668004ed2b7cf00115016558bae8"
3471 +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
3472 dependencies = [
3473 "anyhow",
3474 "candle-core",
@@ -3485,8 +3484,7 @@ dependencies = [
3484 [[package]]
3485 name = "mistralrs-quant"
3486 version = "0.8.1"
3488 -source = "registry+https://github.com/rust-lang/crates.io-index"
3489 -checksum = "980715493d252e9aaf0779c4c101576d67ef4dd9812bfd77a54987f6f17526f4"
3487 +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
3488 dependencies = [
3489 "byteorder",
3490 "candle-core",
@@ -3515,8 +3513,7 @@ dependencies = [
3513 [[package]]
3514 name = "mistralrs-vision"
3515 version = "0.8.1"
3518 -source = "registry+https://github.com/rust-lang/crates.io-index"
3519 -checksum = "10046a3da2de5b702d3e829b5ddef7aa829ad454819dcc4876dea481a6cb5456"
3516 +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006"
3517 dependencies = [
3518 "candle-core",
3519 "image",
@@ -3830,9 +3827,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
3827
3828 [[package]]
3829 name = "onde"
3833 -version = "0.1.1"
3834 -source = "registry+https://github.com/rust-lang/crates.io-index"
3835 -checksum = "5436633863ffc31311f529439353ba06a436dd661d2a5d93c8793c3500123a13"
3830 +version = "0.1.3"
3831 dependencies = [
3832 "anyhow",
3833 "cc",
@@ -3842,6 +3837,7 @@ dependencies = [
3837 "mistralrs",
3838 "mistralrs-core",
3839 "serde",
3840 + "serde_json",
3841 "thiserror 2.0.18",
3842 "tokio",
3843 "tsync",
@@ -5308,6 +5304,8 @@ dependencies = [
5304 "log",
5305 "onde",
5306 "ratatui",
5307 + "regex",
5308 + "serde_json",
5309 "tokio",
5310 "tokio-util",
5311 "uuid 1.23.0",
@@ -5330,8 +5328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5328 checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
5329 dependencies = [
5330 "libc",
5333 - "mio 0.8.11",
5334 - "mio 1.2.0",
5331 + "mio",
5332 "signal-hook",
5333 ]
5334
@@ -6112,7 +6109,7 @@ checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
6109 dependencies = [
6110 "bytes",
6111 "libc",
6115 - "mio 1.2.0",
6112 + "mio",
6113 "parking_lot",
6114 "pin-project-lite",
6115 "signal-hook-registry",
@@ -6292,11 +6289,10 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
6289 [[package]]
6290 name = "tqdm"
6291 version = "0.8.0"
6295 -source = "registry+https://github.com/rust-lang/crates.io-index"
6296 -checksum = "b316d5c2ac649ca856dacd487d0ebb94f3b746bada51355d93dd2c007ab62a2e"
6292 +source = "git+https://github.com/setoelkahfi/tqdm?branch=deps%2Fbump-crossterm#41e4182829136e6dadbdd1c36af824d19219147a"
6293 dependencies = [
6294 "anyhow",
6299 - "crossterm 0.25.0",
6295 + "crossterm 0.29.0",
6296 "once_cell",
6297 ]
6298
@@ -7273,15 +7269,6 @@ dependencies = [
7269 "windows-targets 0.42.2",
7270 ]
7271
7276 -[[package]]
7277 -name = "windows-sys"
7278 -version = "0.48.0"
7279 -source = "registry+https://github.com/rust-lang/crates.io-index"
7280 -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
7281 -dependencies = [
7282 - "windows-targets 0.48.5",
7283 -]
7284 -
7272 [[package]]
7273 name = "windows-sys"
7274 version = "0.52.0"
Cargo.toml
+3 -1
@@ -15,7 +15,7 @@ path = "src/main.rs"
15 agent-client-protocol = "0.10.4"
16
17 # Onde Inference engine (local LLM)
18 -onde = "0.1.1"
18 +onde = { path = "../onde" }
19
20 # Async runtime
21 async-trait = "0.1"
@@ -31,4 +31,6 @@ ratatui = { version = "0.29", default-features = false, features = ["crossterm"]
31 anyhow = "1"
32 log = "0.4"
33 env_logger = "0.11"
34 +serde_json = "1"
35 +regex = "1"
36 uuid = { version = "1", features = ["v4"] }
src/chat.rs
+251 -35
@@ -2,13 +2,19 @@
2 //!
3 //! Takes over the alternate screen and multiplexes terminal events with
4 //! streaming LLM tokens via `tokio::select!`.
5 +//!
6 +//! Inference runs on a background `tokio::spawn` task so the event loop
7 +//! keeps redrawing while the model thinks. Results flow back through an
8 +//! `mpsc` channel as `InferenceUpdate` variants.
9
10 use std::future::pending;
11 +use std::sync::Arc;
12 +use std::time::Duration;
13
14 use anyhow::Result;
15 use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
16 use futures::StreamExt;
11 -use onde::inference::{ChatEngine, StreamChunk};
17 +use onde::inference::{ChatEngine, StreamChunk, ToolDefinition, ToolResult};
18 use ratatui::{
19 Frame,
20 layout::{Constraint, Layout, Position},
@@ -55,6 +61,18 @@ impl ChatMessage {
61 }
62 }
63
64 +// ── Inference updates from background task ───────────────────────────────────
65 +
66 +/// Messages sent from the spawned inference task back to the event loop.
67 +enum InferenceUpdate {
68 + /// The model is calling a tool — show its name in the chat.
69 + ToolUse(String),
70 + /// The model produced a final text response.
71 + Response(String),
72 + /// Something went wrong during inference.
73 + Error(String),
74 +}
75 +
76 // ── App state ────────────────────────────────────────────────────────────────
77
78 struct App {
@@ -64,6 +82,12 @@ struct App {
82 scroll_offset: u16,
83 stream_rx: Option<mpsc::Receiver<StreamChunk>>,
84 stream_buf: String,
85 + /// Channel for receiving results from the background inference task.
86 + inference_rx: Option<mpsc::Receiver<InferenceUpdate>>,
87 + /// True while waiting for inference to finish.
88 + thinking: bool,
89 + /// Counter driving the thinking spinner animation.
90 + thinking_tick: u8,
91 quit: bool,
92 /// Toggled every other tick while streaming — drives the blinking cursor.
93 blink_on: bool,
@@ -85,6 +109,9 @@ const BANNER_ART: &str = "\
109 55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555
110 88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888";
111
112 +/// Spinner frames for the "thinking" animation.
113 +const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
114 +
115 impl App {
116 fn new() -> Self {
117 let mut messages = Vec::new();
@@ -108,12 +135,20 @@ impl App {
135 scroll_offset: 0,
136 stream_rx: None,
137 stream_buf: String::new(),
138 + inference_rx: None,
139 + thinking: false,
140 + thinking_tick: 0,
141 quit: false,
142 blink_on: true,
143 blink_counter: 0,
144 }
145 }
146
147 + /// True when either streaming tokens or waiting for inference.
148 + fn is_busy(&self) -> bool {
149 + self.is_streaming() || self.thinking
150 + }
151 +
152 fn is_streaming(&self) -> bool {
153 self.stream_rx.is_some()
154 }
@@ -134,6 +169,25 @@ impl App {
169 self.blink_on = self.blink_counter % 4 < 2;
170 }
171
172 + fn start_thinking(&mut self) {
173 + self.thinking = true;
174 + self.thinking_tick = 0;
175 + }
176 +
177 + fn stop_thinking(&mut self) {
178 + self.thinking = false;
179 + self.inference_rx = None;
180 + }
181 +
182 + fn tick_thinking(&mut self) {
183 + self.thinking_tick = self.thinking_tick.wrapping_add(1);
184 + }
185 +
186 + fn thinking_frame(&self) -> &'static str {
187 + let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len();
188 + THINKING_FRAMES[idx]
189 + }
190 +
191 /// Total lines the messages area would need (rough estimate for scrolling).
192 fn total_message_lines(&self, width: u16) -> u16 {
193 if width == 0 {
@@ -148,6 +202,10 @@ impl App {
202 if !self.stream_buf.is_empty() {
203 lines += wrapped_line_count(&self.stream_buf, Role::Assistant, w);
204 }
205 + // thinking indicator
206 + if self.thinking {
207 + lines += 1;
208 + }
209 lines
210 }
211
@@ -165,7 +223,7 @@ impl App {
223 fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 {
224 let prefix_len = match role {
225 Role::User => 6, // "you > "
168 - Role::Assistant => 7, // "siGit > " — wait, that's 8. Let's just use 7 for "siGit> "
226 + Role::Assistant => 8, // "siGit > "
227 Role::System => 0,
228 };
229 let effective = if width > prefix_len {
@@ -270,7 +328,7 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect
328 let buf_lines: Vec<&str> = app.stream_buf.split('\n').collect();
329 for (i, segment) in buf_lines.iter().enumerate() {
330 if i > 0 {
273 - lines.push(Line::from(spans.drain(..).collect::<Vec<_>>()));
331 + lines.push(Line::from(std::mem::take(&mut spans)));
332 // continuation lines get no prefix
333 }
334 spans.push(Span::raw(segment.to_string()));
@@ -284,6 +342,25 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect
342 lines.push(Line::from(spans));
343 }
344
345 + // thinking indicator (animated spinner)
346 + if app.thinking {
347 + let frame_char = app.thinking_frame();
348 + lines.push(Line::from(vec![
349 + Span::styled(
350 + "siGit > ",
351 + Style::default()
352 + .fg(Color::Green)
353 + .add_modifier(Modifier::BOLD),
354 + ),
355 + Span::styled(
356 + format!("{frame_char} thinking…"),
357 + Style::default()
358 + .fg(Color::Yellow)
359 + .add_modifier(Modifier::DIM),
360 + ),
361 + ]));
362 + }
363 +
364 // auto-scroll
365 app.auto_scroll(area.height, area.width);
366
@@ -350,19 +427,21 @@ fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
427 let block = Block::default()
428 .borders(Borders::TOP)
429 .border_style(Style::default().fg(Color::DarkGray))
353 - .title(if app.is_streaming() {
430 + .title(if app.thinking {
431 + " thinking… "
432 + } else if app.is_streaming() {
433 " streaming… "
434 } else {
435 " message "
436 })
358 - .title_style(Style::default().fg(if app.is_streaming() {
437 + .title_style(Style::default().fg(if app.is_busy() {
438 Color::Yellow
439 } else {
440 Color::DarkGray
441 }));
442
443 let input_text = Paragraph::new(app.input.as_str())
365 - .style(Style::default().fg(if app.is_streaming() {
444 + .style(Style::default().fg(if app.is_busy() {
445 Color::DarkGray
446 } else {
447 Color::White
@@ -372,7 +451,7 @@ fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
451 frame.render_widget(input_text, area);
452
453 // place cursor inside the input block (1 for border padding)
375 - if !app.is_streaming() {
454 + if !app.is_busy() {
455 let x = area.x + app.cursor as u16 + 1;
456 let y = area.y + 1;
457 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) {
459 }
460
461 fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
383 - let hints: &[(&str, &str)] = if app.is_streaming() {
462 + let hints: &[(&str, &str)] = if app.is_busy() {
463 &[("Ctrl+C", "cancel")]
464 } else {
465 &[("Enter", "send"), ("/help", "commands"), ("Ctrl+C", "quit")]
@@ -395,12 +474,12 @@ fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
474 format!(" {key} "),
475 Style::default()
476 .fg(Color::Black)
398 - .bg(Color::DarkGray)
477 + .bg(Color::Gray)
478 .add_modifier(Modifier::BOLD),
479 ));
480 spans.push(Span::styled(
481 format!(" {label}"),
403 - Style::default().fg(Color::DarkGray),
482 + Style::default().fg(Color::Gray),
483 ));
484 }
485
@@ -513,19 +592,114 @@ async fn exec_slash(app: &mut App, cmd: SlashCommand, engine: &ChatEngine) {
592 }
593 }
594
595 +// ── Background inference task ────────────────────────────────────────────────
596 +
597 +/// Maximum number of tool-calling rounds before forcing a text response.
598 +const MAX_TOOL_ROUNDS: usize = 10;
599 +
600 +/// Build onde `ToolDefinition`s from our agent tools.
601 +fn build_onde_tools() -> Vec<ToolDefinition> {
602 + crate::tools::all_tools()
603 + .into_iter()
604 + .map(|t| ToolDefinition {
605 + name: t.name.to_string(),
606 + description: t.description.to_string(),
607 + parameters_schema: t.parameters_schema.to_string(),
608 + })
609 + .collect()
610 +}
611 +
612 +/// Runs the agentic tool-calling loop on a background task and sends
613 +/// progress updates back through `tx`.
614 +///
615 +/// The sender is dropped when the task finishes, which the event loop
616 +/// detects as `None` from `rx.recv()`.
617 +async fn run_inference_task(
618 + engine: Arc<ChatEngine>,
619 + text: String,
620 + tx: mpsc::Sender<InferenceUpdate>,
621 +) {
622 + let onde_tools = build_onde_tools();
623 +
624 + let mut result = match engine.send_message_with_tools(&text, &onde_tools).await {
625 + Ok(r) => r,
626 + Err(err) => {
627 + let _ = tx.send(InferenceUpdate::Error(err.to_string())).await;
628 + return;
629 + }
630 + };
631 +
632 + let mut round = 0;
633 +
634 + while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
635 + round += 1;
636 + log::info!("tool round {} — {} call(s)", round, result.tool_calls.len());
637 +
638 + let mut tool_results = Vec::new();
639 +
640 + for tc in &result.tool_calls {
641 + log::info!(
642 + " → {}({})",
643 + tc.function_name,
644 + tc.arguments.chars().take(120).collect::<String>()
645 + );
646 +
647 + // Notify the UI about the tool call.
648 + let _ = tx
649 + .send(InferenceUpdate::ToolUse(tc.function_name.clone()))
650 + .await;
651 +
652 + // Execute the tool (synchronous / blocking-ok for file I/O).
653 + let output = crate::tools::execute_tool(&tc.function_name, &tc.arguments);
654 + log::info!(" ← {} chars", output.len());
655 +
656 + tool_results.push(ToolResult {
657 + tool_call_id: tc.id.clone(),
658 + content: output,
659 + });
660 + }
661 +
662 + // Allow further tool calls unless we've hit the limit.
663 + let next_tools = if round < MAX_TOOL_ROUNDS {
664 + Some(onde_tools.as_slice())
665 + } else {
666 + None // force a text response on the last round
667 + };
668 +
669 + match engine.send_tool_results(tool_results, next_tools).await {
670 + Ok(r) => result = r,
671 + Err(err) => {
672 + let _ = tx.send(InferenceUpdate::Error(err.to_string())).await;
673 + return;
674 + }
675 + }
676 + }
677 +
678 + // Send the final text response.
679 + if !result.text.is_empty() && result.tool_calls.is_empty() {
680 + let _ = tx.send(InferenceUpdate::Response(result.text)).await;
681 + }
682 +
683 + log::info!("inference complete — {} tool round(s)", round);
684 + // Sender drops here → event loop sees `None`.
685 +}
686 +
687 // ── Main loop ────────────────────────────────────────────────────────────────
688
689 /// Run the interactive chat UI. Blocks until the user quits.
690 ///
691 /// The caller must have already loaded a model into `engine`.
521 -pub async fn run(engine: &ChatEngine) -> Result<()> {
692 +pub async fn run(engine: Arc<ChatEngine>) -> Result<()> {
693 let mut terminal = ratatui::init();
694 let result = event_loop(&mut terminal, engine).await;
695 ratatui::restore();
696 result
697 }
698
528 -async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine) -> Result<()> {
699 +async fn event_loop(
700 + terminal: &mut ratatui::DefaultTerminal,
701 + engine: Arc<ChatEngine>,
702 +) -> Result<()> {
703 let mut app = App::new();
704 let mut event_stream = EventStream::new();
705
@@ -537,11 +711,12 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine
711 break;
712 }
713
540 - // multiplex terminal events and streaming tokens
714 + // multiplex terminal events, streaming tokens, inference updates,
715 + // and the thinking-spinner timer.
716 tokio::select! {
717 biased;
718
544 - // streaming chunks — only active when we have a receiver
719 + // ── streaming chunks ─────────────────────────────────────────
720 chunk = async {
721 match app.stream_rx.as_mut() {
722 Some(rx) => rx.recv().await,
@@ -564,7 +739,45 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine
739 }
740 }
741
567 - // terminal events
742 + // ── inference updates from background task ───────────────────
743 + update = async {
744 + match app.inference_rx.as_mut() {
745 + Some(rx) => rx.recv().await,
746 + None => pending().await,
747 + }
748 + } => {
749 + match update {
750 + Some(InferenceUpdate::ToolUse(name)) => {
751 + app.messages.push(ChatMessage::system(format!("🔧 {name}")));
752 + }
753 + Some(InferenceUpdate::Response(text)) => {
754 + app.stop_thinking();
755 + app.messages.push(ChatMessage::assistant(text));
756 + }
757 + Some(InferenceUpdate::Error(msg)) => {
758 + app.stop_thinking();
759 + app.messages.push(ChatMessage::system(format!("error: {msg}")));
760 + }
761 + None => {
762 + // Sender dropped — task finished (possibly with no
763 + // text response, e.g. all tool calls with empty final).
764 + app.stop_thinking();
765 + }
766 + }
767 + }
768 +
769 + // ── thinking spinner tick (100ms) ────────────────────────────
770 + _ = async {
771 + if app.thinking {
772 + tokio::time::sleep(Duration::from_millis(100)).await
773 + } else {
774 + pending().await
775 + }
776 + } => {
777 + app.tick_thinking();
778 + }
779 +
780 + // ── terminal events ──────────────────────────────────────────
781 maybe_event = event_stream.next() => {
782 let Some(Ok(event)) = maybe_event else {
783 // stream ended or error — bail
@@ -572,14 +785,21 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine
785 };
786
787 if let Event::Key(key) = event {
575 - // while streaming, only ctrl+c/d work
576 - if app.is_streaming() {
788 + // While busy (streaming or thinking), only Ctrl+C/D work.
789 + if app.is_busy() {
790 if key.kind == KeyEventKind::Press {
791 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
792 if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) {
580 - // drop the receiver to stop reading
581 - app.finalize_stream();
582 - app.messages.push(ChatMessage::system("(cancelled)"));
793 + if app.is_streaming() {
794 + app.finalize_stream();
795 + app.messages.push(ChatMessage::system("(cancelled)"));
796 + }
797 + if app.thinking {
798 + // Drop the receiver — the background task
799 + // will see a closed channel and stop.
800 + app.stop_thinking();
801 + app.messages.push(ChatMessage::system("(cancelled)"));
802 + }
803 }
804 }
805 continue;
@@ -588,26 +808,22 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine
808 if let Some(text) = handle_key(&mut app, key) {
809 // check for slash command first
810 if let Some(cmd) = parse_slash(&text) {
591 - exec_slash(&mut app, cmd, engine).await;
811 + exec_slash(&mut app, cmd, &engine).await;
812 continue;
813 }
814
595 - // regular message — send to engine
815 + // ── Spawn inference on a background task ─────────
816 app.messages.push(ChatMessage::user(&text));
817 + app.start_thinking();
818
598 - match engine.stream_message(text).await {
599 - Ok(rx) => {
600 - app.stream_rx = Some(rx);
601 - app.stream_buf.clear();
602 - app.blink_counter = 0;
603 - app.blink_on = true;
604 - }
605 - Err(err) => {
606 - app.messages.push(ChatMessage::system(format!(
607 - "error: {err}"
608 - )));
609 - }
610 - }
819 + let (tx, rx) = mpsc::channel::<InferenceUpdate>(64);
820 + app.inference_rx = Some(rx);
821 +
822 + let engine_handle = Arc::clone(&engine);
823 + let user_text = text.clone();
824 + tokio::spawn(async move {
825 + run_inference_task(engine_handle, user_text, tx).await;
826 + });
827 }
828 }
829 }
src/main.rs
+124 -28
@@ -24,7 +24,9 @@
24
25 mod chat;
26 mod setup;
27 +mod tools;
28
29 +use std::fs::File;
30 use std::io::IsTerminal;
31 use std::sync::Arc;
32
@@ -35,7 +37,7 @@ use agent_client_protocol::{
37 SessionId, SessionNotification, SessionUpdate, StopReason,
38 };
39 use futures::future::LocalBoxFuture;
38 -use onde::inference::{ChatEngine, GgufModelConfig};
40 +use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult};
41 use tokio::sync::{Mutex, mpsc};
42 use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
43
@@ -53,9 +55,28 @@ You help with:
55 - Software architecture and design patterns
56 - Code review
57
58 +You have access to tools that let you read files, list directories, and search \
59 +code. Use them proactively to understand the codebase before answering questions \
60 +or writing code. Always ground your answers in the actual code.
61 +
62 Be direct and brief. Write clean, idiomatic code. When debugging, go for the \
63 root cause, not the symptom. Correct beats clever.";
64
65 +/// Maximum number of tool-calling rounds before forcing a text response.
66 +const MAX_TOOL_ROUNDS: usize = 10;
67 +
68 +/// Convert the agent tool definitions into onde's `ToolDefinition` type.
69 +fn agent_tools_as_onde() -> Vec<ToolDefinition> {
70 + tools::all_tools()
71 + .into_iter()
72 + .map(|t| ToolDefinition {
73 + name: t.name.to_string(),
74 + description: t.description.to_string(),
75 + parameters_schema: t.parameters_schema.to_string(),
76 + })
77 + .collect()
78 +}
79 +
80 // ── Per-session state ────────────────────────────────────────────────────────
81
82 /// One active session at a time. We store the `SessionId` directly (not as a
@@ -121,8 +142,9 @@ impl Agent for SiGitAgent {
142 self.engine.clear_history().await;
143 } else {
144 // First session — pull the model (if needed) and load it.
124 - log::info!("loading default model (this may take a minute on first run)...");
125 - let config = GgufModelConfig::platform_default();
145 + // Qwen 3 4B is required for tool calling support.
146 + log::info!("loading Qwen 3 4B model (this may take a minute on first run)...");
147 + let config = GgufModelConfig::qwen3_4b();
148 self.engine
149 .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), None)
150 .await
@@ -176,32 +198,93 @@ impl Agent for SiGitAgent {
198 user_text.chars().take(80).collect::<String>()
199 );
200
179 - let mut rx = self
201 + // ── Agentic tool-calling loop ────────────────────────────────────
202 + //
203 + // 1. Send the user message with tool definitions (non-streaming).
204 + // 2. If the model responds with tool calls, execute them, feed
205 + // results back, and repeat (up to MAX_TOOL_ROUNDS).
206 + // 3. Once the model produces a text response (no tool calls),
207 + // stream it to the editor.
208 +
209 + let onde_tools = agent_tools_as_onde();
210 +
211 + let mut result = self
212 .engine
181 - .stream_message(user_text)
213 + .send_message_with_tools(&user_text, &onde_tools)
214 .await
215 .map_err(|e| agent_client_protocol::Error::new(-32603, e.to_string()))?;
216
185 - while let Some(chunk) = rx.recv().await {
186 - if !chunk.delta.is_empty() {
187 - let notification = SessionNotification::new(
188 - session_id.clone(),
189 - SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(
190 - chunk.delta,
191 - ))),
217 + let mut round = 0;
218 +
219 + while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
220 + round += 1;
221 + log::info!(
222 + "prompt({}) tool round {} — {} call(s)",
223 + session_id,
224 + round,
225 + result.tool_calls.len()
226 + );
227 +
228 + let mut tool_results = Vec::new();
229 +
230 + for tc in &result.tool_calls {
231 + log::info!(
232 + " → {}({})",
233 + tc.function_name,
234 + tc.arguments.chars().take(120).collect::<String>()
235 );
193 - // Forwarder gone (client disconnected?) — stop.
194 - if self.notification_tx.send(notification).await.is_err() {
195 - log::warn!("notification channel closed — stopping stream");
196 - break;
197 - }
236 +
237 + // Notify the editor that we're calling a tool.
238 + let status_text = format!("🔧 `{}`\n", tc.function_name,);
239 + let _ = self
240 + .notification_tx
241 + .send(SessionNotification::new(
242 + session_id.clone(),
243 + SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(
244 + status_text,
245 + ))),
246 + ))
247 + .await;
248 +
249 + // Execute the tool.
250 + let output = tools::execute_tool(&tc.function_name, &tc.arguments);
251 +
252 + log::info!(" ← {} chars", output.len());
253 +
254 + tool_results.push(ToolResult {
255 + tool_call_id: tc.id.clone(),
256 + content: output,
257 + });
258 }
199 - if chunk.done {
200 - break;
259 +
260 + // Decide whether to allow further tool calls.
261 + let next_tools = if round < MAX_TOOL_ROUNDS {
262 + Some(onde_tools.as_slice())
263 + } else {
264 + None // force a text response on the last round
265 + };
266 +
267 + result = self
268 + .engine
269 + .send_tool_results(tool_results, next_tools)
270 + .await
271 + .map_err(|e| agent_client_protocol::Error::new(-32603, e.to_string()))?;
272 + }
273 +
274 + // ── Send the final text response ─────────────────────────────────
275 + if !result.text.is_empty() {
276 + let notification = SessionNotification::new(
277 + session_id.clone(),
278 + SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(
279 + result.text,
280 + ))),
281 + );
282 + if self.notification_tx.send(notification).await.is_err() {
283 + log::warn!("notification channel closed");
284 }
285 }
286
204 - log::info!("prompt({}) complete", session_id);
287 + log::info!("prompt({}) complete — {} tool round(s)", session_id, round);
288 Ok(PromptResponse::new(StopReason::EndTurn))
289 }
290
@@ -244,8 +327,8 @@ fn print_banner() {
327 async fn run_interactive() -> anyhow::Result<()> {
328 println!(" Loading model...");
329
247 - let engine = ChatEngine::new();
248 - let config = GgufModelConfig::platform_default();
330 + let engine = Arc::new(ChatEngine::new());
331 + let config = GgufModelConfig::qwen3_4b();
332 engine
333 .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), None)
334 .await
@@ -258,7 +341,7 @@ async fn run_interactive() -> anyhow::Result<()> {
341 info.approx_memory.as_deref().unwrap_or("?"),
342 );
343
261 - chat::run(&engine).await
344 + chat::run(engine).await
345 }
346
347 // ── ACP server mode ──────────────────────────────────────────────────────────
@@ -312,16 +395,29 @@ async fn run_acp_server() -> anyhow::Result<()> {
395
396 #[tokio::main]
397 async fn main() -> anyhow::Result<()> {
315 - // Logs always go to stderr (stdout is either the TUI or the ACP wire).
316 - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
317 - .target(env_logger::Target::Stderr)
318 - .init();
398 + let interactive = std::io::stdin().is_terminal();
399 +
400 + // In interactive/TUI mode, logs go to a file so they don't scribble over
401 + // the alternate screen buffer. In ACP mode they go to stderr as usual.
402 + if interactive {
403 + let log_file = File::create("sigit.log").unwrap_or_else(|_| {
404 + // Fall back to /tmp if cwd is not writable.
405 + File::create(std::env::temp_dir().join("sigit.log")).expect("cannot open any log file")
406 + });
407 + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
408 + .target(env_logger::Target::Pipe(Box::new(log_file)))
409 + .init();
410 + } else {
411 + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
412 + .target(env_logger::Target::Stderr)
413 + .init();
414 + }
415
416 // Shared model cache (macOS App Group) — must run before anything
417 // touches hf-hub or ChatEngine.
418 setup::setup_shared_model_cache();
419
324 - if std::io::stdin().is_terminal() {
420 + if interactive {
421 // Interactive mode — full-screen chat TUI.
422 print_banner();
423 run_interactive().await
src/tools.rs new
+781
@@ -0,0 +1,781 @@
1 +//! Tool definitions and execution for the siGit coding agent.
2 +//!
3 +//! Each tool has:
4 +//! - A schema (JSON Schema) that describes its parameters for the LLM
5 +//! - An execution function that runs the tool and returns a string result
6 +//!
7 +//! # Dependencies
8 +//!
9 +//! This module requires `serde_json` and `regex` crates in `Cargo.toml`:
10 +//! ```toml
11 +//! serde_json = "1"
12 +//! regex = "1"
13 +//! ```
14 +//!
15 +//! # Write Tools
16 +//!
17 +//! - `create_file` — create a new file (fails if it already exists)
18 +//! - `edit_file` — replace an exact old-text span with new text in an existing file
19 +
20 +use regex::Regex;
21 +use serde_json::{Value, json};
22 +use std::fs;
23 +use std::path::Path;
24 +
25 +/// Maximum characters returned from `read_file` before truncation.
26 +const READ_FILE_CHAR_LIMIT: usize = 10_000;
27 +
28 +/// Maximum number of matching lines returned from `search_files`.
29 +const SEARCH_FILES_MATCH_LIMIT: usize = 50;
30 +
31 +// ── Tool schemas ─────────────────────────────────────────────────────────────
32 +
33 +/// A tool definition with its JSON Schema and metadata for the LLM.
34 +pub struct AgentTool {
35 + /// Machine-readable tool name (e.g. `"read_file"`).
36 + pub name: &'static str,
37 + /// Human-readable description shown to the LLM.
38 + pub description: &'static str,
39 + /// JSON Schema describing the tool's parameters.
40 + pub parameters_schema: Value,
41 +}
42 +
43 +/// Return all available agent tools.
44 +pub fn all_tools() -> Vec<AgentTool> {
45 + vec![
46 + AgentTool {
47 + name: "read_file",
48 + description: "Read the contents of a file at the given path. \
49 + Returns the file text, or an error message if the file cannot be read. \
50 + Output is truncated to 10 000 characters.",
51 + parameters_schema: json!({
52 + "type": "object",
53 + "properties": {
54 + "path": {
55 + "type": "string",
56 + "description": "Absolute or relative path to the file to read."
57 + }
58 + },
59 + "required": ["path"],
60 + "additionalProperties": false
61 + }),
62 + },
63 + AgentTool {
64 + name: "list_directory",
65 + description: "List files and directories at the given path. \
66 + Each entry is prefixed with [DIR] or [FILE]. \
67 + Directories are listed first, sorted alphabetically.",
68 + parameters_schema: json!({
69 + "type": "object",
70 + "properties": {
71 + "path": {
72 + "type": "string",
73 + "description": "Absolute or relative path to the directory to list."
74 + }
75 + },
76 + "required": ["path"],
77 + "additionalProperties": false
78 + }),
79 + },
80 + AgentTool {
81 + name: "search_files",
82 + description: "Search for a regex pattern across files in a directory tree. \
83 + Returns matching lines in `file:line_number: content` format. \
84 + Skips binary files and hidden directories. \
85 + Limited to the first 50 matches.",
86 + parameters_schema: json!({
87 + "type": "object",
88 + "properties": {
89 + "pattern": {
90 + "type": "string",
91 + "description": "Regular expression pattern to search for."
92 + },
93 + "path": {
94 + "type": "string",
95 + "description": "Root directory to search in. Defaults to \".\" (current directory)."
96 + }
97 + },
98 + "required": ["pattern"],
99 + "additionalProperties": false
100 + }),
101 + },
102 + AgentTool {
103 + name: "create_file",
104 + description: "Create a new file at the given path with the provided content. \
105 + Parent directories are created automatically if they do not exist. \
106 + Fails if the file already exists — use edit_file to modify existing files.",
107 + parameters_schema: json!({
108 + "type": "object",
109 + "properties": {
110 + "path": {
111 + "type": "string",
112 + "description": "Absolute or relative path for the new file."
113 + },
114 + "content": {
115 + "type": "string",
116 + "description": "The full text content to write into the new file."
117 + }
118 + },
119 + "required": ["path", "content"],
120 + "additionalProperties": false
121 + }),
122 + },
123 + AgentTool {
124 + name: "edit_file",
125 + description: "Edit an existing file by replacing an exact substring (old_text) with \
126 + new text (new_text). The old_text must appear exactly once in the file. \
127 + Use read_file first to see the current content and identify the exact \
128 + text to replace. To append to a file, match the last few lines as \
129 + old_text and include them plus the new content as new_text.",
130 + parameters_schema: json!({
131 + "type": "object",
132 + "properties": {
133 + "path": {
134 + "type": "string",
135 + "description": "Path to the existing file to edit."
136 + },
137 + "old_text": {
138 + "type": "string",
139 + "description": "The exact text span to find and replace. Must match exactly once."
140 + },
141 + "new_text": {
142 + "type": "string",
143 + "description": "The replacement text that will take the place of old_text."
144 + }
145 + },
146 + "required": ["path", "old_text", "new_text"],
147 + "additionalProperties": false
148 + }),
149 + },
150 + ]
151 +}
152 +
153 +// ── Tool execution ───────────────────────────────────────────────────────────
154 +
155 +/// Execute a tool by name with the given JSON arguments string.
156 +///
157 +/// Returns the tool output as a human-readable string. Errors are returned as
158 +/// descriptive strings rather than panicking.
159 +pub fn execute_tool(name: &str, arguments: &str) -> String {
160 + match name {
161 + "read_file" => exec_read_file(arguments),
162 + "list_directory" => exec_list_directory(arguments),
163 + "search_files" => exec_search_files(arguments),
164 + "create_file" => exec_create_file(arguments),
165 + "edit_file" => exec_edit_file(arguments),
166 + _ => format!("Unknown tool: {name}"),
167 + }
168 +}
169 +
170 +// ── read_file ────────────────────────────────────────────────────────────────
171 +
172 +/// Read the contents of a single file, truncating at [`READ_FILE_CHAR_LIMIT`].
173 +fn exec_read_file(arguments: &str) -> String {
174 + let args: Value = match serde_json::from_str(arguments) {
175 + Ok(v) => v,
176 + Err(err) => return format!("Error: failed to parse arguments: {err}"),
177 + };
178 +
179 + let path_str = match args.get("path").and_then(Value::as_str) {
180 + Some(p) => p,
181 + None => return "Error: missing required parameter \"path\"".to_string(),
182 + };
183 +
184 + let path = Path::new(path_str);
185 +
186 + if !path.exists() {
187 + return format!("Error: path does not exist: {path_str}");
188 + }
189 +
190 + if !path.is_file() {
191 + return format!("Error: path is not a file: {path_str}");
192 + }
193 +
194 + match fs::read_to_string(path) {
195 + Ok(contents) => {
196 + if contents.len() > READ_FILE_CHAR_LIMIT {
197 + let truncated: String = contents.chars().take(READ_FILE_CHAR_LIMIT).collect();
198 + format!(
199 + "{truncated}\n\n--- truncated (showing {READ_FILE_CHAR_LIMIT} of {} characters) ---",
200 + contents.len()
201 + )
202 + } else {
203 + contents
204 + }
205 + }
206 + Err(err) => format!("Error: could not read file: {err}"),
207 + }
208 +}
209 +
210 +// ── list_directory ───────────────────────────────────────────────────────────
211 +
212 +/// List directory entries, directories first, sorted alphabetically.
213 +fn exec_list_directory(arguments: &str) -> String {
214 + let args: Value = match serde_json::from_str(arguments) {
215 + Ok(v) => v,
216 + Err(err) => return format!("Error: failed to parse arguments: {err}"),
217 + };
218 +
219 + let path_str = match args.get("path").and_then(Value::as_str) {
220 + Some(p) => p,
221 + None => return "Error: missing required parameter \"path\"".to_string(),
222 + };
223 +
224 + let path = Path::new(path_str);
225 +
226 + if !path.exists() {
227 + return format!("Error: path does not exist: {path_str}");
228 + }
229 +
230 + if !path.is_dir() {
231 + return format!("Error: path is not a directory: {path_str}");
232 + }
233 +
234 + let entries = match fs::read_dir(path) {
235 + Ok(rd) => rd,
236 + Err(err) => return format!("Error: could not read directory: {err}"),
237 + };
238 +
239 + let mut dirs: Vec<String> = Vec::new();
240 + let mut files: Vec<String> = Vec::new();
241 +
242 + for entry in entries {
243 + let entry = match entry {
244 + Ok(e) => e,
245 + Err(err) => {
246 + files.push(format!("[ERR] {err}"));
247 + continue;
248 + }
249 + };
250 +
251 + let name = entry.file_name().to_string_lossy().to_string();
252 +
253 + let is_dir = match entry.file_type() {
254 + Ok(ft) => ft.is_dir(),
255 + Err(_) => false,
256 + };
257 +
258 + if is_dir {
259 + dirs.push(format!("[DIR] {name}"));
260 + } else {
261 + files.push(format!("[FILE] {name}"));
262 + }
263 + }
264 +
265 + dirs.sort();
266 + files.sort();
267 +
268 + // Directories first, then files.
269 + dirs.extend(files);
270 +
271 + if dirs.is_empty() {
272 + return format!("(empty directory: {path_str})");
273 + }
274 +
275 + dirs.join("\n")
276 +}
277 +
278 +// ── search_files ─────────────────────────────────────────────────────────────
279 +
280 +/// Recursively search files for a regex pattern, returning matching lines.
281 +fn exec_search_files(arguments: &str) -> String {
282 + let args: Value = match serde_json::from_str(arguments) {
283 + Ok(v) => v,
284 + Err(err) => return format!("Error: failed to parse arguments: {err}"),
285 + };
286 +
287 + let pattern_str = match args.get("pattern").and_then(Value::as_str) {
288 + Some(p) => p,
289 + None => return "Error: missing required parameter \"pattern\"".to_string(),
290 + };
291 +
292 + let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
293 +
294 + let re = match Regex::new(pattern_str) {
295 + Ok(r) => r,
296 + Err(err) => return format!("Error: invalid regex pattern: {err}"),
297 + };
298 +
299 + let root = Path::new(root_str);
300 +
301 + if !root.exists() {
302 + return format!("Error: path does not exist: {root_str}");
303 + }
304 +
305 + if !root.is_dir() {
306 + return format!("Error: path is not a directory: {root_str}");
307 + }
308 +
309 + let mut matches: Vec<String> = Vec::new();
310 + walk_and_search(root, &re, &mut matches);
311 +
312 + if matches.is_empty() {
313 + return format!("No matches found for pattern: {pattern_str}");
314 + }
315 +
316 + let total = matches.len();
317 + if total > SEARCH_FILES_MATCH_LIMIT {
318 + matches.truncate(SEARCH_FILES_MATCH_LIMIT);
319 + matches.push(format!(
320 + "\n--- truncated (showing {SEARCH_FILES_MATCH_LIMIT} of {total} matches) ---"
321 + ));
322 + }
323 +
324 + matches.join("\n")
325 +}
326 +
327 +/// Recursively walk a directory and collect regex matches.
328 +///
329 +/// Skips hidden directories (names starting with `.`) and binary files.
330 +/// Stops collecting once the match list reaches a generous internal cap (2×
331 +/// the public limit) to avoid unbounded work.
332 +fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) {
333 + // Internal cap to avoid scanning the entire filesystem.
334 + const WALK_CAP: usize = SEARCH_FILES_MATCH_LIMIT * 2;
335 +
336 + let entries = match fs::read_dir(dir) {
337 + Ok(rd) => rd,
338 + Err(_) => return,
339 + };
340 +
341 + // Collect and sort for deterministic output.
342 + let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
343 + sorted.sort_by_key(|e| e.file_name());
344 +
345 + for entry in sorted {
346 + if matches.len() >= WALK_CAP {
347 + return;
348 + }
349 +
350 + let path = entry.path();
351 + let name = entry.file_name();
352 + let name_str = name.to_string_lossy();
353 +
354 + // Skip hidden entries.
355 + if name_str.starts_with('.') {
356 + continue;
357 + }
358 +
359 + if path.is_dir() {
360 + walk_and_search(&path, re, matches);
361 + } else if path.is_file() {
362 + search_file(&path, re, matches);
363 + }
364 + }
365 +}
366 +
367 +/// Search a single file line-by-line for the regex pattern.
368 +///
369 +/// Skips files that cannot be read as UTF-8 (assumed binary).
370 +fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) {
371 + let contents = match fs::read_to_string(path) {
372 + Ok(c) => c,
373 + // Skip binary / unreadable files silently.
374 + Err(_) => return,
375 + };
376 +
377 + let display_path = path.display();
378 +
379 + for (line_idx, line) in contents.lines().enumerate() {
380 + if re.is_match(line) {
381 + let line_number = line_idx + 1;
382 + matches.push(format!("{display_path}:{line_number}: {line}"));
383 + }
384 + }
385 +}
386 +
387 +// ── create_file ──────────────────────────────────────────────────────────────
388 +
389 +/// Create a new file with the provided content.
390 +///
391 +/// Parent directories are created automatically. Fails if the file already
392 +/// exists to prevent accidental overwrites — the LLM should use `edit_file`
393 +/// for existing files.
394 +fn exec_create_file(arguments: &str) -> String {
395 + let args: Value = match serde_json::from_str(arguments) {
396 + Ok(v) => v,
397 + Err(err) => return format!("Error: failed to parse arguments: {err}"),
398 + };
399 +
400 + let path_str = match args.get("path").and_then(Value::as_str) {
401 + Some(p) => p,
402 + None => return "Error: missing required parameter \"path\"".to_string(),
403 + };
404 +
405 + let content = match args.get("content").and_then(Value::as_str) {
406 + Some(c) => c,
407 + None => return "Error: missing required parameter \"content\"".to_string(),
408 + };
409 +
410 + let path = Path::new(path_str);
411 +
412 + if path.exists() {
413 + return format!(
414 + "Error: file already exists: {path_str} — use edit_file to modify existing files"
415 + );
416 + }
417 +
418 + // Create parent directories if needed.
419 + if let Some(parent) = path.parent()
420 + && !parent.as_os_str().is_empty()
421 + && !parent.exists()
422 + && let Err(err) = fs::create_dir_all(parent)
423 + {
424 + return format!("Error: could not create parent directories: {err}");
425 + }
426 +
427 + match fs::write(path, content) {
428 + Ok(()) => format!("Created file: {path_str} ({} bytes)", content.len()),
429 + Err(err) => format!("Error: could not write file: {err}"),
430 + }
431 +}
432 +
433 +// ── edit_file ────────────────────────────────────────────────────────────────
434 +
435 +/// Edit an existing file by replacing an exact occurrence of `old_text` with
436 +/// `new_text`.
437 +///
438 +/// The `old_text` must appear **exactly once** in the file. This prevents
439 +/// ambiguous edits and forces the LLM to read the file first to get the exact
440 +/// text span.
441 +fn exec_edit_file(arguments: &str) -> String {
442 + let args: Value = match serde_json::from_str(arguments) {
443 + Ok(v) => v,
444 + Err(err) => return format!("Error: failed to parse arguments: {err}"),
445 + };
446 +
447 + let path_str = match args.get("path").and_then(Value::as_str) {
448 + Some(p) => p,
449 + None => return "Error: missing required parameter \"path\"".to_string(),
450 + };
451 +
452 + let old_text = match args.get("old_text").and_then(Value::as_str) {
453 + Some(t) => t,
454 + None => return "Error: missing required parameter \"old_text\"".to_string(),
455 + };
456 +
457 + let new_text = match args.get("new_text").and_then(Value::as_str) {
458 + Some(t) => t,
459 + None => return "Error: missing required parameter \"new_text\"".to_string(),
460 + };
461 +
462 + let path = Path::new(path_str);
463 +
464 + if !path.exists() {
465 + return format!("Error: file does not exist: {path_str} — use create_file for new files");
466 + }
467 +
468 + if !path.is_file() {
469 + return format!("Error: path is not a file: {path_str}");
470 + }
471 +
472 + let contents = match fs::read_to_string(path) {
473 + Ok(c) => c,
474 + Err(err) => return format!("Error: could not read file: {err}"),
475 + };
476 +
477 + // Count occurrences to give a clear error message.
478 + let occurrences = contents.matches(old_text).count();
479 +
480 + if occurrences == 0 {
481 + return format!(
482 + "Error: old_text not found in {path_str}. \
483 + Use read_file to see the current content and copy the exact text to replace."
484 + );
485 + }
486 +
487 + if occurrences > 1 {
488 + return format!(
489 + "Error: old_text appears {occurrences} times in {path_str}. \
490 + Include more surrounding context in old_text so it matches exactly once."
491 + );
492 + }
493 +
494 + let updated = contents.replacen(old_text, new_text, 1);
495 +
496 + match fs::write(path, &updated) {
497 + Ok(()) => format!("Edited file: {path_str} ({} bytes written)", updated.len()),
498 + Err(err) => format!("Error: could not write file: {err}"),
499 + }
500 +}
501 +
502 +#[cfg(test)]
503 +mod tests {
504 + use super::*;
505 + use std::fs;
506 +
507 + #[test]
508 + fn test_execute_unknown_tool() {
509 + let result = execute_tool("nonexistent", "{}");
510 + assert!(result.starts_with("Unknown tool:"));
511 + }
512 +
513 + #[test]
514 + fn test_read_file_missing_path_param() {
515 + let result = exec_read_file("{}");
516 + assert!(result.contains("missing required parameter"));
517 + }
518 +
519 + #[test]
520 + fn test_read_file_nonexistent() {
521 + let result = exec_read_file(r#"{"path": "/tmp/__sigit_no_such_file_42__"}"#);
522 + assert!(result.contains("does not exist"));
523 + }
524 +
525 + #[test]
526 + fn test_read_file_success() {
527 + let dir = std::env::temp_dir().join("sigit_test_read_file");
528 + let _ = fs::create_dir_all(&dir);
529 + let file_path = dir.join("hello.txt");
530 + fs::write(&file_path, "hello world").unwrap();
531 +
532 + let args = format!(r#"{{"path": "{}"}}"#, file_path.display());
533 + let result = exec_read_file(&args);
534 + assert_eq!(result, "hello world");
535 +
536 + let _ = fs::remove_dir_all(&dir);
537 + }
538 +
539 + #[test]
540 + fn test_list_directory_missing_path_param() {
541 + let result = exec_list_directory("{}");
542 + assert!(result.contains("missing required parameter"));
543 + }
544 +
545 + #[test]
546 + fn test_list_directory_success() {
547 + let dir = std::env::temp_dir().join("sigit_test_list_dir");
548 + let _ = fs::remove_dir_all(&dir);
549 + fs::create_dir_all(dir.join("subdir")).unwrap();
550 + fs::write(dir.join("aaa.txt"), "").unwrap();
551 + fs::write(dir.join("bbb.rs"), "").unwrap();
552 +
553 + let args = format!(r#"{{"path": "{}"}}"#, dir.display());
554 + let result = exec_list_directory(&args);
555 +
556 + assert!(result.contains("[DIR] subdir"));
557 + assert!(result.contains("[FILE] aaa.txt"));
558 + assert!(result.contains("[FILE] bbb.rs"));
559 +
560 + // Directories should appear before files.
561 + let dir_pos = result.find("[DIR]").unwrap();
562 + let file_pos = result.find("[FILE]").unwrap();
563 + assert!(dir_pos < file_pos);
564 +
565 + let _ = fs::remove_dir_all(&dir);
566 + }
567 +
568 + #[test]
569 + fn test_search_files_invalid_regex() {
570 + let result = exec_search_files(r#"{"pattern": "[invalid", "path": "."}"#);
571 + assert!(result.contains("invalid regex"));
572 + }
573 +
574 + #[test]
575 + fn test_search_files_success() {
576 + let dir = std::env::temp_dir().join("sigit_test_search");
577 + let _ = fs::remove_dir_all(&dir);
578 + fs::create_dir_all(&dir).unwrap();
579 + fs::write(
580 + dir.join("code.rs"),
581 + "fn main() {\n println!(\"hello\");\n}\n",
582 + )
583 + .unwrap();
584 + fs::write(dir.join("other.txt"), "no match here\n").unwrap();
585 +
586 + let args = format!(r#"{{"pattern": "println", "path": "{}"}}"#, dir.display());
587 + let result = exec_search_files(&args);
588 +
589 + assert!(result.contains("code.rs:2:"));
590 + assert!(result.contains("println"));
591 + assert!(!result.contains("other.txt"));
592 +
593 + let _ = fs::remove_dir_all(&dir);
594 + }
595 +
596 + #[test]
597 + fn test_search_files_no_matches() {
598 + let dir = std::env::temp_dir().join("sigit_test_search_none");
599 + let _ = fs::remove_dir_all(&dir);
600 + fs::create_dir_all(&dir).unwrap();
601 + fs::write(dir.join("empty.txt"), "nothing special").unwrap();
602 +
603 + let args = format!(
604 + r#"{{"pattern": "zzz_will_not_match_42", "path": "{}"}}"#,
605 + dir.display()
606 + );
607 + let result = exec_search_files(&args);
608 + assert!(result.contains("No matches found"));
609 +
610 + let _ = fs::remove_dir_all(&dir);
611 + }
612 +
613 + #[test]
614 + fn test_all_tools_count() {
615 + let tools = all_tools();
616 + assert_eq!(tools.len(), 5);
617 + assert_eq!(tools[0].name, "read_file");
618 + assert_eq!(tools[1].name, "list_directory");
619 + assert_eq!(tools[2].name, "search_files");
620 + assert_eq!(tools[3].name, "create_file");
621 + assert_eq!(tools[4].name, "edit_file");
622 + }
623 +
624 + #[test]
625 + fn test_all_tools_schemas_are_valid_json_objects() {
626 + for tool in all_tools() {
627 + assert!(
628 + tool.parameters_schema.is_object(),
629 + "schema for {} is not an object",
630 + tool.name
631 + );
632 + let obj = tool.parameters_schema.as_object().unwrap();
633 + assert!(obj.contains_key("type"));
634 + assert!(obj.contains_key("properties"));
635 + assert!(obj.contains_key("required"));
636 + }
637 + }
638 +
639 + // ── create_file tests ────────────────────────────────────────────────
640 +
641 + #[test]
642 + fn test_create_file_missing_path() {
643 + let result = exec_create_file(r#"{"content": "hello"}"#);
644 + assert!(result.contains("missing required parameter"));
645 + }
646 +
647 + #[test]
648 + fn test_create_file_missing_content() {
649 + let result = exec_create_file(r#"{"path": "/tmp/sigit_test_nope.txt"}"#);
650 + assert!(result.contains("missing required parameter"));
651 + }
652 +
653 + #[test]
654 + fn test_create_file_success() {
655 + let dir = std::env::temp_dir().join("sigit_test_create_file");
656 + let _ = fs::remove_dir_all(&dir);
657 +
658 + let file_path = dir.join("sub").join("new_file.txt");
659 + let args = format!(
660 + r#"{{"path": "{}", "content": "hello world"}}"#,
661 + file_path.display()
662 + );
663 +
664 + let result = exec_create_file(&args);
665 + assert!(result.starts_with("Created file:"), "got: {result}");
666 + assert!(file_path.exists());
667 + assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello world");
668 +
669 + let _ = fs::remove_dir_all(&dir);
670 + }
671 +
672 + #[test]
673 + fn test_create_file_already_exists() {
674 + let dir = std::env::temp_dir().join("sigit_test_create_exists");
675 + let _ = fs::remove_dir_all(&dir);
676 + fs::create_dir_all(&dir).unwrap();
677 +
678 + let file_path = dir.join("existing.txt");
679 + fs::write(&file_path, "original").unwrap();
680 +
681 + let args = format!(
682 + r#"{{"path": "{}", "content": "overwrite attempt"}}"#,
683 + file_path.display()
684 + );
685 +
686 + let result = exec_create_file(&args);
687 + assert!(result.contains("already exists"), "got: {result}");
688 + // Original content untouched.
689 + assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
690 +
691 + let _ = fs::remove_dir_all(&dir);
692 + }
693 +
694 + // ── edit_file tests ──────────────────────────────────────────────────
695 +
696 + #[test]
697 + fn test_edit_file_missing_params() {
698 + let result = exec_edit_file(r#"{"path": "x"}"#);
699 + assert!(result.contains("missing required parameter"));
700 +
701 + let result = exec_edit_file(r#"{"path": "x", "old_text": "a"}"#);
702 + assert!(result.contains("missing required parameter"));
703 + }
704 +
705 + #[test]
706 + fn test_edit_file_nonexistent() {
707 + let result = exec_edit_file(
708 + r#"{"path": "/tmp/__sigit_no_such__", "old_text": "a", "new_text": "b"}"#,
709 + );
710 + assert!(result.contains("does not exist"));
711 + }
712 +
713 + #[test]
714 + fn test_edit_file_success() {
715 + let dir = std::env::temp_dir().join("sigit_test_edit_file");
716 + let _ = fs::remove_dir_all(&dir);
717 + fs::create_dir_all(&dir).unwrap();
718 +
719 + let file_path = dir.join("code.rs");
720 + fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
721 +
722 + let args = format!(
723 + r#"{{"path": "{}", "old_text": "println!(\"hello\")", "new_text": "println!(\"world\")"}}"#,
724 + file_path.display()
725 + );
726 +
727 + let result = exec_edit_file(&args);
728 + assert!(result.starts_with("Edited file:"), "got: {result}");
729 +
730 + let updated = fs::read_to_string(&file_path).unwrap();
731 + assert!(updated.contains("println!(\"world\")"));
732 + assert!(!updated.contains("println!(\"hello\")"));
733 +
734 + let _ = fs::remove_dir_all(&dir);
735 + }
736 +
737 + #[test]
738 + fn test_edit_file_old_text_not_found() {
739 + let dir = std::env::temp_dir().join("sigit_test_edit_notfound");
740 + let _ = fs::remove_dir_all(&dir);
741 + fs::create_dir_all(&dir).unwrap();
742 +
743 + let file_path = dir.join("data.txt");
744 + fs::write(&file_path, "aaa bbb ccc").unwrap();
745 +
746 + let args = format!(
747 + r#"{{"path": "{}", "old_text": "zzz", "new_text": "yyy"}}"#,
748 + file_path.display()
749 + );
750 +
751 + let result = exec_edit_file(&args);
752 + assert!(result.contains("old_text not found"), "got: {result}");
753 +
754 + let _ = fs::remove_dir_all(&dir);
755 + }
756 +
757 + #[test]
758 + fn test_edit_file_ambiguous_match() {
759 + let dir = std::env::temp_dir().join("sigit_test_edit_ambiguous");
760 + let _ = fs::remove_dir_all(&dir);
761 + fs::create_dir_all(&dir).unwrap();
762 +
763 + let file_path = dir.join("repeat.txt");
764 + fs::write(&file_path, "foo bar foo bar foo").unwrap();
765 +
766 + let args = format!(
767 + r#"{{"path": "{}", "old_text": "foo", "new_text": "baz"}}"#,
768 + file_path.display()
769 + );
770 +
771 + let result = exec_edit_file(&args);
772 + assert!(result.contains("appears 3 times"), "got: {result}");
773 + // File should be unchanged.
774 + assert_eq!(
775 + fs::read_to_string(&file_path).unwrap(),
776 + "foo bar foo bar foo"
777 + );
778 +
779 + let _ = fs::remove_dir_all(&dir);
780 + }
781 +}