@setoelkahfi / sigit / commits / 04cacf5

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) ---",