Switch to local onde dependency and make tool execution async
paydii committed
Apr 24, 2026 at 22:02 UTC
084eb04ebef3e88270cb47255a2fcdd05d14289a
5 files changed
+59
-17
Cargo.lock
-1
index 8c9ad8c..e278d30 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3799,7 +3799,6 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "onde"
version = "0.1.8"
-source = "git+https://github.com/ondeinference/onde?branch=development#f0bb0daad7fb07af951002f1bcae16fbd0bc876d"
dependencies = [
"anyhow",
"cc",
Cargo.toml
+2
-2
index 99e2b10..cc33162 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -21,8 +21,8 @@ path = "src/main.rs"
agent-client-protocol = { version = "0.10.4", features = ["unstable_session_fork"] }
# Onde Inference engine (local LLM)
-# For local development: onde = { path = "../onde" }
-onde = { git = "https://github.com/ondeinference/onde", branch = "development" }
+onde = { path = "../onde" }
+# onde = { git = "https://github.com/ondeinference/onde", branch = "development" }
# Async runtime
async-trait = "0.1"
src/chat.rs
+20
-5
index 37108bb..b44b4d5 100644
--- a/src/chat.rs
+++ b/src/chat.rs
@@ -120,6 +120,8 @@ struct App {
load_start: Instant,
/// Display name of the model being loaded (shown in the spinner line).
load_model_name: String,
+ /// Whether the currently loaded model supports tool calling.
+ tool_calling: bool,
}
const BANNER_ART: &str = "\
@@ -142,6 +144,11 @@ const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "
impl App {
fn new(load_model_name: String) -> Self {
+ let tool_calling = SIGIT_MODELS
+ .iter()
+ .find(|m| m.name == load_model_name)
+ .map(|m| m.tool_calling)
+ .unwrap_or(true);
Self {
messages: Vec::new(),
input: String::new(),
@@ -160,6 +167,7 @@ impl App {
load_error: None,
load_start: Instant::now(),
load_model_name,
+ tool_calling,
}
}
@@ -840,6 +848,7 @@ async fn exec_slash<B: ratatui::backend::Backend>(
match engine.load_gguf_model(config, None, Some(sampling)).await {
Ok(_) => {
engine.clear_history().await;
+ app.tool_calling = model.tool_calling;
app.messages.push(ChatMessage::system(format!(
"✓ Switched to {}",
model.name
@@ -892,8 +901,13 @@ async fn run_inference_task(
engine: Arc<ChatEngine>,
text: String,
tx: mpsc::Sender<InferenceUpdate>,
+ tools_enabled: bool,
) {
- let onde_tools = build_onde_tools();
+ let onde_tools = if tools_enabled {
+ build_onde_tools()
+ } else {
+ vec![]
+ };
let mut result = match engine.send_message_with_tools(&text, &onde_tools).await {
Ok(r) => r,
@@ -923,8 +937,8 @@ async fn run_inference_task(
.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);
+ // Execute the tool (async — read_website uses spawn_blocking internally).
+ let output = crate::tools::execute_tool(&tc.function_name, &tc.arguments).await;
log::info!(" ← {} chars", output.len());
tool_results.push(ToolResult {
@@ -985,7 +999,7 @@ pub async fn run_with<B: ratatui::backend::Backend>(
engine: Arc<ChatEngine>,
load_rx: std_mpsc::Receiver<Result<(), String>>,
) -> Result<()> {
- let config = GgufModelConfig::platform_default();
+ let config = GgufModelConfig::qwen3_4b();
let model_name = config.display_name.clone();
event_loop(terminal, engine, load_rx, model_name).await
}
@@ -1149,8 +1163,9 @@ async fn event_loop<B: ratatui::backend::Backend>(
let engine_handle = Arc::clone(&engine);
let user_text = text.clone();
+ let tools_enabled = app.tool_calling;
tokio::spawn(async move {
- run_inference_task(engine_handle, user_text, tx).await;
+ run_inference_task(engine_handle, user_text, tx, tools_enabled).await;
});
}
}
src/main.rs
+24
-4
index 381b551..f1fbff5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -139,7 +139,16 @@ protocol-facing output and tool arguments. Create directories when needed. Run \
builds, tests, and git commands after making changes. Ground your answers in \
the actual code or fetched page content, not in guesses.
+CRITICAL — you CAN access websites. You are NOT a typical LLM without internet \
+access. You have a read_website tool that fetches any http or https URL and \
+returns the page text. When the user gives you a URL or asks you to read, \
+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.
+
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
- prefer absolute paths over relative paths when you mention, return, or pass \
file and directory paths
- if a path does not exist yet, create the directory before creating files in it
@@ -338,8 +347,8 @@ impl Agent for SiGitAgent {
tc.arguments.chars().take(120).collect::<String>()
);
- // Execute the tool.
- let output = tools::execute_tool(&tc.function_name, &tc.arguments);
+ // Execute the tool (async — read_website uses spawn_blocking internally).
+ let output = tools::execute_tool(&tc.function_name, &tc.arguments).await;
log::info!(" ← {} chars", output.len());
@@ -374,6 +383,12 @@ impl Agent for SiGitAgent {
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);
@@ -471,7 +486,11 @@ fn init_logging(is_tty: bool) {
#[cfg(unix)]
async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) -> anyhow::Result<()> {
let engine = Arc::new(ChatEngine::new());
- let config = GgufModelConfig::platform_default();
+ let config = GgufModelConfig::qwen3_4b();
+ let sampling = SamplingConfig {
+ max_tokens: Some(4096),
+ ..SamplingConfig::default()
+ };
// std::sync::mpsc — the loader runs on a dedicated OS thread, completely
// decoupled from the tokio runtime so it can't starve the TUI draw loop.
@@ -481,7 +500,8 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
let system_prompt = SYSTEM_PROMPT.to_string();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("failed to create loader runtime");
- let result = rt.block_on(loader_engine.load_gguf_model(config, Some(system_prompt), None));
+ let result =
+ rt.block_on(loader_engine.load_gguf_model(config, Some(system_prompt), Some(sampling)));
let _ = load_tx.send(result.map(|_| ()).map_err(|e| e.to_string()));
});
src/tools.rs
+13
-5
index 7dc122e..116479a 100644
--- a/src/tools.rs
+++ b/src/tools.rs
@@ -266,12 +266,20 @@ pub fn all_tools() -> Vec<AgentTool> {
///
/// 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 {
+pub async 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),
- "read_website" => exec_read_website(arguments),
+ "read_website" => {
+ // reqwest::blocking panics if called inside a tokio runtime
+ // ("Cannot start a runtime from within a runtime"), so we
+ // off-load it to the blocking thread pool.
+ let args = arguments.to_owned();
+ tokio::task::spawn_blocking(move || exec_read_website(&args))
+ .await
+ .unwrap_or_else(|err| format!("Error: read_website task failed: {err}"))
+ }
"create_directory" => exec_create_directory(arguments),
"create_file" => exec_create_file(arguments),
"edit_file" => exec_edit_file(arguments),
@@ -949,9 +957,9 @@ mod tests {
use super::*;
use std::fs;
- #[test]
- fn test_execute_unknown_tool() {
- let result = execute_tool("nonexistent", "{}");
+ #[tokio::test]
+ async fn test_execute_unknown_tool() {
+ let result = execute_tool("nonexistent", "{}").await;
assert!(result.starts_with("Unknown tool:"));
}