| 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();
engine.is_loaded().await
engine.unload_model().await
Loading a model
let config = GgufModelConfig::platform_default();
engine
.load_gguf_model(
config,
Some("You are a helpful assistant.".to_string()),
None,
)
.await?;
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?;
println!("{}", result.text);
println!("took {}", result.duration_display);
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);
}
if chunk.done {
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);
History management
let history: Vec<ChatMessage> = engine.history().await;
let removed: usize = engine.clear_history().await;
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;
InferenceError variants
match err {
InferenceError::NoModelLoaded => { }
InferenceError::AlreadyLoaded { model_name } => { }
InferenceError::ModelBuild { reason } => { }
InferenceError::Inference { reason } => { }
InferenceError::Cancelled => { }
InferenceError::Other { reason } => { }
}
Map to ACP errors:
.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()
GgufModelConfig::qwen25_1_5b()
GgufModelConfig::qwen25_3b()
GgufModelConfig::qwen25_coder_1_5b()
GgufModelConfig::qwen25_coder_3b()
GgufModelConfig::qwen25_coder_7b()
GgufModelConfig::qwen3_1_7b()
GgufModelConfig::qwen3_4b()
GgufModelConfig::qwen3_8b()
GgufModelConfig::qwen3_14b()
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
onde = "1.1.2"
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>>>,
}
if self.engine.is_loaded().await {
self.engine.clear_history().await;
} 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>>>>,
}
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)
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();
}
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_prompt does not stream token-by-token — it
runs a tool-calling loop through an InferenceBackend and sends the final text
in one AgentMessageChunk. The streaming pattern above still applies if you
want incremental output. See the tool-calling skill 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()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
For resource context (e.g. open files provided by Zed) — note the variant is
TextResourceContents, not Text:
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 a tokio::spawn background task internally — the
mistralrs model must be Send, which it is on all supported platforms.
block_in_place trap: load_gguf_model calls tokio::task::block_in_place
internally, which panics unless it's on a multi-threaded runtime worker. Run
model loads on a dedicated std::thread with its own tokio::runtime::Runtime
and signal back via AtomicBool/oneshot. siGit does exactly this in both ACP
and TUI modes — see the agent-client-protocol and tool-calling skills.
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_model twice without checking is_loaded() first →
InferenceError::AlreadyLoaded. Always guard with is_loaded().await.
-
Blocking on the stream after the channel is closed → the stream naturally
ends when the done flag is true. Don't recv() after done.
-
Losing StreamChunk deltas when delta is empty (whitespace tokens) →
always check !chunk.delta.is_empty() before sending to avoid empty
notifications that waste bandwidth.
-
Sharing one ChatEngine across 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 → prefer
SamplingConfig::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. Use send_message() / stream_message() for the
primary turn loop.