1 ---
2 name: tool-calling
3 description: Implement or debug tool calling in siGit Code across the app, Onde Inference, and mistral.rs. Use when working on tool schemas, execution loops, model support, session cwd handling, or tool-call troubleshooting.
4 ---
5
6 # Skill: Tool Calling in siGit Code
7
8 ## Overview
9
10 siGit Code supports **agentic tool calling** — the LLM invokes tools (read/write files, run commands, read websites) to operate on the user's codebase. This works in both **interactive TUI mode** and **ACP server mode** (Zed editor).
11
12 Tool calling spans three layers:
13
14 ```
15 siGit (agent loop + tool execution)
16 → onde (ChatEngine with tool-aware API)
17 → mistral.rs (model inference + tool call parsing)
18 ```
19
20 ---
21
22 ## Model Requirement
23
24 **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.
25
26 | Model | Constructor | Size | Tool calling | Default |
27 |-------|-----------|------|:---:|:---:|
28 | Qwen 3 8B (Q4_K_M) | `GgufModelConfig::qwen3_8b()` | ~5 GB | ✅ | ✅ **default** |
29 | Qwen 3 4B (Q4_K_M) | `GgufModelConfig::qwen3_4b()` | ~2.7 GB | ✅ | |
30 | Qwen 3 1.7B (Q4_K_M) | `GgufModelConfig::qwen3_1_7b()` | ~1.3 GB | ✅ | |
31 | Qwen 2.5 Coder 3B | `GgufModelConfig::qwen25_coder_3b()` | ~1.93 GB | ❌ | |
32 | Qwen 2.5 Coder 1.5B | `GgufModelConfig::qwen25_coder_1_5b()` | ~941 MB | ❌ | |
33
34 siGit uses **Qwen 3 8B** by default with `max_tokens: 8192` (set in `main.rs` for both TUI and ACP modes).
35
36 ### Why 8B over 4B
37
38 4B can't do `edit_file` reliably. It reads a file, then fails to reproduce the exact `old_text` it just saw. This spirals into 7+ retry rounds that burn through `max_tokens` on `<think>` blocks and return nothing. 8B is the smallest model that actually lands edits.
39
40 ### bartowski GGUF naming convention
41
42 bartowski's repos use the publisher name as a prefix with an underscore:
43
44 | Constant | Value |
45 |----------|-------|
46 | `BARTOWSKI_QWEN3_8B_GGUF` | `"bartowski/Qwen_Qwen3-8B-GGUF"` |
47 | `QWEN3_8B_GGUF_FILE` | `"Qwen_Qwen3-8B-Q4_K_M.gguf"` |
48 | `BARTOWSKI_QWEN3_4B_GGUF` | `"bartowski/Qwen_Qwen3-4B-GGUF"` |
49 | `QWEN3_4B_GGUF_FILE` | `"Qwen_Qwen3-4B-Q4_K_M.gguf"` |
50
51 These constants live in `onde/src/inference/models.rs`.
52
53 ---
54
55 ## Tools (9 total)
56
57 Defined in `sigit/src/tools.rs` via `all_tools()`:
58
59 | # | Tool | Parameters | Behavior |
60 |---|------|-----------|----------|
61 | 1 | `read_file` | `path` | Reads file contents, truncates at 10,000 chars |
62 | 2 | `create_directory` | `path` | Creates directory and all parents |
63 | 3 | `list_directory` | `path` | Lists entries with `[DIR]`/`[FILE]` prefix, dirs first |
64 | 4 | `search_files` | `pattern`, `path` (optional) | Recursive regex search, max 50 matches |
65 | 5 | `read_website` | `url` | Fetches HTTP/HTTPS, strips HTML, returns text |
66 | 6 | `create_file` | `path`, `content` | Creates new file (fails if exists) |
67 | 7 | `edit_file` | `path`, `old_text`, `new_text` | Find-and-replace (must match exactly once) |
68 | 8 | `delete_file` | `path` | Deletes file or empty directory |
69 | 9 | `run_command` | `command`, `cwd` (optional) | Shell command with 120s timeout |
70
71 ### Async handling
72
73 `execute_tool()` is `async`. Most tools run synchronously, except:
74
75 - **`read_website`** — uses `tokio::task::spawn_blocking` because `reqwest::blocking::Client` panics inside a tokio runtime ("Cannot start a runtime from within a runtime")
76
77 ### Tool gating by model
78
79 In TUI mode, `run_inference_task()` takes a `tools_enabled: bool` parameter. When the model's `ModelOption.tool_calling` is `false` (Qwen 2.5), an empty tool list is passed so the model doesn't receive tool schemas it can't use.
80
81 ---
82
83 ## Architecture
84
85 ### Layer 1: mistral.rs (model-level)
86
87 - `RequestBuilder::set_tools(Vec<Tool>)` — attach tool definitions
88 - `RequestBuilder::set_tool_choice(ToolChoice::Auto)` — let model decide
89 - `QwenParser` detects `<tool_call>...</tool_call>` tags in output
90 - Grammar-constrained decoding forces valid JSON inside tool calls
91 - `<think>...</think>` reasoning is separated from tool calls by the reasoning parser
92 - Works identically for GGUF and full-precision models
93
94 ### Layer 2: onde (engine-level)
95
96 #### Key types (`onde/src/inference/types.rs`)
97
98 | Type | Purpose |
99 |------|---------|
100 | `ToolDefinition` | `{ name, description, parameters_schema: String }` |
101 | `ToolCallRequest` | `{ id, function_name, arguments: String }` |
102 | `ToolResult` | `{ tool_call_id, content: String }` |
103 | `ToolAwareResult` | `{ text, tool_calls: Vec<ToolCallRequest>, duration_secs, ... }` |
104
105 #### Key methods (`onde/src/inference/engine.rs`)
106
107 | Method | Purpose |
108 |--------|---------|
109 | `send_message_with_tools(msg, &[ToolDefinition])` | Returns `ToolAwareResult` with possible tool calls |
110 | `send_tool_results(Vec<ToolResult>, Option<&[ToolDefinition]>)` | Feed results back; `None` forces text response |
111
112 #### Internal details
113
114 - `attach_tools()` converts `ToolDefinition` → mistral.rs `Tool`, sets `ToolChoice::Auto` and `strict: Some(true)`
115 - `parse_tool_calls()` extracts tool calls from `choice.message.tool_calls`, generates fallback IDs if empty
116 - `replay_history_with_tools()` uses `.enumerate()` for correct sequential `index` values
117 - Malformed `parameters_schema` JSON logs a warning instead of silently producing empty params
118 - Malformed tool call `arguments` JSON logs a warning for debugging
119
120 ### Layer 3: siGit (agent-level)
121
122 #### ACP session handling (`src/main.rs`)
123
124 All session handlers (`load_session`, `fork_session`, `new_session`) do:
125
126 1. **Store `args.cwd`** in `session_cwd: Mutex<Option<PathBuf>>`
127 2. **`std::env::set_current_dir(&args.cwd)`** — so relative paths in tool calls resolve correctly
128 3. **`engine.clear_history()`** — siGit doesn't persist sessions
129 4. **`engine.push_history(ChatMessage::system(...))`** — injects: *"The user's project working directory is {cwd}. Always use absolute paths..."*
130
131 Without step 4, the model uses the process `cwd` (often `$HOME`) and creates files in the wrong directory.
132
133 #### ACP content block handling (`prompt()`)
134
135 The `prompt()` handler processes all ACP content block types:
136
137 - **`ContentBlock::Text`** — passed through as-is
138 - **`ContentBlock::Resource` (EmbeddedResource)**`TextResourceContents` inlined as `--- {uri} ---\n{text}\n--- end ---`
139 - **`ContentBlock::ResourceLink`**`file://` URIs are read from disk. **Line range fragments** like `#L207:219` are parsed: the `#` fragment is stripped from the path, and only lines 207–219 are extracted and sent to the model
140
141 Example: Zed sends `@ index.html (207:219)` as:
142 ```
143 ResourceLink(name="index.html (207:219)", uri="file:///path/to/index.html#L207:219")
144 ```
145 siGit parses this into path `/path/to/index.html` + lines 207–219.
146
147 ---
148
149 ## The Agentic Loop
150
151 Both ACP mode (`SiGitAgent::prompt()`) and TUI mode (`run_inference_task()`) implement:
152
153 ```
154 1. engine.send_message_with_tools(user_text, &tools) → ToolAwareResult
155 2. while result.tool_calls is non-empty AND round < MAX_TOOL_ROUNDS (10):
156 a. For each tool_call:
157 - Log: → tool_name(arguments)
158 - Execute: tools::execute_tool(name, arguments).await
159 - Log: ← N chars
160 - Collect ToolResult { tool_call_id, content }
161 b. Decide next_tools:
162 - round < MAX_TOOL_ROUNDS → Some(&tools) (allow more calls)
163 - else → None (force text response)
164 c. engine.send_tool_results(results, next_tools) → ToolAwareResult
165 3. Send final result.text to user
166 - Empty reply after tool rounds → log warning (ACP) or show error (TUI)
167 ```
168
169 ---
170
171 ## System Prompt
172
173 The `SYSTEM_PROMPT` in `main.rs` (~122 lines) includes critical instructions:
174
175 - **Never tell the user to run commands** — use `run_command` tool instead
176 - **Can access websites** — use `read_website` tool (overrides RLHF refusal training)
177 - **Prefer absolute paths** in all tool arguments
178 - **Git operations** — always use `run_command` with absolute cwd
179 - **smbCloud domain knowledge** — auth boundaries, deploy flows, project structure
180
181 The session `cwd` is injected as a separate system message at session creation time (not part of the static prompt).
182
183 ---
184
185 ## Model Cache
186
187 Models are stored in the shared Onde App Group container on macOS:
188
189 ```
190 ~/Library/Group Containers/group.com.ondeinference.apps/models/hub/
191 ```
192
193 `setup.rs` sets `HF_HOME` and `HF_HUB_CACHE` to point there at startup, so siGit reuses models downloaded by the Onde desktop app (and vice versa).
194
195 ---
196
197 ## Adding a New Tool
198
199 1. Add an `AgentTool` entry to `all_tools()` in `src/tools.rs`
200 2. Add a match arm to `execute_tool()` — use `spawn_blocking` if the implementation blocks
201 3. Write `exec_your_tool(arguments: &str) -> String`
202 4. Update `test_all_tools_count` test (currently expects 9)
203
204 No changes needed in onde or mistral.rs — tool definitions are passed dynamically.
205
206 ---
207
208 ## Adding a New Model
209
210 1. **`onde/src/inference/models.rs`** — add `pub const` for repo ID and GGUF filename, add to `SUPPORTED_MODELS` array and `SUPPORTED_MODEL_INFO`
211 2. **`onde/src/inference/engine.rs`** — add `pub fn model_name() -> Self` constructor to `impl GgufModelConfig`
212 3. **`sigit/src/chat.rs`** — add `ModelOption` entry to `SIGIT_MODELS` with `tool_calling: true/false`
213 4. **`sigit/src/main.rs`** — update `run_interactive()` and `run_acp_server()` if changing the default
214
215 ---
216
217 ## Debugging
218
219 ### Log locations
220
221 - **TUI mode:** `$TMPDIR/sigit.log` (e.g. `/var/folders/.../sigit.log`)
222 - **ACP mode (Zed):** `~/Library/Logs/Zed/Zed.log` — grep for `agent stderr:.*sigit`
223
224 ### Key log patterns
225
226 ```
227 # Model loaded successfully
228 ChatEngine: model Qwen 3 8B loaded in 6.9s
229
230 # Session cwd captured
231 load_session: id=..., cwd=/path/to/project, additional_directories=[...]
232
233 # Tool call parsed by mistral.rs
234 ChatEngine: tool inference END — 12.3s — tool_calls: 1
235
236 # Tool executed
237 → read_file({"path":"/absolute/path/to/file.rs"})
238 ← 6506 chars
239
240 # Tool result sent back
241 ChatEngine: tool results inference START — 1 results
242
243 # Model returned empty (exhausted max_tokens on thinking)
244 model returned empty reply after 7 tool round(s)
245
246 # ResourceLink received from Zed
247 block[1]: ResourceLink(name=index.html (207:219), uri=file:///path/to/index.html#L207:219)
248
249 # ResourceLink read failed (fragment not stripped — old bug, now fixed)
250 could not read ResourceLink file:///path/to/index.html#L207:219: No such file or directory
251 ```
252
253 ### Common issues
254
255 | Symptom | Cause | Fix |
256 |---------|-------|-----|
257 | Model says "I cannot access websites" | RLHF refusal override not in system prompt | System prompt now has CRITICAL block about `read_website` |
258 | `0 tool call(s)` for every prompt | Wrong model loaded (Qwen 2.5) | Check log for `loading GGUF model` — must be Qwen 3 |
259 | `edit_file` returns `← 161 chars` repeatedly | `old_text not found` — model can't match exact text | Use Qwen 3 8B (not 4B); consider line-based edit tool |
260 | Files created in wrong directory | `cwd` not captured from ACP session | Session handlers must call `set_current_dir` + `push_history` with cwd |
261 | `@ file.html (207:219)` context missing | `#L207:219` fragment not stripped from file path | `prompt()` now parses URI fragments and extracts line ranges |
262 | `read_website` panics/hangs | `reqwest::blocking` inside tokio runtime | `exec_read_website` wrapped in `spawn_blocking` |
263 | Empty reply after many tool rounds | Model exhausted `max_tokens` on `<think>` blocks | Set `max_tokens: 8192`; 8B model wastes fewer tokens on thinking |
264
265 ---
266
267 ## Cargo Dependency Note
268
269 For local development, `sigit/Cargo.toml` must use the path dependency:
270
271 ```toml
272 onde = { path = "../onde" }
273 ```
274
275 For CI/release, switch to the git dependency (after pushing Onde changes):
276
277 ```toml
278 onde = { git = "https://github.com/ondeinference/onde", branch = "development" }
279 ```
280
281 The `qwen3_8b()` constructor only exists in the local Onde SDK until it's pushed to the `development` branch.
282
283 ---
284
285 ## File Map
286
287 | File | What it does |
288 |------|-------------|
289 | `sigit/src/tools.rs` | 9 tool schemas (`all_tools()`), `execute_tool()` dispatch, all `exec_*` implementations |
290 | `sigit/src/main.rs` | `SYSTEM_PROMPT`, `SiGitAgent` struct with `session_cwd`, ACP session handlers (cwd + push_history), `prompt()` with content block parsing, model selection (`qwen3_8b`), `MAX_TOOL_ROUNDS` |
291 | `sigit/src/chat.rs` | `SIGIT_MODELS` array (4 models), `run_inference_task()` with `tools_enabled` gate, TUI tool loop |
292 | `sigit/src/setup.rs` | HF cache setup pointing to shared App Group container |
293 | `onde/src/inference/types.rs` | `ToolDefinition`, `ToolCallRequest`, `ToolResult`, `ToolAwareResult` |
294 | `onde/src/inference/engine.rs` | `send_message_with_tools()`, `send_tool_results()`, `attach_tools()`, `parse_tool_calls()`, `replay_history_with_tools()`, `GgufModelConfig::qwen3_8b()` |
295 | `onde/src/inference/models.rs` | Model constants and `SUPPORTED_MODELS` array |