@setoelkahfi / sigit / commits / 221b03c

Add agent tool-calling support with file and directory tools

- Add src/tools.rs with definitions and execution for read_file, list_directory, search_files, create_file, and edit_file tools - Integrate tool-calling agent loop in chat and main - Switch onde dependency to local path for tool API - Update crossterm to 0.28.1 in Cargo.lock - Add detailed SKILL.md documentation for tool calling architecture and usage

Seto Elkahfi committed Apr 13, 2026 at 22:58 UTC 221b03cd011b6f11f9bb2af667f84175f32b809a
6 files changed +1485 -120
.agents/skills/tool-calling/SKILL.md
+283
new file mode 100644 index 0000000..448286b --- /dev/null +++ b/.agents/skills/tool-calling/SKILL.md @@ -0,0 +1,283 @@ +# Skill: Tool Calling in siGit Code + +## 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). + +Tool calling spans three layers: + +``` +siGit (agent loop + tool execution) + → onde (ChatEngine with tool-aware API) + → mistral.rs (model inference + tool call parsing) +``` + +--- + +## Model Requirement + +**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 | ❌ | + +siGit uses **Qwen 3 4B** by default (set in `main.rs` via `GgufModelConfig::qwen3_4b()`). + +### bartowski GGUF naming convention + +bartowski's repos use the publisher name as a prefix with an underscore: + +| Constant | Value | +|----------|-------| +| `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 + +### Layer 1: mistral.rs (model-level) + +mistral.rs handles the low-level tool calling protocol: + +- **`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 + +Key types (all re-exported from `mistralrs` crate, accessible via `onde::mistralrs::*`): + +| 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` | + +### Layer 2: onde (engine-level) + +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). + +#### 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 | + +#### 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 + +`LoadedModel.history` uses `Vec<HistoryEntry>` (not `Vec<ChatMessage>`) to support tool-related messages: + +```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 + +### Layer 3: siGit (agent-level) + +#### Tool definitions (`sigit/src/tools.rs`) + +Three coding tools with JSON Schema definitions and execution functions: + +| 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 | + +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 + +#### Conversion to onde types + +In both `main.rs` and `chat.rs`, `AgentTool` is converted to `ToolDefinition`: + +```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(); +``` + +--- + +## The Agentic Loop + +Both ACP mode (`main.rs` → `SiGitAgent::prompt()`) and TUI mode (`chat.rs` → event loop) implement the same pattern: + +``` +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) + - 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) + c. engine.send_tool_results(results, next_tools) → ToolAwareResult +3. Send final result.text to user +``` + +### ACP mode specifics (`main.rs`) + +- Tool status is sent as `SessionUpdate::AgentMessageChunk` with a 🔧 prefix +- Final text is sent as a single `AgentMessageChunk` +- Returns `StopReason::EndTurn` + +### TUI mode specifics (`chat.rs`) + +- 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 + +--- + +## System Prompt + +The system prompt in `main.rs` (`SYSTEM_PROMPT`) includes tool-awareness instructions: + +``` +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. +``` + +This is critical — without it, the model may not use the tools even when they're available. + +--- + +## 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), + } +} +``` + +--- + +## Dependencies + +### onde (`Cargo.toml`) + +- `serde_json = "1.0"` — parsing `parameters_schema` JSON strings into `HashMap<String, Value>` for mistral.rs `Function.parameters` + +### siGit (`Cargo.toml`) + +- `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 + +--- + +## Debugging Tool Calling + +### Model doesn't call tools + +- 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 calls fail / wrong arguments + +- 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":"..."})` + +### History replay issues + +- 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 + +--- + +## File Map + +| 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
Cargo.lock
+43 -56
index 2c24312..65475f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1056,15 +1056,16 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crossterm" -version = "0.25.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.11.0", "crossterm_winapi", - "libc", - "mio 0.8.11", + "futures-core", + "mio", "parking_lot", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -1072,16 +1073,17 @@ dependencies = [ [[package]] name = "crossterm" -version = "0.28.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags 2.11.0", "crossterm_winapi", - "futures-core", - "mio 1.2.0", + "derive_more", + "document-features", + "mio", "parking_lot", - "rustix 0.38.44", + "rustix 1.1.4", "signal-hook", "signal-hook-mio", "winapi", @@ -1435,6 +1437,15 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dtoa" version = "1.0.11" @@ -3016,6 +3027,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "llguidance" version = "1.7.2" @@ -3270,18 +3287,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.48.0", -] - [[package]] name = "mio" version = "1.2.0" @@ -3297,8 +3302,7 @@ dependencies = [ [[package]] name = "mistralrs" version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb0a83340b4492ebba9760ba6845364de369c7e10c1f91af8733d574c7405fa" +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006" dependencies = [ "anyhow", "candle-core", @@ -3325,8 +3329,7 @@ dependencies = [ [[package]] name = "mistralrs-audio" version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac5d36f634c7c20c45845bc995be3b6387ab01048410386a5b180cfeaf89c72" +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006" dependencies = [ "anyhow", "apodize", @@ -3337,8 +3340,7 @@ dependencies = [ [[package]] name = "mistralrs-core" version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b8b9e5c94491d9ceeded3a30291cb6af6ae74810a5776dd55e3bbb8b3429d4" +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006" dependencies = [ "ahash", "akin", @@ -3435,8 +3437,7 @@ dependencies = [ [[package]] name = "mistralrs-macros" version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aa9b4794322d3f89fe61d21f33f67be494c16d5bd869604b111218f32544d32" +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -3447,8 +3448,7 @@ dependencies = [ [[package]] name = "mistralrs-mcp" version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fa97d4e3189ed80ebbc730b7da5b2356df0ae004ab489066249340c33c089ab" +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006" dependencies = [ "anyhow", "async-trait", @@ -3468,8 +3468,7 @@ dependencies = [ [[package]] name = "mistralrs-paged-attn" version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e53ddf1537426997b46abdadbe6ca8a7ce7668004ed2b7cf00115016558bae8" +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006" dependencies = [ "anyhow", "candle-core", @@ -3485,8 +3484,7 @@ dependencies = [ [[package]] name = "mistralrs-quant" version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "980715493d252e9aaf0779c4c101576d67ef4dd9812bfd77a54987f6f17526f4" +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006" dependencies = [ "byteorder", "candle-core", @@ -3515,8 +3513,7 @@ dependencies = [ [[package]] name = "mistralrs-vision" version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10046a3da2de5b702d3e829b5ddef7aa829ad454819dcc4876dea481a6cb5456" +source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006" dependencies = [ "candle-core", "image", @@ -3830,9 +3827,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "onde" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5436633863ffc31311f529439353ba06a436dd661d2a5d93c8793c3500123a13" +version = "0.1.3" dependencies = [ "anyhow", "cc", @@ -3842,6 +3837,7 @@ dependencies = [ "mistralrs", "mistralrs-core", "serde", + "serde_json", "thiserror 2.0.18", "tokio", "tsync", @@ -5308,6 +5304,8 @@ dependencies = [ "log", "onde", "ratatui", + "regex", + "serde_json", "tokio", "tokio-util", "uuid 1.23.0", @@ -5330,8 +5328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", - "mio 0.8.11", - "mio 1.2.0", + "mio", "signal-hook", ] @@ -6112,7 +6109,7 @@ checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", - "mio 1.2.0", + "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -6292,11 +6289,10 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tqdm" version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b316d5c2ac649ca856dacd487d0ebb94f3b746bada51355d93dd2c007ab62a2e" +source = "git+https://github.com/setoelkahfi/tqdm?branch=deps%2Fbump-crossterm#41e4182829136e6dadbdd1c36af824d19219147a" dependencies = [ "anyhow", - "crossterm 0.25.0", + "crossterm 0.29.0", "once_cell", ] @@ -7273,15 +7269,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0"
Cargo.toml
+3 -1
index 550dc65..16d2304 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" agent-client-protocol = "0.10.4" # Onde Inference engine (local LLM) -onde = "0.1.1" +onde = { path = "../onde" } # Async runtime async-trait = "0.1" @@ -31,4 +31,6 @@ ratatui = { version = "0.29", default-features = false, features = ["crossterm"] anyhow = "1" log = "0.4" env_logger = "0.11" +serde_json = "1" +regex = "1" uuid = { version = "1", features = ["v4"] }
src/chat.rs
+251 -35
index c7bab80..4e4d1ad 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -2,13 +2,19 @@ //! //! Takes over the alternate screen and multiplexes terminal events with //! streaming LLM tokens via `tokio::select!`. +//! +//! Inference runs on a background `tokio::spawn` task so the event loop +//! keeps redrawing while the model thinks. Results flow back through an +//! `mpsc` channel as `InferenceUpdate` variants. use std::future::pending; +use std::sync::Arc; +use std::time::Duration; use anyhow::Result; use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use futures::StreamExt; -use onde::inference::{ChatEngine, StreamChunk}; +use onde::inference::{ChatEngine, StreamChunk, ToolDefinition, ToolResult}; use ratatui::{ Frame, layout::{Constraint, Layout, Position}, @@ -55,6 +61,18 @@ impl ChatMessage { } } +// ── Inference updates from background task ─────────────────────────────────── + +/// Messages sent from the spawned inference task back to the event loop. +enum InferenceUpdate { + /// The model is calling a tool — show its name in the chat. + ToolUse(String), + /// The model produced a final text response. + Response(String), + /// Something went wrong during inference. + Error(String), +} + // ── App state ──────────────────────────────────────────────────────────────── struct App { @@ -64,6 +82,12 @@ struct App { scroll_offset: u16, stream_rx: Option<mpsc::Receiver<StreamChunk>>, stream_buf: String, + /// Channel for receiving results from the background inference task. + inference_rx: Option<mpsc::Receiver<InferenceUpdate>>, + /// True while waiting for inference to finish. + thinking: bool, + /// Counter driving the thinking spinner animation. + thinking_tick: u8, quit: bool, /// Toggled every other tick while streaming — drives the blinking cursor. blink_on: bool, @@ -85,6 +109,9 @@ const BANNER_ART: &str = "\ 55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555 88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888"; +/// Spinner frames for the "thinking" animation. +const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + impl App { fn new() -> Self { let mut messages = Vec::new(); @@ -108,12 +135,20 @@ impl App { scroll_offset: 0, stream_rx: None, stream_buf: String::new(), + inference_rx: None, + thinking: false, + thinking_tick: 0, quit: false, blink_on: true, blink_counter: 0, } } + /// True when either streaming tokens or waiting for inference. + fn is_busy(&self) -> bool { + self.is_streaming() || self.thinking + } + fn is_streaming(&self) -> bool { self.stream_rx.is_some() } @@ -134,6 +169,25 @@ impl App { self.blink_on = self.blink_counter % 4 < 2; } + fn start_thinking(&mut self) { + self.thinking = true; + self.thinking_tick = 0; + } + + fn stop_thinking(&mut self) { + self.thinking = false; + self.inference_rx = None; + } + + fn tick_thinking(&mut self) { + self.thinking_tick = self.thinking_tick.wrapping_add(1); + } + + fn thinking_frame(&self) -> &'static str { + let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len(); + THINKING_FRAMES[idx] + } + /// Total lines the messages area would need (rough estimate for scrolling). fn total_message_lines(&self, width: u16) -> u16 { if width == 0 { @@ -148,6 +202,10 @@ impl App { if !self.stream_buf.is_empty() { lines += wrapped_line_count(&self.stream_buf, Role::Assistant, w); } + // thinking indicator + if self.thinking { + lines += 1; + } lines } @@ -165,7 +223,7 @@ impl App { fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 { let prefix_len = match role { Role::User => 6, // "you > " - Role::Assistant => 7, // "siGit > " — wait, that's 8. Let's just use 7 for "siGit> " + Role::Assistant => 8, // "siGit > " Role::System => 0, }; let effective = if width > prefix_len { @@ -270,7 +328,7 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect let buf_lines: Vec<&str> = app.stream_buf.split('\n').collect(); for (i, segment) in buf_lines.iter().enumerate() { if i > 0 { - lines.push(Line::from(spans.drain(..).collect::<Vec<_>>())); + lines.push(Line::from(std::mem::take(&mut spans))); // continuation lines get no prefix } spans.push(Span::raw(segment.to_string())); @@ -284,6 +342,25 @@ fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect lines.push(Line::from(spans)); } + // thinking indicator (animated spinner) + if app.thinking { + let frame_char = app.thinking_frame(); + lines.push(Line::from(vec![ + Span::styled( + "siGit > ", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{frame_char} thinking…"), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::DIM), + ), + ])); + } + // auto-scroll app.auto_scroll(area.height, area.width); @@ -350,19 +427,21 @@ fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { let block = Block::default() .borders(Borders::TOP) .border_style(Style::default().fg(Color::DarkGray)) - .title(if app.is_streaming() { + .title(if app.thinking { + " thinking… " + } else if app.is_streaming() { " streaming… " } else { " message " }) - .title_style(Style::default().fg(if app.is_streaming() { + .title_style(Style::default().fg(if app.is_busy() { Color::Yellow } else { Color::DarkGray })); let input_text = Paragraph::new(app.input.as_str()) - .style(Style::default().fg(if app.is_streaming() { + .style(Style::default().fg(if app.is_busy() { Color::DarkGray } else { Color::White @@ -372,7 +451,7 @@ fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { frame.render_widget(input_text, area); // place cursor inside the input block (1 for border padding) - if !app.is_streaming() { + if !app.is_busy() { let x = area.x + app.cursor as u16 + 1; let y = area.y + 1; frame.set_cursor_position(Position::new(x.min(area.right().saturating_sub(1)), y)); @@ -380,7 +459,7 @@ fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { } fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { - let hints: &[(&str, &str)] = if app.is_streaming() { + let hints: &[(&str, &str)] = if app.is_busy() { &[("Ctrl+C", "cancel")] } else { &[("Enter", "send"), ("/help", "commands"), ("Ctrl+C", "quit")] @@ -395,12 +474,12 @@ fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { format!(" {key} "), Style::default() .fg(Color::Black) - .bg(Color::DarkGray) + .bg(Color::Gray) .add_modifier(Modifier::BOLD), )); spans.push(Span::styled( format!(" {label}"), - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Gray), )); } @@ -513,19 +592,114 @@ async fn exec_slash(app: &mut App, cmd: SlashCommand, engine: &ChatEngine) { } } +// ── Background inference task ──────────────────────────────────────────────── + +/// Maximum number of tool-calling rounds before forcing a text response. +const MAX_TOOL_ROUNDS: usize = 10; + +/// Build onde `ToolDefinition`s from our agent tools. +fn build_onde_tools() -> Vec<ToolDefinition> { + crate::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() +} + +/// Runs the agentic tool-calling loop on a background task and sends +/// progress updates back through `tx`. +/// +/// The sender is dropped when the task finishes, which the event loop +/// detects as `None` from `rx.recv()`. +async fn run_inference_task( + engine: Arc<ChatEngine>, + text: String, + tx: mpsc::Sender<InferenceUpdate>, +) { + let onde_tools = build_onde_tools(); + + let mut result = match engine.send_message_with_tools(&text, &onde_tools).await { + Ok(r) => r, + Err(err) => { + let _ = tx.send(InferenceUpdate::Error(err.to_string())).await; + return; + } + }; + + let mut round = 0; + + while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS { + round += 1; + log::info!("tool round {} — {} call(s)", round, result.tool_calls.len()); + + let mut tool_results = Vec::new(); + + for tc in &result.tool_calls { + log::info!( + " → {}({})", + tc.function_name, + tc.arguments.chars().take(120).collect::<String>() + ); + + // Notify the UI about the tool call. + let _ = tx + .send(InferenceUpdate::ToolUse(tc.function_name.clone())) + .await; + + // Execute the tool (synchronous / blocking-ok for file I/O). + let output = crate::tools::execute_tool(&tc.function_name, &tc.arguments); + log::info!(" ← {} chars", output.len()); + + tool_results.push(ToolResult { + tool_call_id: tc.id.clone(), + content: output, + }); + } + + // Allow further tool calls unless we've hit the limit. + let next_tools = if round < MAX_TOOL_ROUNDS { + Some(onde_tools.as_slice()) + } else { + None // force a text response on the last round + }; + + match engine.send_tool_results(tool_results, next_tools).await { + Ok(r) => result = r, + Err(err) => { + let _ = tx.send(InferenceUpdate::Error(err.to_string())).await; + return; + } + } + } + + // Send the final text response. + if !result.text.is_empty() && result.tool_calls.is_empty() { + let _ = tx.send(InferenceUpdate::Response(result.text)).await; + } + + log::info!("inference complete — {} tool round(s)", round); + // Sender drops here → event loop sees `None`. +} + // ── Main loop ──────────────────────────────────────────────────────────────── /// Run the interactive chat UI. Blocks until the user quits. /// /// The caller must have already loaded a model into `engine`. -pub async fn run(engine: &ChatEngine) -> Result<()> { +pub async fn run(engine: Arc<ChatEngine>) -> Result<()> { let mut terminal = ratatui::init(); let result = event_loop(&mut terminal, engine).await; ratatui::restore(); result } -async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine) -> Result<()> { +async fn event_loop( + terminal: &mut ratatui::DefaultTerminal, + engine: Arc<ChatEngine>, +) -> Result<()> { let mut app = App::new(); let mut event_stream = EventStream::new(); @@ -537,11 +711,12 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine break; } - // multiplex terminal events and streaming tokens + // multiplex terminal events, streaming tokens, inference updates, + // and the thinking-spinner timer. tokio::select! { biased; - // streaming chunks — only active when we have a receiver + // ── streaming chunks ───────────────────────────────────────── chunk = async { match app.stream_rx.as_mut() { Some(rx) => rx.recv().await, @@ -564,7 +739,45 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine } } - // terminal events + // ── inference updates from background task ─────────────────── + update = async { + match app.inference_rx.as_mut() { + Some(rx) => rx.recv().await, + None => pending().await, + } + } => { + match update { + Some(InferenceUpdate::ToolUse(name)) => { + app.messages.push(ChatMessage::system(format!("🔧 {name}"))); + } + Some(InferenceUpdate::Response(text)) => { + app.stop_thinking(); + app.messages.push(ChatMessage::assistant(text)); + } + Some(InferenceUpdate::Error(msg)) => { + app.stop_thinking(); + app.messages.push(ChatMessage::system(format!("error: {msg}"))); + } + None => { + // Sender dropped — task finished (possibly with no + // text response, e.g. all tool calls with empty final). + app.stop_thinking(); + } + } + } + + // ── thinking spinner tick (100ms) ──────────────────────────── + _ = async { + if app.thinking { + tokio::time::sleep(Duration::from_millis(100)).await + } else { + pending().await + } + } => { + app.tick_thinking(); + } + + // ── terminal events ────────────────────────────────────────── maybe_event = event_stream.next() => { let Some(Ok(event)) = maybe_event else { // stream ended or error — bail @@ -572,14 +785,21 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine }; if let Event::Key(key) = event { - // while streaming, only ctrl+c/d work - if app.is_streaming() { + // While busy (streaming or thinking), only Ctrl+C/D work. + if app.is_busy() { if key.kind == KeyEventKind::Press { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) { - // drop the receiver to stop reading - app.finalize_stream(); - app.messages.push(ChatMessage::system("(cancelled)")); + if app.is_streaming() { + app.finalize_stream(); + app.messages.push(ChatMessage::system("(cancelled)")); + } + if app.thinking { + // Drop the receiver — the background task + // will see a closed channel and stop. + app.stop_thinking(); + app.messages.push(ChatMessage::system("(cancelled)")); + } } } continue; @@ -588,26 +808,22 @@ async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine if let Some(text) = handle_key(&mut app, key) { // check for slash command first if let Some(cmd) = parse_slash(&text) { - exec_slash(&mut app, cmd, engine).await; + exec_slash(&mut app, cmd, &engine).await; continue; } - // regular message — send to engine + // ── Spawn inference on a background task ───────── app.messages.push(ChatMessage::user(&text)); + app.start_thinking(); - match engine.stream_message(text).await { - Ok(rx) => { - app.stream_rx = Some(rx); - app.stream_buf.clear(); - app.blink_counter = 0; - app.blink_on = true; - } - Err(err) => { - app.messages.push(ChatMessage::system(format!( - "error: {err}" - ))); - } - } + let (tx, rx) = mpsc::channel::<InferenceUpdate>(64); + app.inference_rx = Some(rx); + + let engine_handle = Arc::clone(&engine); + let user_text = text.clone(); + tokio::spawn(async move { + run_inference_task(engine_handle, user_text, tx).await; + }); } } }
src/main.rs
+124 -28
index b2e6711..45ff787 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,9 @@ mod chat; mod setup; +mod tools; +use std::fs::File; use std::io::IsTerminal; use std::sync::Arc; @@ -35,7 +37,7 @@ use agent_client_protocol::{ SessionId, SessionNotification, SessionUpdate, StopReason, }; use futures::future::LocalBoxFuture; -use onde::inference::{ChatEngine, GgufModelConfig}; +use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult}; use tokio::sync::{Mutex, mpsc}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; @@ -53,9 +55,28 @@ You help with: - Software architecture and design patterns - Code review +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. + Be direct and brief. Write clean, idiomatic code. When debugging, go for the \ root cause, not the symptom. Correct beats clever."; +/// Maximum number of tool-calling rounds before forcing a text response. +const MAX_TOOL_ROUNDS: usize = 10; + +/// Convert the agent tool definitions into onde's `ToolDefinition` type. +fn agent_tools_as_onde() -> 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() +} + // ── Per-session state ──────────────────────────────────────────────────────── /// One active session at a time. We store the `SessionId` directly (not as a @@ -121,8 +142,9 @@ impl Agent for SiGitAgent { self.engine.clear_history().await; } else { // First session — pull the model (if needed) and load it. - log::info!("loading default model (this may take a minute on first run)..."); - let config = GgufModelConfig::platform_default(); + // Qwen 3 4B is required for tool calling support. + log::info!("loading Qwen 3 4B model (this may take a minute on first run)..."); + let config = GgufModelConfig::qwen3_4b(); self.engine .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), None) .await @@ -176,32 +198,93 @@ impl Agent for SiGitAgent { user_text.chars().take(80).collect::<String>() ); - let mut rx = self + // ── Agentic tool-calling loop ──────────────────────────────────── + // + // 1. Send the user message with tool definitions (non-streaming). + // 2. If the model responds with tool calls, execute them, feed + // results back, and repeat (up to MAX_TOOL_ROUNDS). + // 3. Once the model produces a text response (no tool calls), + // stream it to the editor. + + let onde_tools = agent_tools_as_onde(); + + let mut result = self .engine - .stream_message(user_text) + .send_message_with_tools(&user_text, &onde_tools) .await .map_err(|e| agent_client_protocol::Error::new(-32603, e.to_string()))?; - while let Some(chunk) = rx.recv().await { - if !chunk.delta.is_empty() { - let notification = SessionNotification::new( - session_id.clone(), - SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from( - chunk.delta, - ))), + let mut round = 0; + + while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS { + round += 1; + log::info!( + "prompt({}) tool round {} — {} call(s)", + session_id, + round, + result.tool_calls.len() + ); + + let mut tool_results = Vec::new(); + + for tc in &result.tool_calls { + log::info!( + " → {}({})", + tc.function_name, + tc.arguments.chars().take(120).collect::<String>() ); - // Forwarder gone (client disconnected?) — stop. - if self.notification_tx.send(notification).await.is_err() { - log::warn!("notification channel closed — stopping stream"); - break; - } + + // Notify the editor that we're calling a tool. + let status_text = format!("🔧 `{}`\n", tc.function_name,); + let _ = self + .notification_tx + .send(SessionNotification::new( + session_id.clone(), + SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from( + status_text, + ))), + )) + .await; + + // Execute the tool. + let output = tools::execute_tool(&tc.function_name, &tc.arguments); + + log::info!(" ← {} chars", output.len()); + + tool_results.push(ToolResult { + tool_call_id: tc.id.clone(), + content: output, + }); } - if chunk.done { - break; + + // Decide whether to allow further tool calls. + let next_tools = if round < MAX_TOOL_ROUNDS { + Some(onde_tools.as_slice()) + } else { + None // force a text response on the last round + }; + + result = self + .engine + .send_tool_results(tool_results, next_tools) + .await + .map_err(|e| agent_client_protocol::Error::new(-32603, e.to_string()))?; + } + + // ── Send the final text response ───────────────────────────────── + if !result.text.is_empty() { + let notification = SessionNotification::new( + session_id.clone(), + SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from( + result.text, + ))), + ); + if self.notification_tx.send(notification).await.is_err() { + log::warn!("notification channel closed"); } } - log::info!("prompt({}) complete", session_id); + log::info!("prompt({}) complete — {} tool round(s)", session_id, round); Ok(PromptResponse::new(StopReason::EndTurn)) } @@ -244,8 +327,8 @@ fn print_banner() { async fn run_interactive() -> anyhow::Result<()> { println!(" Loading model..."); - let engine = ChatEngine::new(); - let config = GgufModelConfig::platform_default(); + let engine = Arc::new(ChatEngine::new()); + let config = GgufModelConfig::qwen3_4b(); engine .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), None) .await @@ -258,7 +341,7 @@ async fn run_interactive() -> anyhow::Result<()> { info.approx_memory.as_deref().unwrap_or("?"), ); - chat::run(&engine).await + chat::run(engine).await } // ── ACP server mode ────────────────────────────────────────────────────────── @@ -312,16 +395,29 @@ async fn run_acp_server() -> anyhow::Result<()> { #[tokio::main] async fn main() -> anyhow::Result<()> { - // Logs always go to stderr (stdout is either the TUI or the ACP wire). - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) - .target(env_logger::Target::Stderr) - .init(); + let interactive = std::io::stdin().is_terminal(); + + // In interactive/TUI mode, logs go to a file so they don't scribble over + // the alternate screen buffer. In ACP mode they go to stderr as usual. + if interactive { + let log_file = File::create("sigit.log").unwrap_or_else(|_| { + // Fall back to /tmp if cwd is not writable. + File::create(std::env::temp_dir().join("sigit.log")).expect("cannot open any log file") + }); + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .target(env_logger::Target::Pipe(Box::new(log_file))) + .init(); + } else { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .target(env_logger::Target::Stderr) + .init(); + } // Shared model cache (macOS App Group) — must run before anything // touches hf-hub or ChatEngine. setup::setup_shared_model_cache(); - if std::io::stdin().is_terminal() { + if interactive { // Interactive mode — full-screen chat TUI. print_banner(); run_interactive().await
src/tools.rs
+781
new file mode 100644 index 0000000..241521d --- /dev/null +++ b/src/tools.rs @@ -0,0 +1,781 @@ +//! Tool definitions and execution for the siGit coding agent. +//! +//! Each tool has: +//! - A schema (JSON Schema) that describes its parameters for the LLM +//! - An execution function that runs the tool and returns a string result +//! +//! # Dependencies +//! +//! This module requires `serde_json` and `regex` crates in `Cargo.toml`: +//! ```toml +//! serde_json = "1" +//! regex = "1" +//! ``` +//! +//! # Write Tools +//! +//! - `create_file` — create a new file (fails if it already exists) +//! - `edit_file` — replace an exact old-text span with new text in an existing file + +use regex::Regex; +use serde_json::{Value, json}; +use std::fs; +use std::path::Path; + +/// Maximum characters returned from `read_file` before truncation. +const READ_FILE_CHAR_LIMIT: usize = 10_000; + +/// Maximum number of matching lines returned from `search_files`. +const SEARCH_FILES_MATCH_LIMIT: usize = 50; + +// ── Tool schemas ───────────────────────────────────────────────────────────── + +/// A tool definition with its JSON Schema and metadata for the LLM. +pub struct AgentTool { + /// Machine-readable tool name (e.g. `"read_file"`). + pub name: &'static str, + /// Human-readable description shown to the LLM. + pub description: &'static str, + /// JSON Schema describing the tool's parameters. + pub parameters_schema: Value, +} + +/// Return all available agent tools. +pub fn all_tools() -> Vec<AgentTool> { + vec![ + AgentTool { + name: "read_file", + description: "Read the contents of a file at the given path. \ + Returns the file text, or an error message if the file cannot be read. \ + 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." + } + }, + "required": ["path"], + "additionalProperties": false + }), + }, + AgentTool { + name: "list_directory", + description: "List files and directories at the given path. \ + Each entry is prefixed with [DIR] or [FILE]. \ + Directories are listed first, sorted alphabetically.", + parameters_schema: json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute or relative path to the directory to list." + } + }, + "required": ["path"], + "additionalProperties": false + }), + }, + AgentTool { + name: "search_files", + description: "Search for a regex pattern across files in a directory tree. \ + Returns matching lines in `file:line_number: content` format. \ + Skips binary files and hidden directories. \ + Limited to the first 50 matches.", + parameters_schema: json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regular expression pattern to search for." + }, + "path": { + "type": "string", + "description": "Root directory to search in. Defaults to \".\" (current directory)." + } + }, + "required": ["pattern"], + "additionalProperties": false + }), + }, + AgentTool { + name: "create_file", + description: "Create a new file at the given path with the provided content. \ + Parent directories are created automatically if they do not exist. \ + Fails if the file already exists — use edit_file to modify existing files.", + parameters_schema: json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute or relative path for the new file." + }, + "content": { + "type": "string", + "description": "The full text content to write into the new file." + } + }, + "required": ["path", "content"], + "additionalProperties": false + }), + }, + AgentTool { + name: "edit_file", + description: "Edit an existing file by replacing an exact substring (old_text) with \ + new text (new_text). The old_text must appear exactly once in the file. \ + Use read_file first to see the current content and identify the exact \ + text to replace. To append to a file, match the last few lines as \ + old_text and include them plus the new content as new_text.", + parameters_schema: json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the existing file to edit." + }, + "old_text": { + "type": "string", + "description": "The exact text span to find and replace. Must match exactly once." + }, + "new_text": { + "type": "string", + "description": "The replacement text that will take the place of old_text." + } + }, + "required": ["path", "old_text", "new_text"], + "additionalProperties": false + }), + }, + ] +} + +// ── Tool execution ─────────────────────────────────────────────────────────── + +/// Execute a tool by name with the given JSON arguments string. +/// +/// Returns the tool output as a human-readable string. Errors are returned as +/// descriptive strings rather than panicking. +pub fn execute_tool(name: &str, arguments: &str) -> String { + match name { + "read_file" => exec_read_file(arguments), + "list_directory" => exec_list_directory(arguments), + "search_files" => exec_search_files(arguments), + "create_file" => exec_create_file(arguments), + "edit_file" => exec_edit_file(arguments), + _ => format!("Unknown tool: {name}"), + } +} + +// ── read_file ──────────────────────────────────────────────────────────────── + +/// Read the contents of a single file, truncating at [`READ_FILE_CHAR_LIMIT`]. +fn exec_read_file(arguments: &str) -> String { + let args: Value = match serde_json::from_str(arguments) { + Ok(v) => v, + Err(err) => return format!("Error: failed to parse arguments: {err}"), + }; + + let path_str = match args.get("path").and_then(Value::as_str) { + Some(p) => p, + None => return "Error: missing required parameter \"path\"".to_string(), + }; + + let path = Path::new(path_str); + + if !path.exists() { + return format!("Error: path does not exist: {path_str}"); + } + + if !path.is_file() { + return format!("Error: path is not a file: {path_str}"); + } + + match fs::read_to_string(path) { + Ok(contents) => { + 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) ---", + contents.len() + ) + } else { + contents + } + } + Err(err) => format!("Error: could not read file: {err}"), + } +} + +// ── list_directory ─────────────────────────────────────────────────────────── + +/// List directory entries, directories first, sorted alphabetically. +fn exec_list_directory(arguments: &str) -> String { + let args: Value = match serde_json::from_str(arguments) { + Ok(v) => v, + Err(err) => return format!("Error: failed to parse arguments: {err}"), + }; + + let path_str = match args.get("path").and_then(Value::as_str) { + Some(p) => p, + None => return "Error: missing required parameter \"path\"".to_string(), + }; + + let path = Path::new(path_str); + + if !path.exists() { + return format!("Error: path does not exist: {path_str}"); + } + + if !path.is_dir() { + return format!("Error: path is not a directory: {path_str}"); + } + + let entries = match fs::read_dir(path) { + Ok(rd) => rd, + Err(err) => return format!("Error: could not read directory: {err}"), + }; + + let mut dirs: Vec<String> = Vec::new(); + let mut files: Vec<String> = Vec::new(); + + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(err) => { + files.push(format!("[ERR] {err}")); + continue; + } + }; + + let name = entry.file_name().to_string_lossy().to_string(); + + let is_dir = match entry.file_type() { + Ok(ft) => ft.is_dir(), + Err(_) => false, + }; + + if is_dir { + dirs.push(format!("[DIR] {name}")); + } else { + files.push(format!("[FILE] {name}")); + } + } + + dirs.sort(); + files.sort(); + + // Directories first, then files. + dirs.extend(files); + + if dirs.is_empty() { + return format!("(empty directory: {path_str})"); + } + + dirs.join("\n") +} + +// ── search_files ───────────────────────────────────────────────────────────── + +/// Recursively search files for a regex pattern, returning matching lines. +fn exec_search_files(arguments: &str) -> String { + let args: Value = match serde_json::from_str(arguments) { + Ok(v) => v, + Err(err) => return format!("Error: failed to parse arguments: {err}"), + }; + + let pattern_str = match args.get("pattern").and_then(Value::as_str) { + Some(p) => p, + None => return "Error: missing required parameter \"pattern\"".to_string(), + }; + + let root_str = args.get("path").and_then(Value::as_str).unwrap_or("."); + + let re = match Regex::new(pattern_str) { + Ok(r) => r, + Err(err) => return format!("Error: invalid regex pattern: {err}"), + }; + + let root = Path::new(root_str); + + if !root.exists() { + return format!("Error: path does not exist: {root_str}"); + } + + if !root.is_dir() { + return format!("Error: path is not a directory: {root_str}"); + } + + let mut matches: Vec<String> = Vec::new(); + walk_and_search(root, &re, &mut matches); + + if matches.is_empty() { + return format!("No matches found for pattern: {pattern_str}"); + } + + let total = matches.len(); + if total > SEARCH_FILES_MATCH_LIMIT { + matches.truncate(SEARCH_FILES_MATCH_LIMIT); + matches.push(format!( + "\n--- truncated (showing {SEARCH_FILES_MATCH_LIMIT} of {total} matches) ---" + )); + } + + matches.join("\n") +} + +/// Recursively walk a directory and collect regex matches. +/// +/// Skips hidden directories (names starting with `.`) and binary files. +/// Stops collecting once the match list reaches a generous internal cap (2× +/// the public limit) to avoid unbounded work. +fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) { + // Internal cap to avoid scanning the entire filesystem. + const WALK_CAP: usize = SEARCH_FILES_MATCH_LIMIT * 2; + + let entries = match fs::read_dir(dir) { + Ok(rd) => rd, + Err(_) => return, + }; + + // Collect and sort for deterministic output. + let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect(); + sorted.sort_by_key(|e| e.file_name()); + + for entry in sorted { + if matches.len() >= WALK_CAP { + return; + } + + let path = entry.path(); + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + // Skip hidden entries. + if name_str.starts_with('.') { + continue; + } + + if path.is_dir() { + walk_and_search(&path, re, matches); + } else if path.is_file() { + search_file(&path, re, matches); + } + } +} + +/// Search a single file line-by-line for the regex pattern. +/// +/// Skips files that cannot be read as UTF-8 (assumed binary). +fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) { + let contents = match fs::read_to_string(path) { + Ok(c) => c, + // Skip binary / unreadable files silently. + Err(_) => return, + }; + + let display_path = path.display(); + + for (line_idx, line) in contents.lines().enumerate() { + if re.is_match(line) { + let line_number = line_idx + 1; + matches.push(format!("{display_path}:{line_number}: {line}")); + } + } +} + +// ── create_file ────────────────────────────────────────────────────────────── + +/// Create a new file with the provided content. +/// +/// Parent directories are created automatically. Fails if the file already +/// exists to prevent accidental overwrites — the LLM should use `edit_file` +/// for existing files. +fn exec_create_file(arguments: &str) -> String { + let args: Value = match serde_json::from_str(arguments) { + Ok(v) => v, + Err(err) => return format!("Error: failed to parse arguments: {err}"), + }; + + let path_str = match args.get("path").and_then(Value::as_str) { + Some(p) => p, + None => return "Error: missing required parameter \"path\"".to_string(), + }; + + let content = match args.get("content").and_then(Value::as_str) { + Some(c) => c, + None => return "Error: missing required parameter \"content\"".to_string(), + }; + + let path = Path::new(path_str); + + if path.exists() { + return format!( + "Error: file already exists: {path_str} — use edit_file to modify existing files" + ); + } + + // Create parent directories if needed. + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + && !parent.exists() + && let Err(err) = fs::create_dir_all(parent) + { + return format!("Error: could not create parent directories: {err}"); + } + + match fs::write(path, content) { + Ok(()) => format!("Created file: {path_str} ({} bytes)", content.len()), + Err(err) => format!("Error: could not write file: {err}"), + } +} + +// ── edit_file ──────────────────────────────────────────────────────────────── + +/// Edit an existing file by replacing an exact occurrence of `old_text` with +/// `new_text`. +/// +/// The `old_text` must appear **exactly once** in the file. This prevents +/// ambiguous edits and forces the LLM to read the file first to get the exact +/// text span. +fn exec_edit_file(arguments: &str) -> String { + let args: Value = match serde_json::from_str(arguments) { + Ok(v) => v, + Err(err) => return format!("Error: failed to parse arguments: {err}"), + }; + + let path_str = match args.get("path").and_then(Value::as_str) { + Some(p) => p, + None => return "Error: missing required parameter \"path\"".to_string(), + }; + + let old_text = match args.get("old_text").and_then(Value::as_str) { + Some(t) => t, + None => return "Error: missing required parameter \"old_text\"".to_string(), + }; + + let new_text = match args.get("new_text").and_then(Value::as_str) { + Some(t) => t, + None => return "Error: missing required parameter \"new_text\"".to_string(), + }; + + let path = Path::new(path_str); + + if !path.exists() { + return format!("Error: file does not exist: {path_str} — use create_file for new files"); + } + + if !path.is_file() { + return format!("Error: path is not a file: {path_str}"); + } + + let contents = match fs::read_to_string(path) { + Ok(c) => c, + Err(err) => return format!("Error: could not read file: {err}"), + }; + + // Count occurrences to give a clear error message. + let occurrences = contents.matches(old_text).count(); + + if occurrences == 0 { + return format!( + "Error: old_text not found in {path_str}. \ + Use read_file to see the current content and copy the exact text to replace." + ); + } + + if occurrences > 1 { + return format!( + "Error: old_text appears {occurrences} times in {path_str}. \ + Include more surrounding context in old_text so it matches exactly once." + ); + } + + let updated = contents.replacen(old_text, new_text, 1); + + match fs::write(path, &updated) { + Ok(()) => format!("Edited file: {path_str} ({} bytes written)", updated.len()), + Err(err) => format!("Error: could not write file: {err}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_execute_unknown_tool() { + let result = execute_tool("nonexistent", "{}"); + assert!(result.starts_with("Unknown tool:")); + } + + #[test] + fn test_read_file_missing_path_param() { + let result = exec_read_file("{}"); + assert!(result.contains("missing required parameter")); + } + + #[test] + fn test_read_file_nonexistent() { + let result = exec_read_file(r#"{"path": "/tmp/__sigit_no_such_file_42__"}"#); + assert!(result.contains("does not exist")); + } + + #[test] + fn test_read_file_success() { + let dir = std::env::temp_dir().join("sigit_test_read_file"); + let _ = fs::create_dir_all(&dir); + let file_path = dir.join("hello.txt"); + fs::write(&file_path, "hello world").unwrap(); + + let args = format!(r#"{{"path": "{}"}}"#, file_path.display()); + let result = exec_read_file(&args); + assert_eq!(result, "hello world"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_list_directory_missing_path_param() { + let result = exec_list_directory("{}"); + assert!(result.contains("missing required parameter")); + } + + #[test] + fn test_list_directory_success() { + let dir = std::env::temp_dir().join("sigit_test_list_dir"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("subdir")).unwrap(); + fs::write(dir.join("aaa.txt"), "").unwrap(); + fs::write(dir.join("bbb.rs"), "").unwrap(); + + let args = format!(r#"{{"path": "{}"}}"#, dir.display()); + let result = exec_list_directory(&args); + + assert!(result.contains("[DIR] subdir")); + assert!(result.contains("[FILE] aaa.txt")); + assert!(result.contains("[FILE] bbb.rs")); + + // Directories should appear before files. + let dir_pos = result.find("[DIR]").unwrap(); + let file_pos = result.find("[FILE]").unwrap(); + assert!(dir_pos < file_pos); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_search_files_invalid_regex() { + let result = exec_search_files(r#"{"pattern": "[invalid", "path": "."}"#); + assert!(result.contains("invalid regex")); + } + + #[test] + fn test_search_files_success() { + let dir = std::env::temp_dir().join("sigit_test_search"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("code.rs"), + "fn main() {\n println!(\"hello\");\n}\n", + ) + .unwrap(); + fs::write(dir.join("other.txt"), "no match here\n").unwrap(); + + let args = format!(r#"{{"pattern": "println", "path": "{}"}}"#, dir.display()); + let result = exec_search_files(&args); + + assert!(result.contains("code.rs:2:")); + assert!(result.contains("println")); + assert!(!result.contains("other.txt")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_search_files_no_matches() { + let dir = std::env::temp_dir().join("sigit_test_search_none"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("empty.txt"), "nothing special").unwrap(); + + let args = format!( + r#"{{"pattern": "zzz_will_not_match_42", "path": "{}"}}"#, + dir.display() + ); + let result = exec_search_files(&args); + assert!(result.contains("No matches found")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_all_tools_count() { + let tools = all_tools(); + assert_eq!(tools.len(), 5); + assert_eq!(tools[0].name, "read_file"); + assert_eq!(tools[1].name, "list_directory"); + assert_eq!(tools[2].name, "search_files"); + assert_eq!(tools[3].name, "create_file"); + assert_eq!(tools[4].name, "edit_file"); + } + + #[test] + fn test_all_tools_schemas_are_valid_json_objects() { + for tool in all_tools() { + assert!( + tool.parameters_schema.is_object(), + "schema for {} is not an object", + tool.name + ); + let obj = tool.parameters_schema.as_object().unwrap(); + assert!(obj.contains_key("type")); + assert!(obj.contains_key("properties")); + assert!(obj.contains_key("required")); + } + } + + // ── create_file tests ──────────────────────────────────────────────── + + #[test] + fn test_create_file_missing_path() { + let result = exec_create_file(r#"{"content": "hello"}"#); + assert!(result.contains("missing required parameter")); + } + + #[test] + fn test_create_file_missing_content() { + let result = exec_create_file(r#"{"path": "/tmp/sigit_test_nope.txt"}"#); + assert!(result.contains("missing required parameter")); + } + + #[test] + fn test_create_file_success() { + let dir = std::env::temp_dir().join("sigit_test_create_file"); + let _ = fs::remove_dir_all(&dir); + + let file_path = dir.join("sub").join("new_file.txt"); + let args = format!( + r#"{{"path": "{}", "content": "hello world"}}"#, + file_path.display() + ); + + let result = exec_create_file(&args); + assert!(result.starts_with("Created file:"), "got: {result}"); + assert!(file_path.exists()); + assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello world"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_create_file_already_exists() { + let dir = std::env::temp_dir().join("sigit_test_create_exists"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let file_path = dir.join("existing.txt"); + fs::write(&file_path, "original").unwrap(); + + let args = format!( + r#"{{"path": "{}", "content": "overwrite attempt"}}"#, + file_path.display() + ); + + let result = exec_create_file(&args); + assert!(result.contains("already exists"), "got: {result}"); + // Original content untouched. + assert_eq!(fs::read_to_string(&file_path).unwrap(), "original"); + + let _ = fs::remove_dir_all(&dir); + } + + // ── edit_file tests ────────────────────────────────────────────────── + + #[test] + fn test_edit_file_missing_params() { + let result = exec_edit_file(r#"{"path": "x"}"#); + assert!(result.contains("missing required parameter")); + + let result = exec_edit_file(r#"{"path": "x", "old_text": "a"}"#); + assert!(result.contains("missing required parameter")); + } + + #[test] + fn test_edit_file_nonexistent() { + let result = exec_edit_file( + r#"{"path": "/tmp/__sigit_no_such__", "old_text": "a", "new_text": "b"}"#, + ); + assert!(result.contains("does not exist")); + } + + #[test] + fn test_edit_file_success() { + let dir = std::env::temp_dir().join("sigit_test_edit_file"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let file_path = dir.join("code.rs"); + fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}\n").unwrap(); + + let args = format!( + r#"{{"path": "{}", "old_text": "println!(\"hello\")", "new_text": "println!(\"world\")"}}"#, + file_path.display() + ); + + let result = exec_edit_file(&args); + assert!(result.starts_with("Edited file:"), "got: {result}"); + + let updated = fs::read_to_string(&file_path).unwrap(); + assert!(updated.contains("println!(\"world\")")); + assert!(!updated.contains("println!(\"hello\")")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_edit_file_old_text_not_found() { + let dir = std::env::temp_dir().join("sigit_test_edit_notfound"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let file_path = dir.join("data.txt"); + fs::write(&file_path, "aaa bbb ccc").unwrap(); + + let args = format!( + r#"{{"path": "{}", "old_text": "zzz", "new_text": "yyy"}}"#, + file_path.display() + ); + + let result = exec_edit_file(&args); + assert!(result.contains("old_text not found"), "got: {result}"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn test_edit_file_ambiguous_match() { + let dir = std::env::temp_dir().join("sigit_test_edit_ambiguous"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let file_path = dir.join("repeat.txt"); + fs::write(&file_path, "foo bar foo bar foo").unwrap(); + + let args = format!( + r#"{{"path": "{}", "old_text": "foo", "new_text": "baz"}}"#, + file_path.display() + ); + + let result = exec_edit_file(&args); + assert!(result.contains("appears 3 times"), "got: {result}"); + // File should be unchanged. + assert_eq!( + fs::read_to_string(&file_path).unwrap(), + "foo bar foo bar foo" + ); + + let _ = fs::remove_dir_all(&dir); + } +}