feature/sigit-code-cloud
claude/code-feature-parity-q003hm
claude/elegant-carson-l1menh
claude/sigit-acp-local-chat-cx6380
claude/sigit-cloud-agent-expansion-reox0a
claude/tool-permission-system
claude/zen-feynman-0u78dk
development
feature/agent-tools-multiedit-glob-todos-remember
feature/background-commands
feature/commit-coauthor-attribution
feature/headless-mode
feature/init-command
feature/load-local-model-explicitly
feature/session-persistence-compaction
feature/sigit-code-cloud
feature/subagent-tool
feature/tool-permission-system
feature/tui-repo-tabs
feature/tui-tabs
main
release/v1.3.1
| 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 | |