main
name: ai-assisted-coding
description: Build or maintain AI-assisted coding features in Rust using Onde Inference. Use when working on ChatEngine integration, model loading, streaming inference, history management, sampling config, or local coding-agent architecture.
Skill: AI-Assisted Coding Agents — Onde Inference Integration
Overview
Building a local AI coding agent in Rust using Onde Inference as the LLM backend. Onde wraps mistral.rs with a clean API for model loading, history management, and streaming inference across macOS (Metal), iOS, Android, Linux, and Windows.
Crate: onde = "1.1.2" (published on crates.io; siGit pins it in Cargo.toml)
Repo: https://github.com/ondeinference/onde
Docs: https://ondeinference.com
Onde ChatEngine API
Construction and lifecycle
use onde::inference::{ChatEngine, GgufModelConfig, SamplingConfig};
let engine = ChatEngine::new(); // starts unloaded
engine.is_loaded().await // -> bool
engine.unload_model().await // -> ()
Loading a model
// Platform-aware default (Qwen 2.5 3B on macOS, 1.5B on iOS/tvOS/Android)
let config = GgufModelConfig::platform_default();
// Load — blocks until model is in memory and on GPU
engine
.load_gguf_model(
config,
Some("You are a helpful assistant.".to_string()), // system prompt
None, // sampling config (uses SamplingConfig::default() internally)
)
.await?;
// AlreadyLoaded error if called twice — check first:
if !engine.is_loaded().await {
engine.load_gguf_model(...).await?;
}
Model sizes (macOS/Windows/Linux default — Qwen 2.5 3B Q4_K_M): ~1.93 GB
Model sizes (iOS/tvOS/Android default — Qwen 2.5 1.5B Q4_K_M): ~941 MB
First run downloads from HuggingFace Hub into ~/.cache/huggingface/.
Blocking (non-streaming) inference
let result = engine.send_message("What is Rust's ownership model?").await?;
// result: InferenceResult
println!("{}", result.text);
println!("took {}", result.duration_display); // e.g. "3.2s"
send_message appends both the user message and assistant reply to conversation
history automatically.
Streaming inference
let mut rx: tokio::sync::mpsc::Receiver<StreamChunk> =
engine.stream_message("Tell me a story.").await?;
while let Some(chunk) = rx.recv().await {
if !chunk.delta.is_empty() {
print!("{}", chunk.delta); // partial token text
}
if chunk.done {
// chunk.finish_reason: Option<String> — e.g. "stop", "length"
break;
}
}
StreamChunk fields:
- delta: String — the new token(s) in this chunk
- done: bool — true on the last chunk
- finish_reason: Option<String> — present on final chunk only
History is updated automatically after the stream completes.
One-shot generation (no history side-effects)
use onde::inference::ChatMessage;
let result = engine.generate(
vec![ChatMessage::user("Expand: a cat in space")],
Some(SamplingConfig::deterministic()),
).await?;
println!("{}", result.text);
// Does NOT modify conversation history
History management
let history: Vec<ChatMessage> = engine.history().await;
let removed: usize = engine.clear_history().await; // returns count cleared
engine.push_history(ChatMessage::user("context")).await;
engine.set_system_prompt("new system prompt").await;
engine.clear_system_prompt().await;
Engine status
let info: EngineInfo = engine.info().await;
// info.status: EngineStatus (Unloaded | Loading | Ready | Generating | Error)
// info.model_name: Option<String>
// info.approx_memory: Option<String> e.g. "~1.93 GB"
// info.history_length: u64
InferenceError variants
match err {
InferenceError::NoModelLoaded => { /* load model first */ }
InferenceError::AlreadyLoaded { model_name } => { /* already loaded */ }
InferenceError::ModelBuild { reason } => { /* load failure */ }
InferenceError::Inference { reason } => { /* runtime inference error */ }
InferenceError::Cancelled => { /* was cancelled */ }
InferenceError::Other { reason } => { /* unexpected */ }
}
Map to ACP errors:
rust
.map_err(|e| agent_client_protocol::Error::new(-32603, e.to_string()))?
SamplingConfig presets
| Preset | temp | top_p | max_tokens | Use case |
|---|---|---|---|---|
SamplingConfig::default() |
0.7 | 0.95 | 512 | General chat |
SamplingConfig::deterministic() |
0.0 | — | 512 | Code / reproducible |
SamplingConfig::mobile() |
0.7 | 0.95 | 128 | Memory-constrained |
SamplingConfig::coding() |
0.0 | — | 512 | Code generation |
SamplingConfig::coding_mobile() |
0.0 | — | 128 | Code on mobile |
GgufModelConfig constructors
GgufModelConfig::platform_default() // auto-selects based on target_os
GgufModelConfig::qwen25_1_5b() // force 1.5B
GgufModelConfig::qwen25_3b() // force 3B
GgufModelConfig::qwen25_coder_1_5b() // coder variant 1.5B
GgufModelConfig::qwen25_coder_3b() // coder variant 3B
GgufModelConfig::qwen25_coder_7b() // coder variant 7B (tool calling)
GgufModelConfig::qwen3_1_7b() // Qwen 3 1.7B (tool calling)
GgufModelConfig::qwen3_4b() // Qwen 3 4B (tool calling)
GgufModelConfig::qwen3_8b() // Qwen 3 8B (tool calling)
GgufModelConfig::qwen3_14b() // Qwen 3 14B (tool calling)
Only the Qwen 3 family and Qwen 2.5 Coder 7B support tool calling — see the
tool-calling skill. The on-device default is the saved selection, falling back
to platform_default() (Qwen 2.5 3B on macOS).
Adding onde as a Rust library dependency
# In your crate's Cargo.toml — onde is published on crates.io
onde = "1.1.2"
# For local SDK development against a checkout, swap to a path dep:
# onde = { path = "../onde" }
Important: onde declares crate-type = ["lib", "cdylib", "staticlib"].
When used as a Rust library dep, only the lib target is compiled. The
cdylib/staticlib targets (used for Swift/Kotlin FFI) are not built. The
uniffi::setup_scaffolding!() macro generates #[no_mangle] extern "C" symbols
but these are harmless in a binary context.
The [patch.crates-io] in onde's Cargo.toml does NOT propagate to dependents
unless they are in the same workspace. The sysctl patch is only needed for
watchOS; macOS/iOS/Linux work without it.
GPU feature selection is automatic via target_os cfg flags in onde's
Cargo.toml — you get Metal on macOS/iOS without any extra features in your crate.
Patterns for coding agents
Single-engine, multi-session via history reset
For a simple MVP where one session is active at a time:
struct MyAgent {
engine: Arc<ChatEngine>,
active_session: Arc<Mutex<Option<SessionId>>>,
}
// new_session handler:
if self.engine.is_loaded().await {
self.engine.clear_history().await; // reuse model, fresh conversation
} else {
self.engine
.load_gguf_model(GgufModelConfig::platform_default(), Some(SYSTEM_PROMPT.into()), None)
.await?;
}
Why: Loading the model is expensive (seconds + GB of RAM). Reloading for each
session would make the agent feel broken. clear_history() resets context in
microseconds.
Per-session engines (multiple concurrent sessions)
When you need truly isolated parallel sessions:
use std::collections::HashMap;
struct MultiSessionAgent {
sessions: Arc<Mutex<HashMap<String, Arc<ChatEngine>>>>,
}
// new_session: create and load a new engine per session
// prompt: look up session engine, call send_message or stream_message
// CAVEAT: each engine holds a separate model copy in GPU memory — expensive!
Better approach for shared GPU memory: use engine.generate() (no history
side-effects) with an explicitly managed message vec per session.
System prompt design for coding agents
const SYSTEM_PROMPT: &str = "\
You are <AgentName>, an expert AI coding agent integrated into your editor \
via the Agent Client Protocol. You specialize in:
- Code analysis, writing, and refactoring
- Bug hunting and debugging
- Git workflows and commit messages
- Software architecture and design patterns
- Code review and best practices
Be concise, precise, and practical. Write clean, idiomatic code with brief \
explanations. Identify root causes when debugging. Prefer correctness over brevity.";
Key principles: - State the agent's role and name clearly (models respond better to named personas) - List specializations explicitly (influences which parts of training are activated) - Set tone expectations: "concise", "practical", "idiomatic" - Avoid verbose instruction lists — they cost tokens on every turn
Streaming tokens to ACP (connecting onde → ACP)
// In the prompt handler — cx: &ConnectionTo<Client> is passed in by the builder
// (agent-client-protocol 0.13). No mpsc forwarder; send through cx directly.
let mut rx = self.engine.stream_message(user_text).await
.map_err(|e| Error::new(-32603, e.to_string()))?;
while let Some(chunk) = rx.recv().await {
if !chunk.delta.is_empty() {
cx.send_notification(SessionNotification::new(
session_id.clone(),
SessionUpdate::AgentMessageChunk(
ContentChunk::new(ContentBlock::from(chunk.delta)),
),
)).ok(); // .ok() — ignore if the client is gone
}
if chunk.done { break; }
}
Ok(PromptResponse::new(StopReason::EndTurn))
The PromptResponse is returned AFTER the stream finishes. The client receives
streaming tokens via session/update notifications while blocking on the
session/prompt response. See the agent-client-protocol skill for the cx
(ConnectionTo<Client>) model that replaced the old mpsc-channel forwarder.
Note: siGit's actual
handle_promptdoes not stream token-by-token — it runs a tool-calling loop through anInferenceBackendand sends the final text in oneAgentMessageChunk. The streaming pattern above still applies if you want incremental output. See thetool-callingskill for the backend loop.
Extracting text from ACP PromptRequest
ACP prompts can contain text, images, resource links, etc. For a text-only coding agent:
let user_text: String = args.prompt.iter()
.filter_map(|block| match block {
ContentBlock::Text(t) => Some(t.text.as_str()),
// Skip images, resource links, embedded resources for now
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
For resource context (e.g. open files provided by Zed) — note the variant is
TextResourceContents, not Text:
rust
ContentBlock::Resource(r) => match &r.resource {
EmbeddedResourceResource::TextResourceContents(t) => Some(t.text.as_str()),
EmbeddedResourceResource::BlobResourceContents(_) => None,
_ => None,
},
siGit also handles ContentBlock::ResourceLink (a file:// reference it reads
from disk, including #L<start>:<end> line-range fragments). See the
tool-calling skill.
ChatEngine threading model
- Internally uses
Arc<tokio::sync::Mutex<Option<LoadedModel>>>—Send + Sync. - Safe to wrap in
Arc<ChatEngine>and share across tasks. stream_message()spawns atokio::spawnbackground task internally — the mistralrs model must beSend, which it is on all supported platforms.block_in_placetrap:load_gguf_modelcallstokio::task::block_in_placeinternally, which panics unless it's on a multi-threaded runtime worker. Run model loads on a dedicatedstd::threadwith its owntokio::runtime::Runtimeand signal back viaAtomicBool/oneshot. siGit does exactly this in both ACP and TUI modes — see theagent-client-protocolandtool-callingskills.
First-run model download
On first use, onde downloads the GGUF model from HuggingFace Hub:
- Requires internet connectivity
- Cached at ~/.cache/huggingface/ (or HF_HUB_CACHE env var)
- HF_TOKEN env var needed for gated models (public Qwen models don't need it)
- Subsequent runs load from disk cache — fast
For sandboxed environments (iOS, tvOS, Android):
- Set HF_HOME and HF_HUB_CACHE to a path inside the app container
- Do this BEFORE calling any ChatEngine method
- See onde/docs/swift-package.md for setupInferenceEnvironment() pattern
Common mistakes
Calling
load_gguf_modeltwice without checkingis_loaded()first →InferenceError::AlreadyLoaded. Always guard withis_loaded().await.Blocking on the stream after the channel is closed → the stream naturally ends when the
doneflag is true. Don'trecv()afterdone.Losing
StreamChunkdeltas whendeltais empty (whitespace tokens) → always check!chunk.delta.is_empty()before sending to avoid empty notifications that waste bandwidth.Sharing one
ChatEngineacross parallel prompts without coordination → the internal Mutex serializes inference, so concurrent prompts queue up. Design for sequential access per engine instance.Using
SamplingConfig::default()for code generation → preferSamplingConfig::coding()(deterministic, temp=0) for more reliable code output.Forgetting that
generate()doesn't update history — use it for one-shot enhancements (prompt expansion, code review) that shouldn't pollute the main conversation. Usesend_message()/stream_message()for the primary turn loop.