main
name: tool-calling
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.
Skill: Tool Calling in siGit Code
Overview
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 these layers:
siGit (agent loop + tool execution)
→ InferenceBackend (src/backend.rs — LocalBackend or OpenAiBackend/cloud)
→ onde ChatEngine (tool-aware API) ── for LocalBackend
→ mistral.rs (model inference + tool call parsing)
└ OpenAI-compatible HTTP endpoint ── for OpenAiBackend (siGit Code Cloud)
The agent loop talks to an InferenceBackend trait object, not the engine
directly. LocalBackend wraps the on-device ChatEngine; OpenAiBackend calls
a remote OpenAI-compatible endpoint (the siGit Code Cloud tiers). Both implement
send_message_with_tools / send_tool_results, so the loop is identical.
Model Requirement
Tool calling needs a model mistral.rs has a tool-call parser for. The supported
set is the Qwen 3 family (<tool_call>...</tool_call> XML) plus Qwen 2.5
Coder 7B. Plain Qwen 2.5 and the smaller Qwen 2.5 Coder variants do NOT support
tool calling. The authoritative list is is_tool_calling() in src/models.rs.
| Model | Constructor | Size | Tool calling |
|---|---|---|---|
| Qwen 3 14B (Q4_K_M) | GgufModelConfig::qwen3_14b() |
~9 GB | ✅ |
| Qwen 3 8B (Q4_K_M) | GgufModelConfig::qwen3_8b() |
~5 GB | ✅ |
| 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 7B | GgufModelConfig::qwen25_coder_7b() |
~5 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 | ❌ |
| Qwen 2.5 3B / 1.5B | qwen25_3b() / qwen25_1_5b() |
~1.93 GB / ~941 MB | ❌ |
Default model
There is no hardcoded default model. Startup uses the saved selection
(setup::startup_model_selection), then the first complete locally-cached model,
falling back to GgufModelConfig::platform_default() (Qwen 2.5 3B on macOS) when
nothing is cached. The TUI/ACP code in main.rs uses qwen25_3b() as that final
fallback. Users pick a tool-calling model via the /models picker.
max_tokens
max_tokens_for() in src/models.rs gives tool-calling models 4096 tokens
and non-tool models 512 (tool models need headroom because <think> blocks
eat the budget). The TUI startup load in run_interactive overrides this to
8192. Don't assume a single value.
Why prefer 8B+ over 4B for editing
4B struggles with edit_file: it reads a file, then fails to reproduce the exact
old_text it just saw, spiralling into retry rounds that burn max_tokens on
<think> blocks and return nothing. 8B (or larger) lands edits far more reliably.
bartowski GGUF naming convention
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" |
These constants live in onde/src/inference/models.rs.
Tools (9 total)
Defined in sigit/src/tools.rs via all_tools():
| # | 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 |
Async handling
execute_tool() is async. Most tools run synchronously, except:
read_website— usestokio::task::spawn_blockingbecausereqwest::blocking::Clientpanics inside a tokio runtime ("Cannot start a runtime from within a runtime")
Tool gating by model
In TUI mode, run_inference_task() takes a tools_enabled: bool parameter. When the picker item's tool_calling (from models::is_tool_calling) is false, an empty tool list is passed so the model doesn't receive tool schemas it can't use.
In ACP mode, handle_prompt currently always passes the full tool set (agent_tools_as_specs()) regardless of the active model — there is no per-model gate on the ACP path.
Architecture
Layer 1: mistral.rs (model-level)
RequestBuilder::set_tools(Vec<Tool>)— attach tool definitionsRequestBuilder::set_tool_choice(ToolChoice::Auto)— let model decideQwenParserdetects<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 } |
ToolCallRequest |
{ id, function_name, arguments: String } |
ToolResult |
{ tool_call_id, content: String } |
ToolAwareResult |
{ text, tool_calls: Vec<ToolCallRequest>, duration_secs, ... } |
Key methods (onde/src/inference/engine.rs)
| Method | Purpose |
|---|---|
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 |
Layer 2.5: the InferenceBackend abstraction (src/backend.rs)
siGit doesn't call the engine directly from the agent loop — it goes through the
InferenceBackend trait so on-device and cloud inference share one code path:
| Item | Purpose |
|---|---|
trait InferenceBackend |
send_message_with_tools / send_tool_results / is_remote |
LocalBackend |
wraps Arc<ChatEngine> — on-device inference |
OpenAiBackend |
OpenAI-compatible HTTP client — siGit Code Cloud tiers |
ToolSpec |
backend-level tool definition (name, description, parameters_schema) |
ToolCall / ToolResult / TurnResult |
backend-level request/result types |
handle_prompt snapshots self.backend.lock().await.clone() once per turn so a
mid-turn model/tier switch can't split the conversation across backends. When
backend.is_remote() it skips the local model load + readiness wait. Cloud tiers
(fast, balanced, large) come from src/provider.rs and are sign-in gated.
Internal details
attach_tools()convertsToolDefinition→ mistral.rsTool, setsToolChoice::Autoandstrict: Some(true)parse_tool_calls()extracts tool calls fromchoice.message.tool_calls, generates fallback IDs if emptyreplay_history_with_tools()uses.enumerate()for correct sequentialindexvalues- Malformed
parameters_schemaJSON logs a warning instead of silently producing empty params - Malformed tool call
argumentsJSON logs a warning for debugging
Layer 3: siGit (agent-level)
ACP session handling (src/main.rs)
All session handlers (load_session, fork_session, new_session) do:
- Store
args.cwdinsession_cwd: Mutex<Option<PathBuf>> std::env::set_current_dir(&args.cwd)— so relative paths in tool calls resolve correctlyengine.clear_history()— siGit doesn't persist sessionsengine.push_history(ChatMessage::system(...))— injects: "The user's project working directory is {cwd}. Always use absolute paths..."
Without step 4, the model uses the process cwd (often $HOME) and creates files in the wrong directory.
ACP content block handling (prompt())
The prompt() handler processes all ACP content block types:
ContentBlock::Text— passed through as-isContentBlock::Resource(EmbeddedResource) —TextResourceContentsinlined as--- {uri} ---\n{text}\n--- end ---ContentBlock::ResourceLink—file://URIs are read from disk. Line range fragments like#L207:219are parsed: the#fragment is stripped from the path, and only lines 207–219 are extracted and sent to the model
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 (SiGitAgent::handle_prompt()) and TUI mode (run_inference_task())
implement the same loop, driven through the active InferenceBackend:
1. backend.send_message_with_tools(user_text, &tools) → TurnResult
2. while result.tool_calls is non-empty AND round < MAX_TOOL_ROUNDS (10):
a. For each tool_call:
- Log: → tool_name(arguments)
- Execute: tools::execute_tool(name, arguments).await
- Log: ← N chars
- Collect ToolResult { tool_call_id, content }
b. Decide next_tools:
- round < MAX_TOOL_ROUNDS → Some(&tools) (allow more calls)
- else → None (force text response)
c. backend.send_tool_results(results, next_tools) → TurnResult
3. Strip <think> blocks (chat::strip_think_blocks), send final text to user
- Empty reply after tool rounds → log warning (ACP) or show error (TUI)
In ACP mode the final text is sent as one AgentMessageChunk; the tool-calling
loop is not streamed token-by-token.
System Prompt
main.rs defines two prompts, picked by system_prompt_for_model(tool_calling):
SYSTEM_PROMPT(~120 lines) — the full agentic prompt for tool-calling models:- Never tell the user to run commands — use
run_commandtool instead - Can access websites — use
read_websitetool (overrides RLHF refusal training) - Prefer absolute paths in all tool arguments
- Git operations — always use
run_commandwith absolute cwd - Always re-read a file before
edit_file— don't trust stale content - smbCloud domain knowledge — auth boundaries, deploy flows, project structure
- Never tell the user to run commands — use
SIMPLE_SYSTEM_PROMPT— a short prompt for non-tool models; the full one wastes context and confuses them.
The session cwd is injected as a separate system message at session creation time (not part of the static prompt).
Model Cache
Models are stored in the shared Onde App Group container on macOS:
~/Library/Group Containers/group.com.ondeinference.apps/models/hub/
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
- Add an
AgentToolentry toall_tools()insrc/tools.rs - Add a match arm to
execute_tool()— usespawn_blockingif the implementation blocks - Write
exec_your_tool(arguments: &str) -> String - Update
test_all_tools_counttest (currently expects 9)
No changes needed in onde or mistral.rs — tool definitions are passed dynamically.
Adding a New Model
onde/src/inference/models.rs— addpub constfor repo ID and GGUF filename, add toSUPPORTED_MODELSarray andSUPPORTED_MODEL_INFOonde/src/inference/engine.rs— addpub fn model_name() -> Selfconstructor toimpl GgufModelConfigsigit/src/models.rs— add a match arm tomodel_id_to_config()mapping the repo ID to the new constructor; if it supports tool calling, add the repo ID tois_tool_calling()(which also drivesmax_tokens_for()). The picker (build_model_picker_items) then surfaces it automatically.sigit/src/main.rs— only if you're changing the fallback default (qwen25_3b())
Debugging
Log locations
- TUI mode:
$TMPDIR/sigit.log(e.g./var/folders/.../sigit.log) - ACP mode (Zed):
~/Library/Logs/Zed/Zed.log— grep foragent stderr:.*sigit
Key log patterns
# Model loaded successfully
ChatEngine: model Qwen 3 8B loaded in 6.9s
# Session cwd captured
load_session: id=..., cwd=/path/to/project, additional_directories=[...]
# Tool call parsed by mistral.rs
ChatEngine: tool inference END — 12.3s — tool_calls: 1
# Tool executed
→ read_file({"path":"/absolute/path/to/file.rs"})
← 6506 chars
# Tool result sent back
ChatEngine: tool results inference START — 1 results
# Model returned empty (exhausted max_tokens on thinking)
model returned empty reply after 7 tool round(s)
# ResourceLink received from Zed
block[1]: ResourceLink(name=index.html (207:219), uri=file:///path/to/index.html#L207:219)
# 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
onde is published on crates.io; sigit/Cargo.toml pins it:
onde = "1.1.2"
The Qwen 3 / Coder-7B constructors (qwen3_8b(), etc.) ship in that release. For
local SDK development against an onde checkout, swap to a path dep
(onde = { path = "../onde" }) — but the committed form must stay the crates.io
version so CI/release builds resolve.
File Map
| File | What it does |
|---|---|
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 + backend, ACP handlers (cwd + push_history), handle_prompt() content-block parsing + tool loop, MAX_TOOL_ROUNDS, ACP builder wiring |
sigit/src/backend.rs |
InferenceBackend trait, LocalBackend, OpenAiBackend, ToolSpec/ToolCall/ToolResult/TurnResult |
sigit/src/models.rs |
ModelPickerItem, model_id_to_config(), is_tool_calling(), max_tokens_for(), build_model_picker_items() / local_picker_items() |
sigit/src/provider.rs |
CLOUD_TIERS, cloud_tier_provider(), cloud endpoint config |
sigit/src/chat.rs |
TUI app, model picker UI (uses build_model_picker_items), run_inference_task() with tools_enabled gate, TUI tool loop |
sigit/src/setup.rs |
HF cache setup (shared App Group container), startup_model_selection() |
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 |