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
+}