Update tool calling docs, improve read_file, and handle empty replies
- Revise SKILL.md to document Qwen 3 8B as default and clarify model table - Add guidance to always re-read files before edit_file calls - Update read_file tool to support start_line and end_line parameters - Improve agent reply handling for empty model responses - Bump cc and data-encoding crate versions in Cargo.lock
paydii committed
Apr 25, 2026 at 11:24 UTC
04cacf5ae7b2c286f7bfce139afae1943161ad83
4 files changed
+254
-192
.agents/skills/tool-calling/SKILL.md
+176
-169
@@ -2,7 +2,7 @@
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).
5
+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).
6
7
Tool calling spans three layers:
8
@@ -18,14 +18,19 @@ siGit (agent loop + tool execution)
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 | ❌ |
21
+| Model | Constructor | Size | Tool calling | Default |
22
+|-------|-----------|------|:---:|:---:|
23
+| Qwen 3 8B (Q4_K_M) | `GgufModelConfig::qwen3_8b()` | ~5 GB | ✅ | ✅ **default** |
24
+| Qwen 3 4B (Q4_K_M) | `GgufModelConfig::qwen3_4b()` | ~2.7 GB | ✅ | |
25
+| Qwen 3 1.7B (Q4_K_M) | `GgufModelConfig::qwen3_1_7b()` | ~1.3 GB | ✅ | |
26
+| Qwen 2.5 Coder 3B | `GgufModelConfig::qwen25_coder_3b()` | ~1.93 GB | ❌ | |
27
+| Qwen 2.5 Coder 1.5B | `GgufModelConfig::qwen25_coder_1_5b()` | ~941 MB | ❌ | |
28
28
-siGit uses **Qwen 3 4B** by default (set in `main.rs` via `GgufModelConfig::qwen3_4b()`).
29
+siGit uses **Qwen 3 8B** by default with `max_tokens: 8192` (set in `main.rs` for both TUI and ACP modes).
30
+
31
+### Why 8B over 4B
32
+
33
+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.
34
35
### bartowski GGUF naming convention
36
@@ -33,238 +38,242 @@ bartowski's repos use the publisher name as a prefix with an underscore:
38
39
| Constant | Value |
40
|----------|-------|
41
+| `BARTOWSKI_QWEN3_8B_GGUF` | `"bartowski/Qwen_Qwen3-8B-GGUF"` |
42
+| `QWEN3_8B_GGUF_FILE` | `"Qwen_Qwen3-8B-Q4_K_M.gguf"` |
43
| `BARTOWSKI_QWEN3_4B_GGUF` | `"bartowski/Qwen_Qwen3-4B-GGUF"` |
44
| `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"` |
45
46
These constants live in `onde/src/inference/models.rs`.
47
48
---
49
45
-## Architecture
50
+## Tools (9 total)
51
47
-### Layer 1: mistral.rs (model-level)
52
+Defined in `sigit/src/tools.rs` via `all_tools()`:
53
49
-mistral.rs handles the low-level tool calling protocol:
54
+| # | Tool | Parameters | Behavior |
55
+|---|------|-----------|----------|
56
+| 1 | `read_file` | `path` | Reads file contents, truncates at 10,000 chars |
57
+| 2 | `create_directory` | `path` | Creates directory and all parents |
58
+| 3 | `list_directory` | `path` | Lists entries with `[DIR]`/`[FILE]` prefix, dirs first |
59
+| 4 | `search_files` | `pattern`, `path` (optional) | Recursive regex search, max 50 matches |
60
+| 5 | `read_website` | `url` | Fetches HTTP/HTTPS, strips HTML, returns text |
61
+| 6 | `create_file` | `path`, `content` | Creates new file (fails if exists) |
62
+| 7 | `edit_file` | `path`, `old_text`, `new_text` | Find-and-replace (must match exactly once) |
63
+| 8 | `delete_file` | `path` | Deletes file or empty directory |
64
+| 9 | `run_command` | `command`, `cwd` (optional) | Shell command with 120s timeout |
65
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
66
+### Async handling
67
56
-Key types (all re-exported from `mistralrs` crate, accessible via `onde::mistralrs::*`):
68
+`execute_tool()` is `async`. Most tools run synchronously, except:
69
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` |
70
+- **`read_website`** — uses `tokio::task::spawn_blocking` because `reqwest::blocking::Client` panics inside a tokio runtime ("Cannot start a runtime from within a runtime")
71
67
-### Layer 2: onde (engine-level)
72
+### Tool gating by model
73
+
74
+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.
75
+
76
+---
77
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).
78
+## Architecture
79
71
-#### Types (`onde/src/inference/types.rs`)
80
+### Layer 1: mistral.rs (model-level)
81
+
82
+- `RequestBuilder::set_tools(Vec<Tool>)` — attach tool definitions
83
+- `RequestBuilder::set_tool_choice(ToolChoice::Auto)` — let model decide
84
+- `QwenParser` detects `<tool_call>...</tool_call>` tags in output
85
+- Grammar-constrained decoding forces valid JSON inside tool calls
86
+- `<think>...</think>` reasoning is separated from tool calls by the reasoning parser
87
+- Works identically for GGUF and full-precision models
88
+
89
+### Layer 2: onde (engine-level)
90
+
91
+#### Key types (`onde/src/inference/types.rs`)
92
93
| Type | Purpose |
94
|------|---------|
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 |
95
+| `ToolDefinition` | `{ name, description, parameters_schema: String }` |
96
+| `ToolCallRequest` | `{ id, function_name, arguments: String }` |
97
+| `ToolResult` | `{ tool_call_id, content: String }` |
98
+| `ToolAwareResult` | `{ text, tool_calls: Vec<ToolCallRequest>, duration_secs, ... }` |
99
80
-#### Methods (`onde/src/inference/engine.rs`)
100
+#### Key methods (`onde/src/inference/engine.rs`)
101
102
| Method | Purpose |
103
|--------|---------|
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
104
+| `send_message_with_tools(msg, &[ToolDefinition])` | Returns `ToolAwareResult` with possible tool calls |
105
+| `send_tool_results(Vec<ToolResult>, Option<&[ToolDefinition]>)` | Feed results back; `None` forces text response |
106
90
-`LoadedModel.history` uses `Vec<HistoryEntry>` (not `Vec<ChatMessage>`) to support tool-related messages:
107
+#### Internal details
108
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
109
+- `attach_tools()` converts `ToolDefinition` → mistral.rs `Tool`, sets `ToolChoice::Auto` and `strict: Some(true)`
110
+- `parse_tool_calls()` extracts tool calls from `choice.message.tool_calls`, generates fallback IDs if empty
111
+- `replay_history_with_tools()` uses `.enumerate()` for correct sequential `index` values
112
+- Malformed `parameters_schema` JSON logs a warning instead of silently producing empty params
113
+- Malformed tool call `arguments` JSON logs a warning for debugging
114
115
### Layer 3: siGit (agent-level)
116
108
-#### Tool definitions (`sigit/src/tools.rs`)
117
+#### ACP session handling (`src/main.rs`)
118
+
119
+All session handlers (`load_session`, `fork_session`, `new_session`) do:
120
110
-Three coding tools with JSON Schema definitions and execution functions:
121
+1. **Store `args.cwd`** in `session_cwd: Mutex<Option<PathBuf>>`
122
+2. **`std::env::set_current_dir(&args.cwd)`** — so relative paths in tool calls resolve correctly
123
+3. **`engine.clear_history()`** — siGit doesn't persist sessions
124
+4. **`engine.push_history(ChatMessage::system(...))`** — injects: *"The user's project working directory is {cwd}. Always use absolute paths..."*
125
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 |
126
+Without step 4, the model uses the process `cwd` (often `$HOME`) and creates files in the wrong directory.
127
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
128
+#### ACP content block handling (`prompt()`)
129
122
-#### Conversion to onde types
130
+The `prompt()` handler processes all ACP content block types:
131
124
-In both `main.rs` and `chat.rs`, `AgentTool` is converted to `ToolDefinition`:
132
+- **`ContentBlock::Text`** — passed through as-is
133
+- **`ContentBlock::Resource` (EmbeddedResource)** — `TextResourceContents` inlined as `--- {uri} ---\n{text}\n--- end ---`
134
+- **`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
135
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();
136
+Example: Zed sends `@ index.html (207:219)` as:
137
```
138
+ResourceLink(name="index.html (207:219)", uri="file:///path/to/index.html#L207:219")
139
+```
140
+siGit parses this into path `/path/to/index.html` + lines 207–219.
141
142
---
143
144
## The Agentic Loop
145
141
-Both ACP mode (`main.rs` → `SiGitAgent::prompt()`) and TUI mode (`chat.rs` → event loop) implement the same pattern:
146
+Both ACP mode (`SiGitAgent::prompt()`) and TUI mode (`run_inference_task()`) implement:
147
148
```
144
-1. engine.send_message_with_tools(user_text, &tools) → ToolAwareResult
149
+1. engine.send_message_with_tools(user_text, &tools) → ToolAwareResult
150
2. while result.tool_calls is non-empty AND round < MAX_TOOL_ROUNDS (10):
151
a. For each tool_call:
147
- - Show status to user (🔧 tool_name)
148
- - Execute: tools::execute_tool(name, arguments)
152
+ - Log: → tool_name(arguments)
153
+ - Execute: tools::execute_tool(name, arguments).await
154
+ - Log: ← N chars
155
- Collect ToolResult { tool_call_id, content }
156
b. Decide next_tools:
151
- - If round < MAX_TOOL_ROUNDS → Some(&tools) (allow more calls)
152
- - Else → None (force text response)
157
+ - round < MAX_TOOL_ROUNDS → Some(&tools) (allow more calls)
158
+ - else → None (force text response)
159
c. engine.send_tool_results(results, next_tools) → ToolAwareResult
160
3. Send final result.text to user
161
+ - Empty reply after tool rounds → log warning (ACP) or show error (TUI)
162
```
163
157
-### ACP mode specifics (`main.rs`)
164
+---
165
+
166
+## System Prompt
167
159
-- Tool status is sent as `SessionUpdate::AgentMessageChunk` with a 🔧 prefix
160
-- Final text is sent as a single `AgentMessageChunk`
161
-- Returns `StopReason::EndTurn`
168
+The `SYSTEM_PROMPT` in `main.rs` (~122 lines) includes critical instructions:
169
163
-### TUI mode specifics (`chat.rs`)
170
+- **Never tell the user to run commands** — use `run_command` tool instead
171
+- **Can access websites** — use `read_website` tool (overrides RLHF refusal training)
172
+- **Prefer absolute paths** in all tool arguments
173
+- **Git operations** — always use `run_command` with absolute cwd
174
+- **smbCloud domain knowledge** — auth boundaries, deploy flows, project structure
175
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
176
+The session `cwd` is injected as a separate system message at session creation time (not part of the static prompt).
177
178
---
179
172
-## System Prompt
180
+## Model Cache
181
174
-The system prompt in `main.rs` (`SYSTEM_PROMPT`) includes tool-awareness instructions:
182
+Models are stored in the shared Onde App Group container on macOS:
183
184
```
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.
185
+~/Library/Group Containers/group.com.ondeinference.apps/models/hub/
186
```
187
182
-This is critical — without it, the model may not use the tools even when they're available.
188
+`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).
189
190
---
191
192
## Adding a New Tool
193
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
-```
194
+1. Add an `AgentTool` entry to `all_tools()` in `src/tools.rs`
195
+2. Add a match arm to `execute_tool()` — use `spawn_blocking` if the implementation blocks
196
+3. Write `exec_your_tool(arguments: &str) -> String`
197
+4. Update `test_all_tools_count` test (currently expects 9)
198
+
199
+No changes needed in onde or mistral.rs — tool definitions are passed dynamically.
200
+
201
+---
202
+
203
+## Adding a New Model
204
+
205
+1. **`onde/src/inference/models.rs`** — add `pub const` for repo ID and GGUF filename, add to `SUPPORTED_MODELS` array and `SUPPORTED_MODEL_INFO`
206
+2. **`onde/src/inference/engine.rs`** — add `pub fn model_name() -> Self` constructor to `impl GgufModelConfig`
207
+3. **`sigit/src/chat.rs`** — add `ModelOption` entry to `SIGIT_MODELS` with `tool_calling: true/false`
208
+4. **`sigit/src/main.rs`** — update `run_interactive()` and `run_acp_server()` if changing the default
209
210
---
211
234
-## Dependencies
212
+## Debugging
213
236
-### onde (`Cargo.toml`)
214
+### Log locations
215
238
-- `serde_json = "1.0"` — parsing `parameters_schema` JSON strings into `HashMap<String, Value>` for mistral.rs `Function.parameters`
216
+- **TUI mode:** `$TMPDIR/sigit.log` (e.g. `/var/folders/.../sigit.log`)
217
+- **ACP mode (Zed):** `~/Library/Logs/Zed/Zed.log` — grep for `agent stderr:.*sigit`
218
240
-### siGit (`Cargo.toml`)
219
+### Key log patterns
220
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
221
+```
222
+# Model loaded successfully
223
+ChatEngine: model Qwen 3 8B loaded in 6.9s
224
246
----
225
+# Session cwd captured
226
+load_session: id=..., cwd=/path/to/project, additional_directories=[...]
227
248
-## Debugging Tool Calling
228
+# Tool call parsed by mistral.rs
229
+ChatEngine: tool inference END — 12.3s — tool_calls: 1
230
250
-### Model doesn't call tools
231
+# Tool executed
232
+→ read_file({"path":"/absolute/path/to/file.rs"})
233
+← 6506 chars
234
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"*
235
+# Tool result sent back
236
+ChatEngine: tool results inference START — 1 results
237
257
-### Tool calls fail / wrong arguments
238
+# Model returned empty (exhausted max_tokens on thinking)
239
+model returned empty reply after 7 tool round(s)
240
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":"..."})`
241
+# ResourceLink received from Zed
242
+block[1]: ResourceLink(name=index.html (207:219), uri=file:///path/to/index.html#L207:219)
243
263
-### History replay issues
244
+# ResourceLink read failed (fragment not stripped — old bug, now fixed)
245
+could not read ResourceLink file:///path/to/index.html#L207:219: No such file or directory
246
+```
247
+
248
+### Common issues
249
+
250
+| Symptom | Cause | Fix |
251
+|---------|-------|-----|
252
+| Model says "I cannot access websites" | RLHF refusal override not in system prompt | System prompt now has CRITICAL block about `read_website` |
253
+| `0 tool call(s)` for every prompt | Wrong model loaded (Qwen 2.5) | Check log for `loading GGUF model` — must be Qwen 3 |
254
+| `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 |
255
+| Files created in wrong directory | `cwd` not captured from ACP session | Session handlers must call `set_current_dir` + `push_history` with cwd |
256
+| `@ file.html (207:219)` context missing | `#L207:219` fragment not stripped from file path | `prompt()` now parses URI fragments and extracts line ranges |
257
+| `read_website` panics/hangs | `reqwest::blocking` inside tokio runtime | `exec_read_website` wrapped in `spawn_blocking` |
258
+| Empty reply after many tool rounds | Model exhausted `max_tokens` on `<think>` blocks | Set `max_tokens: 8192`; 8B model wastes fewer tokens on thinking |
259
+
260
+---
261
+
262
+## Cargo Dependency Note
263
+
264
+For local development, `sigit/Cargo.toml` must use the path dependency:
265
+
266
+```toml
267
+onde = { path = "../onde" }
268
+```
269
+
270
+For CI/release, switch to the git dependency (after pushing Onde changes):
271
+
272
+```toml
273
+onde = { git = "https://github.com/ondeinference/onde", branch = "development" }
274
+```
275
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
276
+The `qwen3_8b()` constructor only exists in the local Onde SDK until it's pushed to the `development` branch.
277
278
---
279
@@ -272,12 +281,10 @@ fn exec_write_file(arguments: &str) -> String {
281
282
| File | What it does |
283
|------|-------------|
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
284
+| `sigit/src/tools.rs` | 9 tool schemas (`all_tools()`), `execute_tool()` dispatch, all `exec_*` implementations |
285
+| `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` |
286
+| `sigit/src/chat.rs` | `SIGIT_MODELS` array (4 models), `run_inference_task()` with `tools_enabled` gate, TUI tool loop |
287
+| `sigit/src/setup.rs` | HF cache setup pointing to shared App Group container |
288
+| `onde/src/inference/types.rs` | `ToolDefinition`, `ToolCallRequest`, `ToolResult`, `ToolAwareResult` |
289
+| `onde/src/inference/engine.rs` | `send_message_with_tools()`, `send_tool_results()`, `attach_tools()`, `parse_tool_calls()`, `replay_history_with_tools()`, `GgufModelConfig::qwen3_8b()` |
290
+| `onde/src/inference/models.rs` | Model constants and `SUPPORTED_MODELS` array |
\ No newline at end of file
Cargo.lock
+7
-7
@@ -741,9 +741,9 @@ dependencies = [
741
742
[[package]]
743
name = "cc"
744
-version = "1.2.60"
744
+version = "1.2.61"
745
source = "registry+https://github.com/rust-lang/crates.io-index"
746
-checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
746
+checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
747
dependencies = [
748
"find-msvc-tools",
749
"jobserver",
@@ -1264,9 +1264,9 @@ dependencies = [
1264
1265
[[package]]
1266
name = "data-encoding"
1267
-version = "2.10.0"
1267
+version = "2.11.0"
1268
source = "registry+https://github.com/rust-lang/crates.io-index"
1269
-checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
1269
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
1270
1271
[[package]]
1272
name = "defmac"
@@ -3799,7 +3799,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
3799
[[package]]
3800
name = "onde"
3801
version = "0.1.8"
3802
-source = "git+https://github.com/ondeinference/onde?branch=development#f0bb0daad7fb07af951002f1bcae16fbd0bc876d"
3802
+source = "git+https://github.com/ondeinference/onde?branch=development#8321bc566cfbca8ff1d4b71f187f2b007fd98433"
3803
dependencies = [
3804
"anyhow",
3805
"cc",
@@ -4807,9 +4807,9 @@ dependencies = [
4807
4808
[[package]]
4809
name = "rustls-pki-types"
4810
-version = "1.14.0"
4810
+version = "1.14.1"
4811
source = "registry+https://github.com/rust-lang/crates.io-index"
4812
-checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
4812
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
4813
dependencies = [
4814
"web-time",
4815
"zeroize",
src/main.rs
+34
-12
@@ -147,6 +147,15 @@ summarize, or inspect a web page, you MUST call the read_website tool with that
147
URL. Never say \"I cannot access websites\" or \"I cannot browse the internet\". \
148
You can. Use the tool.
149
150
+CRITICAL — before every edit_file call, you MUST call read_file on the target \
151
+file first (or the specific line range if one was given). Never rely on file \
152
+content you saw in a previous turn — the user may have reverted, edited, or \
153
+changed the file externally since then. Always re-read to get the current state \
154
+before constructing old_text. \
155
+When the user corrects a previous edit (e.g. \"don't remove X, append instead\"), \
156
+treat it as a fresh task: re-read the file, identify the current content, and \
157
+plan the edit from scratch. Do not assume the file still reflects your last edit.
158
+
159
Tool-use heuristics:
160
- when the user provides a URL or asks about a web page, ALWAYS call \
161
read_website — never refuse or claim you lack internet access
@@ -596,22 +605,35 @@ impl Agent for SiGitAgent {
605
}
606
607
// ── Send the final text response ─────────────────────────────────
599
- if !result.text.is_empty() {
608
+ let reply_text = result.text.trim().to_string();
609
+
610
+ let final_text = if reply_text.is_empty() {
611
+ if round > 0 {
612
+ log::warn!(
613
+ "prompt({}) — model returned empty reply after {} tool round(s)",
614
+ session_id,
615
+ round
616
+ );
617
+ "Something went wrong — the edits didn't go through. Try rephrasing what you need, or point me at the specific lines.".to_string()
618
+ } else {
619
+ log::warn!(
620
+ "prompt({}) — model returned empty reply (no tool rounds)",
621
+ session_id
622
+ );
623
+ String::new()
624
+ }
625
+ } else {
626
+ reply_text
627
+ };
628
+
629
+ if !final_text.is_empty() {
630
let notification = SessionNotification::new(
631
session_id.clone(),
602
- SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(
603
- result.text,
604
- ))),
632
+ SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(final_text))),
633
);
634
if self.notification_tx.send(notification).await.is_err() {
635
log::warn!("notification channel closed");
636
}
609
- } else if result.tool_calls.is_empty() {
610
- log::warn!(
611
- "prompt({}) — model returned empty reply after {} tool round(s)",
612
- session_id,
613
- round
614
- );
637
}
638
639
log::info!("prompt({}) complete — {} tool round(s)", session_id, round);
@@ -711,7 +733,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
733
let engine = Arc::new(ChatEngine::new());
734
let config = GgufModelConfig::qwen3_8b();
735
let sampling = SamplingConfig {
714
- max_tokens: Some(4096),
736
+ max_tokens: Some(8192),
737
..SamplingConfig::default()
738
};
739
@@ -770,7 +792,7 @@ async fn run_acp_server() -> anyhow::Result<()> {
792
let engine = Arc::new(ChatEngine::new());
793
let config = GgufModelConfig::qwen3_8b();
794
let sampling = SamplingConfig {
773
- max_tokens: Some(4096),
795
+ max_tokens: Some(8192),
796
..SamplingConfig::default()
797
};
798
src/tools.rs
+37
-4
@@ -62,15 +62,24 @@ pub fn all_tools() -> Vec<AgentTool> {
62
AgentTool {
63
name: "read_file",
64
description: "Read the contents of a file at the given path. \
65
- Prefer an absolute path when possible. Returns the file text, \
66
- or an error message if the file cannot be read. Output is \
67
- truncated to 10 000 characters.",
65
+ Prefer an absolute path when possible. Use start_line and \
66
+ end_line to read a specific range instead of the whole file — \
67
+ strongly prefer this when you already know which lines matter. \
68
+ Output is truncated to 10 000 characters.",
69
parameters_schema: json!({
70
"type": "object",
71
"properties": {
72
"path": {
73
"type": "string",
74
"description": "Absolute or relative path to the file to read."
75
+ },
76
+ "start_line": {
77
+ "type": "integer",
78
+ "description": "First line to read (1-based, inclusive). Omit to start from the beginning."
79
+ },
80
+ "end_line": {
81
+ "type": "integer",
82
+ "description": "Last line to read (1-based, inclusive). Omit to read to the end."
83
}
84
},
85
"required": ["path"],
@@ -317,6 +326,15 @@ fn exec_read_file(arguments: &str) -> String {
326
None => return "Error: missing required parameter \"path\"".to_string(),
327
};
328
329
+ let start_line = args
330
+ .get("start_line")
331
+ .and_then(Value::as_u64)
332
+ .map(|n| n as usize);
333
+ let end_line = args
334
+ .get("end_line")
335
+ .and_then(Value::as_u64)
336
+ .map(|n| n as usize);
337
+
338
let path = Path::new(path_str);
339
let absolute_path = absolute_path(path);
340
let absolute_path_str = absolute_path.display().to_string();
@@ -331,7 +349,22 @@ fn exec_read_file(arguments: &str) -> String {
349
350
match fs::read_to_string(&absolute_path) {
351
Ok(contents) => {
334
- if contents.len() > READ_FILE_CHAR_LIMIT {
352
+ if start_line.is_some() || end_line.is_some() {
353
+ let lines: Vec<&str> = contents.lines().collect();
354
+ let total = lines.len();
355
+ let start = start_line.unwrap_or(1).max(1);
356
+ let end = end_line.unwrap_or(total).min(total);
357
+
358
+ if start > total {
359
+ return format!(
360
+ "Error: start_line {start} is beyond end of file ({total} lines)"
361
+ );
362
+ }
363
+
364
+ let selected: Vec<&str> = lines[(start - 1)..end].to_vec();
365
+ let range_text = selected.join("\n");
366
+ format!("Lines {start}-{end} of {total} in {absolute_path_str}:\n{range_text}")
367
+ } else if contents.len() > READ_FILE_CHAR_LIMIT {
368
let truncated: String = contents.chars().take(READ_FILE_CHAR_LIMIT).collect();
369
format!(
370
"{truncated}\n\n--- truncated (showing {READ_FILE_CHAR_LIMIT} of {} characters) ---",