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
-191
.agents/skills/tool-calling/SKILL.md
+176
-168
index 448286b..2994a57 100644
--- a/.agents/skills/tool-calling/SKILL.md
+++ b/.agents/skills/tool-calling/SKILL.md
@@ -2,7 +2,7 @@
## Overview
-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).
+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).
Tool calling spans three layers:
@@ -18,14 +18,19 @@ siGit (agent loop + tool execution)
**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.
-| Model | Constructor | Size | Tool calling |
-|-------|-----------|------|:---:|
-| Qwen 3 4B (Q4_K_M) | `GgufModelConfig::qwen3_4b()` | ~2.6 GB | ✅ |
-| Qwen 3 1.7B (Q4_K_M) | `GgufModelConfig::qwen3_1_7b()` | ~1.2 GB | ✅ |
-| Qwen 2.5 Coder 3B | `GgufModelConfig::qwen25_coder_3b()` | ~1.9 GB | ❌ |
-| Qwen 2.5 1.5B | `GgufModelConfig::qwen25_1_5b()` | ~0.9 GB | ❌ |
+| Model | Constructor | Size | Tool calling | Default |
+|-------|-----------|------|:---:|:---:|
+| Qwen 3 8B (Q4_K_M) | `GgufModelConfig::qwen3_8b()` | ~5 GB | ✅ | ✅ **default** |
+| Qwen 3 4B (Q4_K_M) | `GgufModelConfig::qwen3_4b()` | ~2.7 GB | ✅ | |
+| Qwen 3 1.7B (Q4_K_M) | `GgufModelConfig::qwen3_1_7b()` | ~1.3 GB | ✅ | |
+| Qwen 2.5 Coder 3B | `GgufModelConfig::qwen25_coder_3b()` | ~1.93 GB | ❌ | |
+| Qwen 2.5 Coder 1.5B | `GgufModelConfig::qwen25_coder_1_5b()` | ~941 MB | ❌ | |
-siGit uses **Qwen 3 4B** by default (set in `main.rs` via `GgufModelConfig::qwen3_4b()`).
+siGit uses **Qwen 3 8B** by default with `max_tokens: 8192` (set in `main.rs` for both TUI and ACP modes).
+
+### Why 8B over 4B
+
+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.
### bartowski GGUF naming convention
@@ -33,238 +38,242 @@ bartowski's repos use the publisher name as a prefix with an underscore:
| Constant | Value |
|----------|-------|
+| `BARTOWSKI_QWEN3_8B_GGUF` | `"bartowski/Qwen_Qwen3-8B-GGUF"` |
+| `QWEN3_8B_GGUF_FILE` | `"Qwen_Qwen3-8B-Q4_K_M.gguf"` |
| `BARTOWSKI_QWEN3_4B_GGUF` | `"bartowski/Qwen_Qwen3-4B-GGUF"` |
| `QWEN3_4B_GGUF_FILE` | `"Qwen_Qwen3-4B-Q4_K_M.gguf"` |
-| `BARTOWSKI_QWEN3_1_7B_GGUF` | `"bartowski/Qwen_Qwen3-1.7B-GGUF"` |
-| `QWEN3_1_7B_GGUF_FILE` | `"Qwen_Qwen3-1.7B-Q4_K_M.gguf"` |
These constants live in `onde/src/inference/models.rs`.
---
-## Architecture
+## Tools (9 total)
-### Layer 1: mistral.rs (model-level)
+Defined in `sigit/src/tools.rs` via `all_tools()`:
-mistral.rs handles the low-level tool calling protocol:
+| # | Tool | Parameters | Behavior |
+|---|------|-----------|----------|
+| 1 | `read_file` | `path` | Reads file contents, truncates at 10,000 chars |
+| 2 | `create_directory` | `path` | Creates directory and all parents |
+| 3 | `list_directory` | `path` | Lists entries with `[DIR]`/`[FILE]` prefix, dirs first |
+| 4 | `search_files` | `pattern`, `path` (optional) | Recursive regex search, max 50 matches |
+| 5 | `read_website` | `url` | Fetches HTTP/HTTPS, strips HTML, returns text |
+| 6 | `create_file` | `path`, `content` | Creates new file (fails if exists) |
+| 7 | `edit_file` | `path`, `old_text`, `new_text` | Find-and-replace (must match exactly once) |
+| 8 | `delete_file` | `path` | Deletes file or empty directory |
+| 9 | `run_command` | `command`, `cwd` (optional) | Shell command with 120s timeout |
-- **`RequestBuilder::set_tools(Vec<Tool>)`** — attach tool definitions (JSON Schema) to a request
-- **`RequestBuilder::set_tool_choice(ToolChoice::Auto)`** — let the model decide whether to call tools
-- **`RequestBuilder::add_message_with_tool_call(role, content, tool_calls)`** — replay an assistant message that contained tool calls
-- **`RequestBuilder::add_tool_message(content, tool_call_id)`** — send a tool execution result back
+### Async handling
-Key types (all re-exported from `mistralrs` crate, accessible via `onde::mistralrs::*`):
+`execute_tool()` is `async`. Most tools run synchronously, except:
-| Type | Purpose |
-|------|---------|
-| `Tool` | A tool definition: `{ tp: ToolType::Function, function: Function }` |
-| `Function` | Name, description, JSON Schema parameters, `strict` flag |
-| `ToolChoice` | `None`, `Auto`, or `Tool(...)` |
-| `ToolCallResponse` | Model's tool call: `{ id, function: CalledFunction }` |
-| `CalledFunction` | `{ name, arguments }` where arguments is a JSON string |
-| `ToolCallType` | Currently only `Function` |
+- **`read_website`** — uses `tokio::task::spawn_blocking` because `reqwest::blocking::Client` panics inside a tokio runtime ("Cannot start a runtime from within a runtime")
-### Layer 2: onde (engine-level)
+### Tool gating by model
+
+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.
+
+---
-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).
+## Architecture
-#### Types (`onde/src/inference/types.rs`)
+### Layer 1: mistral.rs (model-level)
+
+- `RequestBuilder::set_tools(Vec<Tool>)` — attach tool definitions
+- `RequestBuilder::set_tool_choice(ToolChoice::Auto)` — let model decide
+- `QwenParser` detects `<tool_call>...</tool_call>` tags in output
+- Grammar-constrained decoding forces valid JSON inside tool calls
+- `<think>...</think>` reasoning is separated from tool calls by the reasoning parser
+- Works identically for GGUF and full-precision models
+
+### Layer 2: onde (engine-level)
+
+#### Key types (`onde/src/inference/types.rs`)
| Type | Purpose |
|------|---------|
-| `ToolDefinition` | `{ name, description, parameters_schema: String }` — tool schema for the model |
-| `ToolCallRequest` | `{ id, function_name, arguments: String }` — parsed tool call from model response |
-| `ToolResult` | `{ tool_call_id, content: String }` — execution result to feed back |
-| `ToolAwareResult` | `{ text, tool_calls: Vec<ToolCallRequest>, duration_secs, ... }` — inference result that may contain tool calls |
+| `ToolDefinition` | `{ name, description, parameters_schema: String }` |
+| `ToolCallRequest` | `{ id, function_name, arguments: String }` |
+| `ToolResult` | `{ tool_call_id, content: String }` |
+| `ToolAwareResult` | `{ text, tool_calls: Vec<ToolCallRequest>, duration_secs, ... }` |
-#### Methods (`onde/src/inference/engine.rs`)
+#### Key methods (`onde/src/inference/engine.rs`)
| Method | Purpose |
|--------|---------|
-| `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. |
-| `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. |
-| `stream_tool_results(&self, Vec<ToolResult>, Option<Vec<ToolDefinition>>)` | Streaming variant for the final text response after tool rounds. |
-
-#### Internal history
+| `send_message_with_tools(msg, &[ToolDefinition])` | Returns `ToolAwareResult` with possible tool calls |
+| `send_tool_results(Vec<ToolResult>, Option<&[ToolDefinition]>)` | Feed results back; `None` forces text response |
-`LoadedModel.history` uses `Vec<HistoryEntry>` (not `Vec<ChatMessage>`) to support tool-related messages:
+#### Internal details
-```rust
-enum HistoryEntry {
- Text(ChatMessage), // regular user/assistant/system
- AssistantToolCall { content, tool_calls }, // assistant response with tool calls
- ToolResult { tool_call_id, content }, // tool execution result
-}
-```
-
-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.
-
-When replaying history in requests:
-- `build_request()` (no tools) — `AssistantToolCall` replays as plain assistant text, `ToolResult` is skipped
-- `build_request_with_tools()` — uses `add_message_with_tool_call()` and `add_tool_message()` for full fidelity
+- `attach_tools()` converts `ToolDefinition` → mistral.rs `Tool`, sets `ToolChoice::Auto` and `strict: Some(true)`
+- `parse_tool_calls()` extracts tool calls from `choice.message.tool_calls`, generates fallback IDs if empty
+- `replay_history_with_tools()` uses `.enumerate()` for correct sequential `index` values
+- Malformed `parameters_schema` JSON logs a warning instead of silently producing empty params
+- Malformed tool call `arguments` JSON logs a warning for debugging
### Layer 3: siGit (agent-level)
-#### Tool definitions (`sigit/src/tools.rs`)
+#### ACP session handling (`src/main.rs`)
+
+All session handlers (`load_session`, `fork_session`, `new_session`) do:
-Three coding tools with JSON Schema definitions and execution functions:
+1. **Store `args.cwd`** in `session_cwd: Mutex<Option<PathBuf>>`
+2. **`std::env::set_current_dir(&args.cwd)`** — so relative paths in tool calls resolve correctly
+3. **`engine.clear_history()`** — siGit doesn't persist sessions
+4. **`engine.push_history(ChatMessage::system(...))`** — injects: *"The user's project working directory is {cwd}. Always use absolute paths..."*
-| Tool | Parameters | Behavior |
-|------|-----------|----------|
-| `read_file` | `path` (required) | Reads file contents, truncates at 10,000 chars |
-| `list_directory` | `path` (required) | Lists entries with `[DIR]`/`[FILE]` prefix, dirs first, sorted |
-| `search_files` | `pattern` (required), `path` (optional) | Recursive regex search, max 50 matches, skips hidden dirs |
+Without step 4, the model uses the process `cwd` (often `$HOME`) and creates files in the wrong directory.
-Public API:
-- `all_tools() -> Vec<AgentTool>` — returns tool schemas (name, description, JSON Schema)
-- `execute_tool(name: &str, arguments: &str) -> String` — dispatches by name, returns result string
+#### ACP content block handling (`prompt()`)
-#### Conversion to onde types
+The `prompt()` handler processes all ACP content block types:
-In both `main.rs` and `chat.rs`, `AgentTool` is converted to `ToolDefinition`:
+- **`ContentBlock::Text`** — passed through as-is
+- **`ContentBlock::Resource` (EmbeddedResource)** — `TextResourceContents` inlined as `--- {uri} ---\n{text}\n--- end ---`
+- **`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
-```rust
-let onde_tools: Vec<ToolDefinition> = tools::all_tools()
- .into_iter()
- .map(|t| ToolDefinition {
- name: t.name.to_string(),
- description: t.description.to_string(),
- parameters_schema: t.parameters_schema.to_string(),
- })
- .collect();
+Example: Zed sends `@ index.html (207:219)` as:
```
+ResourceLink(name="index.html (207:219)", uri="file:///path/to/index.html#L207:219")
+```
+siGit parses this into path `/path/to/index.html` + lines 207–219.
---
## The Agentic Loop
-Both ACP mode (`main.rs` → `SiGitAgent::prompt()`) and TUI mode (`chat.rs` → event loop) implement the same pattern:
+Both ACP mode (`SiGitAgent::prompt()`) and TUI mode (`run_inference_task()`) implement:
```
-1. engine.send_message_with_tools(user_text, &tools) → ToolAwareResult
+1. engine.send_message_with_tools(user_text, &tools) → ToolAwareResult
2. while result.tool_calls is non-empty AND round < MAX_TOOL_ROUNDS (10):
a. For each tool_call:
- - Show status to user (🔧 tool_name)
- - Execute: tools::execute_tool(name, arguments)
+ - Log: → tool_name(arguments)
+ - Execute: tools::execute_tool(name, arguments).await
+ - Log: ← N chars
- Collect ToolResult { tool_call_id, content }
b. Decide next_tools:
- - If round < MAX_TOOL_ROUNDS → Some(&tools) (allow more calls)
- - Else → None (force text response)
+ - round < MAX_TOOL_ROUNDS → Some(&tools) (allow more calls)
+ - else → None (force text response)
c. engine.send_tool_results(results, next_tools) → ToolAwareResult
3. Send final result.text to user
+ - Empty reply after tool rounds → log warning (ACP) or show error (TUI)
```
-### ACP mode specifics (`main.rs`)
+---
+
+## System Prompt
-- Tool status is sent as `SessionUpdate::AgentMessageChunk` with a 🔧 prefix
-- Final text is sent as a single `AgentMessageChunk`
-- Returns `StopReason::EndTurn`
+The `SYSTEM_PROMPT` in `main.rs` (~122 lines) includes critical instructions:
-### TUI mode specifics (`chat.rs`)
+- **Never tell the user to run commands** — use `run_command` tool instead
+- **Can access websites** — use `read_website` tool (overrides RLHF refusal training)
+- **Prefer absolute paths** in all tool arguments
+- **Git operations** — always use `run_command` with absolute cwd
+- **smbCloud domain knowledge** — auth boundaries, deploy flows, project structure
-- Tool status shown as `ChatMessage::system("🔧 tool_name")`
-- Forces a `terminal.draw()` after each tool call for visual feedback
-- Final text added as `ChatMessage::assistant(result.text)`
-- The tool loop is **blocking** (non-streaming) — the TUI doesn't accept input during tool execution
+The session `cwd` is injected as a separate system message at session creation time (not part of the static prompt).
---
-## System Prompt
+## Model Cache
-The system prompt in `main.rs` (`SYSTEM_PROMPT`) includes tool-awareness instructions:
+Models are stored in the shared Onde App Group container on macOS:
```
-You have access to tools that let you read files, list directories, and search
-code. Use them proactively to understand the codebase before answering questions
-or writing code. Always ground your answers in the actual code.
+~/Library/Group Containers/group.com.ondeinference.apps/models/hub/
```
-This is critical — without it, the model may not use the tools even when they're available.
+`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).
---
## Adding a New Tool
-1. **Define the schema** in `sigit/src/tools.rs`:
- - Add an `AgentTool` entry to `all_tools()` with name, description, and JSON Schema
- - The `parameters_schema` must be a valid JSON Schema object with `type`, `properties`, and `required`
-
-2. **Implement execution** in `sigit/src/tools.rs`:
- - Add a case to `execute_tool()` match
- - Write `exec_your_tool(arguments: &str) -> String`
- - Parse arguments with `serde_json::from_str::<Value>(arguments)`
- - Return results as a string, handle errors gracefully (never panic)
-
-3. **No changes needed** in onde or mistral.rs — the tool definitions are passed dynamically via `send_message_with_tools()`.
-
-### Example: adding a `write_file` tool
-
-```rust
-// In all_tools():
-AgentTool {
- name: "write_file",
- description: "Create or overwrite a file with the given content.",
- parameters_schema: json!({
- "type": "object",
- "properties": {
- "path": { "type": "string", "description": "File path to write" },
- "content": { "type": "string", "description": "File content" }
- },
- "required": ["path", "content"]
- }),
-}
-
-// In execute_tool():
-"write_file" => exec_write_file(arguments),
-
-// Implementation:
-fn exec_write_file(arguments: &str) -> String {
- let args: Value = serde_json::from_str(arguments).unwrap_or_default();
- let path = args["path"].as_str().unwrap_or("");
- let content = args["content"].as_str().unwrap_or("");
- match std::fs::write(path, content) {
- Ok(()) => format!("Successfully wrote {} bytes to {}", content.len(), path),
- Err(e) => format!("Error writing {}: {}", path, e),
- }
-}
-```
+1. Add an `AgentTool` entry to `all_tools()` in `src/tools.rs`
+2. Add a match arm to `execute_tool()` — use `spawn_blocking` if the implementation blocks
+3. Write `exec_your_tool(arguments: &str) -> String`
+4. Update `test_all_tools_count` test (currently expects 9)
+
+No changes needed in onde or mistral.rs — tool definitions are passed dynamically.
+
+---
+
+## Adding a New Model
+
+1. **`onde/src/inference/models.rs`** — add `pub const` for repo ID and GGUF filename, add to `SUPPORTED_MODELS` array and `SUPPORTED_MODEL_INFO`
+2. **`onde/src/inference/engine.rs`** — add `pub fn model_name() -> Self` constructor to `impl GgufModelConfig`
+3. **`sigit/src/chat.rs`** — add `ModelOption` entry to `SIGIT_MODELS` with `tool_calling: true/false`
+4. **`sigit/src/main.rs`** — update `run_interactive()` and `run_acp_server()` if changing the default
---
-## Dependencies
+## Debugging
-### onde (`Cargo.toml`)
+### Log locations
-- `serde_json = "1.0"` — parsing `parameters_schema` JSON strings into `HashMap<String, Value>` for mistral.rs `Function.parameters`
+- **TUI mode:** `$TMPDIR/sigit.log` (e.g. `/var/folders/.../sigit.log`)
+- **ACP mode (Zed):** `~/Library/Logs/Zed/Zed.log` — grep for `agent stderr:.*sigit`
-### siGit (`Cargo.toml`)
+### Key log patterns
-- `onde = { path = "../onde" }` — local path dep (required during development for the tool calling API)
-- `serde_json = "1"` — parsing tool call arguments
-- `regex = "1"` — used by the `search_files` tool
+```
+# Model loaded successfully
+ChatEngine: model Qwen 3 8B loaded in 6.9s
----
+# Session cwd captured
+load_session: id=..., cwd=/path/to/project, additional_directories=[...]
-## Debugging Tool Calling
+# Tool call parsed by mistral.rs
+ChatEngine: tool inference END — 12.3s — tool_calls: 1
-### Model doesn't call tools
+# Tool executed
+→ read_file({"path":"/absolute/path/to/file.rs"})
+← 6506 chars
-- Verify the model is Qwen 3 (check `GgufModelConfig::qwen3_4b()` in both `main.rs` load sites)
-- Check the system prompt includes tool-awareness instructions
-- Check logs: `ChatEngine: tool inference END — tool_calls: 0` means the model chose not to use tools
-- Try a more explicit prompt: *"Use the read_file tool to read src/main.rs"*
+# Tool result sent back
+ChatEngine: tool results inference START — 1 results
-### Tool calls fail / wrong arguments
+# Model returned empty (exhausted max_tokens on thinking)
+model returned empty reply after 7 tool round(s)
-- Check `ToolDefinition.parameters_schema` is valid JSON Schema
-- Check `strict: Some(true)` is set in onde's `attach_tools()` (it is by default) — this enables constrained decoding
-- Check logs for the raw arguments: `→ read_file({"path":"..."})`
+# ResourceLink received from Zed
+block[1]: ResourceLink(name=index.html (207:219), uri=file:///path/to/index.html#L207:219)
-### History replay issues
+# ResourceLink read failed (fragment not stripped — old bug, now fixed)
+could not read ResourceLink file:///path/to/index.html#L207:219: No such file or directory
+```
+
+### Common issues
+
+| Symptom | Cause | Fix |
+|---------|-------|-----|
+| Model says "I cannot access websites" | RLHF refusal override not in system prompt | System prompt now has CRITICAL block about `read_website` |
+| `0 tool call(s)` for every prompt | Wrong model loaded (Qwen 2.5) | Check log for `loading GGUF model` — must be Qwen 3 |
+| `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 |
+| Files created in wrong directory | `cwd` not captured from ACP session | Session handlers must call `set_current_dir` + `push_history` with cwd |
+| `@ file.html (207:219)` context missing | `#L207:219` fragment not stripped from file path | `prompt()` now parses URI fragments and extracts line ranges |
+| `read_website` panics/hangs | `reqwest::blocking` inside tokio runtime | `exec_read_website` wrapped in `spawn_blocking` |
+| Empty reply after many tool rounds | Model exhausted `max_tokens` on `<think>` blocks | Set `max_tokens: 8192`; 8B model wastes fewer tokens on thinking |
+
+---
+
+## Cargo Dependency Note
+
+For local development, `sigit/Cargo.toml` must use the path dependency:
+
+```toml
+onde = { path = "../onde" }
+```
+
+For CI/release, switch to the git dependency (after pushing Onde changes):
+
+```toml
+onde = { git = "https://github.com/ondeinference/onde", branch = "development" }
+```
-- Tool call history is replayed via `replay_history_with_tools()` which uses `add_message_with_tool_call()` and `add_tool_message()`
-- If history gets corrupted, `/clear` in the TUI or `engine.clear_history()` resets it
-- The non-tool `build_request()` gracefully degrades: `AssistantToolCall` replays as plain text, `ToolResult` entries are skipped
+The `qwen3_8b()` constructor only exists in the local Onde SDK until it's pushed to the `development` branch.
---
@@ -272,12 +281,10 @@ fn exec_write_file(arguments: &str) -> String {
| File | What it does |
|------|-------------|
-| `onde/src/inference/types.rs` | `ToolDefinition`, `ToolCallRequest`, `ToolResult`, `ToolAwareResult` types |
-| `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`) |
-| `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 |
-| `onde/src/inference/mod.rs` | Re-exports `ToolAwareResult`, `ToolCallRequest`, `ToolDefinition`, `ToolResult` |
-| `sigit/src/tools.rs` | `AgentTool` struct, `all_tools()`, `execute_tool()`, implementations for `read_file`, `list_directory`, `search_files` |
-| `sigit/src/main.rs` | `agent_tools_as_onde()` converter, agentic loop in `SiGitAgent::prompt()`, `MAX_TOOL_ROUNDS` constant, Qwen 3 4B model selection |
-| `sigit/src/chat.rs` | TUI agentic loop (same pattern as ACP), tool status display as system messages |
-| `mistral.rs/docs/TOOL_CALLING.md` | Upstream docs for supported models and API |
-| `mistral.rs/mistralrs/examples/advanced/tools/main.rs` | Reference example for mistral.rs tool calling |
\ No newline at end of file
+| `sigit/src/tools.rs` | 9 tool schemas (`all_tools()`), `execute_tool()` dispatch, all `exec_*` implementations |
+| `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` |
+| `sigit/src/chat.rs` | `SIGIT_MODELS` array (4 models), `run_inference_task()` with `tools_enabled` gate, TUI tool loop |
+| `sigit/src/setup.rs` | HF cache setup pointing to shared App Group container |
+| `onde/src/inference/types.rs` | `ToolDefinition`, `ToolCallRequest`, `ToolResult`, `ToolAwareResult` |
+| `onde/src/inference/engine.rs` | `send_message_with_tools()`, `send_tool_results()`, `attach_tools()`, `parse_tool_calls()`, `replay_history_with_tools()`, `GgufModelConfig::qwen3_8b()` |
+| `onde/src/inference/models.rs` | Model constants and `SUPPORTED_MODELS` array |
\ No newline at end of file
Cargo.lock
+7
-7
index 8c9ad8c..e03df0a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -741,9 +741,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.2.60"
+version = "1.2.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
+checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1264,9 +1264,9 @@ dependencies = [
[[package]]
name = "data-encoding"
-version = "2.10.0"
+version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "defmac"
@@ -3799,7 +3799,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "onde"
version = "0.1.8"
-source = "git+https://github.com/ondeinference/onde?branch=development#f0bb0daad7fb07af951002f1bcae16fbd0bc876d"
+source = "git+https://github.com/ondeinference/onde?branch=development#8321bc566cfbca8ff1d4b71f187f2b007fd98433"
dependencies = [
"anyhow",
"cc",
@@ -4807,9 +4807,9 @@ dependencies = [
[[package]]
name = "rustls-pki-types"
-version = "1.14.0"
+version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
src/main.rs
+34
-12
index be6f66c..fc4bb20 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -147,6 +147,15 @@ summarize, or inspect a web page, you MUST call the read_website tool with that
URL. Never say \"I cannot access websites\" or \"I cannot browse the internet\". \
You can. Use the tool.
+CRITICAL — before every edit_file call, you MUST call read_file on the target \
+file first (or the specific line range if one was given). Never rely on file \
+content you saw in a previous turn — the user may have reverted, edited, or \
+changed the file externally since then. Always re-read to get the current state \
+before constructing old_text. \
+When the user corrects a previous edit (e.g. \"don't remove X, append instead\"), \
+treat it as a fresh task: re-read the file, identify the current content, and \
+plan the edit from scratch. Do not assume the file still reflects your last edit.
+
Tool-use heuristics:
- when the user provides a URL or asks about a web page, ALWAYS call \
read_website — never refuse or claim you lack internet access
@@ -596,22 +605,35 @@ impl Agent for SiGitAgent {
}
// ── Send the final text response ─────────────────────────────────
- if !result.text.is_empty() {
+ let reply_text = result.text.trim().to_string();
+
+ let final_text = if reply_text.is_empty() {
+ if round > 0 {
+ log::warn!(
+ "prompt({}) — model returned empty reply after {} tool round(s)",
+ session_id,
+ round
+ );
+ "Something went wrong — the edits didn't go through. Try rephrasing what you need, or point me at the specific lines.".to_string()
+ } else {
+ log::warn!(
+ "prompt({}) — model returned empty reply (no tool rounds)",
+ session_id
+ );
+ String::new()
+ }
+ } else {
+ reply_text
+ };
+
+ if !final_text.is_empty() {
let notification = SessionNotification::new(
session_id.clone(),
- SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(
- result.text,
- ))),
+ SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(final_text))),
);
if self.notification_tx.send(notification).await.is_err() {
log::warn!("notification channel closed");
}
- } else if result.tool_calls.is_empty() {
- log::warn!(
- "prompt({}) — model returned empty reply after {} tool round(s)",
- session_id,
- round
- );
}
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) ->
let engine = Arc::new(ChatEngine::new());
let config = GgufModelConfig::qwen3_8b();
let sampling = SamplingConfig {
- max_tokens: Some(4096),
+ max_tokens: Some(8192),
..SamplingConfig::default()
};
@@ -770,7 +792,7 @@ async fn run_acp_server() -> anyhow::Result<()> {
let engine = Arc::new(ChatEngine::new());
let config = GgufModelConfig::qwen3_8b();
let sampling = SamplingConfig {
- max_tokens: Some(4096),
+ max_tokens: Some(8192),
..SamplingConfig::default()
};
src/tools.rs
+37
-4
index 816f24d..a2d8a63 100644
--- a/src/tools.rs
+++ b/src/tools.rs
@@ -62,15 +62,24 @@ pub fn all_tools() -> Vec<AgentTool> {
AgentTool {
name: "read_file",
description: "Read the contents of a file at the given path. \
- Prefer an absolute path when possible. Returns the file text, \
- or an error message if the file cannot be read. Output is \
- truncated to 10 000 characters.",
+ Prefer an absolute path when possible. Use start_line and \
+ end_line to read a specific range instead of the whole file — \
+ strongly prefer this when you already know which lines matter. \
+ Output is truncated to 10 000 characters.",
parameters_schema: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute or relative path to the file to read."
+ },
+ "start_line": {
+ "type": "integer",
+ "description": "First line to read (1-based, inclusive). Omit to start from the beginning."
+ },
+ "end_line": {
+ "type": "integer",
+ "description": "Last line to read (1-based, inclusive). Omit to read to the end."
}
},
"required": ["path"],
@@ -317,6 +326,15 @@ fn exec_read_file(arguments: &str) -> String {
None => return "Error: missing required parameter \"path\"".to_string(),
};
+ let start_line = args
+ .get("start_line")
+ .and_then(Value::as_u64)
+ .map(|n| n as usize);
+ let end_line = args
+ .get("end_line")
+ .and_then(Value::as_u64)
+ .map(|n| n as usize);
+
let path = Path::new(path_str);
let absolute_path = absolute_path(path);
let absolute_path_str = absolute_path.display().to_string();
@@ -331,7 +349,22 @@ fn exec_read_file(arguments: &str) -> String {
match fs::read_to_string(&absolute_path) {
Ok(contents) => {
- if contents.len() > READ_FILE_CHAR_LIMIT {
+ if start_line.is_some() || end_line.is_some() {
+ let lines: Vec<&str> = contents.lines().collect();
+ let total = lines.len();
+ let start = start_line.unwrap_or(1).max(1);
+ let end = end_line.unwrap_or(total).min(total);
+
+ if start > total {
+ return format!(
+ "Error: start_line {start} is beyond end of file ({total} lines)"
+ );
+ }
+
+ let selected: Vec<&str> = lines[(start - 1)..end].to_vec();
+ let range_text = selected.join("\n");
+ format!("Lines {start}-{end} of {total} in {absolute_path_str}:\n{range_text}")
+ } else if contents.len() > READ_FILE_CHAR_LIMIT {
let truncated: String = contents.chars().take(READ_FILE_CHAR_LIMIT).collect();
format!(
"{truncated}\n\n--- truncated (showing {READ_FILE_CHAR_LIMIT} of {} characters) ---",