From 5969eea2c98108155e530f2ea93ee303ad0051c8 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:12:50 +0200 Subject: [PATCH 01/28] ed-agent: streaming, history restore, one-shot generate, SessionStore Ed::stream gives plain chat a stream of text deltas, and Ed::run forwards deltas to the new EventSink::text_delta (a no-op by default, so existing sinks still compile). Adds restore_history, generate, the system prompt and sampling setters, and the SessionStore trait as the seam for host storage. The FFI exposes the same calls, plus stream_message with cancel_stream and an app-id constructor. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: siGit Code v1.6.3 --- Cargo.lock | 2 + crates/ed-agent-ffi/Cargo.toml | 1 + crates/ed-agent-ffi/src/lib.rs | 141 +++++++++++++++++++++++++- crates/ed-agent/Cargo.toml | 1 + crates/ed-agent/src/ed.rs | 145 ++++++++++++++++++++++++--- crates/ed-agent/src/lib.rs | 7 +- crates/ed-agent/src/session.rs | 178 +++++++++++++++++++++++++++++++++ crates/ed-agent/src/sink.rs | 23 +++++ crates/ed-agent/src/stream.rs | 117 ++++++++++++++++++++++ 9 files changed, 595 insertions(+), 20 deletions(-) create mode 100644 crates/ed-agent/src/session.rs create mode 100644 crates/ed-agent/src/stream.rs diff --git a/Cargo.lock b/Cargo.lock index 93a807a..8bb020c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1731,6 +1731,7 @@ name = "ed-agent" version = "1.0.1" dependencies = [ "async-trait", + "futures-core", "log", "onde", "serde", @@ -1750,6 +1751,7 @@ dependencies = [ "serde_json", "smbcloud-gresiq-sdk", "thiserror 2.0.21", + "tokio", "uniffi", ] diff --git a/crates/ed-agent-ffi/Cargo.toml b/crates/ed-agent-ffi/Cargo.toml index 0fcee5f..a94715c 100644 --- a/crates/ed-agent-ffi/Cargo.toml +++ b/crates/ed-agent-ffi/Cargo.toml @@ -18,6 +18,7 @@ crate-type = ["lib", "cdylib", "staticlib"] ed-agent.workspace = true onde.workspace = true async-trait.workspace = true +tokio.workspace = true serde_json.workspace = true thiserror.workspace = true uniffi.workspace = true diff --git a/crates/ed-agent-ffi/src/lib.rs b/crates/ed-agent-ffi/src/lib.rs index 230503c..241774c 100644 --- a/crates/ed-agent-ffi/src/lib.rs +++ b/crates/ed-agent-ffi/src/lib.rs @@ -216,6 +216,20 @@ impl From for FfiChatMessage { } } +impl From for ChatMessage { + fn from(value: FfiChatMessage) -> Self { + let role = match value.role { + FfiChatRole::System => ed_agent::ChatRole::System, + FfiChatRole::User => ed_agent::ChatRole::User, + FfiChatRole::Assistant => ed_agent::ChatRole::Assistant, + }; + Self { + role, + content: value.content, + } + } +} + #[derive(Debug, Clone, uniffi::Record)] pub struct FfiSamplingConfig { pub temperature: Option, @@ -333,6 +347,8 @@ pub trait FfiApprovalHandler: Send + Sync { #[uniffi::export(callback_interface)] pub trait FfiEventListener: Send + Sync { fn status_changed(&self, update: FfiStatusUpdate); + /// A piece of the reply as it is generated, during `run`. + fn text_delta(&self, delta: String); fn tool_requested(&self, call: FfiToolCall); fn approval_requested(&self, request: FfiApprovalRequest); fn tool_started(&self, call: FfiToolCall); @@ -341,6 +357,12 @@ pub trait FfiEventListener: Send + Sync { fn warning(&self, message: String); } +/// Receives a streamed reply from `stream_message`. +#[uniffi::export(callback_interface)] +pub trait FfiStreamListener: Send + Sync { + fn on_delta(&self, delta: String); +} + struct ForeignExecutor(Arc); #[async_trait] @@ -378,6 +400,10 @@ impl EventSink for FfiSink { }); } + fn text_delta(&self, delta: &str) { + self.0.text_delta(delta.to_owned()); + } + fn tool_requested(&self, call: &ToolCall) { self.0.tool_requested(call.into()); } @@ -410,20 +436,21 @@ impl EventSink for FfiSink { #[derive(uniffi::Object)] pub struct FfiEdAgent { inner: Ed, + /// Ends the stream in progress, if any. + stream_cancel: std::sync::Mutex>>, } -#[uniffi::export(async_runtime = "tokio")] impl FfiEdAgent { - #[uniffi::constructor] - pub fn new( + fn build( executor: Box, approvals: Box, events: Box, config: Option, + app_id: Option, ) -> Arc { let config = config.unwrap_or_default(); Arc::new(Self { - inner: Ed::with_agent( + inner: Ed::with_agent_and_app_id( FfiSink(Arc::from(events)), Arc::new(ForeignExecutor(Arc::from(executor))), Arc::new(ForeignApprovals(Arc::from(approvals))), @@ -432,9 +459,36 @@ impl FfiEdAgent { max_tool_output_chars: usize::try_from(config.max_tool_output_chars) .unwrap_or(usize::MAX), }, + app_id, ), + stream_cancel: std::sync::Mutex::new(None), }) } +} + +#[uniffi::export(async_runtime = "tokio")] +impl FfiEdAgent { + #[uniffi::constructor] + pub fn new( + executor: Box, + approvals: Box, + events: Box, + config: Option, + ) -> Arc { + Self::build(executor, approvals, events, config, None) + } + + /// Like `new`, with an Onde app id so the engine reports usage under it. + #[uniffi::constructor] + pub fn new_with_app_id( + executor: Box, + approvals: Box, + events: Box, + config: Option, + app_id: Option, + ) -> Arc { + Self::build(executor, approvals, events, config, app_id) + } pub async fn register_tool(&self, tool: FfiToolDefinition) -> Result<(), FfiEdError> { self.inner @@ -556,6 +610,85 @@ impl FfiEdAgent { } } + /// Stream the reply to `message` into `listener`, for plain chat without tools. + /// Returns when the reply is complete, on error, or after `cancel_stream`. + /// The turn so far stays in history either way. + pub async fn stream_message( + &self, + message: String, + listener: Box, + ) -> Result<(), FfiEdError> { + let mut stream = self + .inner + .stream(message) + .await + .map_err(|error| FfiEdError::Failure { + reason: error.to_string(), + })?; + let (cancel, mut cancelled) = tokio::sync::oneshot::channel(); + *self.stream_cancel.lock().unwrap_or_else(|e| e.into_inner()) = Some(cancel); + loop { + tokio::select! { + next = stream.next() => match next { + Some(Ok(delta)) => listener.on_delta(delta), + Some(Err(error)) => { + return Err(FfiEdError::Failure { reason: error.to_string() }); + } + None => return Ok(()), + }, + _ = &mut cancelled => return Ok(()), + } + } + } + + /// Stop the `stream_message` in progress. Does nothing when none is. + pub fn cancel_stream(&self) { + if let Some(cancel) = self + .stream_cancel + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + { + let _ = cancel.send(()); + } + } + + /// One-shot generation over `messages`. Leaves the conversation untouched. + pub async fn generate( + &self, + messages: Vec, + sampling: Option, + ) -> Result { + self.inner + .generate( + messages.into_iter().map(Into::into).collect(), + sampling.map(Into::into), + ) + .await + .map_err(|error| FfiEdError::Failure { + reason: error.to_string(), + }) + } + + /// Replace the conversation with `messages`. Needs a loaded model. + pub async fn restore_history(&self, messages: Vec) { + self.inner + .restore_history(messages.into_iter().map(Into::into).collect()) + .await; + } + + pub async fn set_system_prompt(&self, prompt: String) { + self.inner.set_system_prompt(prompt).await; + } + + pub async fn clear_system_prompt(&self) { + self.inner.clear_system_prompt().await; + } + + pub async fn set_sampling(&self, sampling: FfiSamplingConfig) { + self.inner.set_sampling(sampling.into()).await; + } + pub fn cancel(&self) { self.inner.cancel(); } diff --git a/crates/ed-agent/Cargo.toml b/crates/ed-agent/Cargo.toml index aaeb0aa..e7cf2d5 100644 --- a/crates/ed-agent/Cargo.toml +++ b/crates/ed-agent/Cargo.toml @@ -19,6 +19,7 @@ serde_json.workspace = true log.workspace = true thiserror.workspace = true tokio.workspace = true +futures-core = "0.3" [dev-dependencies] tokio = { version = "1", features = ["macros", "rt", "sync"] } diff --git a/crates/ed-agent/src/ed.rs b/crates/ed-agent/src/ed.rs index 6d4e571..1860237 100644 --- a/crates/ed-agent/src/ed.rs +++ b/crates/ed-agent/src/ed.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use std::future::Future; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use log::{error, info}; use onde::inference::types::{format_duration, ChatMessage}; @@ -14,9 +14,10 @@ use onde::inference::{ }; use crate::{ - tool::truncate_output, AgentConfig, AgentError, AgentReply, AgentToolDefinition, - ApprovalDecision, ApprovalHandler, ApprovalRequest, ChatReply, DenyApprovals, EventSink, - NoopSink, RejectingExecutor, ToolCall, ToolExecutionResult, ToolExecutor, ToolRisk, + stream::chunk_error, tool::truncate_output, AgentConfig, AgentError, AgentReply, + AgentToolDefinition, ApprovalDecision, ApprovalHandler, ApprovalRequest, ChatReply, + DenyApprovals, EventSink, NoopSink, RejectingExecutor, TextStream, ToolCall, + ToolExecutionResult, ToolExecutor, ToolRisk, }; /// An embeddable chat agent over a single [`ChatEngine`]. @@ -226,6 +227,60 @@ impl Ed { reply } + /// Stream the reply to one message, for plain chat without tools. + /// + /// Returns once generation has started; the stream yields text deltas as + /// they are produced. Unlike [`run`](Ed::run) this takes no turn lock and + /// reports nothing to the sink, so don't overlap it with another turn. + /// Drop the stream to cancel; a [`cancel`](Ed::cancel) call is not needed. + pub async fn stream(&self, message: impl Into) -> Result { + Ok(TextStream::new(self.engine.stream_message(message).await?)) + } + + /// One-shot generation over explicit messages. + /// + /// Leaves the conversation history untouched; the engine's system prompt + /// is still applied. `sampling` overrides the engine's for this call only. + pub async fn generate( + &self, + messages: Vec, + sampling: Option, + ) -> Result { + Ok(self.engine.generate(messages, sampling).await?.text) + } + + /// Set the system prompt used for every later turn. + /// + /// Like the other engine setters below, and `restore_history`, this does + /// nothing until a model is loaded: `onde` keeps these on the loaded model. + pub async fn set_system_prompt(&self, prompt: impl Into) { + self.engine.set_system_prompt(prompt).await; + } + + pub async fn clear_system_prompt(&self) { + self.engine.clear_system_prompt().await; + } + + /// Change the default sampling parameters. + pub async fn set_sampling(&self, sampling: SamplingConfig) { + self.engine.set_sampling(sampling).await; + } + + /// Replace the conversation with `messages`. + /// + /// For switching to a saved session or restoring one at launch. Clears + /// the current history first, and with it every `AllowForSession` grant, + /// exactly as [`clear_history`](Ed::clear_history) does: a restored + /// conversation starts without consent from the previous one. + pub async fn restore_history(&self, messages: Vec) { + self.clear_history().await; + let count = messages.len(); + for message in messages { + self.engine.push_history(message).await; + } + info!("ed: restored {count} messages"); + } + /// Register a host capability that the model may request. pub async fn register_tool(&self, tool: AgentToolDefinition) -> Result<(), AgentError> { tool.validate()?; @@ -345,20 +400,39 @@ impl Ed { // `ChatReply` to the sink, and a host listening for both events // would render this turn's answer twice. An agent turn reports // `agent_replied` and nothing else. - let result = - self.engine - .send_message(message) - .await - .map_err(|error| AgentError::Inference { - reason: error.to_string(), - })?; - if result.text.trim().is_empty() { + let started = Instant::now(); + let mut rx = self.engine.stream_message(message).await.map_err(|error| { + AgentError::Inference { + reason: error.to_string(), + } + })?; + let mut text = String::new(); + while let Some(chunk) = rx.recv().await { + if self.cancelled.load(Ordering::Acquire) { + // Dropping the receiver stops generation. + return Err(AgentError::Cancelled); + } + if chunk.done { + if let Some(error) = chunk_error(&chunk) { + return Err(AgentError::Inference { + reason: error.to_string(), + }); + } + break; + } + if !chunk.delta.is_empty() { + self.sink.text_delta(&chunk.delta); + text.push_str(&chunk.delta); + } + } + if text.trim().is_empty() { return Err(AgentError::EmptyReply); } + let elapsed = started.elapsed(); let agent_reply = AgentReply { - text: result.text, - duration_seconds: result.duration_secs, - duration: result.duration_display, + text, + duration_seconds: elapsed.as_secs_f64(), + duration: format_duration(elapsed), tool_rounds: 0, }; self.sink.agent_replied(&agent_reply); @@ -482,6 +556,10 @@ impl Ed { AgentError::EmptyReply }); } + // Tool rounds are not streamed: `onde` only parses tool calls out of a + // complete response, so the answer arrives whole. Report it as one + // delta so a host rendering deltas sees every turn the same way. + self.sink.text_delta(&result.text); let reply = AgentReply { text: result.text, duration_seconds, @@ -833,4 +911,41 @@ mod tests { assert!(result.content.contains("JSON object")); assert_eq!(executor.0.load(Ordering::Relaxed), 0); } + + #[tokio::test] + async fn restoring_history_revokes_session_grants() { + let ed = Ed::with_agent( + NoopSink, + Arc::new(CountingExecutor(AtomicUsize::new(0))), + Arc::new(FixedApproval(ApprovalDecision::AllowForSession)), + AgentConfig::default(), + ); + ed.register_tool(AgentToolDefinition::mutating( + "save_note", + "Save a note", + r#"{"type":"object"}"#, + )) + .await + .unwrap(); + let tools = ed.tools.read().await.clone(); + ed.execute_tool_call(&call("save_note"), &tools).await; + assert!(ed.session_grants.lock().await.contains("save_note")); + + ed.restore_history(Vec::new()).await; + + assert!(ed.session_grants.lock().await.is_empty()); + } + + #[tokio::test] + async fn generation_and_streaming_need_a_loaded_model() { + let ed = Ed::new(); + assert!(matches!( + ed.generate(vec![ChatMessage::user("hi")], None).await, + Err(InferenceError::NoModelLoaded) + )); + assert!(matches!( + ed.stream("hi").await, + Err(InferenceError::NoModelLoaded) + )); + } } diff --git a/crates/ed-agent/src/lib.rs b/crates/ed-agent/src/lib.rs index 5667e22..0a78ec8 100644 --- a/crates/ed-agent/src/lib.rs +++ b/crates/ed-agent/src/lib.rs @@ -15,7 +15,8 @@ //! a frontend wants one shape covering both outcomes. That's [`ChatReply`]. //! //! Ed deliberately does *not* own sessions, persistence, model catalogues, or -//! downloads. Those differ per application and belong to the host. +//! downloads. Those differ per application and belong to the host. For +//! persistence it defines the [`SessionStore`] seam the host implements. //! //! # Platforms //! @@ -49,12 +50,16 @@ mod ed; mod reply; +mod session; mod sink; +mod stream; mod tool; pub use ed::Ed; pub use reply::ChatReply; +pub use session::{SessionError, SessionMeta, SessionStore}; pub use sink::{EventSink, NoopSink, StatusSink}; +pub use stream::TextStream; pub use tool::{ AgentConfig, AgentError, AgentReply, AgentToolDefinition, ApprovalDecision, ApprovalHandler, ApprovalRequest, DenyApprovals, RejectingExecutor, ToolCall, ToolExecutionResult, ToolExecutor, diff --git a/crates/ed-agent/src/session.rs b/crates/ed-agent/src/session.rs new file mode 100644 index 0000000..a6c1440 --- /dev/null +++ b/crates/ed-agent/src/session.rs @@ -0,0 +1,178 @@ +//! The seam between Ed and a host's chat storage. +//! +//! Ed owns no storage. A host that keeps several conversations implements +//! [`SessionStore`] over whatever it already has (files, SQLite, a keychain) +//! and hands the messages to [`Ed::restore_history`](crate::Ed::restore_history) +//! when the user switches conversation. + +use async_trait::async_trait; +use onde::inference::types::ChatMessage; +use serde::{Deserialize, Serialize}; + +/// What a session list needs to render a row, without loading the messages. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionMeta { + pub id: String, + pub title: String, + /// Unix seconds. + pub created_at: i64, + /// Unix seconds. + pub updated_at: i64, + pub message_count: usize, +} + +/// A storage failure. The store decides the wording; Ed only carries it. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{0}")] +pub struct SessionError(pub String); + +/// Persistent conversations, keyed by an id the host chooses. +/// +/// Contract: +/// - `load` of an unknown id is `Ok(None)`, not an error. +/// - `save` creates the session if it doesn't exist and replaces its +/// messages if it does. +/// - `delete` of an unknown id succeeds. +/// - `list` is ordered most recently updated first. +#[async_trait] +pub trait SessionStore: Send + Sync + 'static { + async fn list(&self) -> Result, SessionError>; + async fn load(&self, id: &str) -> Result>, SessionError>; + async fn save(&self, id: &str, messages: &[ChatMessage]) -> Result<(), SessionError>; + async fn delete(&self, id: &str) -> Result<(), SessionError>; +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Mutex; + + use super::*; + + struct Stored { + messages: Vec, + created: i64, + updated: i64, + } + + /// The reference implementation of the contract above. + #[derive(Default)] + struct MemoryStore { + sessions: Mutex>, + clock: Mutex, + } + + impl MemoryStore { + fn tick(&self) -> i64 { + let mut clock = self.clock.lock().unwrap(); + *clock += 1; + *clock + } + } + + #[async_trait] + impl SessionStore for MemoryStore { + async fn list(&self) -> Result, SessionError> { + let mut metas: Vec<_> = self + .sessions + .lock() + .unwrap() + .iter() + .map(|(id, stored)| SessionMeta { + id: id.clone(), + title: stored + .messages + .first() + .map(|m| m.content.clone()) + .unwrap_or_default(), + created_at: stored.created, + updated_at: stored.updated, + message_count: stored.messages.len(), + }) + .collect(); + metas.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + Ok(metas) + } + + async fn load(&self, id: &str) -> Result>, SessionError> { + Ok(self + .sessions + .lock() + .unwrap() + .get(id) + .map(|s| s.messages.clone())) + } + + async fn save(&self, id: &str, messages: &[ChatMessage]) -> Result<(), SessionError> { + let now = self.tick(); + let mut sessions = self.sessions.lock().unwrap(); + let created = sessions.get(id).map_or(now, |s| s.created); + sessions.insert( + id.to_owned(), + Stored { + messages: messages.to_vec(), + created, + updated: now, + }, + ); + Ok(()) + } + + async fn delete(&self, id: &str) -> Result<(), SessionError> { + self.sessions.lock().unwrap().remove(id); + Ok(()) + } + } + + #[tokio::test] + async fn loading_an_unknown_session_is_none() { + let store = MemoryStore::default(); + assert!(store.load("nope").await.unwrap().is_none()); + } + + #[tokio::test] + async fn save_creates_then_replaces() { + let store = MemoryStore::default(); + store.save("a", &[ChatMessage::user("hi")]).await.unwrap(); + store + .save( + "a", + &[ChatMessage::user("hi"), ChatMessage::assistant("yo")], + ) + .await + .unwrap(); + + let loaded = store.load("a").await.unwrap().unwrap(); + assert_eq!(loaded.len(), 2); + let listed = store.list().await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].message_count, 2); + assert!(listed[0].updated_at > listed[0].created_at); + } + + #[tokio::test] + async fn list_is_most_recently_updated_first() { + let store = MemoryStore::default(); + store.save("old", &[ChatMessage::user("1")]).await.unwrap(); + store.save("new", &[ChatMessage::user("2")]).await.unwrap(); + store.save("old", &[ChatMessage::user("3")]).await.unwrap(); + + let ids: Vec<_> = store + .list() + .await + .unwrap() + .into_iter() + .map(|m| m.id) + .collect(); + assert_eq!(ids, ["old", "new"]); + } + + #[tokio::test] + async fn delete_is_idempotent() { + let store = MemoryStore::default(); + store.save("a", &[]).await.unwrap(); + store.delete("a").await.unwrap(); + store.delete("a").await.unwrap(); + assert!(store.list().await.unwrap().is_empty()); + } +} diff --git a/crates/ed-agent/src/sink.rs b/crates/ed-agent/src/sink.rs index 316c99c..4069bdd 100644 --- a/crates/ed-agent/src/sink.rs +++ b/crates/ed-agent/src/sink.rs @@ -38,6 +38,14 @@ pub trait EventSink: Send + Sync + 'static { /// pick the result up independently. fn replied(&self, _reply: &ChatReply) {} + /// A piece of the assistant's reply, as it is generated. + /// + /// Called zero or more times during [`Ed::run`](crate::Ed::run) before + /// the final reply is reported. Concatenating the deltas gives the reply + /// text. The default does nothing, so sinks written before streaming + /// existed keep compiling and keep seeing only the finished reply. + fn text_delta(&self, _delta: &str) {} + fn tool_requested(&self, _call: &ToolCall) {} fn approval_requested(&self, _request: &ApprovalRequest) {} fn tool_started(&self, _call: &ToolCall) {} @@ -57,3 +65,18 @@ pub use EventSink as StatusSink; pub struct NoopSink; impl EventSink for NoopSink {} + +#[cfg(test)] +mod tests { + use super::*; + + struct Bare; + impl EventSink for Bare {} + + #[test] + fn text_delta_defaults_to_a_no_op() { + // A sink that predates streaming must still compile and not panic. + Bare.text_delta("hello"); + NoopSink.text_delta("hello"); + } +} diff --git a/crates/ed-agent/src/stream.rs b/crates/ed-agent/src/stream.rs new file mode 100644 index 0000000..e507e63 --- /dev/null +++ b/crates/ed-agent/src/stream.rs @@ -0,0 +1,117 @@ +//! Token streaming for plain chat. + +use std::pin::Pin; +use std::task::{Context, Poll}; + +use futures_core::Stream; +use onde::inference::types::StreamChunk; +use onde::inference::InferenceError; +use tokio::sync::mpsc::Receiver; + +/// The reply to one message, as a stream of text deltas. +/// +/// Yields `Ok(delta)` for each piece and ends after the last one. A failure +/// mid-stream is a final `Err`. Dropping the stream stops generation: `onde` +/// notices the closed channel and keeps only what was delivered. +/// +/// The finished turn is added to the engine's history when the stream ends, +/// as with [`Ed::send`](crate::Ed::send). +pub struct TextStream { + rx: Receiver, + finished: bool, +} + +impl TextStream { + pub(crate) fn new(rx: Receiver) -> Self { + Self { + rx, + finished: false, + } + } +} + +impl TextStream { + /// The next delta, `None` when the reply is complete. For callers without a stream + /// combinator library. + pub async fn next(&mut self) -> Option> { + std::future::poll_fn(|cx| Pin::new(&mut *self).poll_next(cx)).await + } +} + +/// `onde` reports a failed stream as a final chunk whose `finish_reason` +/// starts with `error`. +pub(crate) fn chunk_error(chunk: &StreamChunk) -> Option { + chunk + .finish_reason + .as_deref() + .filter(|reason| reason.starts_with("error")) + .map(|reason| InferenceError::Inference { + reason: reason.trim_start_matches("error:").trim().to_owned(), + }) +} + +impl Stream for TextStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + loop { + if self.finished { + return Poll::Ready(None); + } + match self.rx.poll_recv(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(None) => { + self.finished = true; + return Poll::Ready(None); + } + Poll::Ready(Some(chunk)) => { + if chunk.done { + self.finished = true; + return Poll::Ready(chunk_error(&chunk).map(Err)); + } + if chunk.delta.is_empty() { + continue; + } + return Poll::Ready(Some(Ok(chunk.delta))); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chunk(delta: &str, done: bool, reason: Option<&str>) -> StreamChunk { + StreamChunk { + delta: delta.to_owned(), + done, + finish_reason: reason.map(str::to_owned), + } + } + + #[tokio::test] + async fn yields_deltas_then_ends() { + let (tx, rx) = tokio::sync::mpsc::channel(8); + let mut stream = TextStream::new(rx); + tx.send(chunk("he", false, None)).await.unwrap(); + tx.send(chunk("llo", false, None)).await.unwrap(); + tx.send(chunk("", true, Some("stop"))).await.unwrap(); + + assert_eq!(stream.next().await.unwrap().unwrap(), "he"); + assert_eq!(stream.next().await.unwrap().unwrap(), "llo"); + assert!(stream.next().await.is_none()); + } + + #[tokio::test] + async fn a_failed_stream_ends_with_an_error() { + let (tx, rx) = tokio::sync::mpsc::channel(8); + let mut stream = TextStream::new(rx); + tx.send(chunk("", true, Some("error: boom"))).await.unwrap(); + + let err = stream.next().await.unwrap().unwrap_err(); + assert!(err.to_string().contains("boom")); + assert!(stream.next().await.is_none()); + } +} From 97a85db674583a2ada920af72499e448ad7e52d7 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:12:50 +0200 Subject: [PATCH 02/28] ed-agent-tauri: multi-session state, webview approvals, text deltas EdSessions keeps one Ed over many stored conversations, with a cache and a generation fence so a reply that finishes after logout is not saved. TauriApprovals lets the webview answer approval requests. Adds the chat_text_delta event and the session, submit and approval commands. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: siGit Code v1.6.3 --- Cargo.lock | 4 + Cargo.toml | 1 + crates/ed-agent-tauri/Cargo.toml | 7 + crates/ed-agent-tauri/README.md | 22 +- crates/ed-agent-tauri/src/approvals.rs | 208 ++++++++++++++ crates/ed-agent-tauri/src/commands.rs | 108 +++++++- crates/ed-agent-tauri/src/events.rs | 50 ++++ crates/ed-agent-tauri/src/lib.rs | 62 +++-- crates/ed-agent-tauri/src/scope.rs | 31 +++ crates/ed-agent-tauri/src/sessions.rs | 368 +++++++++++++++++++++++++ crates/ed-agent-tauri/src/sink.rs | 35 ++- 11 files changed, 867 insertions(+), 29 deletions(-) create mode 100644 crates/ed-agent-tauri/src/approvals.rs create mode 100644 crates/ed-agent-tauri/src/scope.rs create mode 100644 crates/ed-agent-tauri/src/sessions.rs diff --git a/Cargo.lock b/Cargo.lock index 8bb020c..2b8c028 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1759,10 +1759,14 @@ dependencies = [ name = "ed-agent-tauri" version = "1.0.1" dependencies = [ + "async-trait", "ed-agent", "log", "serde", + "serde_json", "tauri", + "tokio", + "uuid 1.27.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d5ec0e4..f56a6b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ tauri = { version = "2", features = [] } # versions break extraction with "Invalid string data" errors. uniffi = { version = "=0.31.0", features = ["tokio"] } smbcloud-gresiq-sdk = "0.4.7" +uuid = { version = "1", features = ["v4"] } # Internal ed-agent = { version = "1.0.1", path = "crates/ed-agent" } diff --git a/crates/ed-agent-tauri/Cargo.toml b/crates/ed-agent-tauri/Cargo.toml index 37e598d..772766d 100644 --- a/crates/ed-agent-tauri/Cargo.toml +++ b/crates/ed-agent-tauri/Cargo.toml @@ -16,3 +16,10 @@ ed-agent.workspace = true tauri.workspace = true serde.workspace = true log.workspace = true +async-trait.workspace = true +serde_json.workspace = true +uuid.workspace = true +tokio = { workspace = true, features = ["time"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["time", "macros", "rt"] } diff --git a/crates/ed-agent-tauri/README.md b/crates/ed-agent-tauri/README.md index e53024a..96df0fe 100644 --- a/crates/ed-agent-tauri/README.md +++ b/crates/ed-agent-tauri/README.md @@ -23,16 +23,26 @@ These events reach the webview: `chat_status_changed`, `chat_reply`, `chat_tool_requested`, `chat_approval_requested`, `chat_tool_started`, `chat_tool_finished`, `chat_agent_reply`, and `chat_warning`. +## Several conversations + +`EdSessions` is the multi-session form. Give it a `SessionStore` and it handles +switching, the in-memory cache, logout (`reset`) and approvals. Register +`chat_list_sessions`, `chat_new_session`, `chat_switch_session`, +`chat_delete_session`, `chat_get_session_history`, `chat_submit` and +`chat_respond_approval`. + +`chat_submit` returns immediately; the answer is a `chat_reply` event +`{id, session, reply, duration, error}`, preceded by `chat_text_delta +{session, id, delta}` events. Approvals use `chat_approval_requested +{request_id, session, call, risk}` and `chat_approval_resolved {request_id}`; +answer with `chat_respond_approval`. To rewrite the message first, wrap +`EdSessions::submit_with` in your own command. + Loading a model is deliberately not a command here — which model, and from where, is an application decision. Call `Ed::load` or `Ed::load_with` from your own command. -Tool calling needs both halves from you. Build the agent with `Ed::with_agent` -to pass a `ToolExecutor` and an `ApprovalHandler`; `Ed::with_sink` rejects every -tool and denies every approval. Approvals are not a command either: answering -one means a webview round trip inside an awaited Rust call, and hosts differ -enough about how to wire that up that this crate emits -`chat_approval_requested` and leaves the answering to you. +Without `EdSessions`, tool calling needs both halves from you: build the agent with `Ed::with_agent` to pass a `ToolExecutor` and an `ApprovalHandler`. ## Copyright diff --git a/crates/ed-agent-tauri/src/approvals.rs b/crates/ed-agent-tauri/src/approvals.rs new file mode 100644 index 0000000..efbf247 --- /dev/null +++ b/crates/ed-agent-tauri/src/approvals.rs @@ -0,0 +1,208 @@ +//! Approvals answered by the webview. + +use std::collections::HashMap; +use std::time::Duration; + +use async_trait::async_trait; +use ed_agent::{ApprovalDecision, ApprovalHandler, ApprovalRequest}; +use log::{error, warn}; +use serde::Serialize; +use tauri::{AppHandle, Emitter}; +use tokio::sync::{oneshot, Mutex}; + +use crate::events::{ + ApprovalRequestedPayload, ApprovalResolvedPayload, EVENT_CHAT_APPROVAL_REQUESTED, + EVENT_CHAT_APPROVAL_RESOLVED, +}; +use crate::scope::TurnScope; + +/// How long to wait for the user before denying. +pub const DEFAULT_APPROVAL_TIMEOUT: Duration = Duration::from_secs(120); + +/// An [`ApprovalHandler`] that asks the webview. +/// +/// For each request it emits `chat_approval_requested`, waits for the webview +/// to call `chat_respond_approval` (or [`respond`](TauriApprovals::respond)), +/// and emits `chat_approval_resolved` when the wait ends, however it ends. +/// A timeout, a closed window or [`cancel_all`](TauriApprovals::cancel_all) +/// all mean *deny*. +type Emit = Box Result<(), String> + Send + Sync>; + +pub struct TauriApprovals { + emit: Emit, + scope: TurnScope, + timeout: Duration, + pending: Mutex>>, +} + +impl TauriApprovals { + pub fn new(app: AppHandle, timeout: Duration) -> Self { + Self::scoped(app, TurnScope::default(), timeout) + } + + pub(crate) fn scoped(app: AppHandle, scope: TurnScope, timeout: Duration) -> Self { + let emit: Emit = + Box::new(move |event, payload| app.emit(event, payload).map_err(|err| err.to_string())); + Self::with_emitter(emit, scope, timeout) + } + + fn with_emitter(emit: Emit, scope: TurnScope, timeout: Duration) -> Self { + Self { + emit, + scope, + timeout, + pending: Mutex::new(HashMap::new()), + } + } + + fn emit(&self, event: &str, payload: impl Serialize) -> Result<(), String> { + let value = serde_json::to_value(payload).map_err(|err| err.to_string())?; + (self.emit)(event, value) + } + + /// Answer a pending request. Errors if it already timed out or was + /// answered. + pub async fn respond( + &self, + request_id: &str, + decision: ApprovalDecision, + ) -> Result<(), String> { + let gone = || "This approval request is no longer pending.".to_string(); + let sender = self + .pending + .lock() + .await + .remove(request_id) + .ok_or_else(gone)?; + sender.send(decision).map_err(|_| gone()) + } + + /// Deny everything that is waiting. Use it when the conversation the + /// requests belong to goes away. + pub async fn cancel_all(&self) { + let pending: Vec<_> = self.pending.lock().await.drain().collect(); + for (_, sender) in pending { + // Each waiting `approve` call emits its own resolved event once it + // sees the denial, so every request closes exactly once. + let _ = sender.send(ApprovalDecision::Deny); + } + } +} + +#[async_trait] +impl ApprovalHandler for TauriApprovals { + async fn approve(&self, request: ApprovalRequest) -> ApprovalDecision { + let request_id = uuid::Uuid::new_v4().to_string(); + let (sender, receiver) = oneshot::channel(); + self.pending.lock().await.insert(request_id.clone(), sender); + + let (session, _) = self.scope.get(); + let payload = ApprovalRequestedPayload { + request_id: request_id.clone(), + session, + call: request.call, + risk: request.risk, + }; + if let Err(err) = self.emit(EVENT_CHAT_APPROVAL_REQUESTED, payload) { + self.pending.lock().await.remove(&request_id); + error!("ed-tauri: could not request approval: {err}"); + return ApprovalDecision::Deny; + } + + let decision = match tokio::time::timeout(self.timeout, receiver).await { + Ok(Ok(decision)) => decision, + Ok(Err(_)) => ApprovalDecision::Deny, + Err(_) => { + warn!("ed-tauri: approval {request_id} timed out; denying"); + ApprovalDecision::Deny + } + }; + self.pending.lock().await.remove(&request_id); + if let Err(err) = self.emit( + EVENT_CHAT_APPROVAL_RESOLVED, + ApprovalResolvedPayload { request_id }, + ) { + error!("ed-tauri: could not close approval: {err}"); + } + decision + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use ed_agent::{ToolCall, ToolRisk}; + + use super::*; + + fn request() -> ApprovalRequest { + ApprovalRequest { + call: ToolCall { + id: "1".into(), + name: "send_message".into(), + arguments: "{}".into(), + }, + risk: ToolRisk::Mutating, + } + } + + fn approvals(timeout: Duration) -> Arc { + Arc::new(TauriApprovals::with_emitter( + Box::new(|_, _| Ok(())), + TurnScope::default(), + timeout, + )) + } + + async fn pending_id(approvals: &TauriApprovals) -> String { + loop { + if let Some(id) = approvals.pending.lock().await.keys().next().cloned() { + return id; + } + tokio::task::yield_now().await; + } + } + + #[tokio::test] + async fn the_webview_can_allow() { + let approvals = approvals(Duration::from_secs(5)); + let waiting = tokio::spawn({ + let approvals = approvals.clone(); + async move { approvals.approve(request()).await } + }); + let id = pending_id(&approvals).await; + + approvals + .respond(&id, ApprovalDecision::AllowForSession) + .await + .unwrap(); + + assert_eq!(waiting.await.unwrap(), ApprovalDecision::AllowForSession); + assert!(approvals + .respond(&id, ApprovalDecision::Deny) + .await + .is_err()); + } + + #[tokio::test] + async fn cancel_all_denies_everything_pending() { + let approvals = approvals(Duration::from_secs(5)); + let waiting = tokio::spawn({ + let approvals = approvals.clone(); + async move { approvals.approve(request()).await } + }); + pending_id(&approvals).await; + + approvals.cancel_all().await; + + assert_eq!(waiting.await.unwrap(), ApprovalDecision::Deny); + } + + #[tokio::test] + async fn an_unanswered_request_times_out_as_a_denial() { + let approvals = approvals(Duration::from_millis(20)); + assert_eq!(approvals.approve(request()).await, ApprovalDecision::Deny); + assert!(approvals.pending.lock().await.is_empty()); + } +} diff --git a/crates/ed-agent-tauri/src/commands.rs b/crates/ed-agent-tauri/src/commands.rs index fe8f726..4d865f6 100644 --- a/crates/ed-agent-tauri/src/commands.rs +++ b/crates/ed-agent-tauri/src/commands.rs @@ -1,8 +1,11 @@ //! The commands a webview invokes. -use ed_agent::{AgentReply, ChatMessage, ChatReply, Ed, EngineInfo}; +use std::sync::Arc; + +use ed_agent::{AgentReply, ApprovalDecision, ChatMessage, ChatReply, Ed, EngineInfo, SessionMeta}; use tauri::State; +use crate::sessions::EdSessions; use crate::sink::TauriSink; /// Managed state holding the agent. @@ -12,12 +15,12 @@ use crate::sink::TauriSink; /// runs more than one window against separate engines, needs to control the /// lifetime, and a `static` takes that away. pub struct EdState { - ed: Ed, + ed: Arc>, } impl EdState { pub fn new(ed: Ed) -> Self { - Self { ed } + Self { ed: Arc::new(ed) } } /// The agent, for commands the host defines itself — loading a model, @@ -26,6 +29,44 @@ impl EdState { pub fn ed(&self) -> &Ed { &self.ed } + + /// A shared handle to the agent, for work moved onto a spawned task. + pub fn shared(&self) -> Arc> { + self.ed.clone() + } + + /// Run an agent turn on a spawned task and deliver the result as a + /// `chat_reply` event `{id, session, reply, duration, error}`, with + /// `session` empty. Returns immediately. + /// + /// For single-conversation apps. A long-pending invoke can be dropped by a + /// mobile webview, so the answer travels by event. Text deltas arrive as + /// `chat_text_delta` while it runs. + pub fn submit(&self, app: tauri::AppHandle, id: String, message: String) { + use tauri::Emitter; + let ed = self.ed.clone(); + tokio::spawn(async move { + let payload = match ed.run(message).await { + Ok(reply) => crate::SubmitReplyPayload { + id, + session: String::new(), + reply: Some(reply.text), + duration: Some(reply.duration), + error: None, + }, + Err(err) => crate::SubmitReplyPayload { + id, + session: String::new(), + reply: None, + duration: None, + error: Some(err.to_string()), + }, + }; + if let Err(err) = app.emit(crate::EVENT_CHAT_REPLY, payload) { + log::warn!("ed-tauri: could not emit chat_reply: {err}"); + } + }); + } } /// Current status, loaded model, footprint, and history length. @@ -88,3 +129,64 @@ pub async fn chat_cancel(state: State<'_, EdState>) -> Result<(), String> { state.ed.cancel(); Ok(()) } + +/// Stored sessions, most recently updated first. +#[tauri::command] +pub async fn chat_list_sessions(state: State<'_, EdSessions>) -> Result, String> { + state.list().await.map_err(|e| e.to_string()) +} + +/// Start an empty session and make it live. Returns its id. +#[tauri::command] +pub async fn chat_new_session(state: State<'_, EdSessions>) -> Result { + Ok(state.new_session().await) +} + +/// Make `session` live and return its messages. +#[tauri::command] +pub async fn chat_switch_session( + state: State<'_, EdSessions>, + session: String, +) -> Result, String> { + Ok(state.history(&session).await) +} + +#[tauri::command] +pub async fn chat_delete_session( + state: State<'_, EdSessions>, + session: String, +) -> Result<(), String> { + state.delete(&session).await.map_err(|e| e.to_string()) +} + +/// The messages of `session`, without switching to it. +#[tauri::command] +pub async fn chat_get_session_history( + state: State<'_, EdSessions>, + session: String, +) -> Result, String> { + Ok(state.peek(&session).await) +} + +/// Run a turn in `session`. Returns at once; the result is a `chat_reply` +/// event carrying `id`. +#[tauri::command] +pub async fn chat_submit( + state: State<'_, EdSessions>, + session: String, + id: String, + message: String, +) -> Result<(), String> { + state.submit(&session, &id, message).await; + Ok(()) +} + +/// Answer a `chat_approval_requested` event. +#[tauri::command] +pub async fn chat_respond_approval( + state: State<'_, EdSessions>, + request_id: String, + decision: ApprovalDecision, +) -> Result<(), String> { + state.respond_approval(&request_id, decision).await +} diff --git a/crates/ed-agent-tauri/src/events.rs b/crates/ed-agent-tauri/src/events.rs index 01968fd..cdca303 100644 --- a/crates/ed-agent-tauri/src/events.rs +++ b/crates/ed-agent-tauri/src/events.rs @@ -45,6 +45,56 @@ pub const EVENT_CHAT_AGENT_REPLY: &str = "chat_agent_reply"; /// is still viable, such as an unverified model or a spent tool-round budget. pub const EVENT_CHAT_WARNING: &str = "chat_warning"; +/// Emitted with a [`TextDeltaPayload`] for each piece of a reply as it is +/// generated. Concatenate the deltas of one `id` to grow the assistant bubble; +/// [`EVENT_CHAT_REPLY`] still arrives at the end with the whole text. +pub const EVENT_CHAT_TEXT_DELTA: &str = "chat_text_delta"; + +/// Emitted with an [`ApprovalResolvedPayload`] once an approval request has +/// been answered, timed out, or cancelled, so the webview can close its sheet. +pub const EVENT_CHAT_APPROVAL_RESOLVED: &str = "chat_approval_resolved"; + +/// Payload for [`EVENT_CHAT_TEXT_DELTA`]. +/// +/// `session` and `id` are empty when the turn didn't come through a `submit` +/// call, which is the only thing that knows them. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TextDeltaPayload { + pub session: String, + pub id: String, + pub delta: String, +} + +/// Payload for [`EVENT_CHAT_REPLY`] when the turn came through `submit`. +/// +/// A superset of [`ChatReply`](ed_agent::ChatReply), so a listener written for +/// the single-session event keeps working. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubmitReplyPayload { + pub id: String, + pub session: String, + pub reply: Option, + pub duration: Option, + pub error: Option, +} + +/// Payload for [`EVENT_CHAT_APPROVAL_REQUESTED`] when the approval handler is +/// [`TauriApprovals`](crate::TauriApprovals). Answer it with +/// `chat_respond_approval { request_id, decision }`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApprovalRequestedPayload { + pub request_id: String, + pub session: String, + pub call: ed_agent::ToolCall, + pub risk: ed_agent::ToolRisk, +} + +/// Payload for [`EVENT_CHAT_APPROVAL_RESOLVED`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApprovalResolvedPayload { + pub request_id: String, +} + /// Payload for [`EVENT_CHAT_STATUS_CHANGED`]. /// /// `status` serialises to `"unloaded"`, `"loading"`, `"ready"`, `"generating"`, diff --git a/crates/ed-agent-tauri/src/lib.rs b/crates/ed-agent-tauri/src/lib.rs index b68bd8b..1a64a48 100644 --- a/crates/ed-agent-tauri/src/lib.rs +++ b/crates/ed-agent-tauri/src/lib.rs @@ -43,21 +43,41 @@ //! where, is an application decision. Call [`Ed::load`](ed_agent::Ed::load) //! from your own command or during setup. //! +//! # Several conversations +//! +//! For an app with a session list, build an [`EdSessions`] instead of an +//! [`EdState`]. You supply a [`SessionStore`](ed_agent::SessionStore); it owns +//! the live session, the cache, the logout fence and approvals: +//! +//! ```text +//! let sessions = EdSessions::builder(app.handle().clone(), MyStore::new()) +//! .executor(my_executor) +//! .build(); +//! app.manage(sessions); +//! ``` +//! +//! Register `chat_list_sessions`, `chat_new_session`, `chat_switch_session`, +//! `chat_delete_session`, `chat_get_session_history`, `chat_submit` and +//! `chat_respond_approval`. `chat_submit` returns at once and the answer comes +//! as a `chat_reply` event `{id, session, reply, duration, error}`, with +//! `chat_text_delta {session, id, delta}` events while it generates. +//! +//! A host that has to rewrite the message first (to add context) or recover +//! from an engine failure wraps [`EdSessions::submit_with`] in a command of its +//! own. Call [`EdSessions::reset`] on logout or account switch. +//! //! # Tools //! -//! Tool calling works through this crate, but the host owns both halves of it. -//! Build the agent with [`Ed::with_agent`](ed_agent::Ed::with_agent) to supply -//! a [`ToolExecutor`](ed_agent::ToolExecutor) and an -//! [`ApprovalHandler`](ed_agent::ApprovalHandler); the default `Ed::with_sink` -//! rejects every tool and denies every approval, which is the right default but -//! not a working agent. +//! Tool calling works through this crate, but the host supplies the executor. +//! With [`EdSessions`] approvals are handled for you: [`TauriApprovals`] emits +//! `chat_approval_requested {request_id, session, call, risk}`, waits (120 s by +//! default) for `chat_respond_approval {request_id, decision}`, then emits +//! `chat_approval_resolved {request_id}`. A timeout or a reset denies. //! -//! Approvals are deliberately not a command. Answering one means a webview -//! round trip in the middle of an awaited Rust call, and how a host wants to -//! wire that up (a channel, a shared map, a modal it blocks on) differs enough -//! that guessing here would be worse than leaving it. What this crate does give -//! you is [`EVENT_CHAT_APPROVAL_REQUESTED`], so the sheet can go up at the -//! right moment. +//! With [`EdState`], build the agent with +//! [`Ed::with_agent`](ed_agent::Ed::with_agent) and your own +//! [`ApprovalHandler`](ed_agent::ApprovalHandler); the default `Ed::with_sink` +//! rejects every tool and denies every approval. //! //! # Events //! @@ -74,20 +94,30 @@ //! [`ToolExecutionResult`](ed_agent::ToolExecutionResult) //! - [`EVENT_CHAT_AGENT_REPLY`] with an [`AgentReply`](ed_agent::AgentReply) //! - [`EVENT_CHAT_WARNING`] with a string +//! - [`EVENT_CHAT_TEXT_DELTA`] with a [`TextDeltaPayload`] +//! - [`EVENT_CHAT_APPROVAL_RESOLVED`] with an [`ApprovalResolvedPayload`] #![forbid(unsafe_code)] +mod approvals; mod commands; mod events; +mod scope; +mod sessions; mod sink; +pub use approvals::{TauriApprovals, DEFAULT_APPROVAL_TIMEOUT}; pub use commands::{ - chat_cancel, chat_clear_history, chat_get_history, chat_get_status, chat_run, - chat_send_message, EdState, + chat_cancel, chat_clear_history, chat_delete_session, chat_get_history, + chat_get_session_history, chat_get_status, chat_list_sessions, chat_new_session, + chat_respond_approval, chat_run, chat_send_message, chat_submit, chat_switch_session, EdState, }; pub use events::{ - ChatStatusPayload, EVENT_CHAT_AGENT_REPLY, EVENT_CHAT_APPROVAL_REQUESTED, EVENT_CHAT_REPLY, - EVENT_CHAT_STATUS_CHANGED, EVENT_CHAT_TOOL_FINISHED, EVENT_CHAT_TOOL_REQUESTED, + ApprovalRequestedPayload, ApprovalResolvedPayload, ChatStatusPayload, SubmitReplyPayload, + TextDeltaPayload, EVENT_CHAT_AGENT_REPLY, EVENT_CHAT_APPROVAL_REQUESTED, + EVENT_CHAT_APPROVAL_RESOLVED, EVENT_CHAT_REPLY, EVENT_CHAT_STATUS_CHANGED, + EVENT_CHAT_TEXT_DELTA, EVENT_CHAT_TOOL_FINISHED, EVENT_CHAT_TOOL_REQUESTED, EVENT_CHAT_TOOL_STARTED, EVENT_CHAT_WARNING, }; +pub use sessions::{EdSessions, EdSessionsBuilder}; pub use sink::TauriSink; diff --git a/crates/ed-agent-tauri/src/scope.rs b/crates/ed-agent-tauri/src/scope.rs new file mode 100644 index 0000000..0083f9e --- /dev/null +++ b/crates/ed-agent-tauri/src/scope.rs @@ -0,0 +1,31 @@ +//! Which session and message a running turn belongs to. + +use std::sync::{Arc, Mutex}; + +/// Shared between the sink and the approval handler so that events emitted +/// from inside Ed (which knows nothing about sessions) can still say which +/// session they belong to. +/// +/// Ed runs one turn at a time, so one slot is enough. +#[derive(Clone, Default)] +pub(crate) struct TurnScope(Arc>>); + +impl TurnScope { + pub(crate) fn set(&self, session: &str, id: &str) { + *self.0.lock().unwrap_or_else(|e| e.into_inner()) = + Some((session.to_owned(), id.to_owned())); + } + + pub(crate) fn clear(&self) { + *self.0.lock().unwrap_or_else(|e| e.into_inner()) = None; + } + + /// `(session, id)`, both empty outside a `submit`. + pub(crate) fn get(&self) -> (String, String) { + self.0 + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + .unwrap_or_default() + } +} diff --git a/crates/ed-agent-tauri/src/sessions.rs b/crates/ed-agent-tauri/src/sessions.rs new file mode 100644 index 0000000..c1b4b18 --- /dev/null +++ b/crates/ed-agent-tauri/src/sessions.rs @@ -0,0 +1,368 @@ +//! Several conversations over one Ed. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use ed_agent::{ + AgentConfig, AgentError, AgentReply, ApprovalDecision, ChatMessage, Ed, RejectingExecutor, + SessionError, SessionMeta, SessionStore, ToolExecutor, +}; +use log::{error, info, warn}; +use tauri::{AppHandle, Emitter}; +use tokio::sync::Mutex; + +use crate::approvals::{TauriApprovals, DEFAULT_APPROVAL_TIMEOUT}; +use crate::events::{SubmitReplyPayload, EVENT_CHAT_REPLY}; +use crate::scope::TurnScope; +use crate::sink::TauriSink; + +#[derive(Default)] +struct State { + active: Option, + /// Histories of sessions that were switched away from, so switching back + /// doesn't hit the store. + cache: HashMap>, +} + +struct Shared { + app: AppHandle, + ed: Arc>, + store: Arc, + approvals: Arc, + scope: TurnScope, + /// Bumped by [`EdSessions::reset`]. A turn captures it at dispatch and + /// refuses to save or emit if it changed by completion, so a reply that + /// finishes after logout or an account switch can't land in the next + /// account's storage. + generation: AtomicU64, + state: Mutex, + error_message: Option, + new_id: Box String + Send + Sync>, +} + +/// Multi-session chat state: one [`Ed`], many conversations. +/// +/// Register it with `app.manage(sessions)` and the `chat_*_session` commands. +/// It owns which session is live in the engine, an in-memory cache, the +/// generation fence, and approvals; persistence stays with the host's +/// [`SessionStore`]. +/// +/// Cheap to clone. +#[derive(Clone)] +pub struct EdSessions { + shared: Arc, +} + +/// Configuration for [`EdSessions`]. +pub struct EdSessionsBuilder { + app: AppHandle, + store: Arc, + executor: Arc, + config: AgentConfig, + onde_app_id: Option, + approval_timeout: Duration, + error_message: Option, + new_id: Box String + Send + Sync>, +} + +impl EdSessionsBuilder { + /// Run tools through `executor`. Defaults to rejecting every tool. + pub fn executor(mut self, executor: Arc) -> Self { + self.executor = executor; + self + } + + pub fn config(mut self, config: AgentConfig) -> Self { + self.config = config; + self + } + + /// Associate telemetry with an Onde app id. + pub fn onde_app_id(mut self, id: impl Into) -> Self { + self.onde_app_id = Some(id.into()); + self + } + + /// How long an approval waits for the user. Default 120 seconds. + pub fn approval_timeout(mut self, timeout: Duration) -> Self { + self.approval_timeout = timeout; + self + } + + /// Show this to the user instead of the error text when a turn fails. The + /// real error is still logged. + pub fn error_message(mut self, message: impl Into) -> Self { + self.error_message = Some(message.into()); + self + } + + /// How [`EdSessions::new_session`] names a session. Defaults to a random UUID. A store that + /// treats some ids as ephemeral (by prefix, say) can mint matching ones here. + pub fn id_generator(mut self, generator: impl Fn() -> String + Send + Sync + 'static) -> Self { + self.new_id = Box::new(generator); + self + } + + pub fn build(self) -> EdSessions { + let scope = TurnScope::default(); + let approvals = Arc::new(TauriApprovals::scoped( + self.app.clone(), + scope.clone(), + self.approval_timeout, + )); + let sink = TauriSink::scoped(self.app.clone(), scope.clone(), false); + let ed = Ed::with_agent_and_app_id( + sink, + self.executor, + approvals.clone(), + self.config, + self.onde_app_id, + ); + EdSessions { + shared: Arc::new(Shared { + app: self.app, + ed: Arc::new(ed), + store: self.store, + approvals, + scope, + generation: AtomicU64::new(0), + state: Mutex::new(State::default()), + error_message: self.error_message, + new_id: self.new_id, + }), + } + } +} + +impl EdSessions { + pub fn builder(app: AppHandle, store: impl SessionStore) -> EdSessionsBuilder { + EdSessionsBuilder { + app, + store: Arc::new(store), + executor: Arc::new(RejectingExecutor), + config: AgentConfig::default(), + onde_app_id: None, + approval_timeout: DEFAULT_APPROVAL_TIMEOUT, + error_message: None, + new_id: Box::new(|| uuid::Uuid::new_v4().to_string()), + } + } + + /// The agent, for loading a model and anything else Ed exposes. + pub fn ed(&self) -> &Arc> { + &self.shared.ed + } + + pub fn approvals(&self) -> &Arc { + &self.shared.approvals + } + + /// The current generation. Capture it before slow work and compare with + /// [`is_current`](EdSessions::is_current) afterwards. + pub fn generation(&self) -> u64 { + self.shared.generation.load(Ordering::SeqCst) + } + + pub fn is_current(&self, generation: u64) -> bool { + self.generation() == generation + } + + /// The id of the session live in the engine, if any. + pub async fn active(&self) -> Option { + self.shared.state.lock().await.active.clone() + } + + /// Stored sessions, most recently updated first. + pub async fn list(&self) -> Result, SessionError> { + self.shared.store.list().await + } + + /// Make `session` the live conversation. + /// + /// The outgoing session's history is kept in memory; the incoming one + /// comes from memory, then the store. An unknown id starts empty. + pub async fn activate(&self, session: &str) { + let shared = &self.shared; + let mut state = shared.state.lock().await; + if state.active.as_deref() == Some(session) { + return; + } + if let Some(previous) = state.active.take() { + let live = shared.ed.history().await; + state.cache.insert(previous, live); + } + let stored = match state.cache.remove(session) { + Some(messages) => messages, + None => match shared.store.load(session).await { + Ok(messages) => messages.unwrap_or_default(), + Err(err) => { + error!("ed-tauri: could not load session {session}: {err}"); + Vec::new() + } + }, + }; + shared.ed.restore_history(stored).await; + state.active = Some(session.to_owned()); + } + + /// Start an empty session and make it live. Returns its id. The store + /// hears about it when the first turn completes. + pub async fn new_session(&self) -> String { + let id = (self.shared.new_id)(); + self.activate(&id).await; + id + } + + /// The messages of `session`, activating it. + pub async fn history(&self, session: &str) -> Vec { + self.activate(session).await; + self.shared.ed.history().await + } + + /// The messages of `session` without switching to it: the live history if it is the active + /// session, else the cache, else the store. For rendering a session that is not on screen, + /// without disturbing a turn that is running in another. + pub async fn peek(&self, session: &str) -> Vec { + let shared = &self.shared; + let state = shared.state.lock().await; + if state.active.as_deref() == Some(session) { + return shared.ed.history().await; + } + if let Some(cached) = state.cache.get(session) { + return cached.clone(); + } + drop(state); + match shared.store.load(session).await { + Ok(messages) => messages.unwrap_or_default(), + Err(err) => { + error!("ed-tauri: could not load session {session}: {err}"); + Vec::new() + } + } + } + + /// Delete a session from the store and memory. If it was live, the engine + /// is cleared and no session is live until the next activate. + pub async fn delete(&self, session: &str) -> Result<(), SessionError> { + let shared = &self.shared; + let result = shared.store.delete(session).await; + let mut state = shared.state.lock().await; + state.cache.remove(session); + if state.active.as_deref() == Some(session) { + state.active = None; + shared.ed.clear_history().await; + } + result + } + + /// Drop all in-memory state and fence off in-flight turns. For logout and + /// account switches: the next account must never see this one's chats. + /// Stored sessions are left alone. + pub async fn reset(&self) { + let shared = &self.shared; + shared.generation.fetch_add(1, Ordering::SeqCst); + shared.ed.cancel(); + shared.approvals.cancel_all().await; + let mut state = shared.state.lock().await; + state.cache.clear(); + state.active = None; + shared.ed.clear_history().await; + } + + /// Save the live history under `session`, unless `generation` is stale. + pub async fn save(&self, session: &str, generation: u64) { + let shared = &self.shared; + let live = shared.ed.history().await; + let mut state = shared.state.lock().await; + if !self.is_current(generation) { + return; + } + if let Err(err) = shared.store.save(session, &live).await { + error!("ed-tauri: could not save session {session}: {err}"); + } + state.cache.insert(session.to_owned(), live); + } + + /// Run one agent turn in `session` and deliver the result as a + /// `chat_reply` event. + /// + /// Returns as soon as the session is active and the turn is spawned. A + /// long-pending invoke can be dropped by a mobile webview, so the answer + /// travels by event: `{id, session, reply, duration, error}`. + pub async fn submit(&self, session: &str, id: &str, message: impl Into) { + let message = message.into(); + self.submit_with(session, id, move |ed| async move { ed.run(message).await }) + .await; + } + + /// Like [`submit`](EdSessions::submit), with the turn supplied by the + /// host: rewrite the message, or recover and retry on a known engine + /// failure. The closure runs on the spawned task. + pub async fn submit_with(&self, session: &str, id: &str, turn: F) + where + F: FnOnce(Arc>) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + { + self.activate(session).await; + let generation = self.generation(); + let this = self.clone(); + let session = session.to_owned(); + let id = id.to_owned(); + info!("ed-tauri: dispatch session={session} id={id}"); + + tokio::spawn(async move { + this.shared.scope.set(&session, &id); + let result = turn(this.shared.ed.clone()).await; + this.shared.scope.clear(); + + if !this.is_current(generation) { + info!("ed-tauri: reply dropped, the account context changed (session={session})"); + return; + } + this.save(&session, generation).await; + if !this.is_current(generation) { + return; + } + + let payload = match result { + Ok(reply) => SubmitReplyPayload { + id, + session, + reply: Some(reply.text), + duration: Some(reply.duration), + error: None, + }, + Err(err) => { + error!("ed-tauri: turn failed: {err}"); + SubmitReplyPayload { + id, + session, + reply: None, + duration: None, + error: Some( + this.shared + .error_message + .clone() + .unwrap_or_else(|| err.to_string()), + ), + } + } + }; + if let Err(err) = this.shared.app.emit(EVENT_CHAT_REPLY, payload) { + warn!("ed-tauri: could not emit chat_reply: {err}"); + } + }); + } + + /// Answer a pending approval. + pub async fn respond_approval( + &self, + request_id: &str, + decision: ApprovalDecision, + ) -> Result<(), String> { + self.shared.approvals.respond(request_id, decision).await + } +} diff --git a/crates/ed-agent-tauri/src/sink.rs b/crates/ed-agent-tauri/src/sink.rs index b4a3f0e..b267986 100644 --- a/crates/ed-agent-tauri/src/sink.rs +++ b/crates/ed-agent-tauri/src/sink.rs @@ -8,10 +8,11 @@ use serde::Serialize; use tauri::{AppHandle, Emitter}; use crate::events::{ - ChatStatusPayload, EVENT_CHAT_AGENT_REPLY, EVENT_CHAT_APPROVAL_REQUESTED, EVENT_CHAT_REPLY, - EVENT_CHAT_STATUS_CHANGED, EVENT_CHAT_TOOL_FINISHED, EVENT_CHAT_TOOL_REQUESTED, - EVENT_CHAT_TOOL_STARTED, EVENT_CHAT_WARNING, + ChatStatusPayload, TextDeltaPayload, EVENT_CHAT_AGENT_REPLY, EVENT_CHAT_APPROVAL_REQUESTED, + EVENT_CHAT_REPLY, EVENT_CHAT_STATUS_CHANGED, EVENT_CHAT_TEXT_DELTA, EVENT_CHAT_TOOL_FINISHED, + EVENT_CHAT_TOOL_REQUESTED, EVENT_CHAT_TOOL_STARTED, EVENT_CHAT_WARNING, }; +use crate::scope::TurnScope; /// An [`EventSink`] that emits to the webview. /// @@ -21,11 +22,21 @@ use crate::events::{ /// listening. pub struct TauriSink { app: AppHandle, + scope: TurnScope, + emit_approvals: bool, } impl TauriSink { pub fn new(app: AppHandle) -> Self { - Self { app } + Self::scoped(app, TurnScope::default(), true) + } + + pub(crate) fn scoped(app: AppHandle, scope: TurnScope, emit_approvals: bool) -> Self { + Self { + app, + scope, + emit_approvals, + } } fn emit(&self, event: &str, payload: impl Serialize + Clone) { @@ -55,7 +66,23 @@ impl EventSink for TauriSink { self.emit(EVENT_CHAT_TOOL_REQUESTED, call); } + fn text_delta(&self, delta: &str) { + let (session, id) = self.scope.get(); + self.emit( + EVENT_CHAT_TEXT_DELTA, + TextDeltaPayload { + session, + id, + delta: delta.to_owned(), + }, + ); + } + fn approval_requested(&self, request: &ApprovalRequest) { + // `TauriApprovals` emits its own, richer event for the same name. + if !self.emit_approvals { + return; + } self.emit(EVENT_CHAT_APPROVAL_REQUESTED, request); } From 5f0a2a3b198b3e50f4555ce612e88fe786c997d2 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:12:51 +0200 Subject: [PATCH 03/28] Add ed-mcp: MCP client glue over rmcp, with streamable HTTP Server configuration for stdio and streamable HTTP (with an optional bearer token), tool namespacing, timeouts, progress and cancellation. The agent feature adds McpExecutor, which serves a toolset to Ed with risk taken from readOnlyHint plus a host override list. The acp feature maps ACP server lists. Tests run against an in-process rmcp HTTP server. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: siGit Code v1.6.3 --- Cargo.lock | 440 +++++++++++++++++ Cargo.toml | 24 +- crates/ed-mcp/Cargo.toml | 42 ++ crates/ed-mcp/README.md | 33 ++ crates/ed-mcp/src/executor.rs | 199 ++++++++ crates/ed-mcp/src/lib.rs | 821 +++++++++++++++++++++++++++++++ crates/ed-mcp/src/test_server.rs | 185 +++++++ crates/ed-mcp/tests/http.rs | 219 +++++++++ 8 files changed, 1962 insertions(+), 1 deletion(-) create mode 100644 crates/ed-mcp/Cargo.toml create mode 100644 crates/ed-mcp/README.md create mode 100644 crates/ed-mcp/src/executor.rs create mode 100644 crates/ed-mcp/src/lib.rs create mode 100644 crates/ed-mcp/src/test_server.rs create mode 100644 crates/ed-mcp/tests/http.rs diff --git a/Cargo.lock b/Cargo.lock index 2b8c028..105da47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,56 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "agent-client-protocol" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13f780597b2c88ab83d02509bcc3e86366751f86fa12114005e35c55ed691763" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-io", + "async-process", + "blocking", + "futures", + "futures-concurrency", + "rustc-hash 2.1.3", + "rustix", + "schemars 1.2.2", + "serde", + "serde_json", + "shell-words", + "tracing", + "uuid 1.27.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4893bfb11e0e193aa5a64611a126ee87567acb1879556519c902722899229de" +dependencies = [ + "quote", + "syn 3.0.6", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d595a79e1665d02c91c7dd1bedd118501e75cc0a3d5b1651b5150ec4c5c1e0d" +dependencies = [ + "anyhow", + "derive_more", + "schemars 1.2.2", + "serde", + "serde_json", + "serde_with", + "strum 0.28.0", + "tracing", +] + [[package]] name = "ahash" version = "0.8.12" @@ -272,6 +322,18 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compat" version = "0.2.6" @@ -285,6 +347,77 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.92" @@ -408,6 +541,58 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.13.1" @@ -525,6 +710,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bm25" version = "2.3.2" @@ -961,6 +1159,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.15.11" @@ -1530,6 +1737,7 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.119", + "unicode-xid", ] [[package]] @@ -1769,6 +1977,25 @@ dependencies = [ "uuid 1.27.0", ] +[[package]] +name = "ed-mcp" +version = "1.0.1" +dependencies = [ + "agent-client-protocol", + "anyhow", + "async-trait", + "axum", + "ed-agent", + "ed-mcp", + "futures", + "http", + "rmcp", + "serde_json", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "ed-uniffi-bindgen" version = "0.1.0" @@ -1931,6 +2158,26 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "exr" version = "1.74.2" @@ -2013,6 +2260,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -2140,6 +2393,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec 1.16.2", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -2163,6 +2429,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -3032,6 +3311,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.11.1" @@ -3046,6 +3331,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec 1.16.2", @@ -3871,6 +4157,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "matrixmultiply" version = "0.3.11" @@ -5045,6 +5337,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -5145,12 +5443,43 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -5202,6 +5531,20 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.3", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -5318,6 +5661,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-wrap" +version = "10.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f21b97672d2dc848e7b25701ab4535618b92f4861c13cc3f7f7bed52ad3c8da" +dependencies = [ + "futures", + "indexmap 2.14.2", + "nix", + "tokio", + "tracing", + "windows 0.62.2", +] + [[package]] name = "profiling" version = "1.0.18" @@ -5908,6 +6265,39 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "indexmap 2.14.2", + "pastey 0.2.3", + "pin-project-lite", + "process-wrap", + "rand 0.10.3", + "reqwest 0.13.5", + "schemars 1.2.2", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid 1.27.0", +] + [[package]] name = "rubato" version = "0.16.2" @@ -6171,6 +6561,7 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ + "chrono", "dyn-clone", "ref-cast", "schemars_derive 1.2.2", @@ -6442,6 +6833,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_plain" version = "1.0.2" @@ -6587,6 +6989,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -6850,6 +7258,19 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -7860,6 +8281,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -8046,6 +8478,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -8084,6 +8517,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -8314,6 +8748,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unicode_categories" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index f56a6b6..950f984 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,12 @@ [workspace] resolver = "2" -members = ["crates/ed-agent", "crates/ed-agent-ffi", "crates/ed-agent-tauri", "uniffi-bindgen"] +members = [ + "crates/ed-agent", + "crates/ed-agent-ffi", + "crates/ed-agent-tauri", + "crates/ed-mcp", + "uniffi-bindgen", +] # Shared across every crate in the workspace so a release moves one number. # `sync-version` tooling (see the Makefile) reads these. @@ -30,10 +36,26 @@ tauri = { version = "2", features = [] } # versions break extraction with "Invalid string data" errors. uniffi = { version = "=0.31.0", features = ["tokio"] } smbcloud-gresiq-sdk = "0.4.7" + +# Onde Agent Platform v0.1 baseline (onde-web .agents/specs/onde-agent-platform, §5.4). +# The ACP crates (ed-mcp, ed-acp, ed-acp-tui, ed-acp-testkit) use these; ed-agent does not. +agent-client-protocol = "2.2" +rmcp = { version = "3.5", default-features = false } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "stream"] } +ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "layout-cache", "unstable-rendered-line-info"] } +crossterm = { version = "0.29", features = ["event-stream"] } +tokio-util = "0.7" +futures = "0.3" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1", features = ["v4"] } +directories = "6" +base64 = "0.23" # Internal ed-agent = { version = "1.0.1", path = "crates/ed-agent" } +ed-mcp = { version = "1.0.1", path = "crates/ed-mcp" } # gemm-common (via candle, via onde) depends on sysctl for every # `target_vendor = "apple"`, but sysctl 0.6.0 only has a `sys` module behind diff --git a/crates/ed-mcp/Cargo.toml b/crates/ed-mcp/Cargo.toml new file mode 100644 index 0000000..c184014 --- /dev/null +++ b/crates/ed-mcp/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "ed-mcp" +description = "MCP for Onde agents: server configuration, tool namespacing, timeouts, progress and cancellation over rmcp, the official Rust SDK." +keywords = ["mcp", "llm", "agent", "onde"] +categories = ["asynchronous"] +version.workspace = true +edition = "2024" +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +agent-client-protocol = { workspace = true, optional = true } +ed-agent = { workspace = true, optional = true } +async-trait = { workspace = true, optional = true } +http = "1" +rmcp = { workspace = true, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "reqwest"] } +tokio = { workspace = true, features = ["sync", "time", "process", "macros", "io-std", "io-util", "rt"] } +tokio-util.workspace = true +futures.workspace = true +serde_json.workspace = true +anyhow.workspace = true +tracing.workspace = true + +[features] +# Map ACP `session/new` MCP server lists (`McpToolset::connect`, `ServerSpec::from_acp`). +# Pulls in `agent-client-protocol`; `ed-acp` enables it. +acp = ["dep:agent-client-protocol"] +# `McpExecutor`: an `McpToolset` as Ed tools. Pulls in `ed-agent` (and with it `onde`). +agent = ["dep:ed-agent", "dep:async-trait"] +# A scriptable MCP server for tests: `echo`, `slow_progress`, `wait_for_cancel`, and the +# demucs-shaped `list_models` / `separate_stems`. +test-server = [] + +[dev-dependencies] +rmcp = { workspace = true, features = ["server", "transport-streamable-http-server"] } +axum = "0.8" +tokio = { workspace = true, features = ["net", "rt-multi-thread"] } +# Run the feature-gated code's tests under a plain `cargo test`. +ed-mcp = { path = ".", features = ["acp", "agent", "test-server"] } diff --git a/crates/ed-mcp/README.md b/crates/ed-mcp/README.md new file mode 100644 index 0000000..efdd3bf --- /dev/null +++ b/crates/ed-mcp/README.md @@ -0,0 +1,33 @@ +# ed-mcp + +MCP client glue over [`rmcp`](https://crates.io/crates/rmcp), the official Rust SDK, for +[Ed](https://crates.io/crates/ed-agent) agents. + +`rmcp` is the client. This crate owns what every agent was writing around it: server +configuration (stdio child processes and streamable HTTP with an optional bearer token), +`mcp____` namespacing, per-server timeouts, progress and cancellation, and read-only +gating from `readOnlyHint`. A server that fails to start is logged and left out. + +```rust +use ed_mcp::{ClientInfo, McpToolset, ServerSpec, ToolsetConfig}; + +let spec = ServerSpec::http("mail", "https://api.example.com/v1/mcp").with_bearer(token); +let tools = McpToolset::connect_specs( + ToolsetConfig::new(ClientInfo::new("my-app", "1.0")), + &[spec], + &[], +) +.await; +``` + +## Features + +- `acp`: map `McpServer` lists from an Agent Client Protocol `session/new` request. +- `agent`: `McpExecutor` serves a toolset to Ed as tools, with risk from `readOnlyHint` plus a + host override list, and optional unprefixed names for single-server apps. +- `test-server`: a scriptable MCP server for tests. + +## Copyright + +© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)). +Licensed under MIT or Apache-2.0. diff --git a/crates/ed-mcp/src/executor.rs b/crates/ed-mcp/src/executor.rs new file mode 100644 index 0000000..6f0c66d --- /dev/null +++ b/crates/ed-mcp/src/executor.rs @@ -0,0 +1,199 @@ +//! An [`McpToolset`] as [Ed](ed_agent) tools. Behind the `agent` feature. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use async_trait::async_trait; +use ed_agent::{AgentToolDefinition, Ed, EventSink, ToolExecutor, ToolRisk}; +use serde_json::Value; +use tokio::sync::{Mutex, RwLock}; +use tokio_util::sync::CancellationToken; + +use crate::{McpTool, McpToolset}; + +/// How MCP tools are presented to Ed. +#[derive(Debug, Clone, Default)] +pub struct ToolPolicy { + read_only: HashSet, + unprefixed: bool, +} + +impl ToolPolicy { + /// Default policy: tool names are `mcp____`, and a tool is read-only only when + /// its server sets `readOnlyHint: true`. + pub fn new() -> Self { + Self::default() + } + + /// Treat these tools as read-only (no approval) whatever their server says. Names are the + /// server's own tool names, or the names the model sees. For servers that do not set + /// `readOnlyHint`. + pub fn read_only(mut self, names: &[&str]) -> Self { + self.read_only.extend(names.iter().map(|n| (*n).to_owned())); + self + } + + /// Show the model the server's own tool names, without the `mcp____` prefix. For apps + /// with one server whose tool names are already in the system prompt. If two servers use the + /// same tool name the first one wins. + pub fn unprefixed(mut self) -> Self { + self.unprefixed = true; + self + } + + fn risk(&self, tool: &McpTool, exposed: &str) -> ToolRisk { + if tool.read_only || self.read_only.contains(exposed) || self.read_only.contains(&tool.tool) + { + ToolRisk::ReadOnly + } else { + ToolRisk::Mutating + } + } +} + +struct Loaded { + toolset: Arc, + /// Name the model sees -> qualified name in the toolset. + names: HashMap, + definitions: Vec, +} + +/// Runs the tools of an [`McpToolset`] for Ed. +/// +/// Cheap to clone. Hand one clone to [`Ed::with_agent`] as the executor, and keep another to +/// connect and disconnect servers as the user signs in and out: +/// +/// ```ignore +/// let mcp = McpExecutor::new(ToolPolicy::new().read_only(&["list_messages"]).unprefixed()); +/// let ed = Ed::with_agent(sink, Arc::new(mcp.clone()), approvals, config); +/// mcp.set_toolset(Some(toolset)); +/// mcp.sync(&ed).await; +/// ``` +#[derive(Clone)] +pub struct McpExecutor { + policy: Arc, + loaded: Arc>>, + registered: Arc>>, +} + +impl McpExecutor { + pub fn new(policy: ToolPolicy) -> Self { + Self { + policy: Arc::new(policy), + loaded: Arc::default(), + registered: Arc::default(), + } + } + + /// Use `toolset`, or none, and shut the previous toolset down. Call + /// [`sync`](McpExecutor::sync) afterwards. A tool call still running on the old toolset + /// finishes first; its connections close when it does. + pub async fn set_toolset(&self, toolset: Option) { + let new = toolset.map(|toolset| { + let mut names = HashMap::new(); + let mut definitions = Vec::new(); + for tool in toolset.tools() { + let exposed = if self.policy.unprefixed { + tool.tool.clone() + } else { + tool.name.clone() + }; + if names.contains_key(&exposed) { + tracing::warn!( + "duplicate tool name `{exposed}` after prefix removal; keeping the first" + ); + continue; + } + let description = if self.policy.unprefixed { + tool.description + .strip_prefix(&format!("[{} MCP server] ", tool.server)) + .unwrap_or(&tool.description) + .to_owned() + } else { + tool.description.clone() + }; + let description = if description.trim().is_empty() { + format!("The `{}` tool.", tool.tool) + } else { + description + }; + let schema = tool.parameters.to_string(); + definitions.push(match self.policy.risk(tool, &exposed) { + ToolRisk::ReadOnly => { + AgentToolDefinition::read_only(exposed.clone(), description, schema) + } + ToolRisk::Mutating => { + AgentToolDefinition::mutating(exposed.clone(), description, schema) + } + }); + names.insert(exposed, tool.name.clone()); + } + Loaded { + toolset: Arc::new(toolset), + names, + definitions, + } + }); + let old = std::mem::replace(&mut *self.loaded.write().await, new); + if let Some(old) = old + && let Some(toolset) = Arc::into_inner(old.toolset) + { + toolset.shutdown().await; + } + } + + /// The tools Ed should offer, for the connected toolset (empty when none). + pub async fn definitions(&self) -> Vec { + self.loaded + .read() + .await + .as_ref() + .map(|l| l.definitions.clone()) + .unwrap_or_default() + } + + /// Make `ed`'s registered tools match the connected toolset: the ones this executor registered + /// before are removed, the current ones added. + pub async fn sync(&self, ed: &Ed) { + let mut registered = self.registered.lock().await; + for name in registered.drain(..) { + ed.unregister_tool(&name).await; + } + for definition in self.definitions().await { + let name = definition.name.clone(); + match ed.register_tool(definition).await { + Ok(()) => registered.push(name), + Err(error) => tracing::warn!("could not register MCP tool `{name}`: {error}"), + } + } + } +} + +#[async_trait] +impl ToolExecutor for McpExecutor { + async fn execute(&self, tool_name: &str, arguments: &str) -> Result { + let args: Value = + serde_json::from_str(arguments).map_err(|e| format!("bad arguments: {e}"))?; + // Clone out of the lock: a call can run for a long time and must not hold up a + // disconnect. + let (toolset, qualified) = { + let guard = self.loaded.read().await; + let loaded = guard.as_ref().ok_or("no MCP server is connected")?; + let qualified = loaded + .names + .get(tool_name) + .ok_or_else(|| format!("unknown tool `{tool_name}`"))? + .clone(); + (loaded.toolset.clone(), qualified) + }; + let outcome = toolset + .call(&qualified, args, &CancellationToken::new(), &|_| {}) + .await + .map_err(|e| e.to_string())?; + if outcome.failed { + Err(outcome.text) + } else { + Ok(outcome.text) + } + } +} diff --git a/crates/ed-mcp/src/lib.rs b/crates/ed-mcp/src/lib.rs new file mode 100644 index 0000000..0aee09b --- /dev/null +++ b/crates/ed-mcp/src/lib.rs @@ -0,0 +1,821 @@ +//! MCP for Onde agents, over [`rmcp`], the official Rust SDK. +//! +//! This crate is not an MCP client: `rmcp` is. It owns what every Onde agent was writing for +//! itself around one: +//! +//! - **Server configuration.** Stdio servers supplied by an ACP client in +//! `session/new|load|resume`, plus process-wide built-in servers ([`SharedServer`]) that start +//! at most once, on first need. A client-supplied server with the same name as a built-in wins. +//! - **Tool namespacing.** Tools are exposed to the model as `mcp____`, sanitised +//! and capped at [`MAX_TOOL_NAME`] bytes. +//! - **Read-only gating.** A tool is read-only only when its server sets `readOnlyHint: true`. +//! - **Calls.** Progress notifications are handed to a callback, session cancellation is +//! forwarded as `notifications/cancelled`, and every call has a deadline. +//! +//! A server that fails to start or to list its tools is never fatal: it is logged and its tools +//! are absent. +//! +//! Servers are reached over stdio (a child process) or streamable HTTP with an optional bearer +//! token, described by a [`ServerSpec`]. +//! +//! Features: `acp` maps ACP `McpServer` lists, `agent` adds [`McpExecutor`] to expose a toolset +//! as [Ed](ed_agent) tools, and `test-server` adds a scriptable server for tests. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow, bail}; +use futures::StreamExt; +use rmcp::handler::client::progress::ProgressDispatcher; +use rmcp::model::{ + CallToolRequestParams, ClientCapabilities, ClientRequest, ContentBlock, Implementation, + ProgressNotificationParam, Request, ServerResult, Tool, +}; +use rmcp::service::{NotificationContext, PeerRequestOptions, RunningService}; +use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; +use rmcp::transport::{StreamableHttpClientTransport, TokioChildProcess}; +use rmcp::{ClientHandler, RoleClient, ServiceExt}; +use serde_json::{Map, Value, json}; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +pub use rmcp; + +#[cfg(feature = "agent")] +mod executor; +#[cfg(feature = "agent")] +pub use executor::{McpExecutor, ToolPolicy}; + +#[cfg(feature = "test-server")] +pub mod test_server; + +/// Prefix of every MCP tool name the model sees. +pub const PREFIX: &str = "mcp__"; +/// Longest tool name sent to the model; OpenAI-compatible endpoints reject longer ones. +pub const MAX_TOOL_NAME: usize = 64; +/// Time a server has to finish the `initialize` handshake and answer `tools/list`. +pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(20); +/// Upper bound for one tool call. Long, since some tools (stem separation) take many minutes. +pub const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(60 * 60); + +/// Split `mcp____` into its parts. +pub fn split_qualified(name: &str) -> Option<(&str, &str)> { + let rest = name.strip_prefix(PREFIX)?; + let (server, tool) = rest.split_once("__")?; + (!server.is_empty() && !tool.is_empty()).then_some((server, tool)) +} + +/// The name the model sees for `tool` on `server`. +pub fn qualified(server: &str, tool: &str) -> String { + let mut name = format!("{PREFIX}{}__{}", sanitize(server), sanitize(tool)); + name.truncate(MAX_TOOL_NAME); + name +} + +fn sanitize(part: &str) -> String { + let mut out: String = part + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c + } else { + '_' + } + }) + .collect(); + while out.contains("__") { + out = out.replace("__", "_"); + } + out.trim_matches('_').to_string() +} + +/// Who is connecting: sent to every server in `initialize` as `clientInfo`. +#[derive(Debug, Clone)] +pub struct ClientInfo { + pub name: String, + pub version: String, +} + +impl ClientInfo { + pub fn new(name: impl Into, version: impl Into) -> Self { + Self { + name: name.into(), + version: version.into(), + } + } +} + +/// How to reach a server. +#[derive(Clone)] +pub enum Transport { + /// Start `command args…` with extra environment variables and talk over its stdio. + Stdio { + command: PathBuf, + args: Vec, + env: Vec<(String, String)>, + }, + /// Streamable HTTP. `bearer` is the token without the `Bearer ` prefix. + Http { + url: String, + bearer: Option, + headers: Vec<(String, String)>, + }, +} + +impl std::fmt::Debug for Transport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Stdio { command, args, .. } => f + .debug_struct("Stdio") + .field("command", command) + .field("args", args) + .finish_non_exhaustive(), + // The token and header values are credentials; never print them. + Self::Http { url, .. } => f + .debug_struct("Http") + .field("url", url) + .finish_non_exhaustive(), + } + } +} + +/// A server to connect to. +#[derive(Debug, Clone)] +pub struct ServerSpec { + pub name: String, + pub transport: Transport, +} + +impl ServerSpec { + /// A stdio server. + pub fn stdio( + name: impl Into, + command: impl Into, + args: Vec, + env: Vec<(String, String)>, + ) -> Self { + Self { + name: name.into(), + transport: Transport::Stdio { + command: command.into(), + args, + env, + }, + } + } + + /// A streamable HTTP server. + pub fn http(name: impl Into, url: impl Into) -> Self { + Self { + name: name.into(), + transport: Transport::Http { + url: url.into(), + bearer: None, + headers: Vec::new(), + }, + } + } + + /// Send `Authorization: Bearer `. No effect on a stdio server. + pub fn with_bearer(mut self, token: impl Into) -> Self { + if let Transport::Http { bearer, .. } = &mut self.transport { + *bearer = Some(token.into()); + } + self + } + + /// Send an extra header on every request. No effect on a stdio server. + pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { + if let Transport::Http { headers, .. } = &mut self.transport { + headers.push((name.into(), value.into())); + } + self + } + + /// The spec for an ACP-supplied server. + #[cfg(feature = "acp")] + pub fn from_acp(server: &agent_client_protocol::schema::v1::McpServer) -> Option { + use agent_client_protocol::schema::v1::McpServer; + match server { + McpServer::Stdio(s) => Some(Self::stdio( + s.name.clone(), + s.command.clone(), + s.args.clone(), + s.env + .iter() + .map(|e| (e.name.clone(), e.value.clone())) + .collect(), + )), + McpServer::Http(h) => Some(Self { + name: h.name.clone(), + transport: Transport::Http { + url: h.url.clone(), + bearer: None, + headers: h + .headers + .iter() + .map(|h| (h.name.clone(), h.value.clone())) + .collect(), + }, + }), + _ => None, + } + } +} + +struct Handler { + progress: ProgressDispatcher, + info: ClientInfo, +} + +impl ClientHandler for Handler { + async fn on_progress( + &self, + params: ProgressNotificationParam, + _context: NotificationContext, + ) { + self.progress.handle_notification(params).await; + } + + fn get_info(&self) -> rmcp::model::ClientConfig { + rmcp::model::ClientConfig::new( + ClientCapabilities::default(), + Implementation::new(self.info.name.clone(), self.info.version.clone()), + ) + } +} + +/// One live connection to an MCP server. +pub struct Connection { + name: String, + service: RunningService, + tools: Vec, +} + +impl Connection { + /// Connect to `spec`, run the handshake and list its tools, each within `timeout`. + pub async fn connect(spec: &ServerSpec, info: &ClientInfo, timeout: Duration) -> Result { + let handler = Handler { + progress: ProgressDispatcher::new(), + info: info.clone(), + }; + let handshake = match &spec.transport { + Transport::Stdio { command, args, env } => { + let mut cmd = tokio::process::Command::new(command); + cmd.args(args).envs(env.iter().map(|(k, v)| (k, v))); + let transport = TokioChildProcess::new(cmd) + .with_context(|| format!("starting {}", command.display()))?; + tokio::time::timeout(timeout, handler.serve(transport)).await + } + Transport::Http { + url, + bearer, + headers, + } => { + let mut config = StreamableHttpClientTransportConfig::with_uri(url.as_str()); + if let Some(token) = bearer { + config = config.auth_header(token.as_str()); + } + if !headers.is_empty() { + let mut map = HashMap::new(); + for (name, value) in headers { + map.insert( + http::HeaderName::from_bytes(name.as_bytes()) + .with_context(|| format!("invalid header name `{name}`"))?, + http::HeaderValue::from_str(value) + .with_context(|| format!("invalid value for header `{name}`"))?, + ); + } + config = config.custom_headers(map); + } + let transport = StreamableHttpClientTransport::from_config(config); + tokio::time::timeout(timeout, handler.serve(transport)).await + } + }; + let service = handshake + .map_err(|_| anyhow!("no MCP handshake within {}s", timeout.as_secs()))? + .map_err(|e| anyhow!("MCP handshake failed: {e}"))?; + let tools = tokio::time::timeout(timeout, service.peer().list_all_tools()) + .await + .map_err(|_| anyhow!("tools/list timed out"))? + .map_err(|e| anyhow!("tools/list failed: {e}"))?; + Ok(Self { + name: spec.name.clone(), + service, + tools, + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + /// Call a tool on this server directly, outside any turn (for example to build a + /// permission prompt). Answers `None` on any failure or after `timeout`. + pub async fn call_raw(&self, tool: &str, args: Value, timeout: Duration) -> Option { + let mut params = CallToolRequestParams::new(tool.to_string()); + if let Value::Object(map) = args { + params = params.with_arguments(map); + } + let result = tokio::time::timeout(timeout, self.service.peer().call_tool(params)) + .await + .ok()? + .ok()?; + serde_json::to_value(result).ok() + } + + async fn close(mut self) { + let _ = self + .service + .close_with_timeout(Duration::from_secs(2)) + .await; + } +} + +enum SharedState { + NotStarted, + Unavailable, + Running(Arc), +} + +/// A built-in server shared by every session in the process, started at most once, on first +/// need. `resolve` decides at that moment whether it is available: `None` (disabled, binary +/// missing) is logged once and the server stays absent for the life of the process. +/// +/// ```ignore +/// static DEMUCS: SharedServer = SharedServer::new("demucs", demucs_spec); +/// ``` +pub struct SharedServer { + name: &'static str, + resolve: fn() -> Option, + state: Mutex, +} + +impl SharedServer { + pub const fn new(name: &'static str, resolve: fn() -> Option) -> Self { + Self { + name, + resolve, + state: Mutex::const_new(SharedState::NotStarted), + } + } + + pub fn name(&self) -> &'static str { + self.name + } + + async fn get(&self, info: &ClientInfo, timeout: Duration) -> Option> { + let mut state = self.state.lock().await; + match &*state { + SharedState::Running(conn) => return Some(conn.clone()), + SharedState::Unavailable => return None, + SharedState::NotStarted => {} + } + *state = SharedState::Unavailable; + let Some(spec) = (self.resolve)() else { + tracing::info!("built-in MCP server `{}` is not available", self.name); + return None; + }; + match Connection::connect(&spec, info, timeout).await { + Ok(conn) => { + tracing::info!("built-in MCP server `{}` available", self.name); + let conn = Arc::new(conn); + *state = SharedState::Running(conn.clone()); + Some(conn) + } + Err(e) => { + tracing::info!("built-in MCP server `{}` unavailable: {e:#}", self.name); + None + } + } + } + + /// Stop the server if it was started. Call once every session's [`McpToolset`] has been + /// shut down, so this is the last owner. + pub async fn shutdown(&self) { + let state = std::mem::replace(&mut *self.state.lock().await, SharedState::Unavailable); + if let SharedState::Running(conn) = state + && let Some(conn) = Arc::into_inner(conn) + { + conn.close().await; + } + } +} + +/// How to connect a session's servers. +#[derive(Clone)] +pub struct ToolsetConfig { + pub client: ClientInfo, + pub connect_timeout: Duration, + pub call_timeout: Duration, +} + +impl ToolsetConfig { + pub fn new(client: ClientInfo) -> Self { + Self { + client, + connect_timeout: DEFAULT_CONNECT_TIMEOUT, + call_timeout: DEFAULT_CALL_TIMEOUT, + } + } +} + +/// One MCP tool as the model sees it. +#[derive(Debug, Clone)] +pub struct McpTool { + /// `mcp____`. + pub name: String, + pub server: String, + /// The tool's name on its server. + pub tool: String, + /// The server's description, prefixed with `[ MCP server]`. + pub description: String, + /// JSON Schema for the arguments; always has `"type": "object"`. + pub parameters: Value, + pub read_only: bool, +} + +struct Entry { + conn: Arc, + tool: McpTool, +} + +/// What a call produced, flattened for the model. +#[derive(Debug, Clone)] +pub struct CallOutcome { + /// Text content joined by newlines; non-text blocks are described in brackets, and + /// `structuredContent` is used when there is no content at all. + pub text: String, + /// The server reported `isError: true`, or the call failed, was cancelled or timed out. + pub failed: bool, + /// `structuredContent`, when the server sent one. + pub structured: Option, +} + +impl CallOutcome { + fn failed(text: impl Into) -> Self { + Self { + text: text.into(), + failed: true, + structured: None, + } + } +} + +/// The MCP tools available to one session. +pub struct McpToolset { + config: ToolsetConfig, + tools: HashMap, + conns: Vec>, +} + +impl McpToolset { + /// A toolset with no servers. + pub fn empty(config: ToolsetConfig) -> Self { + Self { + config, + tools: HashMap::new(), + conns: Vec::new(), + } + } + + /// Connect the client-supplied ACP servers (stdio and HTTP) and every built-in the client + /// did not supply a server of the same name for. + #[cfg(feature = "acp")] + pub async fn connect( + config: ToolsetConfig, + client_servers: &[agent_client_protocol::schema::v1::McpServer], + builtins: &[&'static SharedServer], + ) -> Self { + let specs: Vec = client_servers + .iter() + .filter_map(|server| { + let spec = ServerSpec::from_acp(server); + if spec.is_none() { + tracing::info!("ignoring MCP server of an unsupported transport"); + } + spec + }) + .collect(); + Self::connect_specs(config, &specs, builtins).await + } + + /// Connect `servers` and every built-in no server of the same name was supplied for. A server + /// that fails is logged and left out. + pub async fn connect_specs( + config: ToolsetConfig, + servers: &[ServerSpec], + builtins: &[&'static SharedServer], + ) -> Self { + let mut set = Self::empty(config); + for spec in servers { + match Connection::connect(spec, &set.config.client, set.config.connect_timeout).await { + Ok(conn) => set.add(Arc::new(conn)), + Err(e) => tracing::warn!("MCP server `{}` unavailable: {e:#}", spec.name), + } + } + for builtin in builtins { + if servers.iter().any(|s| s.name == builtin.name()) { + continue; + } + if let Some(conn) = builtin + .get(&set.config.client, set.config.connect_timeout) + .await + { + set.add(conn); + } + } + set + } + + fn add(&mut self, conn: Arc) { + for tool in &conn.tools { + let name = qualified(&conn.name, &tool.name); + if self.tools.contains_key(&name) { + tracing::warn!("duplicate MCP tool name {name}; keeping the first"); + continue; + } + let mut parameters = Value::Object((*tool.input_schema).clone()); + if parameters.get("type").is_none() { + parameters["type"] = json!("object"); + } + let read_only = tool + .annotations + .as_ref() + .and_then(|a| a.read_only_hint) + .unwrap_or(false); + let entry = McpTool { + name: name.clone(), + server: conn.name.clone(), + tool: tool.name.to_string(), + description: format!( + "[{} MCP server] {}", + conn.name, + tool.description.as_deref().unwrap_or("") + ), + parameters, + read_only, + }; + self.tools.insert( + name, + Entry { + conn: conn.clone(), + tool: entry, + }, + ); + } + self.conns.push(conn); + } + + /// Every tool, sorted by name. + pub fn tools(&self) -> Vec<&McpTool> { + let mut tools: Vec<&McpTool> = self.tools.values().map(|e| &e.tool).collect(); + tools.sort_by(|a, b| a.name.cmp(&b.name)); + tools + } + + /// The tool the model calls `name`, if any. + pub fn tool(&self, name: &str) -> Option<&McpTool> { + self.tools.get(name).map(|e| &e.tool) + } + + /// Whether `tool` on `server` is connected. + pub fn has_tool(&self, server: &str, tool: &str) -> bool { + self.tools.contains_key(&qualified(server, tool)) + } + + /// The connection serving the model-facing tool `name`. + pub fn connection(&self, name: &str) -> Option<&Connection> { + self.tools.get(name).map(|e| e.conn.as_ref()) + } + + /// Call `name` with `args`. `on_progress` gets one line per MCP progress notification + /// (`"Separating (25%)"`). Cancelling `cancel` sends `notifications/cancelled` and returns + /// a failed outcome. Only an unknown tool or malformed arguments are an `Err`. + pub async fn call( + &self, + name: &str, + args: Value, + cancel: &CancellationToken, + on_progress: &(dyn Fn(String) + Send + Sync), + ) -> Result { + let entry = self + .tools + .get(name) + .ok_or_else(|| anyhow!("unknown tool `{name}`"))?; + let arguments: Map = match args { + Value::Object(o) => o, + Value::Null => Map::new(), + _ => bail!("tool arguments must be a JSON object"), + }; + let params = CallToolRequestParams::new(entry.tool.tool.clone()).with_arguments(arguments); + let mut handle = match entry + .conn + .service + .peer() + .send_cancellable_request( + ClientRequest::CallToolRequest(Request::new(params)), + PeerRequestOptions::no_options(), + ) + .await + { + Ok(handle) => handle, + Err(e) => { + return Ok(CallOutcome::failed(format!( + "MCP server `{}` is not responding: {e}", + entry.conn.name + ))); + } + }; + let mut progress = entry + .conn + .service + .service() + .progress + .subscribe(handle.progress_token.clone()) + .await; + let deadline = tokio::time::sleep(self.config.call_timeout); + tokio::pin!(deadline); + + enum End { + Done(Box>), + Cancelled, + TimedOut, + } + let end = loop { + tokio::select! { + r = &mut handle.rx => { + break End::Done(Box::new(r.unwrap_or(Err(rmcp::ServiceError::TransportClosed)))); + } + Some(p) = progress.next() => on_progress(progress_line(&p)), + () = cancel.cancelled() => break End::Cancelled, + () = &mut deadline => break End::TimedOut, + } + }; + Ok(match end { + End::Done(done) => match *done { + Ok(ServerResult::CallToolResult(r)) => outcome_from(r), + Ok(_) => CallOutcome::failed("The MCP server sent an unexpected response."), + Err(e) => CallOutcome::failed(format!("MCP call failed: {e}")), + }, + End::Cancelled => { + let _ = handle.cancel(Some("cancelled by user".into())).await; + CallOutcome::failed("Cancelled by user.") + } + End::TimedOut => { + let _ = handle.cancel(Some("timed out".into())).await; + CallOutcome::failed(format!( + "Timed out after {} minutes.", + self.config.call_timeout.as_secs() / 60 + )) + } + }) + } + + /// Close this session's own servers. Built-ins stay up for other sessions. + pub async fn shutdown(self) { + drop(self.tools); + for conn in self.conns { + if let Some(conn) = Arc::into_inner(conn) { + conn.close().await; + } + } + } +} + +/// One line for an MCP progress notification: its message, and a percentage when it has a +/// total. +pub fn progress_line(p: &ProgressNotificationParam) -> String { + let pct = p + .total + .filter(|t| *t > 0.0) + .map(|t| format!(" ({:.0}%)", 100.0 * p.progress / t)); + format!( + "{}{}", + p.message.as_deref().unwrap_or("Working…"), + pct.unwrap_or_default() + ) +} + +fn outcome_from(r: rmcp::model::CallToolResult) -> CallOutcome { + let mut parts = Vec::new(); + for block in &r.content { + match block { + ContentBlock::Text(t) => parts.push(t.text.clone()), + ContentBlock::Image(i) => parts.push(format!("[image: {}]", i.mime_type)), + ContentBlock::Audio(a) => parts.push(format!("[audio: {}]", a.mime_type)), + ContentBlock::ResourceLink(l) => parts.push(format!("[resource: {}]", l.uri)), + ContentBlock::Resource(_) => parts.push("[embedded resource]".into()), + _ => parts.push("[unsupported content]".into()), + } + } + let mut text = parts.join("\n"); + if text.is_empty() { + text = r + .structured_content + .as_ref() + .map(|v| v.to_string()) + .unwrap_or_else(|| "(no output)".into()); + } + CallOutcome { + text, + failed: r.is_error == Some(true), + structured: r.structured_content, + } +} + +/// `true` when an `*_OFF`-style switch is set to `off`, `0`, `false` or `no`. +pub fn switched_off(var: &str) -> bool { + std::env::var(var).is_ok_and(|v| { + matches!( + v.to_ascii_lowercase().as_str(), + "off" | "0" | "false" | "no" + ) + }) +} + +/// `override_var` when set and non-empty, else `binary` found on `PATH`. +pub fn find_binary(override_var: &str, binary: &str) -> Option { + if let Some(bin) = std::env::var_os(override_var).filter(|v| !v.is_empty()) { + return Some(bin.into()); + } + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|dir| dir.join(binary)) + .find(|candidate| is_file(candidate)) +} + +fn is_file(path: &Path) -> bool { + path.is_file() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn qualified_names_round_trip() { + let q = qualified("My Server!", "do.thing"); + assert_eq!(q, "mcp__My_Server__do_thing"); + assert_eq!(split_qualified(&q), Some(("My_Server", "do_thing"))); + assert_eq!(split_qualified("read_file"), None); + assert_eq!(split_qualified("mcp__x"), None); + assert_eq!(split_qualified("mcp____t"), None); + assert!(qualified("s", &"t".repeat(100)).len() <= MAX_TOOL_NAME); + } + + #[test] + fn progress_text() { + let p = ProgressNotificationParam::new( + rmcp::model::ProgressToken(rmcp::model::NumberOrString::Number(1)), + 25.0, + ) + .with_total(100.0) + .with_message("Separating"); + assert_eq!(progress_line(&p), "Separating (25%)"); + let bare = ProgressNotificationParam::new( + rmcp::model::ProgressToken(rmcp::model::NumberOrString::Number(2)), + 3.0, + ); + assert_eq!(progress_line(&bare), "Working…"); + } + + #[test] + fn structured_content_stands_in_for_empty_content() { + let r: rmcp::model::CallToolResult = serde_json::from_value(json!({ + "content": [], + "structuredContent": {"stems": []}, + "isError": false + })) + .unwrap(); + let out = outcome_from(r); + assert_eq!(out.text, r#"{"stems":[]}"#); + assert!(!out.failed); + assert_eq!(out.structured, Some(json!({"stems": []}))); + } + + #[test] + fn errors_are_reported_as_failed() { + let r: rmcp::model::CallToolResult = serde_json::from_value(json!({ + "content": [{"type": "text", "text": "busy"}], + "isError": true + })) + .unwrap(); + let out = outcome_from(r); + assert_eq!(out.text, "busy"); + assert!(out.failed); + } + + #[test] + fn off_switches() { + // SAFETY: only this test touches this variable. + unsafe { std::env::set_var("ED_MCP_TEST_SWITCH", "OFF") }; + assert!(switched_off("ED_MCP_TEST_SWITCH")); + unsafe { std::env::set_var("ED_MCP_TEST_SWITCH", "on") }; + assert!(!switched_off("ED_MCP_TEST_SWITCH")); + unsafe { std::env::remove_var("ED_MCP_TEST_SWITCH") }; + assert!(!switched_off("ED_MCP_TEST_SWITCH")); + } +} diff --git a/crates/ed-mcp/src/test_server.rs b/crates/ed-mcp/src/test_server.rs new file mode 100644 index 0000000..6401e54 --- /dev/null +++ b/crates/ed-mcp/src/test_server.rs @@ -0,0 +1,185 @@ +//! A test MCP server over stdio (newline-delimited JSON-RPC). Stands in for third-party servers +//! and for built-ins such as `demucs --mcp`: it ignores its arguments. A product exposes it as a +//! binary whose `main` calls [`run`] and points `ed_acp_testkit::Target::mcp_stub` at it. Behind the +//! `test-server` feature. +//! +//! Set `STUB_PID_FILE` to have it write its process id, so a test can check it was stopped. +//! +//! Tools: `echo` (read-only), `slow_progress` (three progress notifications), +//! `wait_for_cancel` (answers only after `notifications/cancelled`), and the demucs-shaped +//! `list_models` / `separate_stems`. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +fn tools() -> Value { + json!([ + { + "name": "echo", + "description": "Echo the text back.", + "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}, + "annotations": {"readOnlyHint": true} + }, + { + "name": "slow_progress", + "description": "Report progress three times, then finish.", + "inputSchema": {"type": "object", "properties": {}} + }, + { + "name": "wait_for_cancel", + "description": "Blocks until the call is cancelled.", + "inputSchema": {"type": "object", "properties": {}} + }, + { + "name": "list_models", + "description": "List stem separation models.", + "inputSchema": {"type": "object", "properties": {}}, + "annotations": {"readOnlyHint": true} + }, + { + "name": "separate_stems", + "description": "Pretend to separate stems.", + "inputSchema": {"type": "object", "properties": {"input": {"type": "string"}, "model": {"type": "string"}}, "required": ["input"]} + } + ]) +} + +/// Serve on stdin/stdout until stdin closes. +pub async fn run() { + // Lets tests find out whether this process is still alive after the agent closes it. + if let Some(path) = std::env::var_os("STUB_PID_FILE") { + let _ = std::fs::write(path, std::process::id().to_string()); + } + let out = Arc::new(tokio::sync::Mutex::new(tokio::io::stdout())); + let cancelled: Arc>> = Arc::default(); + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + + let send = { + let out = out.clone(); + move |msg: Value| { + let out = out.clone(); + async move { + let mut o = out.lock().await; + let _ = o.write_all(format!("{msg}\n").as_bytes()).await; + let _ = o.flush().await; + } + } + }; + + while let Ok(Some(line)) = lines.next_line().await { + let Ok(msg) = serde_json::from_str::(&line) else { + continue; + }; + let method = msg + .get("method") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let id = msg.get("id").cloned(); + let params = msg.get("params").cloned().unwrap_or(Value::Null); + let Some(id) = id else { + if method == "notifications/cancelled" + && let Some(rid) = params.get("requestId") + { + cancelled.lock().unwrap().insert(rid.to_string()); + } + continue; + }; + match method.as_str() { + "initialize" => { + let version = params + .get("protocolVersion") + .cloned() + .unwrap_or(json!("2025-06-18")); + send(json!({"jsonrpc": "2.0", "id": id, "result": { + "protocolVersion": version, + "capabilities": {"tools": {}}, + "serverInfo": {"name": "stub", "version": "0.0.0"} + }})) + .await; + } + "ping" => send(json!({"jsonrpc": "2.0", "id": id, "result": {}})).await, + "tools/list" => { + send(json!({"jsonrpc": "2.0", "id": id, "result": {"tools": tools()}})).await + } + "tools/call" => { + let send = send.clone(); + let cancelled = cancelled.clone(); + tokio::spawn(async move { + let name = params + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let args = params.get("arguments").cloned().unwrap_or(json!({})); + let token = params.pointer("/_meta/progressToken").cloned(); + let progress = |n: u32, msg: &str| { + token.as_ref().map(|t| { + json!({"jsonrpc": "2.0", "method": "notifications/progress", + "params": {"progressToken": t, "progress": n, "total": 100, "message": msg}}) + }) + }; + let result = match name.as_str() { + "echo" => { + let text = args.get("text").and_then(Value::as_str).unwrap_or(""); + json!({"content": [{"type": "text", "text": format!("echo: {text}")}], "isError": false}) + } + "slow_progress" | "separate_stems" => { + for (n, m) in [(10, "starting"), (50, "halfway"), (90, "almost")] { + if let Some(p) = progress(n, m) { + send(p).await; + } + tokio::time::sleep(Duration::from_millis(40)).await; + } + if name == "slow_progress" { + json!({"content": [{"type": "text", "text": "done"}], "isError": false}) + } else { + let input = args + .get("input") + .and_then(Value::as_str) + .unwrap_or("/tmp/x.wav"); + let dir = format!("{}_stems", input.trim_end_matches(".wav")); + let stems: Vec = ["drums", "bass", "vocals", "other"] + .iter() + .map(|s| json!({"id": s, "path": format!("{dir}/{s}.wav")})) + .collect(); + json!({ + "content": [{"type": "text", "text": format!("Separated into {} stems", stems.len())}], + "structuredContent": {"stems": stems}, + "isError": false + }) + } + } + "list_models" => json!({ + "content": [{"type": "text", "text": "htdemucs 80 MB"}], + "structuredContent": {"models": [ + {"id": "htdemucs", "size_mb": 80, "cached": false}, + {"id": "htdemucs_6s", "size_mb": 55, "cached": true} + ]}, + "isError": false + }), + "wait_for_cancel" => { + let key = id.to_string(); + while !cancelled.lock().unwrap().contains(&key) { + tokio::time::sleep(Duration::from_millis(20)).await; + } + json!({"content": [{"type": "text", "text": "cancelled"}], "isError": true}) + } + other => { + send(json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32602, "message": format!("unknown tool {other}")}})).await; + return; + } + }; + send(json!({"jsonrpc": "2.0", "id": id, "result": result})).await; + }); + } + other => { + send(json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32601, "message": format!("method not found: {other}")}})).await; + } + } + } +} diff --git a/crates/ed-mcp/tests/http.rs b/crates/ed-mcp/tests/http.rs new file mode 100644 index 0000000..a799652 --- /dev/null +++ b/crates/ed-mcp/tests/http.rs @@ -0,0 +1,219 @@ +//! Streamable HTTP against an in-process rmcp server, and the Ed executor on top of it. + +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::Request; +use axum::http::StatusCode; +use axum::middleware::{self, Next}; +use axum::response::Response; +use ed_agent::{ToolExecutor, ToolRisk}; +use ed_mcp::{ + ClientInfo, Connection, McpExecutor, McpToolset, ServerSpec, ToolPolicy, ToolsetConfig, +}; +use rmcp::RoleServer; +use rmcp::ServerHandler; +use rmcp::model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult, + ServerCapabilities, ServerConfig, Tool, ToolAnnotations, +}; +use rmcp::service::RequestContext; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; +use serde_json::json; +use tokio_util::sync::CancellationToken; + +const TOKEN: &str = "s3cret"; + +#[derive(Clone)] +struct Mailbox; + +impl ServerHandler for Mailbox { + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let schema = Arc::new(json!({"type": "object"}).as_object().unwrap().clone()); + Ok(ListToolsResult { + tools: vec![ + // Declares itself read-only. + Tool::new("whoami", "Who is signed in.", schema.clone()) + .annotate(ToolAnnotations::new().read_only(true)), + // Says nothing, like a server that predates annotations. + Tool::new("list_messages", "Recent messages.", schema.clone()), + Tool::new("send_message", "Send mail.", schema), + ], + ..Default::default() + }) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + Ok( + CallToolResult::success(vec![ContentBlock::text(format!("ran {}", request.name))]) + .into(), + ) + } +} + +async fn require_bearer(request: Request, next: Next) -> Result { + let ok = request + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + == Some(&format!("Bearer {TOKEN}")); + if ok { + Ok(next.run(request).await) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + +/// Serve [`Mailbox`] behind bearer auth; returns its URL. +async fn serve() -> String { + let config = StreamableHttpServerConfig::default() + .with_legacy_session_mode(true) + .with_sse_keep_alive(None) + .with_cancellation_token(CancellationToken::new()); + let service: StreamableHttpService = + StreamableHttpService::new(|| Ok(Mailbox), Default::default(), config); + let router = axum::Router::new() + .nest_service("/mcp", service) + .layer(middleware::from_fn(require_bearer)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + format!("http://{addr}/mcp") +} + +fn client() -> ClientInfo { + ClientInfo::new("ed-mcp-test", "0") +} + +#[tokio::test] +async fn connects_lists_and_calls_with_a_bearer_token() { + let url = serve().await; + let spec = ServerSpec::http("mail", url).with_bearer(TOKEN); + + let set = McpToolset::connect_specs(ToolsetConfig::new(client()), &[spec], &[]).await; + + let names: Vec<_> = set.tools().iter().map(|t| t.name.clone()).collect(); + assert_eq!( + names, + [ + "mcp__mail__list_messages", + "mcp__mail__send_message", + "mcp__mail__whoami" + ] + ); + let out = set + .call( + "mcp__mail__whoami", + json!({}), + &CancellationToken::new(), + &|_| {}, + ) + .await + .unwrap(); + assert_eq!(out.text, "ran whoami"); + assert!(!out.failed); + set.shutdown().await; +} + +#[tokio::test] +async fn a_wrong_bearer_token_is_rejected() { + let url = serve().await; + let spec = ServerSpec::http("mail", url).with_bearer("nope"); + + let err = Connection::connect(&spec, &client(), Duration::from_secs(5)) + .await + .err() + .expect("the server answers 401"); + assert!(err.to_string().contains("handshake failed"), "{err:#}"); + + // A rejected server is left out, not fatal. + let set = McpToolset::connect_specs(ToolsetConfig::new(client()), &[spec], &[]).await; + assert!(set.tools().is_empty()); +} + +#[tokio::test] +async fn a_missing_bearer_token_is_rejected() { + let url = serve().await; + let spec = ServerSpec::http("mail", url); + assert!( + Connection::connect(&spec, &client(), Duration::from_secs(5)) + .await + .is_err() + ); +} + +async fn executor(policy: ToolPolicy) -> McpExecutor { + let url = serve().await; + let spec = ServerSpec::http("mail", url).with_bearer(TOKEN); + let set = McpToolset::connect_specs(ToolsetConfig::new(client()), &[spec], &[]).await; + let exec = McpExecutor::new(policy); + exec.set_toolset(Some(set)).await; + exec +} + +fn risk(defs: &[ed_agent::AgentToolDefinition], name: &str) -> ToolRisk { + defs.iter().find(|d| d.name == name).unwrap().risk +} + +#[tokio::test] +async fn risk_follows_the_read_only_hint() { + let defs = executor(ToolPolicy::new()).await.definitions().await; + + assert_eq!(risk(&defs, "mcp__mail__whoami"), ToolRisk::ReadOnly); + // No hint means it may change things. + assert_eq!(risk(&defs, "mcp__mail__list_messages"), ToolRisk::Mutating); + assert_eq!(risk(&defs, "mcp__mail__send_message"), ToolRisk::Mutating); +} + +#[tokio::test] +async fn the_host_can_override_risk_and_drop_the_prefix() { + let policy = ToolPolicy::new() + .read_only(&["list_messages", "whoami"]) + .unprefixed(); + let exec = executor(policy).await; + let defs = exec.definitions().await; + + assert_eq!(risk(&defs, "list_messages"), ToolRisk::ReadOnly); + assert_eq!(risk(&defs, "whoami"), ToolRisk::ReadOnly); + assert_eq!(risk(&defs, "send_message"), ToolRisk::Mutating); + assert_eq!( + defs.iter() + .find(|d| d.name == "send_message") + .unwrap() + .description, + "Send mail." + ); + + // The executor answers to the names the model sees. + assert_eq!( + exec.execute("send_message", "{}").await.unwrap(), + "ran send_message" + ); + assert!(exec.execute("mcp__mail__send_message", "{}").await.is_err()); +} + +#[tokio::test] +async fn disconnecting_removes_the_tools() { + let exec = executor(ToolPolicy::new()).await; + assert!(!exec.definitions().await.is_empty()); + + exec.set_toolset(None).await; + + assert!(exec.definitions().await.is_empty()); + assert!(exec.execute("mcp__mail__whoami", "{}").await.is_err()); +} From de60c9c9508c65791e9a41e8c833a2fe5378fc7e Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:12:51 +0200 Subject: [PATCH 04/28] ed-acp: prompt content, the Onde Cloud client, session store, CLI First part of the ed-acp crate, split across three PRs so each fits a review. The crate joins the workspace in the third one, so nothing here is built yet. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: siGit Code v1.6.3 --- crates/ed-acp/src/cli.rs | 141 +++++++++ crates/ed-acp/src/content.rs | 411 ++++++++++++++++++++++++++ crates/ed-acp/src/llm.rs | 543 +++++++++++++++++++++++++++++++++++ crates/ed-acp/src/store.rs | 174 +++++++++++ 4 files changed, 1269 insertions(+) create mode 100644 crates/ed-acp/src/cli.rs create mode 100644 crates/ed-acp/src/content.rs create mode 100644 crates/ed-acp/src/llm.rs create mode 100644 crates/ed-acp/src/store.rs diff --git a/crates/ed-acp/src/cli.rs b/crates/ed-acp/src/cli.rs new file mode 100644 index 0000000..5f30793 --- /dev/null +++ b/crates/ed-acp/src/cli.rs @@ -0,0 +1,141 @@ +//! Command-line pieces every Onde agent shares: `--setup` (store and verify the Onde key), +//! `--list-models`, flag parsing and logging. + +use std::io::IsTerminal; + +use anyhow::Context; + +use crate::AgentInfo; +use crate::llm::{LlmClient, LlmConfig, LlmEnv}; + +/// Log to stderr, filtered by `RUST_LOG`. Stdout is reserved for ACP. +pub fn init_logging() { + let _ = tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .try_init(); +} + +/// `1` or `true` (any case). +pub fn env_flag(name: &str) -> bool { + std::env::var(name).is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) +} + +/// Onde credentials are `app-id:app-secret`: exactly one colon, both halves non-empty. +pub fn valid_onde_key(key: &str) -> bool { + let mut parts = key.split(':'); + matches!( + (parts.next(), parts.next(), parts.next()), + (Some(id), Some(secret), None) if !id.is_empty() && !secret.is_empty() + ) +} + +/// `env` file content with `ONDE_API_KEY` set, keeping any other lines. +pub fn with_api_key(existing: &str, key: &str) -> String { + let mut lines: Vec = existing + .lines() + .filter(|l| { + let l = l.trim().strip_prefix("export ").unwrap_or(l.trim()); + !l.starts_with("ONDE_API_KEY=") + }) + .map(String::from) + .collect(); + lines.push(format!("ONDE_API_KEY={key}")); + lines.join("\n") + "\n" +} + +/// Interactive first-run setup (`--setup`): prompt for the Onde key, verify it against Onde +/// Cloud, and store it in the platform config dir (`config_dir()/env`, mode 0600). +pub async fn setup(info: &AgentInfo) -> anyhow::Result<()> { + use std::io::Write; + + if !std::io::stdin().is_terminal() { + anyhow::bail!("--setup needs an interactive terminal"); + } + println!("{} setup\n", info.name); + println!("Get credentials: sign in at https://ondeinference.com/root/login,"); + println!("register an app and assign a model. Your key is \"app-id:app-secret\".\n"); + let key = loop { + print!("Paste your ONDE_API_KEY: "); + std::io::stdout().flush()?; + let mut line = String::new(); + if std::io::stdin().read_line(&mut line)? == 0 { + anyhow::bail!("no input"); + } + let key = line.trim().to_string(); + if valid_onde_key(&key) { + break key; + } + println!("Onde credentials look like \"app-id:app-secret\" (exactly one colon)."); + }; + + // Verify before writing anything. + let mut config = LlmConfig::from_env(&info.env); + config.api_key = Some(key.clone()); + print!("Verifying the key with Onde Cloud… "); + std::io::stdout().flush()?; + match LlmClient::new(config).check_auth().await { + Ok(()) => println!("OK"), + Err(e) => anyhow::bail!("\nKey check failed: {e:#}\nNothing was written."), + } + + let dir = info + .env + .config_dir() + .context("no config directory available")?; + std::fs::create_dir_all(&dir)?; + let path = dir.join("env"); + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + std::fs::write(&path, with_api_key(&existing, &key))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + println!("\nWrote {}. You're all set.", path.display()); + Ok(()) +} + +/// Print the models the configured endpoint serves, one per line (`idowner`). +pub async fn list_models(info: &AgentInfo) -> anyhow::Result<()> { + let client = LlmClient::new(LlmConfig::from_env(&info.env)); + for m in client.models().await? { + match m.owned_by { + Some(owner) => println!("{}\t{}", m.id, owner), + None => println!("{}", m.id), + } + } + Ok(()) +} + +/// How the agent was launched: `tui` (interactive terminal UI) or `acp` (editor). +/// The TUI sets `_SURFACE=tui` on its subprocess; editors launch `--acp` +/// directly so the default is `acp`. +pub fn surface(env: &LlmEnv) -> &'static str { + match std::env::var(env.var("SURFACE")) { + Ok(s) if s == "tui" => "tui", + _ => "acp", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn onde_key_shape() { + assert!(valid_onde_key("app:secret")); + assert!(!valid_onde_key("secret")); + assert!(!valid_onde_key("a:b:c")); + assert!(!valid_onde_key(":secret")); + assert!(!valid_onde_key("app:")); + assert!(!valid_onde_key("")); + } + + #[test] + fn env_file_keeps_other_lines_and_replaces_key() { + let out = with_api_key("# hi\nX_MODEL=m\nexport ONDE_API_KEY=old:old\n", "new:key"); + assert_eq!(out, "# hi\nX_MODEL=m\nONDE_API_KEY=new:key\n"); + assert_eq!(with_api_key("", "a:b"), "ONDE_API_KEY=a:b\n"); + } +} diff --git a/crates/ed-acp/src/content.rs b/crates/ed-acp/src/content.rs new file mode 100644 index 0000000..28456bf --- /dev/null +++ b/crates/ed-acp/src/content.rs @@ -0,0 +1,411 @@ +//! Converting ACP prompt content into model content: text, embedded and linked files +//! (inlined, a selection as just its lines), images, and audio saved to a scratch file. + +use std::path::{Path, PathBuf}; + +use agent_client_protocol::schema::v1::{ + ClientCapabilities, ContentBlock, EmbeddedResource, EmbeddedResourceResource, + ReadTextFileRequest, ResourceLink, SessionId, TextResourceContents, +}; +use agent_client_protocol::{Client, ConnectionTo}; +use anyhow::{Context, Result}; +use base64::Engine; +use serde_json::{Value, json}; + +fn embedded_text(res: &EmbeddedResource) -> Option { + match &res.resource { + EmbeddedResourceResource::TextResourceContents(r) => { + Some(format!("\n{}\n", r.uri, r.text)) + } + // Describe binary resources rather than dropping them, so the model knows they exist. + EmbeddedResourceResource::BlobResourceContents(b) => Some(format!( + "[Attached binary resource: {} ({})]", + b.uri, + b.mime_type.as_deref().unwrap_or("unknown type") + )), + _ => None, + } +} + +/// How audio reaches the model. A product with an audio tool points the model at it here. +#[derive(Clone, Copy)] +pub struct AudioHints { + /// The text for a `file://` link to an audio file, given its path. + pub link: fn(&Path) -> String, + /// The text for an attached clip, given its MIME type and the path it was saved to. + pub attachment: fn(&str, &Path) -> String, +} + +impl Default for AudioHints { + fn default() -> Self { + Self { + link: |path| format!("[Referenced audio file: {}]", path.display()), + attachment: |mime, path| { + format!("[Attached audio ({mime}) saved at {}]", path.display()) + }, + } + } +} + +/// How a resource link is shown to the model. Audio files are never read as text; they get +/// the product's [`AudioHints::link`] text instead. +fn link_text(link: &ResourceLink, hints: &AudioHints) -> String { + match parse_file_link(&link.uri) { + Some(FileLink { path, .. }) if is_audio(&path, link.mime_type.as_deref()) => { + (hints.link)(&path) + } + _ => format!("[Referenced: {}]", link.uri), + } +} + +/// Common audio file extensions; a link to one is never read as text. +const AUDIO_EXTENSIONS: &[&str] = &[ + "wav", "wave", "mp3", "flac", "m4a", "aac", "mp4", "ogg", "oga", "opus", "aif", "aiff", "aifc", + "caf", "webm", "mkv", +]; + +fn is_audio(path: &Path, mime: Option<&str>) -> bool { + mime.is_some_and(|m| m.starts_with("audio/")) + || path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| AUDIO_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str())) +} + +/// Largest file a `file://` resource link is inlined for; bigger ones stay as references. +const MAX_LINKED_FILE_BYTES: usize = 256 * 1024; + +/// Replace `file://` resource links to text files with their embedded contents, so the model +/// sees the file. Reads through the client when it supports `fs/read_text_file` (unsaved +/// buffers), else from disk. A link to a selection (`#L10:20`) is inlined as just those +/// lines. Audio files, links that can't be read and oversized files stay as references. +pub async fn resolve_resource_links( + blocks: &[ContentBlock], + caps: &ClientCapabilities, + connection: &ConnectionTo, + session_id: &SessionId, +) -> Vec { + let mut out = Vec::with_capacity(blocks.len()); + for block in blocks { + if let ContentBlock::ResourceLink(link) = block + && let Some(FileLink { path, lines }) = parse_file_link(&link.uri) + && !is_audio(&path, link.mime_type.as_deref()) + { + let text = if caps.fs.read_text_file { + connection + .send_request(ReadTextFileRequest::new(session_id.clone(), path.clone())) + .block_task() + .await + .map(|r| r.content) + .map_err(|e| e.to_string()) + } else { + tokio::fs::read_to_string(&path) + .await + .map_err(|e| e.to_string()) + }; + let text = match lines { + Some(range) => text.map(|t| slice_lines(&t, range)), + None => text, + }; + match text { + Ok(text) if lines.is_some() && text.is_empty() => { + tracing::debug!("{} selects no lines", link.uri) + } + Ok(text) if text.len() <= MAX_LINKED_FILE_BYTES => { + out.push(ContentBlock::Resource(EmbeddedResource::new( + EmbeddedResourceResource::TextResourceContents( + TextResourceContents::new(text, link.uri.clone()) + .mime_type(link.mime_type.clone()), + ), + ))); + continue; + } + Ok(_) => tracing::debug!("{} too large to inline", link.uri), + Err(e) => tracing::debug!("could not read {}: {e}", link.uri), + } + } + out.push(block.clone()); + } + out +} + +/// A `file://` resource link: the absolute path, and the lines it selects, if any. +#[derive(Debug, PartialEq)] +struct FileLink { + path: PathBuf, + /// 1-based, inclusive. + lines: Option<(usize, usize)>, +} + +/// Parse a `file://` URI, percent-decoding the path. Editors put more than the path in it: +/// Zed links a selection as `file:///a.md?column=5#L10:20` and a symbol as +/// `file:///a.rs?symbol=main#L3:9`. The query is dropped and the fragment read as a line +/// range; a literal `?` or `#` in a file name arrives percent-encoded, so splitting first is +/// safe. +fn parse_file_link(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + let (rest, fragment) = match rest.split_once('#') { + Some((rest, fragment)) => (rest, Some(fragment)), + None => (rest, None), + }; + let rest = rest.split_once('?').map_or(rest, |(rest, _)| rest); + // Allow an authority of "" or "localhost"; reject other hosts. + let path = if rest.starts_with('/') { + rest + } else { + rest.strip_prefix("localhost")? + }; + let bytes = path.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && i + 2 < bytes.len() + && let Ok(b) = u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).ok()?, 16) + { + decoded.push(b); + i += 3; + } else { + decoded.push(bytes[i]); + i += 1; + } + } + let path = PathBuf::from(String::from_utf8(decoded).ok()?); + path.is_absolute().then(|| FileLink { + path, + lines: fragment.and_then(parse_line_range), + }) +} + +/// A `#L10:20` fragment as a 1-based inclusive range. Accepts `L10:20`, `L10-20`, `L10-L20` +/// and a single line `L10`. +fn parse_line_range(fragment: &str) -> Option<(usize, usize)> { + let range = fragment.strip_prefix('L')?; + let (start, end) = range + .split_once(':') + .or_else(|| range.split_once('-')) + .unwrap_or((range, range)); + let end = end.strip_prefix('L').unwrap_or(end); + let (start, end) = (start.parse::().ok()?, end.parse::().ok()?); + (start >= 1 && end >= start).then_some((start, end)) +} + +/// Lines `start..=end` (1-based) of `text`, keeping their line endings. +fn slice_lines(text: &str, (start, end): (usize, usize)) -> String { + text.split_inclusive('\n') + .skip(start - 1) + .take(end - start + 1) + .collect() +} + +/// Flatten text-like prompt blocks into one string (used for titles and slash commands). +pub fn prompt_to_text(blocks: &[ContentBlock], hints: &AudioHints) -> String { + let mut parts = Vec::new(); + for block in blocks { + match block { + ContentBlock::Text(t) => parts.push(t.text.clone()), + ContentBlock::ResourceLink(link) => parts.push(link_text(link, hints)), + ContentBlock::Resource(res) => parts.extend(embedded_text(res)), + _ => {} + } + } + parts.join("\n\n") +} + +fn audio_extension(mime: &str) -> &'static str { + match mime.split(';').next().unwrap_or("").trim() { + "audio/wav" | "audio/x-wav" | "audio/wave" | "audio/vnd.wave" => "wav", + "audio/mpeg" | "audio/mp3" => "mp3", + "audio/flac" | "audio/x-flac" => "flac", + "audio/ogg" | "audio/vorbis" | "application/ogg" => "ogg", + "audio/mp4" | "audio/m4a" | "audio/x-m4a" | "audio/aac" => "m4a", + "audio/aiff" | "audio/x-aiff" => "aiff", + "audio/webm" | "video/webm" => "webm", + _ => "audio", + } +} + +/// Write each audio block under `dir`; returns the saved path per audio block, in order. +pub async fn save_audio_blocks(blocks: &[ContentBlock], dir: &Path) -> Result> { + let mut saved = Vec::new(); + for block in blocks { + let ContentBlock::Audio(audio) = block else { + continue; + }; + let bytes = base64::engine::general_purpose::STANDARD + .decode(audio.data.trim()) + .context("audio block is not valid base64")?; + tokio::fs::create_dir_all(dir).await?; + let path = dir.join(format!( + "audio-{}.{}", + uuid::Uuid::new_v4().simple(), + audio_extension(&audio.mime_type) + )); + tokio::fs::write(&path, bytes).await?; + saved.push(path); + } + Ok(saved) +} + +/// Convert prompt blocks to an OpenAI `content` value: a string for text only, or parts when +/// images are present. Audio is referenced by the path it was saved to, worded by +/// [`AudioHints::attachment`]. `slash` replaces the first text block (an expanded slash +/// command). +pub fn prompt_to_content( + blocks: &[ContentBlock], + audio_paths: &[PathBuf], + slash: Option<&str>, + hints: &AudioHints, +) -> Value { + let mut audio = audio_paths.iter(); + let mut parts: Vec = Vec::new(); + let mut slash = slash; + let mut has_image = false; + let text = |t: String| json!({ "type": "text", "text": t }); + for block in blocks { + match block { + ContentBlock::Text(t) => match slash.take() { + Some(expanded) => parts.push(text(expanded.to_string())), + None => parts.push(text(t.text.clone())), + }, + ContentBlock::ResourceLink(link) => parts.push(text(link_text(link, hints))), + ContentBlock::Resource(res) => { + if let Some(t) = embedded_text(res) { + parts.push(text(t)); + } + } + ContentBlock::Image(img) => { + has_image = true; + let url = format!("data:{};base64,{}", img.mime_type, img.data); + parts.push(json!({ "type": "image_url", "image_url": { "url": url } })); + } + ContentBlock::Audio(a) => match audio.next() { + Some(path) => parts.push(text((hints.attachment)(&a.mime_type, path))), + None => parts.push(text("[Attached audio could not be saved]".into())), + }, + _ => {} + } + } + if has_image { + return Value::Array(parts); + } + let joined = parts + .iter() + .filter_map(|p| p.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n\n"); + Value::String(joined) +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_client_protocol::schema::v1::{AudioContent, ImageContent, TextContent}; + + fn link(uri: &str) -> Option { + parse_file_link(uri) + } + + #[test] + fn plain_paths_decode_and_must_be_absolute() { + assert_eq!( + link("file:///a%20b/c.md"), + Some(FileLink { + path: "/a b/c.md".into(), + lines: None + }) + ); + assert_eq!( + link("file://localhost/x.md").unwrap().path, + Path::new("/x.md") + ); + assert_eq!(link("file://other-host/x.md"), None); + assert_eq!(link("https://example.com/x.md"), None); + } + + #[test] + fn zed_selection_and_symbol_links() { + let sel = link("file:///notes/song.md?column=5#L10:20").unwrap(); + assert_eq!(sel.path, Path::new("/notes/song.md")); + assert_eq!(sel.lines, Some((10, 20))); + assert_eq!( + link("file:///a.rs?symbol=main#L3:9").unwrap().lines, + Some((3, 9)) + ); + } + + #[test] + fn line_range_forms() { + assert_eq!(parse_line_range("L10:20"), Some((10, 20))); + assert_eq!(parse_line_range("L10-20"), Some((10, 20))); + assert_eq!(parse_line_range("L10-L20"), Some((10, 20))); + assert_eq!(parse_line_range("L7"), Some((7, 7))); + assert_eq!(parse_line_range("L0"), None); + assert_eq!(parse_line_range("L5:2"), None); + assert_eq!(parse_line_range("x"), None); + } + + #[test] + fn slices_inclusive_lines() { + assert_eq!(slice_lines("a\nb\nc\nd\n", (2, 3)), "b\nc\n"); + assert_eq!(slice_lines("a\nb", (2, 9)), "b"); + assert_eq!(slice_lines("a\n", (5, 6)), ""); + } + + #[test] + fn audio_links_use_the_product_hint_and_are_never_read() { + let hints = AudioHints { + link: |p| format!("analyze {}", p.display()), + ..AudioHints::default() + }; + let wav = ResourceLink::new("mix.wav", "file:///music/mix%20v2.WAV"); + assert_eq!(link_text(&wav, &hints), "analyze /music/mix v2.WAV"); + let by_mime = + ResourceLink::new("take", "file:///t/take").mime_type("audio/flac".to_string()); + assert_eq!( + link_text(&by_mime, &AudioHints::default()), + "[Referenced audio file: /t/take]" + ); + let md = ResourceLink::new("notes", "file:///t/notes.md"); + assert_eq!(link_text(&md, &hints), "[Referenced: file:///t/notes.md]"); + } + + #[tokio::test] + async fn audio_is_saved_and_referenced() { + let dir = std::env::temp_dir().join(format!("sf-prompt-{}", uuid::Uuid::new_v4())); + let data = base64::engine::general_purpose::STANDARD.encode(b"RIFFfake"); + let blocks = vec![ + ContentBlock::Text(TextContent::new("what key?")), + ContentBlock::Audio(AudioContent::new(data, "audio/wav")), + ]; + let saved = save_audio_blocks(&blocks, &dir).await.unwrap(); + assert_eq!(saved.len(), 1); + assert_eq!(saved[0].extension().unwrap(), "wav"); + assert_eq!(std::fs::read(&saved[0]).unwrap(), b"RIFFfake"); + let content = prompt_to_content(&blocks, &saved, None, &AudioHints::default()); + let s = content.as_str().unwrap(); + assert!(s.contains("what key?") && s.contains(&saved[0].display().to_string())); + std::fs::remove_dir_all(dir).ok(); + + let bad = vec![ContentBlock::Audio(AudioContent::new("!!!", "audio/wav"))]; + assert!( + save_audio_blocks(&bad, &std::env::temp_dir()) + .await + .is_err() + ); + } + + #[test] + fn images_use_parts_and_slash_replaces_text() { + let blocks = vec![ + ContentBlock::Text(TextContent::new("/chords Am F")), + ContentBlock::Image(ImageContent::new("AAAA", "image/png")), + ]; + let v = prompt_to_content(&blocks, &[], Some("EXPANDED"), &AudioHints::default()); + let parts = v.as_array().unwrap(); + assert_eq!(parts[0]["text"], "EXPANDED"); + assert_eq!(parts[1]["type"], "image_url"); + let plain = prompt_to_content(&blocks[..1], &[], None, &AudioHints::default()); + assert_eq!(plain, json!("/chords Am F")); + } +} diff --git a/crates/ed-acp/src/llm.rs b/crates/ed-acp/src/llm.rs new file mode 100644 index 0000000..8e35123 --- /dev/null +++ b/crates/ed-acp/src/llm.rs @@ -0,0 +1,543 @@ +//! Streaming client for Onde Cloud's OpenAI-compatible `/chat/completions` endpoint: text and +//! reasoning deltas, tool-call accumulation, token usage, and `GET /models`. +//! +//! Superseded by `onde-backend` once it exists (Onde Agent Platform §5.2); the agent depends +//! only on this module's public types so that swap stays local. + +use anyhow::{Context, Result, bail}; +use futures::StreamExt; +use serde::Deserialize; +use serde_json::{Value, json}; + +/// Onde Cloud's OpenAI-compatible endpoint. +pub const DEFAULT_BASE_URL: &str = "https://cloud.ondeinference.com/v1"; +/// The model used when neither the profile nor the environment names one. +pub const DEFAULT_MODEL: &str = "onde-kkk"; +/// The credential every Onde agent reads: `app-id:app-secret`. +pub const API_KEY_VAR: &str = "ONDE_API_KEY"; + +/// Where an agent's settings come from: `_BASE_URL`, `_MODEL` and +/// `_DATA_DIR` in the environment, then `//env`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LlmEnv { + /// Environment prefix, e.g. `SPLITFIRE`. + pub prefix: &'static str, + /// Directory name under the platform config dir, e.g. `splitfire-agent`. + pub dir_name: &'static str, + /// Model when `_MODEL` is unset. + pub default_model: &'static str, +} + +impl LlmEnv { + pub fn var(&self, suffix: &str) -> String { + format!("{}_{suffix}", self.prefix) + } + + /// Directory for the stored `env` file: `~/.config/` on Linux, + /// `~/Library/Application Support/` on macOS, `%APPDATA%\\` on + /// Windows. `_DATA_DIR` overrides it (tests). `None` without a home directory. + pub fn config_dir(&self) -> Option { + if let Some(dir) = std::env::var_os(self.var("DATA_DIR")).filter(|v| !v.is_empty()) { + return Some(dir.into()); + } + directories::BaseDirs::new().map(|dirs| { + #[cfg(target_os = "linux")] + let base = dirs.config_dir(); + #[cfg(not(target_os = "linux"))] + let base = dirs.data_dir(); + base.join(self.dir_name) + }) + } + + /// The stored `env` file `--setup` writes and `logout` removes. + pub fn config_file(&self) -> Option { + self.config_dir().map(|d| d.join("env")) + } + + /// Where durable state (sessions, audio scratch) lives. Same directory as the `env` file. + pub fn data_dir(&self) -> std::path::PathBuf { + self.config_dir() + .unwrap_or_else(|| std::env::temp_dir().join(self.dir_name)) + } + + fn load_file_vars(&self) -> std::collections::HashMap { + let mut vars = std::collections::HashMap::new(); + let content = self + .config_file() + .and_then(|p| std::fs::read_to_string(p).ok()); + let Some(content) = content else { + return vars; + }; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let line = line.strip_prefix("export ").unwrap_or(line).trim(); + if let Some((k, v)) = line.split_once('=') { + let mut v = v.trim(); + if v.len() >= 2 + && ((v.starts_with('"') && v.ends_with('"')) + || (v.starts_with('\'') && v.ends_with('\''))) + { + v = &v[1..v.len() - 1]; + } + vars.insert(k.trim().to_string(), v.to_string()); + } + } + vars + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LlmConfig { + pub base_url: String, + /// Onde credentials: `app-id:app-secret`, sent as the bearer token. + pub api_key: Option, + pub model: String, +} + +impl LlmConfig { + /// `ONDE_API_KEY` from the environment, falling back to the stored `env` file. + /// `_BASE_URL` and `_MODEL` override the endpoint and default model. + /// Empty values are ignored: GUI launchers (e.g. Zed via launchd) often export + /// variables set to "", which must not mask the config file. + pub fn from_env(env: &LlmEnv) -> Self { + let file_vars = env.load_file_vars(); + Self::from_lookup(env, |name| { + std::env::var(name) + .ok() + .filter(|v| !v.is_empty()) + .or_else(|| file_vars.get(name).cloned()) + }) + } + + /// Build a config from an arbitrary variable lookup (tests, setup). + pub fn from_lookup(env: &LlmEnv, lookup: impl Fn(&str) -> Option) -> Self { + let get = |name: &str| lookup(name).filter(|v| !v.is_empty()); + Self { + base_url: get(&env.var("BASE_URL")) + .unwrap_or_else(|| DEFAULT_BASE_URL.into()) + .trim_end_matches('/') + .to_string(), + api_key: get(API_KEY_VAR), + model: get(&env.var("MODEL")).unwrap_or_else(|| env.default_model.into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ENV: LlmEnv = LlmEnv { + prefix: "SPLITFIRE", + dir_name: "splitfire-agent", + default_model: DEFAULT_MODEL, + }; + + fn config(vars: &[(&str, &str)]) -> LlmConfig { + LlmConfig::from_lookup(&ENV, |name| { + vars.iter() + .find(|(k, _)| *k == name) + .map(|(_, v)| v.to_string()) + }) + } + + #[test] + fn defaults_point_at_onde_cloud() { + let c = config(&[("ONDE_API_KEY", "app:secret")]); + assert_eq!(c.base_url, "https://cloud.ondeinference.com/v1"); + assert_eq!(c.api_key.as_deref(), Some("app:secret")); + assert_eq!(c.model, "onde-kkk"); + assert_eq!(config(&[]).api_key, None); + assert_eq!(config(&[("ONDE_API_KEY", "")]).api_key, None); + } + + #[test] + fn base_url_and_model_overrides_apply() { + let c = config(&[ + ("ONDE_API_KEY", "app:secret"), + ("SPLITFIRE_MODEL", "onde-prism"), + ("SPLITFIRE_BASE_URL", "http://x/v1/"), + ]); + assert_eq!( + (c.base_url.as_str(), c.model.as_str()), + ("http://x/v1", "onde-prism") + ); + } + + /// One test touches the process environment, so it can't race another. + #[test] + fn key_from_env_then_file_ignoring_empty_env() { + let dir = std::env::temp_dir().join(format!("sf-llm-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("env"), + "# comment\nexport ONDE_API_KEY=\"file:key\"\nSPLITFIRE_MODEL=m1\n", + ) + .unwrap(); + // SAFETY: only this test mutates these variables. + unsafe { + std::env::set_var("SPLITFIRE_DATA_DIR", &dir); + std::env::set_var("ONDE_API_KEY", ""); + } + let c = LlmConfig::from_env(&ENV); + assert_eq!(c.api_key.as_deref(), Some("file:key")); + assert_eq!(c.model, "m1"); + unsafe { std::env::set_var("ONDE_API_KEY", "env:key") }; + assert_eq!( + LlmConfig::from_env(&ENV).api_key.as_deref(), + Some("env:key") + ); + unsafe { + std::env::remove_var("ONDE_API_KEY"); + std::env::remove_var("SPLITFIRE_DATA_DIR"); + } + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn usage_tolerates_missing_total() { + let chunk: Chunk = serde_json::from_str( + r#"{"choices":[],"usage":{"prompt_tokens":7,"completion_tokens":3}}"#, + ) + .unwrap(); + assert_eq!(chunk.usage.unwrap().used(), 10); + let full = Usage { + prompt_tokens: 1, + completion_tokens: 1, + total_tokens: 5, + }; + assert_eq!(full.used(), 5); + } + + #[test] + fn parses_models_response() { + let body = r#"{ + "object": "list", + "data": [ + {"id": "onde-balanced", "object": "model", "created": 1700000000, "owned_by": "onde"}, + {"id": "custom-model"} + ] + }"#; + let parsed: ModelsResponse = serde_json::from_str(body).unwrap(); + assert_eq!(parsed.data.len(), 2); + assert_eq!(parsed.data[0].owned_by.as_deref(), Some("onde")); + assert_eq!(parsed.data[1].owned_by, None); + } +} + +#[derive(Debug, Clone, Default)] +pub struct ToolCallRequest { + pub id: String, + pub name: String, + pub arguments: String, +} + +#[derive(Debug, Default)] +pub struct Completion { + pub content: String, + pub tool_calls: Vec, + pub finish_reason: Option, + /// Token usage, when the endpoint reports it (`stream_options.include_usage`). + pub usage: Option, +} + +#[derive(Debug, Clone, Copy, Default, Deserialize)] +pub struct Usage { + #[serde(default)] + pub prompt_tokens: u64, + #[serde(default)] + pub completion_tokens: u64, + #[serde(default)] + pub total_tokens: u64, +} + +impl Usage { + /// Tokens in the context after this completion. Some servers leave `total_tokens` at 0. + pub fn used(&self) -> u64 { + self.total_tokens + .max(self.prompt_tokens + self.completion_tokens) + } +} + +impl Completion { + /// The assistant message to append to the conversation history. + pub fn to_message(&self) -> Value { + let mut msg = json!({ "role": "assistant", "content": self.content }); + if !self.tool_calls.is_empty() { + msg["tool_calls"] = self + .tool_calls + .iter() + .map(|tc| { + json!({ + "id": tc.id, + "type": "function", + "function": { "name": tc.name, "arguments": tc.arguments }, + }) + }) + .collect(); + } + msg + } +} + +/// Streamed pieces surfaced to the caller as they arrive. +pub enum Delta<'a> { + Text(&'a str), + Reasoning(&'a str), +} + +#[derive(Deserialize)] +struct Chunk { + #[serde(default)] + choices: Vec, + usage: Option, +} + +#[derive(Deserialize)] +struct Choice { + #[serde(default)] + delta: ChunkDelta, + finish_reason: Option, +} + +#[derive(Deserialize, Default)] +struct ChunkDelta { + content: Option, + // Non-standard but common (DeepSeek, vLLM, OpenRouter, llama.cpp). + reasoning_content: Option, + reasoning: Option, + #[serde(default)] + tool_calls: Vec, +} + +#[derive(Deserialize)] +struct ToolCallDelta { + #[serde(default)] + index: usize, + id: Option, + function: Option, +} + +#[derive(Deserialize)] +struct FunctionDelta { + name: Option, + arguments: Option, +} + +/// A model from `GET /v1/models`. Extra fields in the response are ignored. +#[derive(Debug, Clone, Deserialize)] +pub struct ModelInfo { + pub id: String, + #[serde(default)] + pub owned_by: Option, +} + +#[derive(Deserialize)] +struct ModelsResponse { + data: Vec, +} + +#[derive(Debug, Clone)] +pub struct LlmClient { + http: reqwest::Client, + config: LlmConfig, +} + +impl LlmClient { + pub fn new(config: LlmConfig) -> Self { + Self { + http: reqwest::Client::new(), + config, + } + } + + pub fn model(&self) -> &str { + &self.config.model + } + + pub fn config(&self) -> &LlmConfig { + &self.config + } + + /// Whether an API key is configured. + pub fn has_api_key(&self) -> bool { + self.config.api_key.is_some() + } + + /// Verify the configured key with a minimal chat completion. + /// Returns `Err` when no key is set, the key is rejected, or the endpoint is unreachable. + pub async fn check_auth(&self) -> Result<()> { + let Some(key) = &self.config.api_key else { + bail!("no ONDE_API_KEY configured"); + }; + let body = json!({ + "model": self.config.model, + "messages": [{ "role": "user", "content": "ping" }], + "max_tokens": 1, + "stream": false, + }); + let resp = self + .http + .post(format!("{}/chat/completions", self.config.base_url)) + .json(&body) + .bearer_auth(key) + .send() + .await + .context("reaching Onde Cloud")?; + let status = resp.status(); + if matches!(status.as_u16(), 401 | 403) { + bail!("Onde Cloud rejected the API key ({status})"); + } + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + bail!("Onde Cloud returned {status}: {text}"); + } + Ok(()) + } + + /// List the models the configured endpoint serves (`GET {base_url}/models`). + pub async fn models(&self) -> Result> { + let url = format!("{}/models", self.config.base_url); + let mut req = self.http.get(&url); + if let Some(key) = &self.config.api_key { + req = req.bearer_auth(key); + } + let mut resp = req.send().await.context("sending models request")?; + // Some endpoints (Onde Cloud) list models publicly but reject an invalid key, so a + // bad key shouldn't hide the catalog: retry without auth. + if self.config.api_key.is_some() && matches!(resp.status().as_u16(), 401 | 403) { + resp = self + .http + .get(&url) + .send() + .await + .context("sending models request")?; + } + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + bail!("models endpoint returned {status}: {text}"); + } + let mut models = resp + .json::() + .await + .context("parsing models response")? + .data; + models.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(models) + } + + /// Run one streaming chat completion, invoking `on_delta` for every text fragment. + pub async fn complete( + &self, + model: &str, + messages: &[Value], + tools: &Value, + mut on_delta: impl FnMut(Delta<'_>), + ) -> Result { + if self.config.api_key.is_none() { + bail!( + "No API key configured. Set ONDE_API_KEY in the environment or run the agent's `--setup`" + ); + } + let mut body = json!({ + "model": model, + "messages": messages, + "tools": tools, + "stream": true, + "stream_options": { "include_usage": true }, + }); + let send = |body: &Value| { + let mut req = self + .http + .post(format!("{}/chat/completions", self.config.base_url)) + .json(body); + if let Some(key) = &self.config.api_key { + req = req.bearer_auth(key); + } + req.send() + }; + let mut resp = send(&body) + .await + .context("sending chat completion request")?; + // Not every OpenAI-compatible server knows `stream_options`; retry without it. + if matches!(resp.status().as_u16(), 400 | 422) { + body.as_object_mut().unwrap().remove("stream_options"); + resp = send(&body) + .await + .context("sending chat completion request")?; + } + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + bail!("LLM endpoint returned {status}: {text}"); + } + + let mut out = Completion::default(); + let mut stream = resp.bytes_stream(); + let mut buf: Vec = Vec::new(); + while let Some(bytes) = stream.next().await { + buf.extend_from_slice(&bytes.context("reading response stream")?); + while let Some(pos) = buf.iter().position(|&b| b == b'\n') { + let line: Vec = buf.drain(..=pos).collect(); + let line = String::from_utf8_lossy(&line); + let Some(data) = line.trim().strip_prefix("data:") else { + continue; + }; + let data = data.trim(); + if data == "[DONE]" { + return Ok(out); + } + let chunk: Chunk = match serde_json::from_str(data) { + Ok(c) => c, + Err(e) => { + tracing::warn!("skipping unparseable chunk ({e}): {data}"); + continue; + } + }; + if chunk.usage.is_some() { + out.usage = chunk.usage; + } + for choice in chunk.choices { + let d = choice.delta; + if let Some(r) = d.reasoning_content.as_deref().or(d.reasoning.as_deref()) + && !r.is_empty() + { + on_delta(Delta::Reasoning(r)); + } + if let Some(t) = d.content.as_deref() + && !t.is_empty() + { + out.content.push_str(t); + on_delta(Delta::Text(t)); + } + for tc in d.tool_calls { + if out.tool_calls.len() <= tc.index { + out.tool_calls + .resize(tc.index + 1, ToolCallRequest::default()); + } + let slot = &mut out.tool_calls[tc.index]; + if let Some(id) = tc.id { + slot.id = id; + } + if let Some(f) = tc.function { + if let Some(n) = f.name { + slot.name.push_str(&n); + } + if let Some(a) = f.arguments { + slot.arguments.push_str(&a); + } + } + } + if choice.finish_reason.is_some() { + out.finish_reason = choice.finish_reason; + } + } + } + } + Ok(out) + } +} diff --git a/crates/ed-acp/src/store.rs b/crates/ed-acp/src/store.rs new file mode 100644 index 0000000..a9a4d62 --- /dev/null +++ b/crates/ed-acp/src/store.rs @@ -0,0 +1,174 @@ +//! Durable sessions: one JSON file per session under `/sessions/`. +//! +//! The history stored here is the OpenAI-style message list without the system prompt +//! (that is rebuilt every turn). Tool results are `role: "tool"` messages, which is what +//! lets `session/load` replay tool calls with their output. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StoredSession { + pub cwd: PathBuf, + #[serde(default)] + pub roots: Vec, + pub model: String, + #[serde(default)] + pub title: Option, + pub updated_at: u64, + #[serde(default)] + pub messages: Vec, +} + +#[derive(Debug, Clone)] +pub struct SessionStore { + dir: PathBuf, +} + +/// Session ids end up in file names: allow only characters that cannot escape the directory. +pub fn sanitize_id(id: &str) -> Result<&str> { + let ok = !id.is_empty() + && id.len() <= 128 + && id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + if !ok { + bail!("invalid session id"); + } + Ok(id) +} + +impl SessionStore { + pub fn new(data_dir: &Path) -> Self { + Self { + dir: data_dir.join("sessions"), + } + } + + fn path(&self, id: &str) -> Result { + Ok(self.dir.join(format!("{}.json", sanitize_id(id)?))) + } + + /// Atomic write: temp file in the same directory, then rename. + pub fn save(&self, id: &str, session: &StoredSession) -> Result<()> { + let path = self.path(id)?; + std::fs::create_dir_all(&self.dir) + .with_context(|| format!("creating {}", self.dir.display()))?; + let tmp = self.dir.join(format!(".{id}.{}.tmp", uuid::Uuid::new_v4())); + std::fs::write(&tmp, serde_json::to_vec(session)?)?; + std::fs::rename(&tmp, &path).inspect_err(|_| { + let _ = std::fs::remove_file(&tmp); + })?; + Ok(()) + } + + /// `Ok(None)` when no such session exists (or the id is not a valid one). + pub fn load(&self, id: &str) -> Result> { + let Ok(path) = self.path(id) else { + return Ok(None); + }; + match std::fs::read(&path) { + Ok(bytes) => Ok(Some( + serde_json::from_slice(&bytes) + .with_context(|| format!("parsing {}", path.display()))?, + )), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), + } + } + + /// All stored sessions, newest first. Unreadable files are skipped. + pub fn list(&self) -> Vec<(String, StoredSession)> { + let Ok(rd) = std::fs::read_dir(&self.dir) else { + return Vec::new(); + }; + let mut out: Vec<(String, StoredSession)> = rd + .filter_map(|e| { + let path = e.ok()?.path(); + if path.extension()? != "json" { + return None; + } + let id = path.file_stem()?.to_str()?.to_string(); + let session = serde_json::from_slice(&std::fs::read(&path).ok()?).ok()?; + Some((id, session)) + }) + .collect(); + out.sort_by(|a, b| b.1.updated_at.cmp(&a.1.updated_at).then(a.0.cmp(&b.0))); + out + } + + /// Returns whether a file was removed. + pub fn delete(&self, id: &str) -> Result { + let Ok(path) = self.path(id) else { + return Ok(false); + }; + match std::fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e).with_context(|| format!("deleting {}", path.display())), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn tmp() -> PathBuf { + let d = std::env::temp_dir().join(format!("sf-store-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&d).unwrap(); + d + } + + fn sample(updated_at: u64) -> StoredSession { + StoredSession { + cwd: "/work".into(), + roots: vec!["/other".into()], + model: "m".into(), + title: Some("hello".into()), + updated_at, + messages: vec![ + json!({"role": "user", "content": "hi"}), + json!({"role": "tool", "tool_call_id": "c1", "content": "out"}), + ], + } + } + + #[test] + fn round_trip_list_delete() { + let dir = tmp(); + let store = SessionStore::new(&dir); + assert!(store.load("a").unwrap().is_none()); + store.save("a", &sample(10)).unwrap(); + store.save("b", &sample(20)).unwrap(); + assert_eq!(store.load("a").unwrap().unwrap(), sample(10)); + let ids: Vec<_> = store.list().into_iter().map(|(id, _)| id).collect(); + assert_eq!(ids, ["b", "a"]); + assert!(store.delete("a").unwrap()); + assert!(!store.delete("a").unwrap()); + assert!(store.load("a").unwrap().is_none()); + // No temp files left behind. + let leftovers = std::fs::read_dir(dir.join("sessions")) + .unwrap() + .filter(|e| e.as_ref().unwrap().path().extension().unwrap() != "json") + .count(); + assert_eq!(leftovers, 0); + std::fs::remove_dir_all(dir).ok(); + } + + #[test] + fn rejects_path_traversal_ids() { + let dir = tmp(); + let store = SessionStore::new(&dir); + for bad in ["../x", "a/b", "", "a.b", "..", "a\\b"] { + assert!(store.save(bad, &sample(1)).is_err(), "{bad}"); + assert!(store.load(bad).unwrap().is_none()); + assert!(!store.delete(bad).unwrap()); + } + std::fs::remove_dir_all(dir).ok(); + } +} From b8d3b5a490d2df7ec49a2c8c853438a033740179 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:12:52 +0200 Subject: [PATCH 05/28] ed-acp: workspace tools, tool dispatch, ACP server Second part of the ed-acp crate: the workspace tools routed through the client's fs and terminal methods, tool dispatch with approval, and the server loop that advertises mcpCapabilities.http. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: siGit Code v1.6.3 --- crates/ed-acp/src/server.rs | 285 ++++++++++++++++++++ crates/ed-acp/src/tools.rs | 460 +++++++++++++++++++++++++++++++++ crates/ed-acp/src/workspace.rs | 459 ++++++++++++++++++++++++++++++++ 3 files changed, 1204 insertions(+) create mode 100644 crates/ed-acp/src/server.rs create mode 100644 crates/ed-acp/src/tools.rs create mode 100644 crates/ed-acp/src/workspace.rs diff --git a/crates/ed-acp/src/server.rs b/crates/ed-acp/src/server.rs new file mode 100644 index 0000000..4b44ea7 --- /dev/null +++ b/crates/ed-acp/src/server.rs @@ -0,0 +1,285 @@ +//! The stdio wiring: one handler per ACP method, each spawned off the dispatch loop when it +//! awaits anything (a model list, MCP servers, disk, or a turn that makes client requests). + +use std::sync::Arc; + +use agent_client_protocol::schema::v1::{ + AgentAuthCapabilities, AgentCapabilities, AuthenticateRequest, CancelNotification, + CloseSessionRequest, DeleteSessionRequest, Implementation, InitializeRequest, + InitializeResponse, ListSessionsRequest, LoadSessionRequest, LogoutCapabilities, LogoutRequest, + McpCapabilities, NewSessionRequest, PromptRequest, ResumeSessionRequest, + SessionAdditionalDirectoriesCapabilities, SessionCapabilities, SessionCloseCapabilities, + SessionDeleteCapabilities, SessionListCapabilities, SessionResumeCapabilities, + SetSessionConfigOptionRequest, +}; +use agent_client_protocol::{Agent, Stdio}; + +use crate::agent::{self, AcpServer}; +use crate::{AgentInfo, Profile, cli}; + +/// How the server was started. +#[derive(Debug, Clone, Copy)] +pub struct ServeOptions { + /// Default for each session's auto-approve: `--yolo` or `_YOLO=1`. + pub yolo: bool, + /// `tui` when launched by the terminal UI (it sets `_SURFACE=tui`), else `acp`. + pub surface: &'static str, +} + +impl ServeOptions { + /// `_YOLO` and `_SURFACE` from the environment. + pub fn from_env(info: &AgentInfo) -> Self { + Self { + yolo: cli::env_flag(&info.env.var("YOLO")), + surface: cli::surface(&info.env), + } + } +} + +/// Serve ACP over stdin/stdout until the client disconnects, then stop every MCP server. +/// Logs must go to stderr: stdout carries JSON-RPC only. +pub async fn serve_stdio( + profile: Arc, + opts: ServeOptions, +) -> agent_client_protocol::Result<()> { + let info = profile.info(); + let prompt_caps = profile.prompt_capabilities(); + let agent = AcpServer::new(profile, opts.yolo, opts.surface); + tracing::info!("{} starting with model {}", info.name, agent.model()); + + let result = Agent + .builder() + .name(info.name) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: InitializeRequest, responder, _cx| { + agent.set_client_caps(req.client_capabilities.clone()); + // Terminal auth is only usable by clients that can run it. + let methods = if agent::supports_terminal_auth(&req.client_capabilities) { + agent::auth_methods() + } else { + Vec::new() + }; + responder.respond( + InitializeResponse::new(agent::negotiate_version(req.protocol_version)) + .auth_methods(methods) + .agent_capabilities( + AgentCapabilities::new() + .prompt_capabilities(prompt_caps.clone()) + .session_capabilities( + SessionCapabilities::new() + .list(SessionListCapabilities::new()) + .additional_directories( + SessionAdditionalDirectoriesCapabilities::new(), + ) + .resume(SessionResumeCapabilities::new()) + .close(SessionCloseCapabilities::new()) + .delete(SessionDeleteCapabilities::new()), + ) + .auth( + AgentAuthCapabilities::new() + .logout(LogoutCapabilities::new()), + ) + .mcp_capabilities(McpCapabilities::new().http(true)) + .load_session(true), + ) + .agent_info(Implementation::new(info.name, info.version)), + ) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: AuthenticateRequest, responder, cx| { + let agent = agent.clone(); + cx.spawn(async move { + match agent.authenticate(req).await { + Ok(resp) => responder.respond(resp), + Err(e) => responder.respond_with_error(e), + } + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |_req: LogoutRequest, responder, cx| { + let agent = agent.clone(); + cx.spawn(async move { + match agent.logout().await { + Ok(resp) => responder.respond(resp), + Err(e) => responder.respond_with_error(e), + } + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: NewSessionRequest, responder, cx| { + // Listing models and starting MCP servers are slow; keep them off the + // dispatch loop. + let agent = agent.clone(); + let connection = cx.clone(); + cx.spawn(async move { + // Picks up a key stored by `--setup` since this process started. + if !agent.refresh_credentials().await { + return responder.respond_with_error(agent.auth_required()); + } + match agent.new_session(req).await { + Ok(resp) => { + let id = resp.session_id.clone(); + responder.respond(resp)?; + agent.send_commands(&connection, &id) + } + Err(e) => responder.respond_with_error(e), + } + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: ListSessionsRequest, responder, cx| { + // Reads the session directory from disk. + let agent = agent.clone(); + cx.spawn(async move { + match tokio::task::spawn_blocking(move || agent.list_sessions(req)).await { + Ok(Ok(resp)) => responder.respond(resp), + Ok(Err(e)) => responder.respond_with_error(e), + Err(e) => responder.respond_with_internal_error(e.to_string()), + } + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: LoadSessionRequest, responder, cx| { + let agent = agent.clone(); + let connection = cx.clone(); + cx.spawn(async move { + // Editors reopen a thread with session/load after terminal auth, so + // AUTH_REQUIRED here shows sign-in instead of a dead thread. + if !agent.refresh_credentials().await { + return responder.respond_with_error(agent.auth_required()); + } + let id = req.session_id.clone(); + match agent.load_session(req, &connection).await { + Ok(resp) => { + responder.respond(resp)?; + agent.send_commands(&connection, &id) + } + Err(e) => responder.respond_with_error(e), + } + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: ResumeSessionRequest, responder, cx| { + let agent = agent.clone(); + let connection = cx.clone(); + cx.spawn(async move { + if !agent.refresh_credentials().await { + return responder.respond_with_error(agent.auth_required()); + } + let id = req.session_id.clone(); + match agent.resume_session(req, &connection).await { + Ok(resp) => { + responder.respond(resp)?; + agent.send_commands(&connection, &id) + } + Err(e) => responder.respond_with_error(e), + } + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: CloseSessionRequest, responder, _cx| match agent.close_session(req) + { + Ok(resp) => responder.respond(resp), + Err(e) => responder.respond_with_error(e), + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: DeleteSessionRequest, responder, _cx| match agent + .delete_session(req) + { + Ok(resp) => responder.respond(resp), + Err(e) => responder.respond_with_error(e), + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: SetSessionConfigOptionRequest, responder, cx| { + let agent = agent.clone(); + let connection = cx.clone(); + cx.spawn(async move { + match agent.set_config_option(req, &connection).await { + Ok(resp) => responder.respond(resp), + Err(e) => responder.respond_with_error(e), + } + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let agent = agent.clone(); + async move |req: PromptRequest, responder, cx| { + // Run the turn off the dispatch loop so it can make requests to the client. + let agent = agent.clone(); + let connection = cx.clone(); + cx.spawn(async move { + if !agent.has_api_key() && !agent.refresh_credentials().await { + return responder.respond_with_error(agent.auth_required()); + } + agent.prompt(req, responder, connection).await + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_notification( + { + let agent = agent.clone(); + async move |n: CancelNotification, _cx| { + agent.cancel(&n.session_id); + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .connect_to(Stdio::new()) + .await; + // The editor went away: stop MCP servers instead of leaving them to be orphaned. + agent.shutdown().await; + result +} diff --git a/crates/ed-acp/src/tools.rs b/crates/ed-acp/src/tools.rs new file mode 100644 index 0000000..cf2666f --- /dev/null +++ b/crates/ed-acp/src/tools.rs @@ -0,0 +1,460 @@ +//! The tool layer: what a tool gets ([`ToolCtx`]), what it returns ([`ToolOutcome`]), the +//! approval flow, ACP path rules, and dispatch to product [`Toolset`]s and MCP servers. + +use std::collections::HashSet; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use agent_client_protocol::schema::v1::{ + ClientCapabilities, PermissionOption, PermissionOptionKind, RequestPermissionOutcome, + RequestPermissionRequest, SessionId, SessionNotification, SessionUpdate, ToolCallContent, + ToolCallLocation, ToolCallUpdate, ToolCallUpdateFields, ToolKind, +}; +use agent_client_protocol::{Client, ConnectionTo}; +use anyhow::{Result, anyhow, bail}; +use async_trait::async_trait; +use ed_mcp::McpToolset; +use serde_json::{Value, json}; +use tokio_util::sync::CancellationToken; + +use crate::{McpCall, Profile}; + +/// Cap on tool output sent to the model and on client terminal output. +pub const MAX_OUTPUT_BYTES: usize = 32 * 1024; + +/// Everything a tool needs to act on behalf of one session. +pub struct ToolCtx { + pub connection: ConnectionTo, + pub session_id: SessionId, + /// Primary working directory: the base for relative paths and `run_command`. + pub cwd: PathBuf, + /// Additional workspace roots the model may address with the `root` parameter. + pub roots: Vec, + pub caps: ClientCapabilities, + pub cancel: CancellationToken, + /// Approve tool calls without asking: `--yolo`, or the session's `auto_approve` option. + pub auto_approve: Arc, + /// Tool names the user chose "always allow" for in this session. + pub always_allowed: Arc>>, + /// Tool names the user chose "always reject" for in this session. + pub always_rejected: Arc>>, + /// Tools from MCP servers available to this session. + pub mcp: Arc, +} + +/// What a tool call produced. +pub struct ToolOutcome { + /// Text returned to the model. + pub text: String, + /// Rich content shown to the user in the client. + pub content: Vec, + pub failed: bool, + /// Files the tool produced or touched, surfaced as tool-call locations. + pub locations: Vec, +} + +impl ToolOutcome { + pub fn ok(text: impl Into) -> Self { + let text = text.into(); + Self { + content: vec![ToolCallContent::from(text.clone())], + text, + failed: false, + locations: Vec::new(), + } + } + pub fn err(text: impl Into) -> Self { + let text = text.into(); + Self { + content: vec![ToolCallContent::from(text.clone())], + text, + failed: true, + locations: Vec::new(), + } + } +} + +/// An OpenAI `tools` entry. +pub fn function_def(name: &str, desc: &str, params: Value) -> Value { + json!({ "type": "function", "function": { "name": name, "description": desc, "parameters": params } }) +} + +/// The session's directories, for describing a tool call without running it (also used when +/// `session/load` replays history). +pub struct DescribeCtx<'a> { + pub cwd: &'a Path, + pub roots: &'a [PathBuf], +} + +/// A group of tools a product offers to the model. +#[async_trait] +pub trait Toolset: Send + Sync { + /// OpenAI `tools` entries, built with [`function_def`]. + fn definitions(&self) -> Vec; + /// Whether `name` is one of this toolset's tools. + fn handles(&self, name: &str) -> bool; + /// Title, kind and affected locations shown in the client when the call starts. Locations + /// must be absolute ([`absolutize`]). + fn describe( + &self, + name: &str, + args: &Value, + ctx: &DescribeCtx<'_>, + ) -> (String, ToolKind, Vec); + /// Run the tool. A mutating tool asks first with [`ToolCtx::permit`]. An `Err` is shown to + /// the model as `Error: …` and the call is marked failed. + async fn execute( + &self, + ctx: &ToolCtx, + tool_call_id: &str, + name: &str, + args: Value, + ) -> Result; +} + +/// All tool definitions for one session, in OpenAI `tools` format: product toolsets first, +/// then MCP tools sorted by name. +pub fn definitions(toolsets: &[Arc], mcp: &McpToolset) -> Value { + let mut defs: Vec = toolsets.iter().flat_map(|t| t.definitions()).collect(); + defs.extend( + mcp.tools() + .into_iter() + .map(|t| function_def(&t.name, &t.description, t.parameters.clone())), + ); + Value::Array(defs) +} + +/// Title, kind and affected locations shown in the client when the call starts. +pub fn describe( + toolsets: &[Arc], + cwd: &Path, + roots: &[PathBuf], + name: &str, + args: &Value, +) -> (String, ToolKind, Vec) { + if let Some((server, tool)) = ed_mcp::split_qualified(name) { + let loc = ["input", "path", "file"] + .iter() + .filter_map(|k| args.get(*k).and_then(Value::as_str)) + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .map(|p| ToolCallLocation::new(absolutize(&p))) + .collect(); + return (format!("{server}: {tool}"), ToolKind::Other, loc); + } + let ctx = DescribeCtx { cwd, roots }; + match toolsets.iter().find(|t| t.handles(name)) { + Some(t) => t.describe(name, args, &ctx), + None => (name.to_string(), ToolKind::Other, vec![]), + } +} + +/// Run one tool call: a product toolset's, or an MCP server's. Never fails; errors become a +/// failed outcome the model sees. +pub async fn execute( + ctx: &ToolCtx, + toolsets: &[Arc], + profile: &dyn Profile, + tool_call_id: &str, + name: &str, + args: Value, +) -> ToolOutcome { + let result = if ed_mcp::split_qualified(name).is_some() { + execute_mcp(ctx, profile, tool_call_id, name, args).await + } else if let Some(t) = toolsets.iter().find(|t| t.handles(name)) { + t.execute(ctx, tool_call_id, name, args).await + } else { + Err(anyhow!("unknown tool `{name}`")) + }; + result.unwrap_or_else(|e| ToolOutcome::err(format!("Error: {e:#}"))) +} + +async fn execute_mcp( + ctx: &ToolCtx, + profile: &dyn Profile, + tool_call_id: &str, + name: &str, + args: Value, +) -> Result { + let Some(tool) = ctx.mcp.tool(name) else { + bail!("unknown tool `{name}`"); + }; + if !tool.read_only { + let call = McpCall { + tool, + args: &args, + connection: ctx.mcp.connection(name), + }; + let preview = match profile.mcp_permission_preview(call).await { + Some(text) => text, + None => default_mcp_preview(tool, &args), + }; + let preview = vec![ToolCallContent::from(preview)]; + if !ctx.permit(tool_call_id, name, preview).await? { + return Ok(ToolOutcome::err("User rejected running this tool.")); + } + } + let on_progress = |line: String| { + let _ = ctx.update( + tool_call_id, + ToolCallUpdateFields::new().content(vec![ToolCallContent::from(line)]), + ); + }; + let out = ctx.mcp.call(name, args, &ctx.cancel, &on_progress).await?; + let mut outcome = if out.failed { + ToolOutcome::err(truncate(out.text)) + } else { + ToolOutcome::ok(truncate(out.text)) + }; + if let Some(structured) = &out.structured { + outcome.locations = profile + .mcp_result_locations(tool, structured) + .into_iter() + .filter(|p| p.is_absolute()) + .map(|p| absolutize(&p)) + .collect(); + } + Ok(outcome) +} + +/// The permission prompt for an MCP tool when the product has nothing better to say. +fn default_mcp_preview(tool: &ed_mcp::McpTool, args: &Value) -> String { + let mut text = format!("Run `{}` on the {} MCP server.", tool.tool, tool.server); + if args.as_object().is_some_and(|o| !o.is_empty()) { + let shown = serde_json::to_string_pretty(args).unwrap_or_default(); + text.push_str(&format!("\nArguments:\n```json\n{}\n```", truncate(shown))); + } + text +} + +/// The base directory for a tool call's `root` parameter: a workspace root or any directory +/// beneath one (so the model can pass e.g. `root/crate/src`). Paths that escape a root via +/// `..` are rejected. +pub fn base_for(cwd: &Path, roots: &[PathBuf], root: Option<&str>) -> Result { + let Some(root) = root else { + return Ok(cwd.to_path_buf()); + }; + // Lexical normalization resolves `..`, so `root/../elsewhere` can't pass the prefix check. + let candidate = normalize(Path::new(root)); + let all_roots = all_roots(cwd, roots); + for ws_root in &all_roots { + if candidate == *ws_root { + return Ok(ws_root.clone()); + } + // Allow any directory below a root (e.g. a crate dir like `root/crate/src`), + // as long as it doesn't escape the root via `..`. + if let Ok(rest) = candidate.strip_prefix(ws_root) + && rest.components().all(|c| matches!(c, Component::Normal(_))) + { + return Ok(candidate); + } + } + let known = all_roots + .iter() + .map(|r| r.display().to_string()) + .collect::>() + .join(", "); + bail!( + "unknown root `{}` (workspace roots: {known})", + candidate.display() + ) +} + +fn all_roots(cwd: &Path, roots: &[PathBuf]) -> Vec { + std::iter::once(cwd.to_path_buf()) + .chain(roots.iter().filter(|r| *r != cwd).cloned()) + .collect() +} + +impl ToolCtx { + pub fn base_for(&self, root: Option<&str>) -> Result { + base_for(&self.cwd, &self.roots, root) + } + + pub fn resolve_with(&self, root: Option<&str>, path: &str) -> Result { + Ok(resolve_in(&self.base_for(root)?, path)) + } + + /// Ask the user for permission. Returns `Ok(false)` if rejected or cancelled. Honours + /// "always allow" and "always reject" choices for `tool` and the session's auto-approve. + pub async fn permit( + &self, + id: &str, + tool: &str, + content: Vec, + ) -> Result { + if self.always_rejected.lock().unwrap().contains(tool) { + return Ok(false); + } + if self.auto_approve.load(Ordering::Relaxed) + || self.always_allowed.lock().unwrap().contains(tool) + { + return Ok(true); + } + let mut fields = ToolCallUpdateFields::new(); + if !content.is_empty() { + fields = fields.content(content); + } + let req = RequestPermissionRequest::new( + self.session_id.clone(), + ToolCallUpdate::new(id.to_string(), fields), + vec![ + PermissionOption::new("allow_once", "Allow", PermissionOptionKind::AllowOnce), + PermissionOption::new( + "allow_always", + "Always allow", + PermissionOptionKind::AllowAlways, + ), + PermissionOption::new("reject_once", "Reject", PermissionOptionKind::RejectOnce), + PermissionOption::new( + "reject_always", + "Always reject", + PermissionOptionKind::RejectAlways, + ), + ], + ); + let resp = tokio::select! { + r = self.connection.send_request(req).block_task() => r?, + () = self.cancel.cancelled() => return Ok(false), + }; + Ok(match resp.outcome { + RequestPermissionOutcome::Selected(sel) => match &*sel.option_id.0 { + "allow_always" => { + self.always_allowed.lock().unwrap().insert(tool.to_string()); + true + } + "allow_once" => true, + "reject_always" => { + self.always_rejected + .lock() + .unwrap() + .insert(tool.to_string()); + false + } + _ => false, + }, + _ => false, + }) + } + + pub fn update(&self, id: &str, fields: ToolCallUpdateFields) -> Result<()> { + let update = SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(id.to_string(), fields)); + self.connection + .send_notification(SessionNotification::new(self.session_id.clone(), update))?; + Ok(()) + } +} + +/// Join `path` onto `base`, keeping absolute paths as-is. +pub fn resolve_in(base: &Path, path: &str) -> PathBuf { + let p = Path::new(path); + if p.is_absolute() { + p.to_path_buf() + } else { + base.join(p) + } +} + +/// Cut `s` to [`MAX_OUTPUT_BYTES`] on a character boundary, marking the cut. +pub fn truncate(mut s: String) -> String { + if s.len() > MAX_OUTPUT_BYTES { + let mut cut = MAX_OUTPUT_BYTES; + while !s.is_char_boundary(cut) { + cut -= 1; + } + s.truncate(cut); + s.push_str("\n[truncated]"); + } + s +} + +/// Make `path` absolute and free of `.`/`..` components, as the ACP v1 spec requires +/// absolute paths everywhere. Existing paths are canonicalized (resolving symlinks +/// and giving the client the true on-disk location); paths that don't exist yet +/// are normalized lexically with the existing parent canonicalized when possible. +pub fn absolutize(path: &Path) -> PathBuf { + if let Ok(c) = std::fs::canonicalize(path) { + return c; + } + // Not on disk (yet): normalize lexically, anchoring at the canonicalized parent. + let parent = path.parent().filter(|p| !p.as_os_str().is_empty()); + let base = parent.and_then(|p| std::fs::canonicalize(p).ok()); + let file_name = path.file_name().map(|f| f.to_os_string()); + match (base, file_name) { + (Some(mut b), Some(f)) => { + b.push(f); + b + } + _ => normalize(path), + } +} + +/// Lexically normalize a path: resolve `.` and `..` without touching the filesystem. +/// Absolute paths stay absolute; relative paths are made absolute against the cwd. +pub fn normalize(path: &Path) -> PathBuf { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + }; + let mut out: Vec = Vec::new(); + for c in absolute.components() { + match c { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other.as_os_str().to_os_string()), + } + } + out.into_iter().collect() +} + +#[cfg(test)] +mod fs_path_tests { + use super::*; + + #[test] + fn normalize_resolves_dot_dot_lexically() { + assert_eq!(normalize(Path::new("/a/b/../c")), PathBuf::from("/a/c")); + assert_eq!(normalize(Path::new("/a/./b")), PathBuf::from("/a/b")); + assert_eq!(normalize(Path::new("/a/b/..")), PathBuf::from("/a")); + } + + #[test] + fn normalize_makes_relative_paths_absolute() { + let n = normalize(Path::new("src/../README.md")); + assert!(n.is_absolute()); + assert!(n.ends_with("README.md")); + assert!(!n.components().any(|c| c == Component::ParentDir)); + } + + #[test] + fn absolutize_canonicalizes_existing_paths() { + let n = absolutize(&std::env::temp_dir()); + assert!(n.is_absolute()); + assert!(!n.components().any(|c| c == Component::ParentDir)); + } + + #[test] + fn absolutize_normalizes_missing_paths() { + let n = absolutize(Path::new("/definitely/does/not/../exist.txt")); + assert_eq!(n, PathBuf::from("/definitely/does/exist.txt")); + } + + #[test] + fn base_for_rejects_escapes_and_accepts_subdirs() { + let cwd = PathBuf::from("/ws/a"); + let roots = vec![PathBuf::from("/ws/b")]; + assert_eq!(base_for(&cwd, &roots, None).unwrap(), cwd); + assert_eq!( + base_for(&cwd, &roots, Some("/ws/b/sub/../x")).unwrap(), + PathBuf::from("/ws/b/x") + ); + assert!(base_for(&cwd, &roots, Some("/ws/b/../c")).is_err()); + assert!(base_for(&cwd, &roots, Some("/etc")).is_err()); + } +} diff --git a/crates/ed-acp/src/workspace.rs b/crates/ed-acp/src/workspace.rs new file mode 100644 index 0000000..3bf7f30 --- /dev/null +++ b/crates/ed-acp/src/workspace.rs @@ -0,0 +1,459 @@ +//! Workspace tools: `read_file`, `write_file`, `edit_file`, `list_directory` and +//! `run_command`, routed through the ACP client (`fs/*`, `terminal/*`) when it advertises +//! support and falling back to the local filesystem and `sh -c` otherwise. Every tool takes an +//! optional `root` for multi-root workspaces. Writes and commands ask for approval. + +use std::path::Path; +use std::time::Duration; + +use agent_client_protocol::ErrorCode; +use agent_client_protocol::schema::v1::{ + CreateTerminalRequest, Diff, KillTerminalRequest, ReadTextFileRequest, ReleaseTerminalRequest, + Terminal, TerminalOutputRequest, ToolCallContent, ToolCallLocation, ToolCallUpdateFields, + ToolKind, WaitForTerminalExitRequest, WriteTextFileRequest, +}; +use anyhow::{Context, Result, anyhow, bail}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::tools::{ + DescribeCtx, MAX_OUTPUT_BYTES, ToolCtx, ToolOutcome, Toolset, absolutize, base_for, + function_def, resolve_in, truncate, +}; + +const COMMAND_TIMEOUT: Duration = Duration::from_secs(120); + +/// The workspace tools as a [`Toolset`]. +#[derive(Debug, Default, Clone, Copy)] +pub struct WorkspaceTools; + +const NAMES: &[&str] = &[ + "read_file", + "write_file", + "edit_file", + "list_directory", + "run_command", +]; + +fn tool_definitions() -> Vec { + let f = |name: &str, desc: &str, params: Value| function_def(name, desc, params); + let root_prop = || { + json!({ + "type": "string", + "description": "Workspace root to act in: absolute path of a session root or a directory beneath one. Defaults to the primary working directory." + }) + }; + vec![ + f( + "read_file", + "Read a text file. Paths may be relative to the working directory.", + json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "line": { "type": "integer", "description": "1-based line to start from" }, + "limit": { "type": "integer", "description": "Max lines to read" }, + "root": root_prop() + }, + "required": ["path"] + }), + ), + f( + "write_file", + "Create or overwrite a file with the given content.", + json!({ + "type": "object", + "properties": { "path": { "type": "string" }, "content": { "type": "string" }, "root": root_prop() }, + "required": ["path", "content"] + }), + ), + f( + "edit_file", + "Replace an exact, unique occurrence of old_string with new_string in a file.", + json!({ + "type": "object", + "properties": { + "path": { "type": "string" }, + "old_string": { "type": "string" }, + "new_string": { "type": "string" }, + "root": root_prop() + }, + "required": ["path", "old_string", "new_string"] + }), + ), + f( + "list_directory", + "List entries of a directory (directories end with '/').", + json!({ + "type": "object", + "properties": { "path": { "type": "string", "description": "Defaults to the working directory" }, "root": root_prop() } + }), + ), + f( + "run_command", + "Run a shell command (sh -c) in the working directory and return its output.", + json!({ + "type": "object", + "properties": { "command": { "type": "string" }, "root": root_prop() }, + "required": ["command"] + }), + ), + ] +} + +#[async_trait] +impl Toolset for WorkspaceTools { + fn definitions(&self) -> Vec { + tool_definitions() + } + + fn handles(&self, name: &str) -> bool { + NAMES.contains(&name) + } + + fn describe( + &self, + name: &str, + args: &Value, + ctx: &DescribeCtx<'_>, + ) -> (String, ToolKind, Vec) { + let str_arg = |k: &str| args.get(k).and_then(Value::as_str); + let base = + base_for(ctx.cwd, ctx.roots, str_arg("root")).unwrap_or_else(|_| ctx.cwd.to_path_buf()); + // ACP v1: ToolCallLocation.path must be an absolute path. + let path = str_arg("path").map(|p| absolutize(&resolve_in(&base, p))); + let loc = path + .clone() + .map(ToolCallLocation::new) + .into_iter() + .collect(); + let shown = path + .as_deref() + .map_or_else(|| base.display().to_string(), |p| p.display().to_string()); + match name { + "read_file" => (format!("Read {shown}"), ToolKind::Read, loc), + "write_file" => (format!("Write {shown}"), ToolKind::Edit, loc), + "edit_file" => (format!("Edit {shown}"), ToolKind::Edit, loc), + "list_directory" => (format!("List {shown}"), ToolKind::Search, loc), + "run_command" => { + let cmd = str_arg("command").unwrap_or(""); + let where_ = if base == ctx.cwd { + String::new() + } else { + format!(" in {}", base.display()) + }; + (format!("`{cmd}`{where_}"), ToolKind::Execute, vec![]) + } + _ => (name.to_string(), ToolKind::Other, vec![]), + } + } + + async fn execute( + &self, + ctx: &ToolCtx, + tool_call_id: &str, + name: &str, + args: Value, + ) -> Result { + match name { + "read_file" => ctx.read_file(args).await, + "write_file" => ctx.write_file(tool_call_id, args).await, + "edit_file" => ctx.edit_file(tool_call_id, args).await, + "list_directory" => ctx.list_directory(args).await, + "run_command" => ctx.run_command(tool_call_id, args).await, + other => Err(anyhow!("unknown tool `{other}`")), + } + } +} + +#[derive(Deserialize)] +struct ReadArgs { + path: String, + line: Option, + limit: Option, + root: Option, +} +#[derive(Deserialize)] +struct WriteArgs { + path: String, + content: String, + root: Option, +} +#[derive(Deserialize)] +struct EditArgs { + path: String, + old_string: String, + new_string: String, + root: Option, +} +#[derive(Deserialize)] +struct ListArgs { + path: Option, + root: Option, +} +#[derive(Deserialize)] +struct CommandArgs { + command: String, + root: Option, +} + +impl ToolCtx { + pub(crate) async fn read_file(&self, args: Value) -> Result { + let a: ReadArgs = serde_json::from_value(args)?; + let path = self.resolve_with(a.root.as_deref(), &a.path)?; + let text = if self.caps.fs.read_text_file { + // ACP v1 fs/read_text_file requires an absolute path. + let mut req = ReadTextFileRequest::new(self.session_id.clone(), absolutize(&path)); + if let Some(l) = a.line { + req = req.line(l); + } + if let Some(l) = a.limit { + req = req.limit(l); + } + self.connection + .send_request(req) + .block_task() + .await? + .content + } else { + let full = tokio::fs::read_to_string(&path) + .await + .with_context(|| format!("reading {}", path.display()))?; + let skip = a.line.map_or(0, |l| l.saturating_sub(1) as usize); + let take = a.limit.map_or(usize::MAX, |l| l as usize); + full.lines() + .skip(skip) + .take(take) + .collect::>() + .join("\n") + }; + // Show the user a short summary; give the model the full text. + let mut out = ToolOutcome::ok(format!("{} lines", text.lines().count())); + out.text = truncate(text); + Ok(out) + } + + /// Read the current file contents for diffs/edits; `Ok(None)` if it doesn't exist. + /// A client-fs read failure is an error, distinct from a missing file (ACP + /// `ResourceNotFound`), so callers don't mistake a failed read for a new file. + pub(crate) async fn read_existing(&self, path: &Path) -> Result> { + if self.caps.fs.read_text_file { + let req = ReadTextFileRequest::new(self.session_id.clone(), absolutize(path)); + match self.connection.send_request(req).block_task().await { + Ok(r) => Ok(Some(r.content)), + Err(e) if e.code == ErrorCode::ResourceNotFound => Ok(None), + Err(e) => Err(anyhow!("client read of {} failed: {e}", path.display())), + } + } else { + match tokio::fs::read_to_string(path).await { + Ok(content) => Ok(Some(content)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(anyhow!("reading {}: {e}", path.display())), + } + } + } + + pub(crate) async fn write_text(&self, path: &Path, content: &str) -> Result<()> { + if self.caps.fs.write_text_file { + let req = WriteTextFileRequest::new(self.session_id.clone(), absolutize(path), content); + self.connection.send_request(req).block_task().await?; + } else { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(path, content).await?; + } + Ok(()) + } + + pub(crate) async fn write_file(&self, id: &str, args: Value) -> Result { + let a: WriteArgs = serde_json::from_value(args)?; + let path = self.resolve_with(a.root.as_deref(), &a.path)?; + let old = self.read_existing(&path).await?; + // ACP v1: Diff.path must be an absolute file path. + let diff = Diff::new(absolutize(&path), a.content.clone()).old_text(old); + self.apply_edit(id, "write_file", &path, &a.content, diff) + .await + } + + pub(crate) async fn edit_file(&self, id: &str, args: Value) -> Result { + let a: EditArgs = serde_json::from_value(args)?; + let path = self.resolve_with(a.root.as_deref(), &a.path)?; + let old = self + .read_existing(&path) + .await? + .ok_or_else(|| anyhow!("{} does not exist", path.display()))?; + match old.matches(&a.old_string).count() { + 0 => bail!("old_string not found in {}", path.display()), + 1 => {} + n => bail!( + "old_string occurs {n} times in {}; make it unique", + path.display() + ), + } + let new = old.replacen(&a.old_string, &a.new_string, 1); + let diff = Diff::new(absolutize(&path), new.clone()).old_text(old); + self.apply_edit(id, "edit_file", &path, &new, diff).await + } + + pub(crate) async fn apply_edit( + &self, + id: &str, + tool: &str, + path: &Path, + content: &str, + diff: Diff, + ) -> Result { + let diff = ToolCallContent::from(diff); + if !self.permit(id, tool, vec![diff.clone()]).await? { + return Ok(ToolOutcome::err("User rejected this change.")); + } + self.write_text(path, content).await?; + Ok(ToolOutcome { + text: format!("Wrote {}", path.display()), + content: vec![diff], + failed: false, + locations: Vec::new(), + }) + } + + pub(crate) async fn list_directory(&self, args: Value) -> Result { + let a: ListArgs = serde_json::from_value(args)?; + let base = self.base_for(a.root.as_deref())?; + let path = a + .path + .map_or_else(|| base.clone(), |p| resolve_in(&base, &p)); + let mut entries = Vec::new(); + let mut rd = tokio::fs::read_dir(&path) + .await + .with_context(|| format!("listing {}", path.display()))?; + while let Some(e) = rd.next_entry().await? { + let mut name = e.file_name().to_string_lossy().into_owned(); + if e.file_type().await.is_ok_and(|t| t.is_dir()) { + name.push('/'); + } + entries.push(name); + } + entries.sort(); + Ok(ToolOutcome::ok(entries.join("\n"))) + } + + pub(crate) async fn run_command(&self, id: &str, args: Value) -> Result { + let a: CommandArgs = serde_json::from_value(args)?; + let base = self.base_for(a.root.as_deref())?; + if !self.permit(id, "run_command", vec![]).await? { + return Ok(ToolOutcome::err("User rejected running this command.")); + } + if self.caps.terminal { + self.run_in_client_terminal(id, &a.command, &base).await + } else { + self.run_locally(&a.command, &base).await + } + } + + pub(crate) async fn run_in_client_terminal( + &self, + id: &str, + command: &str, + cwd: &Path, + ) -> Result { + let sid = self.session_id.clone(); + let req = CreateTerminalRequest::new(sid.clone(), "sh") + .args(vec!["-c".into(), command.into()]) + // ACP v1: the terminal cwd must be an absolute path. + .cwd(absolutize(cwd)) + .output_byte_limit(MAX_OUTPUT_BYTES as u64); + let terminal_id = self + .connection + .send_request(req) + .block_task() + .await? + .terminal_id; + // Embed the live terminal in the tool call so the user can watch it. + let content = vec![ToolCallContent::Terminal(Terminal::new( + terminal_id.clone(), + ))]; + self.update(id, ToolCallUpdateFields::new().content(content.clone()))?; + + let wait = self + .connection + .send_request(WaitForTerminalExitRequest::new( + sid.clone(), + terminal_id.clone(), + )) + .block_task(); + let exit = tokio::select! { + r = wait => Some(r?.exit_status), + () = tokio::time::sleep(COMMAND_TIMEOUT) => None, + () = self.cancel.cancelled() => None, + }; + if exit.is_none() { + let _ = self + .connection + .send_request(KillTerminalRequest::new(sid.clone(), terminal_id.clone())) + .block_task() + .await; + } + let output = self + .connection + .send_request(TerminalOutputRequest::new(sid.clone(), terminal_id.clone())) + .block_task() + .await?; + let _ = self + .connection + .send_request(ReleaseTerminalRequest::new(sid, terminal_id)) + .block_task() + .await; + + let status = match &exit { + Some(s) => match (s.exit_code, &s.signal) { + (Some(c), _) => format!("exit code {c}"), + (None, Some(sig)) => format!("killed by {sig}"), + _ => "exited".into(), + }, + None if self.cancel.is_cancelled() => "cancelled".into(), + None => format!("timed out after {}s", COMMAND_TIMEOUT.as_secs()), + }; + let failed = !matches!(exit.as_ref().and_then(|s| s.exit_code), Some(0)); + let trunc = if output.truncated { + "\n[output truncated]" + } else { + "" + }; + Ok(ToolOutcome { + text: format!("{}{trunc}\n[{status}]", output.output), + content, + failed, + locations: Vec::new(), + }) + } + + pub(crate) async fn run_locally(&self, command: &str, cwd: &Path) -> Result { + let child = tokio::process::Command::new("sh") + .arg("-c") + .arg(command) + .current_dir(cwd) + .stdin(std::process::Stdio::null()) + .kill_on_drop(true) + .output(); + let out = tokio::select! { + r = tokio::time::timeout(COMMAND_TIMEOUT, child) => match r { + Ok(r) => r?, + Err(_) => return Ok(ToolOutcome::err(format!("Command timed out after {}s", COMMAND_TIMEOUT.as_secs()))), + }, + () = self.cancel.cancelled() => return Ok(ToolOutcome::err("Command cancelled")), + }; + let mut text = String::from_utf8_lossy(&out.stdout).into_owned(); + text.push_str(&String::from_utf8_lossy(&out.stderr)); + let status = out + .status + .code() + .map_or_else(|| "killed by signal".into(), |c| format!("exit code {c}")); + let text = format!("{}\n[{status}]", truncate(text)); + let mut outcome = ToolOutcome::ok(format!("```\n{text}\n```")); + outcome.text = text; + outcome.failed = !out.status.success(); + Ok(outcome) + } +} From 1084b4151ca064bee771f11790c2c55842c68736 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:12:52 +0200 Subject: [PATCH 06/28] ed-acp: the agent and the Profile API; add the crate to the workspace Last part of the ed-acp crate: sessions, the turn loop and the Profile a product implements. The crate builds from here on. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: siGit Code v1.6.3 --- Cargo.lock | 30 + Cargo.toml | 2 + crates/ed-acp/Cargo.toml | 32 + crates/ed-acp/README.md | 17 + crates/ed-acp/src/agent.rs | 1342 ++++++++++++++++++++++++++++++++++++ crates/ed-acp/src/lib.rs | 136 ++++ 6 files changed, 1559 insertions(+) create mode 100644 crates/ed-acp/Cargo.toml create mode 100644 crates/ed-acp/README.md create mode 100644 crates/ed-acp/src/agent.rs create mode 100644 crates/ed-acp/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 105da47..dfc045d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1770,6 +1770,15 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1934,6 +1943,27 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" +[[package]] +name = "ed-acp" +version = "1.0.1" +dependencies = [ + "agent-client-protocol", + "anyhow", + "async-trait", + "base64 0.23.1", + "directories", + "ed-mcp", + "futures", + "reqwest 0.13.5", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "uuid 1.27.0", +] + [[package]] name = "ed-agent" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 950f984..d147848 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/ed-agent-ffi", "crates/ed-agent-tauri", "crates/ed-mcp", + "crates/ed-acp", "uniffi-bindgen", ] @@ -56,6 +57,7 @@ base64 = "0.23" # Internal ed-agent = { version = "1.0.1", path = "crates/ed-agent" } ed-mcp = { version = "1.0.1", path = "crates/ed-mcp" } +ed-acp = { version = "1.0.1", path = "crates/ed-acp" } # gemm-common (via candle, via onde) depends on sysctl for every # `target_vendor = "apple"`, but sysctl 0.6.0 only has a `sys` module behind diff --git a/crates/ed-acp/Cargo.toml b/crates/ed-acp/Cargo.toml new file mode 100644 index 0000000..299b5c4 --- /dev/null +++ b/crates/ed-acp/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "ed-acp" +description = "Agent Client Protocol (ACP) v1 server for Onde agents: sessions, auth, the turn loop on Onde Cloud, tool approval, workspace tools and MCP. A product supplies a Profile." +keywords = ["acp", "agent", "llm", "onde", "mcp"] +categories = ["asynchronous", "development-tools"] +version.workspace = true +edition = "2024" +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +ed-mcp = { workspace = true, features = ["acp"] } +agent-client-protocol.workspace = true +async-trait.workspace = true +tokio = { workspace = true, features = ["full"] } +tokio-util.workspace = true +futures.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +uuid.workspace = true +directories.workspace = true +base64.workspace = true + +[dev-dependencies] +tokio = { version = "1", features = ["full"] } diff --git a/crates/ed-acp/README.md b/crates/ed-acp/README.md new file mode 100644 index 0000000..47eb9e9 --- /dev/null +++ b/crates/ed-acp/README.md @@ -0,0 +1,17 @@ +# ed-acp + +An [Agent Client Protocol](https://agentclientprotocol.com) v1 server for Onde agents. + +It is the part of an ACP agent that is the same for every product: `initialize` and version +negotiation, auth, durable sessions, the model picker, prompt content, the turn loop against Onde +Cloud with streaming, tool approval, workspace tools routed through the client, and MCP servers +(stdio and streamable HTTP) through [`ed-mcp`](https://crates.io/crates/ed-mcp). A product +supplies a `Profile`: its name, prompt, tools, slash commands and built-in MCP servers. + +Test an agent built on it with [`ed-acp-testkit`](https://crates.io/crates/ed-acp-testkit), and +drive it from a terminal with [`ed-acp-tui`](https://crates.io/crates/ed-acp-tui). + +## Copyright + +© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)). +Licensed under MIT or Apache-2.0. diff --git a/crates/ed-acp/src/agent.rs b/crates/ed-acp/src/agent.rs new file mode 100644 index 0000000..4d92764 --- /dev/null +++ b/crates/ed-acp/src/agent.rs @@ -0,0 +1,1342 @@ +//! [`AcpServer`]: the ACP v1 agent behind every Onde product. Owns sessions (in memory, backed +//! by [`SessionStore`]), the turn loop, auth, and the model and auto-approve config options. +//! Everything product-specific comes from the [`Profile`]. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use agent_client_protocol::schema::ProtocolVersion; +use agent_client_protocol::schema::v1::{ + AuthMethod, AuthMethodTerminal, AuthenticateRequest, AuthenticateResponse, + AvailableCommandsUpdate, ClientCapabilities, CloseSessionRequest, CloseSessionResponse, + ConfigOptionUpdate, ContentBlock, ContentChunk, DeleteSessionRequest, DeleteSessionResponse, + ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, + LogoutResponse, McpServer, MessageId, NewSessionRequest, NewSessionResponse, PromptRequest, + PromptResponse, ResumeSessionRequest, ResumeSessionResponse, SessionConfigOption, + SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigSelectOptions, SessionId, + SessionInfo, SessionInfoUpdate, SessionNotification, SessionUpdate, + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall, + ToolCallContent, ToolCallLocation, ToolCallStatus, ToolCallUpdateFields, UsageUpdate, +}; +use agent_client_protocol::{Client, ConnectionTo, Responder}; +use serde_json::{Value, json}; +use tokio_util::sync::CancellationToken; + +use ed_mcp::{ClientInfo, McpToolset, ToolsetConfig}; + +use crate::content as prompt; +use crate::llm::{Delta, LlmClient, LlmConfig, LlmEnv}; +use crate::store::{SessionStore, StoredSession}; +use crate::tools::{self, ToolCtx, ToolOutcome, Toolset, absolutize, normalize}; +use crate::{Profile, PromptCtx, SessionCtx}; + +const MAX_TURNS: usize = 50; +/// Id of the session config option that selects the model. +pub const MODEL_CONFIG_ID: &str = "model"; +/// Id of the boolean session config option that skips permission prompts. +pub const AUTO_APPROVE_CONFIG_ID: &str = "auto_approve"; +/// Context window reported in `usage_update` when `_CONTEXT_WINDOW` is unset. +const DEFAULT_CONTEXT_WINDOW: u64 = 128_000; +/// Id of the terminal auth method advertised on initialize. +pub const AUTH_METHOD_ID: &str = "terminal-setup"; +/// Cap on the sessions returned by `session/list`. +const MAX_LISTED: usize = 200; + +/// The terminal auth method: clients run ` --setup`. +pub fn auth_methods() -> Vec { + vec![AuthMethod::Terminal( + AuthMethodTerminal::new(AUTH_METHOD_ID, "Run in terminal") + .description("Interactive setup: store your Onde Inference API key") + .args(vec!["--setup".into()]), + )] +} + +/// Whether the client can run terminal auth. ACP v1 has the `auth.terminal` capability; clients +/// that predate it (including the ACP registry's validator, which probes every listed agent) +/// say so with `_meta["terminal-auth"]: true` instead. Offering no method to such a client +/// fails registry validation, so either signal counts. +pub fn supports_terminal_auth(caps: &ClientCapabilities) -> bool { + caps.auth.terminal + || caps + .meta + .as_ref() + .and_then(|m| m.get("terminal-auth")) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +pub fn auth_required_error(agent: &str) -> agent_client_protocol::Error { + agent_client_protocol::Error::auth_required().data(format!( + "No Onde API key configured. Authenticate with the terminal method (`{agent} --setup`)." + )) +} + +/// The only protocol version this agent speaks is v1. A client proposing a newer version gets +/// v1 back (it can then decide whether to continue); an older (v0) client is told v1 too, as +/// the spec requires the latest supported version when the requested one isn't supported. +pub fn negotiate_version(client: ProtocolVersion) -> ProtocolVersion { + let _ = client; + ProtocolVersion::V1 +} + +struct Session { + cwd: PathBuf, + /// Additional workspace roots (from the request's `additional_directories`). + roots: Vec, + model: String, + /// Conversation history without the system prompt (rebuilt every turn). + messages: Vec, + cancel: CancellationToken, + always_allowed: Arc>>, + always_rejected: Arc>>, + /// Approve tool calls without asking; the `auto_approve` config option. Not persisted: + /// a reopened session starts from the process default (`--yolo`). + auto_approve: Arc, + title: Option, + /// Last activity, seconds since the Unix epoch. + updated_at: u64, + mcp: Arc, + /// A turn is running; its history is checked out of `messages`. + busy: bool, +} + +impl Session { + fn fresh( + cwd: PathBuf, + roots: Vec, + model: String, + yolo: bool, + mcp: Arc, + ) -> Self { + Self::restored( + StoredSession { + cwd, + roots, + model, + title: None, + updated_at: now_secs(), + messages: Vec::new(), + }, + yolo, + mcp, + ) + } + + /// A session brought back from the store. Permission grants start fresh; MCP servers are + /// connected by the request that reopens it. + fn restored(stored: StoredSession, yolo: bool, mcp: Arc) -> Self { + Self { + cwd: stored.cwd, + roots: stored.roots, + model: stored.model, + messages: stored.messages, + cancel: CancellationToken::new(), + always_allowed: Arc::default(), + always_rejected: Arc::default(), + auto_approve: Arc::new(AtomicBool::new(yolo)), + title: stored.title, + updated_at: stored.updated_at, + mcp, + busy: false, + } + } + + fn stored(&self) -> StoredSession { + StoredSession { + cwd: self.cwd.clone(), + roots: self.roots.clone(), + model: self.model.clone(), + title: self.title.clone(), + updated_at: self.updated_at, + messages: self.messages.clone(), + } + } +} + +fn new_message_id() -> MessageId { + MessageId::new(uuid::Uuid::new_v4().to_string()) +} + +/// ACP v1 requires absolute `cwd` and `additionalDirectories`. +fn check_absolute(cwd: &Path, roots: &[PathBuf]) -> agent_client_protocol::Result<()> { + if !cwd.is_absolute() { + return Err(invalid("cwd must be an absolute path")); + } + if roots.iter().any(|r| !r.is_absolute()) { + return Err(invalid( + "additionalDirectories entries must be absolute paths", + )); + } + Ok(()) +} + +pub fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// ISO 8601 UTC timestamp without sub-second precision (no chrono dependency). +pub fn iso8601(secs: u64) -> String { + let days = secs / 86_400; + let secs_of_day = secs % 86_400; + let (h, m, s) = ( + secs_of_day / 3600, + secs_of_day % 3600 / 60, + secs_of_day % 60, + ); + // Civil-from-days algorithm (Howard Hinnant). + let z = days as i64 + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let mo = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if mo <= 2 { y + 1 } else { y }; + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") +} + +/// Derive a short session title from the first user prompt. +pub fn session_title(prompt: &str) -> String { + const MAX: usize = 80; + let first = prompt.lines().next().unwrap_or("").trim(); + if first.chars().count() <= MAX { + first.to_string() + } else { + let cut: String = first.chars().take(MAX - 1).collect(); + format!("{}…", cut.trim_end()) + } +} + +fn invalid(msg: impl Into) -> agent_client_protocol::Error { + agent_client_protocol::Error::invalid_params().data(msg.into()) +} + +fn unknown_session(id: &SessionId) -> agent_client_protocol::Error { + invalid(format!("unknown session {id}")) +} + +/// A tool message; `failed` is kept in a private field that never reaches the model. +fn tool_message(id: &str, content: &str, failed: bool) -> Value { + let mut m = json!({ "role": "tool", "tool_call_id": id, "content": content }); + if failed { + m["x_failed"] = json!(true); + } + m +} + +fn wire_message(m: &Value) -> Value { + match m.as_object() { + Some(o) if o.contains_key("x_failed") => { + let mut o = o.clone(); + o.remove("x_failed"); + Value::Object(o) + } + _ => m.clone(), + } +} + +fn locations_of(paths: &[PathBuf]) -> Vec { + paths + .iter() + .map(|p| ToolCallLocation::new(absolutize(p))) + .collect() +} + +/// Text of a stored user message (`content` is a string, or an array of parts). +fn user_text(msg: &Value) -> String { + match msg.get("content") { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(parts)) => parts + .iter() + .filter_map(|p| match p.get("type").and_then(Value::as_str) { + Some("text") => p.get("text").and_then(Value::as_str).map(String::from), + Some("image_url") => Some("[image]".into()), + _ => None, + }) + .collect::>() + .join("\n\n"), + _ => String::new(), + } +} + +#[derive(Clone)] +pub struct AcpServer { + profile: Arc, + /// The profile's toolsets, built once. + toolsets: Arc>>, + env: LlmEnv, + /// Replaced when credentials change: `authenticate`, `logout`, or a key stored by + /// `--setup` in another process. + llm: Arc>, + /// Process default for each session's `auto_approve` (`--yolo` / `_YOLO`). + yolo: bool, + /// How the agent was launched: `tui` (interactive terminal UI) or `acp` (editor). + surface: &'static str, + client_caps: Arc>, + sessions: Arc>>, + /// Models offered for selection, fetched from `GET /models` on first use and dropped + /// when credentials change. + models: Arc>>>, + store: SessionStore, + data_dir: PathBuf, +} + +impl AcpServer { + pub fn new(profile: Arc, yolo: bool, surface: &'static str) -> Self { + let env = profile.info().env; + let data_dir = env.data_dir(); + Self { + toolsets: Arc::new(profile.toolsets()), + profile, + env, + llm: Arc::new(Mutex::new(LlmClient::new(LlmConfig::from_env(&env)))), + yolo, + surface, + client_caps: Arc::default(), + sessions: Arc::default(), + models: Arc::default(), + store: SessionStore::new(&data_dir), + data_dir, + } + } + + pub fn set_client_caps(&self, caps: ClientCapabilities) { + *self.client_caps.lock().unwrap() = caps; + } + + fn llm(&self) -> LlmClient { + self.llm.lock().unwrap().clone() + } + + pub fn has_api_key(&self) -> bool { + self.llm().has_api_key() + } + + pub fn model(&self) -> String { + self.llm().model().to_string() + } + + /// Re-read credentials (environment + stored `env` file) and report whether a key is + /// configured. Editors run terminal auth (` --setup`) in a separate process + /// and then retry `session/new` or `session/load` on this one without calling + /// `authenticate`, so the stored key has to be picked up here. The model list is only + /// dropped when the configuration actually changed. + pub async fn refresh_credentials(&self) -> bool { + let fresh = LlmConfig::from_env(&self.env); + let changed = fresh != *self.llm().config(); + if changed { + tracing::info!("credentials changed; reloading the Onde client"); + *self.llm.lock().unwrap() = LlmClient::new(fresh); + *self.models.lock().await = None; + } + self.has_api_key() + } + + /// `authenticate`: the client has run ` --setup`; pick up the stored key. + pub async fn authenticate( + &self, + req: AuthenticateRequest, + ) -> agent_client_protocol::Result { + if &*req.method_id.0 != AUTH_METHOD_ID { + return Err(invalid(format!("unknown auth method {}", req.method_id.0))); + } + if self.refresh_credentials().await { + Ok(AuthenticateResponse::new()) + } else { + Err(self.auth_required()) + } + } + + /// `logout`: remove the stored key and forget it. A key exported in the agent's own + /// environment can't be removed from here and keeps the agent signed in. + pub async fn logout(&self) -> agent_client_protocol::Result { + let removed = match self.env.config_file() { + Some(path) => match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => { + Err(format!("removing {}: {e}", path.display())) + } + _ => Ok(()), + }, + None => Ok(()), + }; + self.refresh_credentials().await; + removed.map_err(|e| agent_client_protocol::Error::internal_error().data(e))?; + Ok(LogoutResponse::new()) + } + + fn scratch_dir(&self, id: &SessionId) -> Option { + let id = crate::store::sanitize_id(&id.0).ok()?; + Some(self.data_dir.join("scratch").join(id)) + } + + fn remove_scratch(&self, id: &SessionId) { + if let Some(dir) = self.scratch_dir(id) { + let _ = std::fs::remove_dir_all(dir); + } + } + + /// `AUTH_REQUIRED`, naming this agent's setup command. + pub fn auth_required(&self) -> agent_client_protocol::Error { + auth_required_error(self.profile.info().name) + } + + /// How a session's MCP servers are connected. + fn mcp_config(&self) -> ToolsetConfig { + let info = self.profile.info(); + ToolsetConfig::new(ClientInfo::new(info.name, info.version)) + } + + async fn connect_mcp( + &self, + servers: &[agent_client_protocol::schema::v1::McpServer], + ) -> Arc { + Arc::new( + McpToolset::connect( + self.mcp_config(), + servers, + &self.profile.builtin_mcp_servers(), + ) + .await, + ) + } + + fn system_prompt(&self, cwd: &Path, roots: &[PathBuf], mcp: &McpToolset) -> String { + self.profile.system_prompt(&PromptCtx { + surface: self.surface, + cwd, + roots, + mcp, + }) + } + + /// The selectable models. Falls back to just the configured model if the endpoint + /// can't list them; the configured model is always offered. + async fn models(&self) -> Vec { + let mut cache = self.models.lock().await; + if let Some(ids) = &*cache { + return ids.clone(); + } + let llm = self.llm(); + let default = llm.model().to_string(); + // An explicit list wins; editors show this list as the model picker. + let configured: Vec = std::env::var(self.env.var("MODELS")) + .unwrap_or_default() + .split(',') + .map(|m| m.trim().to_string()) + .filter(|m| !m.is_empty()) + .collect(); + let mut ids = if !configured.is_empty() { + configured + } else { + match llm.models().await { + Ok(models) => models.into_iter().map(|m| m.id).collect(), + Err(e) => { + tracing::warn!("listing models failed, offering only {default}: {e:#}"); + Vec::new() + } + } + }; + if !ids.contains(&default) { + ids.insert(0, default); + } + *cache = Some(ids.clone()); + ids + } + + /// Whether the client advertised `session.configOptions.boolean`, which ACP requires + /// before an agent may offer `type: "boolean"` options. + fn boolean_options_supported(&self) -> bool { + self.client_caps + .lock() + .unwrap() + .session + .as_ref() + .and_then(|s| s.config_options.as_ref()) + .is_some_and(|c| c.boolean.is_some()) + } + + async fn config_options(&self, model: &str, auto_approve: bool) -> Vec { + let options = self + .models() + .await + .into_iter() + .map(|id| SessionConfigSelectOption::new(id.clone(), id)) + .collect(); + let mut config = vec![ + SessionConfigOption::select( + MODEL_CONFIG_ID, + "Model", + model.to_string(), + SessionConfigSelectOptions::Ungrouped(options), + ) + .category(SessionConfigOptionCategory::Model), + ]; + if self.boolean_options_supported() { + config.push( + SessionConfigOption::boolean( + AUTO_APPROVE_CONFIG_ID, + "Auto-approve actions", + auto_approve, + ) + .description("Run tools that change things without asking for permission"), + ); + } + config + } + + /// The model and auto-approve setting of a live session. + fn session_settings(&self, id: &SessionId) -> Option<(String, bool)> { + self.sessions + .lock() + .unwrap() + .get(id) + .map(|s| (s.model.clone(), s.auto_approve.load(Ordering::Relaxed))) + } + + async fn session_config_options( + &self, + id: &SessionId, + ) -> agent_client_protocol::Result> { + let (model, auto_approve) = self + .session_settings(id) + .ok_or_else(|| unknown_session(id))?; + Ok(self.config_options(&model, auto_approve).await) + } + + pub async fn set_config_option( + &self, + req: SetSessionConfigOptionRequest, + connection: &ConnectionTo, + ) -> agent_client_protocol::Result { + let id = req.session_id.clone(); + self.restore(&id).await?; + match &*req.config_id.0 { + MODEL_CONFIG_ID => { + let Some(model) = req.value.as_value_id().map(|v| v.0.to_string()) else { + return Err(invalid("model must be a value id")); + }; + if !self.models().await.contains(&model) { + return Err(invalid(format!("unknown model {model}"))); + } + match self.sessions.lock().unwrap().get_mut(&id) { + Some(s) => s.model = model.clone(), + None => return Err(unknown_session(&id)), + } + self.persist(&id).await; + tracing::info!("session {id} now uses model {model}"); + } + AUTO_APPROVE_CONFIG_ID if self.boolean_options_supported() => { + let Some(on) = req.value.as_bool() else { + return Err(invalid("auto_approve must be a boolean")); + }; + match self.sessions.lock().unwrap().get(&id) { + Some(s) => s.auto_approve.store(on, Ordering::Relaxed), + None => return Err(unknown_session(&id)), + } + tracing::info!("session {id} auto-approve {on}"); + } + other => return Err(invalid(format!("unknown config option {other}"))), + } + let options = self.session_config_options(&id).await?; + // Tell every view of the session about the change, not just the requester. + connection.send_notification(SessionNotification::new( + id, + SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(options.clone())), + ))?; + Ok(SetSessionConfigOptionResponse::new(options)) + } + + /// Save a session that is not running a turn (a turn checks the history out; the + /// end-of-turn save covers it). + async fn persist(&self, id: &SessionId) { + let stored = self + .sessions + .lock() + .unwrap() + .get(id) + .filter(|s| !s.busy) + .map(Session::stored); + let Some(stored) = stored else { return }; + let store = self.store.clone(); + let sid = id.0.to_string(); + match tokio::task::spawn_blocking(move || store.save(&sid, &stored)).await { + Ok(Ok(())) => {} + Ok(Err(e)) => tracing::warn!("saving session {id} failed: {e:#}"), + Err(e) => tracing::warn!("saving session {id} failed: {e}"), + } + } + + /// Bring a stored session into memory without rebinding it (a prompt or config change + /// for a thread the client reopened without `session/load`). Errors if it is unknown. + async fn restore(&self, id: &SessionId) -> agent_client_protocol::Result<()> { + if self.sessions.lock().unwrap().contains_key(id) { + return Ok(()); + } + let stored = self + .load_stored(id) + .await? + .ok_or_else(|| unknown_session(id))?; + tracing::info!("restored session {id} from disk"); + self.sessions + .lock() + .unwrap() + .entry(id.clone()) + .or_insert_with(|| Session::restored(stored, self.yolo, self.no_mcp())); + Ok(()) + } + + async fn load_stored( + &self, + id: &SessionId, + ) -> agent_client_protocol::Result> { + let store = self.store.clone(); + let sid = id.0.to_string(); + tokio::task::spawn_blocking(move || store.load(&sid)) + .await + .map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string()))? + .map_err(|e| agent_client_protocol::Error::internal_error().data(format!("{e:#}"))) + } + + /// A session placeholder until its MCP servers are connected. + fn no_mcp(&self) -> Arc { + Arc::new(McpToolset::empty(self.mcp_config())) + } + + fn session_mcp(&self, id: &SessionId) -> Arc { + self.sessions + .lock() + .unwrap() + .get(id) + .map(|s| s.mcp.clone()) + .unwrap_or_else(|| self.no_mcp()) + } + + /// `AvailableCommandsUpdate` for a session, sent after the response that created it. + pub fn send_commands( + &self, + connection: &ConnectionTo, + id: &SessionId, + ) -> agent_client_protocol::Result<()> { + let mcp = self.session_mcp(id); + let commands = self.profile.slash_commands(&SessionCtx { mcp: &mcp }); + connection.send_notification(SessionNotification::new( + id.clone(), + SessionUpdate::AvailableCommandsUpdate(AvailableCommandsUpdate::new(commands)), + )) + } + + pub async fn new_session( + &self, + req: NewSessionRequest, + ) -> agent_client_protocol::Result { + check_absolute(&req.cwd, &req.additional_directories)?; + // Lexical normalization only: the client sees `cwd` again in `session/list`, so + // symlinks in it are not rewritten. + let cwd = normalize(&req.cwd); + let roots: Vec = req + .additional_directories + .iter() + .map(|r| normalize(r)) + .collect(); + let id = SessionId::new(uuid::Uuid::new_v4().to_string()); + let model = self.model(); + let mcp = self.connect_mcp(&req.mcp_servers).await; + let session = Session::fresh(cwd, roots, model.clone(), self.yolo, mcp); + self.sessions.lock().unwrap().insert(id.clone(), session); + // Saved right away so the thread survives a restart before its first prompt. + self.persist(&id).await; + let options = self.config_options(&model, self.yolo).await; + Ok(NewSessionResponse::new(id).config_options(options)) + } + + /// Live and stored sessions, optionally filtered by working directory, newest first. + pub fn list_sessions( + &self, + req: ListSessionsRequest, + ) -> agent_client_protocol::Result { + // Every session fits on one page, so no cursor is ever handed out; any cursor is stale. + if let Some(cursor) = &req.cursor { + return Err(invalid(format!("invalid cursor {cursor}"))); + } + struct Row { + id: SessionId, + cwd: PathBuf, + roots: Vec, + title: Option, + updated_at: u64, + } + let mut rows: HashMap = self + .store + .list() + .into_iter() + .map(|(id, s)| { + ( + id.clone(), + Row { + id: SessionId::new(id), + cwd: s.cwd, + roots: s.roots, + title: s.title, + updated_at: s.updated_at, + }, + ) + }) + .collect(); + // Live state wins over the (possibly older) stored copy. + for (id, s) in self.sessions.lock().unwrap().iter() { + rows.insert( + id.0.to_string(), + Row { + id: id.clone(), + cwd: s.cwd.clone(), + roots: s.roots.clone(), + title: s.title.clone(), + updated_at: s.updated_at, + }, + ); + } + // Stored cwds are normalized, so normalize the filter the same way. + let wanted = req.cwd.as_deref().map(normalize); + let mut rows: Vec = rows + .into_values() + .filter(|r| wanted.as_ref().is_none_or(|cwd| *cwd == normalize(&r.cwd))) + .collect(); + rows.sort_by(|a, b| { + b.updated_at + .cmp(&a.updated_at) + .then_with(|| a.id.0.cmp(&b.id.0)) + }); + rows.truncate(MAX_LISTED); + Ok(ListSessionsResponse::new( + rows.into_iter() + .map(|r| { + SessionInfo::new(r.id, r.cwd) + .additional_directories(r.roots) + .title(r.title) + .updated_at(iso8601(r.updated_at)) + }) + .collect(), + )) + } + + pub fn cancel(&self, session_id: &SessionId) { + if let Some(s) = self.sessions.lock().unwrap().get(session_id) { + s.cancel.cancel(); + } + } + + /// Make `id` live for `session/load` and `session/resume`: from memory, else from the + /// store; rebind cwd and roots, and reconnect MCP servers from the request. A well-formed + /// id with no saved history (a thread from a process that never saved it) reopens empty + /// under the same id, so the editor's thread opens instead of failing to launch. Returns + /// whether the history was lost. + async fn reopen( + &self, + id: &SessionId, + cwd: &Path, + roots: &[PathBuf], + mcp_servers: &[McpServer], + ) -> agent_client_protocol::Result { + check_absolute(cwd, roots)?; + if crate::store::sanitize_id(&id.0).is_err() { + return Err(unknown_session(id)); + } + let live = self.sessions.lock().unwrap().contains_key(id); + let stored = if live { + None + } else { + self.load_stored(id).await? + }; + let history_lost = !live && stored.is_none(); + let cwd = normalize(cwd); + let roots: Vec = roots.iter().map(|r| normalize(r)).collect(); + let mcp = self.connect_mcp(mcp_servers).await; + + let old_mcp = { + let mut sessions = self.sessions.lock().unwrap(); + if !sessions.contains_key(id) { + let session = match stored { + Some(stored) => Session::restored(stored, self.yolo, self.no_mcp()), + None => { + tracing::warn!("session {id} has no saved history; reopening it empty"); + Session::fresh( + cwd.clone(), + roots.clone(), + self.model(), + self.yolo, + self.no_mcp(), + ) + } + }; + sessions.insert(id.clone(), session); + } + let s = sessions.get_mut(id).ok_or_else(|| unknown_session(id))?; + s.cwd = cwd; + if !roots.is_empty() { + s.roots = roots; + } + s.updated_at = now_secs(); + std::mem::replace(&mut s.mcp, mcp) + }; + if let Some(old) = Arc::into_inner(old_mcp) { + tokio::spawn(old.shutdown()); + } + self.persist(id).await; + Ok(history_lost) + } + + /// Tell the user a reopened thread starts without its earlier history. Shown in the + /// thread only; the model doesn't see it. + fn notify_history_lost( + &self, + connection: &ConnectionTo, + id: &SessionId, + ) -> agent_client_protocol::Result<()> { + connection.send_notification(SessionNotification::new( + id.clone(), + SessionUpdate::AgentMessageChunk( + ContentChunk::new( + format!( + "{} no longer has this conversation's history, so it starts fresh \ + from here.", + self.profile.info().display_name + ) + .into(), + ) + .message_id(new_message_id()), + ), + )) + } + + /// Replay a session's conversation as `session/update` notifications (text, tool calls + /// with their status and output), then respond with config options. `session/load`. + pub async fn load_session( + &self, + req: LoadSessionRequest, + connection: &ConnectionTo, + ) -> agent_client_protocol::Result { + let id = req.session_id.clone(); + let history_lost = self + .reopen(&id, &req.cwd, &req.additional_directories, &req.mcp_servers) + .await?; + let (messages, cwd, roots) = { + let sessions = self.sessions.lock().unwrap(); + let s = sessions.get(&id).ok_or_else(|| unknown_session(&id))?; + (s.messages.clone(), s.cwd.clone(), s.roots.clone()) + }; + let notify = |update: SessionUpdate| { + connection.send_notification(SessionNotification::new(id.clone(), update)) + }; + // Tool output by call id, from the stored `tool` messages. + let results: HashMap<&str, (&str, bool)> = messages + .iter() + .filter(|m| m.get("role").and_then(Value::as_str) == Some("tool")) + .filter_map(|m| { + Some(( + m.get("tool_call_id")?.as_str()?, + ( + m.get("content").and_then(Value::as_str).unwrap_or(""), + m.get("x_failed").and_then(Value::as_bool).unwrap_or(false), + ), + )) + }) + .collect(); + for msg in &messages { + match msg.get("role").and_then(Value::as_str).unwrap_or("") { + "user" => { + let text = user_text(msg); + if !text.is_empty() { + notify(SessionUpdate::UserMessageChunk( + ContentChunk::new(text.into()).message_id(new_message_id()), + ))?; + } + } + "assistant" => { + let text = msg.get("content").and_then(Value::as_str).unwrap_or(""); + if !text.is_empty() { + notify(SessionUpdate::AgentMessageChunk( + ContentChunk::new(text.to_string().into()).message_id(new_message_id()), + ))?; + } + let calls = msg.get("tool_calls").and_then(Value::as_array); + for tc in calls.into_iter().flatten() { + let call_id = tc.get("id").and_then(Value::as_str).unwrap_or("unknown"); + let name = tc + .pointer("/function/name") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let args: Value = tc + .pointer("/function/arguments") + .and_then(Value::as_str) + .and_then(|a| serde_json::from_str(a).ok()) + .unwrap_or_else(|| json!({})); + let (title, kind, locations) = + tools::describe(&self.toolsets, &cwd, &roots, name, &args); + let mut call = ToolCall::new(call_id.to_string(), title) + .name(name.to_string()) + .kind(kind) + .locations(locations) + .raw_input(args); + match results.get(call_id) { + Some((output, failed)) => { + call = call + .status(if *failed { + ToolCallStatus::Failed + } else { + ToolCallStatus::Completed + }) + .content(vec![ToolCallContent::from(output.to_string())]) + .raw_output(json!({ "output": output, "failed": failed })); + } + // The turn was interrupted before this call produced a result. + None => call = call.status(ToolCallStatus::Failed), + } + notify(SessionUpdate::ToolCall(call))?; + } + } + // system and tool messages are not replayed on their own. + _ => {} + } + } + if history_lost { + self.notify_history_lost(connection, &id)?; + } + Ok(LoadSessionResponse::new().config_options(self.session_config_options(&id).await?)) + } + + /// Rebind a session to a new cwd / additional directories without replaying it. + pub async fn resume_session( + &self, + req: ResumeSessionRequest, + connection: &ConnectionTo, + ) -> agent_client_protocol::Result { + let id = req.session_id.clone(); + let history_lost = self + .reopen(&id, &req.cwd, &req.additional_directories, &req.mcp_servers) + .await?; + if history_lost { + self.notify_history_lost(connection, &id)?; + } + Ok(ResumeSessionResponse::new().config_options(self.session_config_options(&id).await?)) + } + + /// Cancel any in-progress work, release MCP servers and scratch audio. The stored + /// conversation stays; `session/delete` removes it. + pub fn close_session( + &self, + req: CloseSessionRequest, + ) -> agent_client_protocol::Result { + let id = req.session_id.clone(); + let removed = self.sessions.lock().unwrap().remove(&id); + match removed { + Some(s) => self.release(&id, s), + // A session from an earlier process that was never reopened has nothing to close. + None if self.store.load(&id.0).ok().flatten().is_some() => {} + None => return Err(unknown_session(&id)), + } + Ok(CloseSessionResponse::new()) + } + + /// Forget a session, in memory and on disk, with its scratch audio. Idempotent: deleting + /// an unknown session succeeds. + pub fn delete_session( + &self, + req: DeleteSessionRequest, + ) -> agent_client_protocol::Result { + let id = req.session_id.clone(); + if let Some(s) = self.sessions.lock().unwrap().remove(&id) { + self.release(&id, s); + } + // A malformed id can't name a stored session; nothing to remove. + if crate::store::sanitize_id(&id.0).is_ok() { + self.store.delete(&id.0).map_err(|e| { + agent_client_protocol::Error::internal_error().data(format!("{e:#}")) + })?; + } + self.remove_scratch(&id); + Ok(DeleteSessionResponse::new()) + } + + fn release(&self, id: &SessionId, s: Session) { + s.cancel.cancel(); + self.remove_scratch(id); + if let Some(mcp) = Arc::into_inner(s.mcp) { + tokio::spawn(mcp.shutdown()); + } + } + + /// Stop everything: used when the connection ends. + pub async fn shutdown(&self) { + let sessions: Vec = self + .sessions + .lock() + .unwrap() + .drain() + .map(|(_, s)| s) + .collect(); + for s in sessions { + s.cancel.cancel(); + if let Some(mcp) = Arc::into_inner(s.mcp) { + mcp.shutdown().await; + } + } + for server in self.profile.builtin_mcp_servers() { + server.shutdown().await; + } + } + + pub async fn prompt( + &self, + request: PromptRequest, + responder: Responder, + connection: ConnectionTo, + ) -> agent_client_protocol::Result<()> { + // A client may prompt a reopened thread without loading or resuming it first. + if let Err(e) = self.restore(&request.session_id).await { + return responder.respond_with_error(e); + } + match self.run_turn(request, connection).await { + Ok(stop) => responder.respond(PromptResponse::new(stop)), + Err(e) if e.downcast_ref::().is_some() => { + responder.respond_with_error(invalid(e.to_string())) + } + Err(e) => responder.respond_with_error( + agent_client_protocol::Error::internal_error().data(format!("{e:#}")), + ), + } + } + + async fn run_turn( + &self, + request: PromptRequest, + connection: ConnectionTo, + ) -> anyhow::Result { + let session_id = request.session_id.clone(); + let caps = self.client_caps.lock().unwrap().clone(); + let blocks = + prompt::resolve_resource_links(&request.prompt, &caps, &connection, &session_id).await; + let prompt_text = prompt::prompt_to_text(&blocks, &self.profile.audio_hints()); + // Check the session out for the turn. + let ( + cwd, + roots, + model, + history, + cancel, + always_allowed, + always_rejected, + auto_approve, + mcp, + new_title, + ) = { + let mut sessions = self.sessions.lock().unwrap(); + let s = sessions + .get_mut(&session_id) + .ok_or_else(|| UnknownSession(session_id.to_string()))?; + if s.busy { + anyhow::bail!("a prompt is already running for session {session_id}"); + } + s.busy = true; + s.cancel = CancellationToken::new(); + s.updated_at = now_secs(); + let new_title = if s.title.is_none() && !prompt_text.trim().is_empty() { + s.title = Some(session_title(&prompt_text)); + s.title.clone() + } else { + None + }; + ( + s.cwd.clone(), + s.roots.clone(), + s.model.clone(), + std::mem::take(&mut s.messages), + s.cancel.clone(), + s.always_allowed.clone(), + s.always_rejected.clone(), + s.auto_approve.clone(), + s.mcp.clone(), + new_title, + ) + }; + if let Some(title) = new_title { + let update = SessionInfoUpdate::new() + .title(title) + .updated_at(iso8601(now_secs())); + let _ = connection.send_notification(SessionNotification::new( + session_id.clone(), + SessionUpdate::SessionInfoUpdate(update), + )); + } + + let mut messages = vec![json!({ + "role": "system", + "content": self.system_prompt(&cwd, &roots, &mcp), + })]; + messages.extend(history); + + let ctx = ToolCtx { + connection: connection.clone(), + session_id: session_id.clone(), + cwd, + roots, + caps, + cancel, + auto_approve, + always_allowed, + always_rejected, + mcp, + }; + let result = self.turn(&ctx, &model, &mut messages, &blocks).await; + + // Check the history back in, even when the turn failed. + let history = messages.split_off(1); + let stored = { + let mut sessions = self.sessions.lock().unwrap(); + sessions.get_mut(&session_id).map(|s| { + s.messages = history; + s.busy = false; + s.updated_at = now_secs(); + s.stored() + }) + }; + if let Some(stored) = stored { + let updated_at = stored.updated_at; + let store = self.store.clone(); + let id = session_id.0.to_string(); + let saved = tokio::task::spawn_blocking(move || store.save(&id, &stored)).await; + if let Ok(Err(e)) | Err(e) = saved.map_err(anyhow::Error::from) { + tracing::warn!("saving session {session_id} failed: {e:#}"); + } + let _ = connection.send_notification(SessionNotification::new( + session_id.clone(), + SessionUpdate::SessionInfoUpdate( + SessionInfoUpdate::new().updated_at(iso8601(updated_at)), + ), + )); + } + result + } + + /// Everything inside a turn after the session is checked out. + async fn turn( + &self, + ctx: &ToolCtx, + model: &str, + messages: &mut Vec, + blocks: &[ContentBlock], + ) -> anyhow::Result { + let audio_paths = match self.scratch_dir(&ctx.session_id) { + Some(dir) => prompt::save_audio_blocks(blocks, &dir).await?, + None => Vec::new(), + }; + let hints = self.profile.audio_hints(); + let slash = self.profile.expand_slash( + &prompt::prompt_to_text(blocks, &hints), + &SessionCtx { mcp: &ctx.mcp }, + ); + let content = prompt::prompt_to_content(blocks, &audio_paths, slash.as_deref(), &hints); + messages.push(json!({ "role": "user", "content": content })); + self.agent_loop(ctx, model, messages).await + } + + async fn agent_loop( + &self, + ctx: &ToolCtx, + model: &str, + messages: &mut Vec, + ) -> anyhow::Result { + let llm = self.llm(); + let tool_defs = tools::definitions(&self.toolsets, &ctx.mcp); + let notify = |update: SessionUpdate| { + ctx.connection + .send_notification(SessionNotification::new(ctx.session_id.clone(), update)) + }; + // `size` is the model's context window, which OpenAI-style endpoints don't report. + let context_window = std::env::var(self.env.var("CONTEXT_WINDOW")) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_CONTEXT_WINDOW); + + for _ in 0..MAX_TURNS { + // One message id per assistant message (and per thought), so clients can group + // the streamed chunks. + let message_id = new_message_id(); + let thought_id = new_message_id(); + let wire: Vec = messages.iter().map(wire_message).collect(); + let completion = tokio::select! { + r = llm.complete(model, &wire, &tool_defs, |delta| { + let update = match delta { + Delta::Text(t) => SessionUpdate::AgentMessageChunk( + ContentChunk::new(t.to_string().into()).message_id(message_id.clone()), + ), + Delta::Reasoning(t) => SessionUpdate::AgentThoughtChunk( + ContentChunk::new(t.to_string().into()).message_id(thought_id.clone()), + ), + }; + if let Err(e) = notify(update) { + tracing::warn!("failed to send update: {e}"); + } + }) => r?, + () = ctx.cancel.cancelled() => return Ok(StopReason::Cancelled), + }; + messages.push(completion.to_message()); + if let Some(usage) = completion.usage { + let used = usage.used(); + notify(SessionUpdate::UsageUpdate(UsageUpdate::new( + used, + context_window.max(used), + )))?; + } + + if completion.tool_calls.is_empty() { + return Ok(match completion.finish_reason.as_deref() { + Some("length") => StopReason::MaxTokens, + Some("content_filter") => StopReason::Refusal, + _ => StopReason::EndTurn, + }); + } + + for tc in &completion.tool_calls { + // Every tool call needs a matching tool message, even after cancellation. + if ctx.cancel.is_cancelled() { + messages.push(tool_message(&tc.id, "Cancelled by user.", true)); + continue; + } + let parsed: Result = serde_json::from_str(&tc.arguments); + let args = parsed.as_ref().cloned().unwrap_or_else(|_| json!({})); + let (title, kind, locations) = + tools::describe(&self.toolsets, &ctx.cwd, &ctx.roots, &tc.name, &args); + notify(SessionUpdate::ToolCall( + ToolCall::new(tc.id.clone(), title) + .name(tc.name.clone()) + .kind(kind) + .status(ToolCallStatus::InProgress) + .locations(locations) + .raw_input(args.clone()), + ))?; + + let outcome: ToolOutcome = if parsed.is_err() { + ToolOutcome::err(format!("Invalid JSON arguments: {}", tc.arguments)) + } else { + tools::execute( + ctx, + &self.toolsets, + self.profile.as_ref(), + &tc.id, + &tc.name, + args, + ) + .await + }; + + let mut fields = ToolCallUpdateFields::new() + .status(if outcome.failed { + ToolCallStatus::Failed + } else { + ToolCallStatus::Completed + }) + .content(outcome.content) + .raw_output(json!({ "output": outcome.text, "failed": outcome.failed })); + if !outcome.locations.is_empty() { + fields = fields.locations(locations_of(&outcome.locations)); + } + ctx.update(&tc.id, fields)?; + messages.push(tool_message(&tc.id, &outcome.text, outcome.failed)); + } + if ctx.cancel.is_cancelled() { + return Ok(StopReason::Cancelled); + } + } + Ok(StopReason::MaxTurnRequests) + } +} + +/// Marker error: the prompt named a session that doesn't exist. +#[derive(Debug)] +struct UnknownSession(String); + +impl std::fmt::Display for UnknownSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "unknown session {}", self.0) + } +} + +impl std::error::Error for UnknownSession {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn iso8601_known_dates() { + assert_eq!(iso8601(0), "1970-01-01T00:00:00Z"); + assert_eq!(iso8601(1_700_000_000), "2023-11-14T22:13:20Z"); + assert_eq!(iso8601(951_782_400), "2000-02-29T00:00:00Z"); + } + + #[test] + fn titles_are_trimmed_and_single_line() { + assert_eq!(session_title(" hello\nworld"), "hello"); + let long = "x".repeat(200); + let t = session_title(&long); + assert_eq!(t.chars().count(), 80); + assert!(t.ends_with('…')); + } + + #[test] + fn terminal_auth_support_from_capability_or_legacy_meta() { + let caps = |v: Value| serde_json::from_value::(v).unwrap(); + assert!(!supports_terminal_auth(&caps(json!({})))); + assert!(supports_terminal_auth(&caps( + json!({"auth": {"terminal": true}}) + ))); + // The ACP registry validator's payload. + assert!(supports_terminal_auth(&caps( + json!({"terminal": true, "_meta": {"terminal_output": true, "terminal-auth": true}}) + ))); + assert!(!supports_terminal_auth(&caps( + json!({"_meta": {"terminal-auth": false}}) + ))); + } + + #[test] + fn version_negotiation_always_v1() { + assert_eq!(negotiate_version(ProtocolVersion::V1), ProtocolVersion::V1); + assert_eq!( + negotiate_version(ProtocolVersion::from(7)), + ProtocolVersion::V1 + ); + assert_eq!( + negotiate_version(ProtocolVersion::from(0)), + ProtocolVersion::V1 + ); + } + + #[test] + fn private_failure_flag_never_reaches_the_wire() { + let m = tool_message("c1", "boom", true); + assert_eq!(m["x_failed"], true); + let w = wire_message(&m); + assert!(w.get("x_failed").is_none()); + assert_eq!(w["content"], "boom"); + assert!(tool_message("c1", "ok", false).get("x_failed").is_none()); + } + + #[test] + fn user_text_handles_parts() { + let m = json!({"role": "user", "content": [ + {"type": "text", "text": "hi"}, + {"type": "image_url", "image_url": {"url": "x"}} + ]}); + assert_eq!(user_text(&m), "hi\n\n[image]"); + assert_eq!(user_text(&json!({"content": "plain"})), "plain"); + } +} diff --git a/crates/ed-acp/src/lib.rs b/crates/ed-acp/src/lib.rs new file mode 100644 index 0000000..84cb4bb --- /dev/null +++ b/crates/ed-acp/src/lib.rs @@ -0,0 +1,136 @@ +//! An Agent Client Protocol (ACP) v1 server for Onde agents. +//! +//! `ed-acp` is the part of an ACP agent that is the same for every product: `initialize` and +//! version negotiation, terminal auth, `authenticate` and `logout`, durable sessions (`list`, +//! `load` with full replay, `resume`, `close`, `delete`), the model picker and auto-approve +//! config options, slash-command advertisement, prompt content (embedded and linked files, +//! images, audio), the turn loop against Onde Cloud with streaming, message ids and usage, +//! tool approval, the workspace tools routed through the client's `fs/*` and `terminal/*`, and +//! MCP servers through [`ed_mcp`]. +//! +//! A product supplies a [`Profile`]: its name, prompt, tools, slash commands and built-in MCP +//! servers. Everything in the Onde Agent Platform §5.5 conformance table is implemented here +//! once. +//! +//! ```ignore +//! let profile = Arc::new(MyProfile); +//! ed_acp::serve_stdio(profile, ServeOptions::from_env(&info)).await?; +//! ``` + +mod agent; +pub mod cli; +pub mod content; +pub mod llm; +mod server; +pub mod store; +pub mod tools; +mod workspace; + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use agent_client_protocol::schema::v1::{AvailableCommand, PromptCapabilities}; +use async_trait::async_trait; +use ed_mcp::{Connection, McpTool, McpToolset, SharedServer}; +use serde_json::Value; + +pub use agent_client_protocol; +pub use content::AudioHints; +pub use ed_mcp; +pub use llm::{LlmConfig, LlmEnv}; +pub use server::{ServeOptions, serve_stdio}; +pub use tools::{ + DescribeCtx, ToolCtx, ToolOutcome, Toolset, absolutize, function_def, normalize, resolve_in, + truncate, +}; +pub use workspace::WorkspaceTools; + +/// Who the agent is. +#[derive(Debug, Clone, Copy)] +pub struct AgentInfo { + /// Binary and protocol name, e.g. `splitfire-agent`. Reported as `agentInfo.name` and in + /// `clientInfo` to MCP servers. + pub name: &'static str, + /// Name shown to people, e.g. `SplitFire`. + pub display_name: &'static str, + pub version: &'static str, + /// Where settings and credentials come from. + pub env: LlmEnv, +} + +/// What the system prompt is built from, every turn. +pub struct PromptCtx<'a> { + /// `acp` or `tui`; see [`ServeOptions::surface`]. + pub surface: &'a str, + pub cwd: &'a Path, + pub roots: &'a [PathBuf], + /// The session's MCP tools, so the prompt can mention optional capabilities only when + /// they are connected. + pub mcp: &'a McpToolset, +} + +/// What slash commands depend on. +pub struct SessionCtx<'a> { + pub mcp: &'a McpToolset, +} + +/// An MCP call awaiting approval. +pub struct McpCall<'a> { + pub tool: &'a McpTool, + pub args: &'a Value, + /// The server, for products that consult it (e.g. to report a download size). + pub connection: Option<&'a Connection>, +} + +/// The product-specific part of an ACP agent. +#[async_trait] +pub trait Profile: Send + Sync + 'static { + fn info(&self) -> AgentInfo; + + /// The system prompt for one turn. + fn system_prompt(&self, ctx: &PromptCtx<'_>) -> String; + + /// The tools the model may call, besides MCP tools. Include [`WorkspaceTools`] to offer + /// file and shell access. + fn toolsets(&self) -> Vec>; + + /// Commands advertised with `available_commands_update` after a session is created or + /// reopened. + fn slash_commands(&self, _ctx: &SessionCtx<'_>) -> Vec { + Vec::new() + } + + /// When `text` starts with one of this profile's commands, the instruction the model gets + /// in its place. + fn expand_slash(&self, _text: &str, _ctx: &SessionCtx<'_>) -> Option { + None + } + + /// Process-wide MCP servers offered to every session unless the client supplies one of + /// the same name. + fn builtin_mcp_servers(&self) -> Vec<&'static SharedServer> { + Vec::new() + } + + /// Advertised in `initialize`. Audio is accepted only when this says so. + fn prompt_capabilities(&self) -> PromptCapabilities { + PromptCapabilities::new().embedded_context(true).image(true) + } + + /// How audio links and attachments are described to the model. + fn audio_hints(&self) -> AudioHints { + AudioHints::default() + } + + /// The permission prompt for a mutating MCP tool. `None` uses the default: the tool, its + /// server and its arguments. + async fn mcp_permission_preview(&self, _call: McpCall<'_>) -> Option { + None + } + + /// Files an MCP result produced, from its `structuredContent`, shown as tool-call + /// locations. Relative paths are dropped. + fn mcp_result_locations(&self, _tool: &McpTool, _structured: &Value) -> Vec { + Vec::new() + } +} From b08dd59ccac2cf3d9d40f8316a84e03546a0a960 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:12:53 +0200 Subject: [PATCH 07/28] Add ed-acp-testkit: mock endpoint, raw client, ACP conformance suite A dev-dependency for ACP agents: a mock Onde Cloud endpoint, a raw JSON-RPC client for the agent binary, the registry probe, and the ACP v1 conformance suite behind conformance_suite!. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: siGit Code v1.6.3 --- Cargo.lock | 9 + Cargo.toml | 2 + crates/ed-acp-testkit/Cargo.toml | 17 + crates/ed-acp-testkit/README.md | 17 + crates/ed-acp-testkit/src/conformance.rs | 795 +++++++++++++++++++++++ crates/ed-acp-testkit/src/lib.rs | 155 +++++ crates/ed-acp-testkit/src/mock.rs | 207 ++++++ crates/ed-acp-testkit/src/raw.rs | 289 ++++++++ 8 files changed, 1491 insertions(+) create mode 100644 crates/ed-acp-testkit/Cargo.toml create mode 100644 crates/ed-acp-testkit/README.md create mode 100644 crates/ed-acp-testkit/src/conformance.rs create mode 100644 crates/ed-acp-testkit/src/lib.rs create mode 100644 crates/ed-acp-testkit/src/mock.rs create mode 100644 crates/ed-acp-testkit/src/raw.rs diff --git a/Cargo.lock b/Cargo.lock index dfc045d..b289d5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1964,6 +1964,15 @@ dependencies = [ "uuid 1.27.0", ] +[[package]] +name = "ed-acp-testkit" +version = "1.0.1" +dependencies = [ + "serde_json", + "tokio", + "uuid 1.27.0", +] + [[package]] name = "ed-agent" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index d147848..ea9babb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/ed-agent-tauri", "crates/ed-mcp", "crates/ed-acp", + "crates/ed-acp-testkit", "uniffi-bindgen", ] @@ -58,6 +59,7 @@ base64 = "0.23" ed-agent = { version = "1.0.1", path = "crates/ed-agent" } ed-mcp = { version = "1.0.1", path = "crates/ed-mcp" } ed-acp = { version = "1.0.1", path = "crates/ed-acp" } +ed-acp-testkit = { version = "1.0.1", path = "crates/ed-acp-testkit" } # gemm-common (via candle, via onde) depends on sysctl for every # `target_vendor = "apple"`, but sysctl 0.6.0 only has a `sys` module behind diff --git a/crates/ed-acp-testkit/Cargo.toml b/crates/ed-acp-testkit/Cargo.toml new file mode 100644 index 0000000..3be9b58 --- /dev/null +++ b/crates/ed-acp-testkit/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ed-acp-testkit" +description = "Test kit for Onde ACP agents: a mock Onde Cloud endpoint, a raw JSON-RPC client for the agent binary, a test MCP server, the ACP registry probe and the ACP v1 conformance suite." +keywords = ["acp", "agent", "testing", "conformance", "onde"] +categories = ["development-tools::testing"] +version.workspace = true +edition = "2024" +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +tokio = { workspace = true, features = ["full"] } +serde_json.workspace = true +uuid.workspace = true diff --git a/crates/ed-acp-testkit/README.md b/crates/ed-acp-testkit/README.md new file mode 100644 index 0000000..e297af9 --- /dev/null +++ b/crates/ed-acp-testkit/README.md @@ -0,0 +1,17 @@ +# ed-acp-testkit + +Test support for Onde ACP agents, used as a dev-dependency. + +- A mock Onde Cloud endpoint (streaming, tool calls, usage, `content_filter`). +- A raw JSON-RPC client that drives the real agent binary over stdio. +- The ACP registry validator's `initialize` probe. +- The ACP v1 conformance suite, adopted with one macro call in an integration test: + `ed_acp_testkit::conformance_suite!(target);` + +The scriptable MCP server for tests is in [`ed-mcp`](https://crates.io/crates/ed-mcp) behind its +`test-server` feature. + +## Copyright + +© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)). +Licensed under MIT or Apache-2.0. diff --git a/crates/ed-acp-testkit/src/conformance.rs b/crates/ed-acp-testkit/src/conformance.rs new file mode 100644 index 0000000..d6b67fa --- /dev/null +++ b/crates/ed-acp-testkit/src/conformance.rs @@ -0,0 +1,795 @@ +//! The ACP v1 conformance suite (Onde Agent Platform §5.5). Each function drives the real +//! agent binary over stdio against the mock Onde endpoint. Run them all with +//! [`conformance_suite!`](crate::conformance_suite). +//! +//! The suite assumes the agent offers [`WorkspaceTools`] (`read_file`, `write_file`) and +//! terminal auth, as every Onde ACP agent does today. +//! +//! [`WorkspaceTools`]: https://docs.rs/ed-acp + +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::Target; +use crate::mock::{MODEL, Step, TEST_KEY, mock, temp_dir}; +use crate::raw::{Perm, Raw, alive, error_code, read_pid, stub_server, tool_text, wait_dead}; + +pub async fn initialize_negotiates_v1_and_advertises_capabilities(t: &Target) { + let data = temp_dir("sf-data"); + let (url, _) = mock(vec![Step::Final("x")]).await; + let mut agent = Raw::spawn(&t.env(&data, &url)); + // A client from the future gets v1 back, not an echo of its own version. + let r = agent + .ok( + "initialize", + json!({"protocolVersion": 99, "clientCapabilities": {"auth": {"terminal": true}}}), + ) + .await; + assert_eq!(r["protocolVersion"], 1); + let caps = &r["agentCapabilities"]; + assert_eq!(caps["loadSession"], true); + assert_eq!(caps["promptCapabilities"]["image"], true); + assert_eq!(caps["promptCapabilities"]["embeddedContext"], true); + let sc = &caps["sessionCapabilities"]; + for k in ["list", "resume", "close", "delete", "additionalDirectories"] { + assert!(sc.get(k).is_some(), "sessionCapabilities.{k} missing: {sc}"); + } + assert_eq!(r["agentInfo"]["name"], t.name); + assert!( + caps["auth"].get("logout").is_some(), + "auth.logout missing: {caps}" + ); + // Terminal auth is offered to clients that declare they can run it... + assert_eq!(r["authMethods"][0]["id"], "terminal-setup"); + assert_eq!(r["authMethods"][0]["args"][0], "--setup"); + agent.finish().await; + + let mut agent = Raw::spawn(&t.env(&data, &url)); + let r = agent + .ok( + "initialize", + json!({"protocolVersion": 1, "clientCapabilities": {}}), + ) + .await; + assert_eq!(r["protocolVersion"], 1); + // ...and not to clients that can't. + assert!( + r.get("authMethods") + .and_then(Value::as_array) + .is_none_or(|m| m.is_empty()), + "{r}" + ); + agent.finish().await; +} + +/// The ACP registry probes every listed agent with this exact `initialize` (copied from +/// `.github/workflows/client.py` in agentclientprotocol/registry), from an empty HOME with no +/// credentials, and rejects the agent if `authMethods` is empty. It signals terminal-auth +/// support only through `_meta`, never through `auth.terminal`. +pub async fn registry_validator_probe_sees_terminal_auth(t: &Target) { + let data = temp_dir("sf-data"); + let (url, _) = mock(vec![Step::Final("x")]).await; + let mut env = t.env(&data, &url); + env.unset("ONDE_API_KEY"); + let mut agent = Raw::spawn(&env); + let r = agent + .ok( + "initialize", + json!({ + "protocolVersion": 1, + "clientInfo": {"name": "ACP Registry Validator", "version": "1.0.0"}, + "clientCapabilities": { + "terminal": true, + "fs": {"readTextFile": true, "writeTextFile": true}, + "_meta": {"terminal_output": true, "terminal-auth": true} + } + }), + ) + .await; + let methods = r["authMethods"].as_array().expect("authMethods"); + assert!(!methods.is_empty(), "registry validation would fail: {r}"); + assert_eq!(methods[0]["type"], "terminal", "{methods:?}"); + assert_eq!(methods[0]["args"][0], "--setup"); + agent.finish().await; +} + +pub async fn auth_required_then_authenticate_reloads_config(t: &Target) { + let data = temp_dir("sf-data"); + let (url, _) = mock(vec![Step::Final("hello")]).await; + let mut env = t.env(&data, &url); + env.unset("ONDE_API_KEY"); + let mut agent = Raw::spawn(&env); + agent.initialize().await; + let cwd = temp_dir("sf-work"); + + // No key anywhere: session/new and session/prompt are refused with auth_required (-32000). + let e = agent + .call("session/new", json!({"cwd": cwd, "mcpServers": []})) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32000, "{e}"); + let e = agent + .call("session/prompt", json!({"sessionId": "x", "prompt": []})) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32000, "{e}"); + // authenticate fails while there is still no key, and rejects unknown methods. + let e = agent + .call("authenticate", json!({"methodId": "terminal-setup"})) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32000, "{e}"); + let e = agent + .call("authenticate", json!({"methodId": "nope"})) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32602, "{e}"); + + // load/resume also answer auth_required, so an editor reopening a thread shows sign-in. + let e = agent + .call( + "session/load", + json!({"sessionId": "x", "cwd": cwd, "mcpServers": []}), + ) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32000, "{e}"); + + // `--setup` stored a key in the config file; authenticate picks it up without a restart. + std::fs::write(data.join("env"), format!("ONDE_API_KEY={TEST_KEY}\n")).unwrap(); + agent + .ok("authenticate", json!({"methodId": "terminal-setup"})) + .await; + let sid = agent.new_session(&cwd, json!([])).await; + let r = agent.text_prompt(&sid, "hi").await; + assert_eq!(r["stopReason"], "end_turn"); + + // logout removes the stored key; the agent is signed out without a restart. + agent.ok("logout", json!({})).await; + assert!(!data.join("env").exists()); + let e = agent + .call("session/new", json!({"cwd": cwd, "mcpServers": []})) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32000, "{e}"); + + // Zed's flow: terminal auth runs `--setup` in another process, then retries session/new + // on this one without calling authenticate. The stored key must be picked up anyway. + std::fs::write(data.join("env"), format!("ONDE_API_KEY={TEST_KEY}\n")).unwrap(); + agent.new_session(&cwd, json!([])).await; + agent.finish().await; +} + +pub async fn sessions_survive_a_killed_process_and_replay_tool_output(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + std::fs::write(work.join("notes.txt"), "chorus in Dm").unwrap(); + let (url, _) = mock(vec![ + Step::ToolCall { + name: "read_file", + arguments: json!({"path": "notes.txt"}), + }, + Step::ToolCall { + name: "read_file", + arguments: json!({"path": "missing.txt"}), + }, + Step::Final("The chorus is in Dm."), + ]) + .await; + let env = t.env(&data, &url); + + let mut agent = Raw::spawn(&env); + agent.initialize().await; + let sid = agent.new_session(&work, json!([])).await; + let r = agent.text_prompt(&sid, "What key is the chorus in?").await; + assert_eq!(r["stopReason"], "end_turn"); + // Title is pushed as a session_info_update. + let infos = agent.updates("session_info_update"); + assert!( + infos + .iter() + .any(|u| u["title"] == "What key is the chorus in?"), + "no title update: {infos:?}" + ); + // The process dies without a graceful close. + agent.child.kill().await.unwrap(); + drop(agent); + + let mut agent = Raw::spawn(&env); + agent.initialize().await; + let list = agent.ok("session/list", json!({})).await; + let sessions = list["sessions"].as_array().unwrap(); + assert_eq!(sessions.len(), 1, "{list}"); + assert_eq!(sessions[0]["sessionId"], sid.as_str()); + assert_eq!(sessions[0]["title"], "What key is the chorus in?"); + assert!(sessions[0]["updatedAt"].as_str().unwrap().ends_with('Z')); + // cwd filter. + let other = agent.ok("session/list", json!({"cwd": "/nowhere"})).await; + assert!(other["sessions"].as_array().unwrap().is_empty()); + + agent.notes.clear(); + agent + .ok( + "session/load", + json!({"sessionId": sid, "cwd": work, "mcpServers": []}), + ) + .await; + agent.settle().await; + let user = agent.updates("user_message_chunk"); + assert_eq!(user[0]["content"]["text"], "What key is the chorus in?"); + let said: String = agent + .updates("agent_message_chunk") + .iter() + .filter_map(|u| u["content"]["text"].as_str()) + .collect(); + assert!(said.contains("The chorus is in Dm."), "{said}"); + let calls = agent.updates("tool_call"); + assert_eq!(calls.len(), 2, "{calls:?}"); + assert_eq!(calls[0]["status"], "completed"); + assert!( + tool_text(&calls[0]).contains("chorus in Dm"), + "{}", + calls[0] + ); + assert_eq!( + calls[1]["status"], "failed", + "failed call must replay as failed: {}", + calls[1] + ); + assert!( + calls[0]["locations"][0]["path"] + .as_str() + .unwrap() + .ends_with("notes.txt") + ); + // Commands are advertised after the load response. + let cmds = agent.updates("available_commands_update"); + assert!(!cmds.is_empty()); + agent.finish().await; +} + +pub async fn resume_close_delete_and_unknown_sessions(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + let work2 = temp_dir("sf-work"); + let (url, stats) = mock(vec![Step::Final("ok")]).await; + let env = t.env(&data, &url); + let mut agent = Raw::spawn(&env); + agent.initialize().await; + + let sid = agent.new_session(&work, json!([])).await; + agent.text_prompt(&sid, "first").await; + + // Unknown sessions are invalid_params where nothing can be reopened. load/resume of a + // well-formed id reopen it instead (covered below); a malformed id can't name a session. + for (method, params) in [ + ( + "session/load", + json!({"sessionId": "../nope", "cwd": work, "mcpServers": []}), + ), + ( + "session/resume", + json!({"sessionId": "../nope", "cwd": work, "mcpServers": []}), + ), + ( + "session/load", + json!({"sessionId": "relative-cwd", "cwd": "work", "mcpServers": []}), + ), + ("session/new", json!({"cwd": "work", "mcpServers": []})), + ("session/close", json!({"sessionId": "nope"})), + ("session/list", json!({"cursor": "stale"})), + ( + "session/prompt", + json!({"sessionId": "nope", "prompt": [{"type": "text", "text": "x"}]}), + ), + ( + "session/set_config_option", + json!({"sessionId": "nope", "configId": "model", "value": "other-model"}), + ), + ] { + let e = agent.call(method, params).await.unwrap_err(); + assert_eq!(error_code(&e), -32602, "{method}: {e}"); + } + + // Config options: model picker lists the endpoint's models; bad ids are rejected. + let created = agent + .ok("session/new", json!({"cwd": work, "mcpServers": []})) + .await; + let opts = created["configOptions"].as_array().unwrap(); + assert_eq!(opts[0]["id"], "model"); + assert_eq!(opts[0]["category"], "model"); + assert_eq!(opts[0]["currentValue"], MODEL); + let values: Vec<&str> = opts[0]["options"] + .as_array() + .unwrap() + .iter() + .map(|o| o["value"].as_str().unwrap()) + .collect(); + assert!( + values.contains(&MODEL) && values.contains(&"other-model"), + "{values:?}" + ); + let sid2 = created["sessionId"].as_str().unwrap().to_string(); + let e = agent + .call( + "session/set_config_option", + json!({"sessionId": sid2, "configId": "model", "value": "bogus"}), + ) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32602, "{e}"); + let e = agent + .call( + "session/set_config_option", + json!({"sessionId": sid2, "configId": "temperature", "value": "x"}), + ) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32602, "{e}"); + let r = agent + .ok( + "session/set_config_option", + json!({"sessionId": sid2, "configId": "model", "value": "other-model"}), + ) + .await; + assert_eq!(r["configOptions"][0]["currentValue"], "other-model"); + agent.text_prompt(&sid2, "second").await; + let last = stats.payloads.lock().unwrap().last().cloned().unwrap(); + assert_eq!(last["model"], "other-model"); + + // Resume rebinds the cwd without replaying anything. + agent.notes.clear(); + agent + .ok( + "session/resume", + json!({"sessionId": sid, "cwd": work2, "mcpServers": []}), + ) + .await; + assert!( + agent.updates("user_message_chunk").is_empty(), + "resume must not replay" + ); + let listed = agent.ok("session/list", json!({"cwd": work2})).await; + assert_eq!(listed["sessions"].as_array().unwrap().len(), 1); + + // Close keeps the stored conversation; delete removes it. + agent.ok("session/close", json!({"sessionId": sid})).await; + let e = agent.call("session/close", json!({"sessionId": sid})).await; + assert!( + e.is_ok(), + "close of a stored-only session is a no-op, not an error" + ); + let all = agent.ok("session/list", json!({})).await; + assert_eq!(all["sessions"].as_array().unwrap().len(), 2); + agent.ok("session/delete", json!({"sessionId": sid})).await; + let all = agent.ok("session/list", json!({})).await; + assert_eq!(all["sessions"].as_array().unwrap().len(), 1); + assert!(!data.join("sessions").join(format!("{sid}.json")).exists()); + // Delete is idempotent: a second delete, or one for a session that never existed, succeeds. + agent.ok("session/delete", json!({"sessionId": sid})).await; + agent + .ok("session/delete", json!({"sessionId": "nope"})) + .await; + + // A well-formed id with no saved history (a thread from a process that never saved it) + // reopens empty under the same id, with a notice, instead of failing to launch. + agent.notes.clear(); + let r = agent + .ok( + "session/load", + json!({"sessionId": "lost-thread", "cwd": work, "mcpServers": []}), + ) + .await; + assert_eq!(r["configOptions"][0]["id"], "model"); + let notice = agent.updates("agent_message_chunk"); + assert_eq!(notice.len(), 1, "{notice:?}"); + assert!( + notice[0]["content"]["text"] + .as_str() + .unwrap() + .contains("starts fresh"), + "{notice:?}" + ); + assert!(notice[0]["messageId"].is_string()); + agent.text_prompt("lost-thread", "hello again").await; + agent.finish().await; +} + +pub async fn streaming_reports_message_ids_usage_and_inlines_file_links(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + std::fs::write( + work.join("chart.md"), + "Verse: Am F C G\nChorus: F G Em Am\nBridge: Dm E\n", + ) + .unwrap(); + let (url, stats) = mock(vec![Step::Final("Looks like A minor.")]).await; + let mut env = t.env(&data, &url); + let window = env.var("CONTEXT_WINDOW"); + env.set(&window, "1000"); + let mut agent = Raw::spawn(&env); + agent.initialize().await; + let sid = agent.new_session(&work, json!([])).await; + let chart = format!("file://{}?column=1#L2:2", work.join("chart.md").display()); + agent + .prompt( + &sid, + json!([ + {"type": "text", "text": "What key is the chorus in?"}, + {"type": "resource_link", "name": "chart.md", "uri": chart}, + ]), + ) + .await; + + // The selection link is inlined as just that line. + let payload = stats.payloads.lock().unwrap()[0].clone(); + assert!(payload["stream_options"]["include_usage"] == true); + let user = payload["messages"].as_array().unwrap().last().unwrap()["content"] + .as_str() + .unwrap() + .to_string(); + assert!(user.contains("Chorus: F G Em Am"), "{user}"); + assert!( + !user.contains("Verse:") && !user.contains("Bridge:"), + "{user}" + ); + + // Every chunk of one assistant message carries the same message id. + let chunks = agent.updates("agent_message_chunk"); + assert!(!chunks.is_empty()); + assert!( + chunks.iter().all(|c| c["messageId"].is_string()), + "{chunks:?}" + ); + // Usage from the endpoint, sized by _CONTEXT_WINDOW. + let usage = agent.updates("usage_update"); + assert_eq!(usage.len(), 1, "{usage:?}"); + assert_eq!( + (usage[0]["used"].as_u64(), usage[0]["size"].as_u64()), + (Some(150), Some(1000)) + ); + agent.finish().await; +} + +pub async fn permissions_reject_always_raw_output_and_auto_approve(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + let write = |path: &'static str| Step::ToolCall { + name: "write_file", + arguments: json!({"path": path, "content": "x"}), + }; + let (url, _) = mock(vec![write("a.txt"), write("b.txt"), Step::Final("done")]).await; + let mut agent = Raw::spawn(&t.env(&data, &url)); + // A client that supports boolean config options gets the auto-approve toggle. + let init = agent + .ok( + "initialize", + json!({"protocolVersion": 1, "clientCapabilities": {"session": {"configOptions": {"boolean": {}}}}}), + ) + .await; + assert_eq!(init["protocolVersion"], 1); + let created = agent + .ok("session/new", json!({"cwd": work, "mcpServers": []})) + .await; + agent.settle().await; + let sid = created["sessionId"].as_str().unwrap().to_string(); + let opts = created["configOptions"].as_array().unwrap(); + assert_eq!(opts[1]["id"], "auto_approve"); + assert_eq!(opts[1]["type"], "boolean"); + assert_eq!(opts[1]["currentValue"], false); + + // "Always reject" is offered, and once chosen the tool is refused without asking again. + agent.perm = Perm::RejectAlways; + agent.text_prompt(&sid, "write two files").await; + assert_eq!( + agent.permissions.len(), + 1, + "asked again after reject_always" + ); + let kinds: Vec<&str> = agent.permissions[0]["options"] + .as_array() + .unwrap() + .iter() + .map(|o| o["kind"].as_str().unwrap()) + .collect(); + assert!(kinds.contains(&"reject_always"), "{kinds:?}"); + assert!(!work.join("a.txt").exists() && !work.join("b.txt").exists()); + // Tool calls carry the tool name; updates carry the raw output the model saw. + let calls = agent.updates("tool_call"); + assert!(calls.iter().all(|c| c["name"] == "write_file"), "{calls:?}"); + let done = agent.updates("tool_call_update"); + let failed: Vec<&Value> = done.iter().filter(|u| u["status"] == "failed").collect(); + assert_eq!(failed.len(), 2, "{done:?}"); + assert_eq!(failed[0]["rawOutput"]["failed"], true); + assert!( + failed[0]["rawOutput"]["output"] + .as_str() + .unwrap() + .contains("rejected") + ); + + // Turning auto-approve on is broadcast as a config_option_update and skips the prompts. + agent.notes.clear(); + let r = agent + .ok( + "session/set_config_option", + json!({"sessionId": sid, "configId": "auto_approve", "type": "boolean", "value": true}), + ) + .await; + assert_eq!(r["configOptions"][1]["currentValue"], true); + let broadcast = agent.updates("config_option_update"); + assert_eq!(broadcast.len(), 1, "{broadcast:?}"); + let e = agent + .call( + "session/set_config_option", + json!({"sessionId": sid, "configId": "auto_approve", "value": "yes"}), + ) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32602, "{e}"); + + // A fresh session (no reject_always grant) now writes without asking. + let sid2 = agent.new_session(&work, json!([])).await; + agent + .ok( + "session/set_config_option", + json!({"sessionId": sid2, "configId": "auto_approve", "type": "boolean", "value": true}), + ) + .await; + agent.permissions.clear(); + agent.text_prompt(&sid2, "write two files").await; + assert!(agent.permissions.is_empty(), "{:?}", agent.permissions); + assert!(work.join("a.txt").exists() && work.join("b.txt").exists()); + agent.finish().await; +} + +pub async fn auto_approve_is_hidden_from_clients_without_boolean_options(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + let (url, _) = mock(vec![Step::Final("x")]).await; + let mut agent = Raw::spawn(&t.env(&data, &url)); + agent.initialize().await; + let created = agent + .ok("session/new", json!({"cwd": work, "mcpServers": []})) + .await; + agent.settle().await; + let opts = created["configOptions"].as_array().unwrap(); + assert_eq!(opts.len(), 1, "{opts:?}"); + let e = agent + .call( + "session/set_config_option", + json!({"sessionId": created["sessionId"], "configId": "auto_approve", "type": "boolean", "value": true}), + ) + .await + .unwrap_err(); + assert_eq!(error_code(&e), -32602, "{e}"); + agent.finish().await; +} + +pub async fn content_filter_is_a_refusal_and_private_flags_stay_off_the_wire(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + let (url, stats) = mock(vec![ + Step::ToolCall { + name: "read_file", + arguments: json!({"path": "missing.txt"}), + }, + Step::ContentFilter, + ]) + .await; + let mut agent = Raw::spawn(&t.env(&data, &url)); + agent.initialize().await; + let sid = agent.new_session(&work, json!([])).await; + let r = agent.text_prompt(&sid, "read it").await; + assert_eq!(r["stopReason"], "refusal"); + let payloads = stats.payloads.lock().unwrap().clone(); + let second = payloads.last().unwrap()["messages"] + .as_array() + .unwrap() + .clone(); + let tool = second.iter().find(|m| m["role"] == "tool").unwrap(); + assert!( + tool["content"].as_str().unwrap().starts_with("Error:"), + "{tool}" + ); + assert!( + tool.get("x_failed").is_none(), + "private field leaked to the model: {tool}" + ); + // The system prompt is rebuilt each turn and never stored with the history. + assert_eq!(second[0]["role"], "system"); + let stored: Value = serde_json::from_slice( + &std::fs::read(data.join("sessions").join(format!("{sid}.json"))).unwrap(), + ) + .unwrap(); + assert!( + stored["messages"] + .as_array() + .unwrap() + .iter() + .all(|m| m["role"] != "system") + ); + agent.finish().await; +} + +pub async fn client_supplied_mcp_server_is_offered_gated_and_streams_progress(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + let pid_file = data.join("stub.pid"); + let (url, stats) = mock(vec![ + Step::ToolCall { + name: "mcp__stub__echo", + arguments: json!({"text": "hi"}), + }, + Step::ToolCall { + name: "mcp__stub__slow_progress", + arguments: json!({}), + }, + Step::Final("all done"), + ]) + .await; + let mut agent = Raw::spawn(&t.env(&data, &url)); + agent.initialize().await; + let sid = agent + .new_session( + &work, + json!([stub_server(&t.mcp_stub, "stub", Some(&pid_file))]), + ) + .await; + let r = agent.text_prompt(&sid, "use the stub").await; + assert_eq!(r["stopReason"], "end_turn"); + + // Offered: the namespaced tools ride along with the built-ins. + let payload = stats.payloads.lock().unwrap()[0].clone(); + let names: Vec = payload["tools"] + .as_array() + .unwrap() + .iter() + .map(|t| t["function"]["name"].as_str().unwrap().to_string()) + .collect(); + assert!(names.contains(&"mcp__stub__echo".to_string()), "{names:?}"); + assert_eq!(names.len(), t.base_tool_count + 5, "{names:?}"); + + // Gated: only the tool without readOnlyHint asked for permission. + assert_eq!(agent.permissions.len(), 1, "{:?}", agent.permissions); + let perm = &agent.permissions[0]; + assert_eq!(perm["toolCall"]["toolCallId"], "call_2"); + + // Callable, with the result fed back to the model. + let payloads = stats.payloads.lock().unwrap().clone(); + let tools: Vec<&Value> = payloads[1]["messages"] + .as_array() + .unwrap() + .iter() + .filter(|m| m["role"] == "tool") + .collect(); + assert_eq!(tools[0]["content"], "echo: hi"); + + // Progress: MCP notifications/progress became tool_call_update content. + let progress: Vec = agent + .updates("tool_call_update") + .iter() + .filter(|u| u["toolCallId"] == "call_2") + .map(tool_text) + .collect(); + assert!( + progress.iter().any(|t| t == "halfway (50%)"), + "{progress:?}" + ); + let done = agent + .updates("tool_call_update") + .into_iter() + .rfind(|u| u["toolCallId"] == "call_2") + .unwrap(); + assert_eq!(done["status"], "completed"); + + // Children are killed when the session closes. + let pid = read_pid(&pid_file); + assert!(alive(pid)); + agent.ok("session/close", json!({"sessionId": sid})).await; + assert!( + wait_dead(pid).await, + "stub server still running after session/close" + ); + agent.finish().await; +} + +pub async fn mcp_children_die_when_the_agent_exits(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + let pid_file = data.join("stub.pid"); + let (url, _) = mock(vec![Step::Final("x")]).await; + let mut agent = Raw::spawn(&t.env(&data, &url)); + agent.initialize().await; + agent + .new_session( + &work, + json!([stub_server(&t.mcp_stub, "stub", Some(&pid_file))]), + ) + .await; + let pid = read_pid(&pid_file); + assert!(alive(pid)); + agent.finish().await; + assert!(wait_dead(pid).await, "stub server outlived the agent"); +} + +pub async fn broken_mcp_server_is_not_fatal(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + let (url, stats) = mock(vec![Step::Final("fine")]).await; + let mut agent = Raw::spawn(&t.env(&data, &url)); + agent.initialize().await; + let sid = agent + .new_session( + &work, + json!([ + {"name": "ghost", "command": "/definitely/not/here", "args": [], "env": []}, + {"name": "http", "type": "http", "url": "http://127.0.0.1:1/mcp", "headers": []} + ]), + ) + .await; + let r = agent.text_prompt(&sid, "hello").await; + assert_eq!(r["stopReason"], "end_turn"); + let payload = stats.payloads.lock().unwrap()[0].clone(); + assert_eq!( + payload["tools"].as_array().unwrap().len(), + t.base_tool_count + ); + agent.finish().await; +} + +pub async fn cancelling_a_turn_cancels_the_mcp_call(t: &Target) { + let data = temp_dir("sf-data"); + let work = temp_dir("sf-work"); + let (url, _) = mock(vec![ + Step::ToolCall { + name: "mcp__stub__wait_for_cancel", + arguments: json!({}), + }, + Step::Final("should not get here"), + ]) + .await; + let mut agent = Raw::spawn(&t.env(&data, &url)); + agent.initialize().await; + let sid = agent + .new_session(&work, json!([stub_server(&t.mcp_stub, "stub", None)])) + .await; + // Fire the prompt without waiting, then cancel once the call is in flight. + let id = agent.next_id; + agent.next_id += 1; + agent + .send( + json!({"jsonrpc": "2.0", "id": id, "method": "session/prompt", + "params": {"sessionId": sid, "prompt": [{"type": "text", "text": "wait"}]}}), + ) + .await; + let mut cancelled = false; + let stop = loop { + let msg = agent.next_message().await; + if msg.get("method").is_some() { + if msg.get("id").is_some() { + agent.serve(&msg).await; + // The permission request means the tool call is about to start. + tokio::time::sleep(Duration::from_millis(300)).await; + agent + .notify("session/cancel", json!({"sessionId": sid})) + .await; + cancelled = true; + } else { + agent.notes.push(msg); + } + } else if msg["id"] == json!(id) { + break msg["result"]["stopReason"].clone(); + } + }; + assert!(cancelled, "the call should have asked for permission first"); + assert_eq!(stop, "cancelled"); + let last = agent + .updates("tool_call_update") + .into_iter() + .last() + .unwrap(); + assert_eq!(last["status"], "failed"); + agent.finish().await; +} diff --git a/crates/ed-acp-testkit/src/lib.rs b/crates/ed-acp-testkit/src/lib.rs new file mode 100644 index 0000000..c72bbd6 --- /dev/null +++ b/crates/ed-acp-testkit/src/lib.rs @@ -0,0 +1,155 @@ +//! Test support for Onde ACP agents: a mock Onde Cloud endpoint ([`mock`]), a raw JSON-RPC +//! client that drives the real agent binary over stdio ([`Raw`]), the ACP registry's validator +//! request ([`REGISTRY_VALIDATOR_INITIALIZE`]), +//! and the ACP v1 conformance suite ([`conformance`]). +//! +//! The test MCP server lives in `ed-mcp` (feature `test-server`) because it must be a binary, +//! and binaries cannot use dev-dependencies. An agent adopts the whole suite with one macro call +//! in an integration test: +//! +//! ```ignore +//! fn target() -> ed_acp_testkit::Target { +//! ed_acp_testkit::Target { +//! bin: env!("CARGO_BIN_EXE_my-agent").into(), +//! name: "my-agent", +//! env_prefix: "MY_AGENT", +//! mcp_stub: env!("CARGO_BIN_EXE_mcp_stdio_stub").into(), +//! base_tool_count: 5, +//! env: vec![], +//! } +//! } +//! ed_acp_testkit::conformance_suite!(target); +//! ``` + +pub mod conformance; +pub mod mock; +mod raw; + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +pub use mock::{MODEL, MockStats, Script, Step, TEST_KEY, mock, start_mock_llm, temp_dir}; +pub use raw::{ + Env, Perm, Raw, TIMEOUT, alive, error_code, read_pid, stub_server, tool_text, wait_dead, +}; + +/// The agent under test. +#[derive(Debug, Clone)] +pub struct Target { + /// The agent binary, usually `env!("CARGO_BIN_EXE_")`. + pub bin: PathBuf, + /// Expected `agentInfo.name`. + pub name: &'static str, + /// The agent's environment prefix: the suite sets `_DATA_DIR`, `_BASE_URL` + /// and `_MODEL`. + pub env_prefix: &'static str, + /// A binary whose `main` runs `ed_mcp::test_server::run`. + pub mcp_stub: PathBuf, + /// How many tools the agent offers with no MCP server connected. + pub base_tool_count: usize, + /// Extra environment for every run, e.g. switching built-in MCP servers off. + pub env: Vec<(String, String)>, +} + +impl Target { + /// The environment for one run: an isolated data dir, the mock endpoint at `base_url`, the + /// test key, and `PATH` set to the data dir so no stray binaries are found. + pub fn env(&self, data_dir: &Path, base_url: &str) -> Env { + let mut e = Env { + bin: self.bin.clone(), + prefix: self.env_prefix, + vars: Vec::new(), + }; + let (data, url, model) = (e.var("DATA_DIR"), e.var("BASE_URL"), e.var("MODEL")); + e.set(&data, data_dir.display().to_string()); + e.set(&url, base_url); + e.set(&model, MODEL); + e.set("ONDE_API_KEY", TEST_KEY); + e.set("PATH", data_dir.display().to_string()); + for (k, v) in &self.env { + e.set(k, v.clone()); + } + e + } +} + +/// The `initialize` params the ACP registry's validator sends, copied from +/// `agentclientprotocol/registry` `.github/workflows/client.py`. It signals terminal-auth +/// support only through `_meta`, never through `auth.terminal`. +pub fn registry_validator_initialize() -> Value { + json!({ + "protocolVersion": 1, + "clientInfo": {"name": "ACP Registry Validator", "version": "1.0.0"}, + "clientCapabilities": { + "terminal": true, + "fs": {"readTextFile": true, "writeTextFile": true}, + "_meta": {"terminal_output": true, "terminal-auth": true} + } + }) +} + +/// See [`registry_validator_initialize`]. +pub const REGISTRY_VALIDATOR_INITIALIZE: &str = r#"{"protocolVersion":1,"clientInfo":{"name":"ACP Registry Validator","version":"1.0.0"},"clientCapabilities":{"terminal":true,"fs":{"readTextFile":true,"writeTextFile":true},"_meta":{"terminal_output":true,"terminal-auth":true}}}"#; + +/// Generate one `#[tokio::test]` per conformance check, in a module named `acp_conformance`. +/// `$target` names a function, in scope where the macro is called, that returns the [`Target`]. +#[macro_export] +macro_rules! conformance_suite { + ($target:ident) => { + mod acp_conformance { + $crate::conformance_suite!(@tests $target; + initialize_negotiates_v1_and_advertises_capabilities, + registry_validator_probe_sees_terminal_auth, + auth_required_then_authenticate_reloads_config, + sessions_survive_a_killed_process_and_replay_tool_output, + resume_close_delete_and_unknown_sessions, + streaming_reports_message_ids_usage_and_inlines_file_links, + permissions_reject_always_raw_output_and_auto_approve, + auto_approve_is_hidden_from_clients_without_boolean_options, + content_filter_is_a_refusal_and_private_flags_stay_off_the_wire, + client_supplied_mcp_server_is_offered_gated_and_streams_progress, + mcp_children_die_when_the_agent_exits, + broken_mcp_server_is_not_fatal, + cancelling_a_turn_cancels_the_mcp_call, + ); + } + }; + (@tests $target:ident; $($name:ident),* $(,)?) => { + $( + #[tokio::test] + async fn $name() { + $crate::conformance::$name(&super::$target()).await; + } + )* + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_constant_and_the_value_agree() { + let parsed: Value = serde_json::from_str(REGISTRY_VALIDATOR_INITIALIZE).unwrap(); + assert_eq!(parsed, registry_validator_initialize()); + } + + #[test] + fn env_uses_the_prefix_and_target_overrides() { + let t = Target { + bin: "/bin/agent".into(), + name: "agent", + env_prefix: "MY", + mcp_stub: "/bin/stub".into(), + base_tool_count: 0, + env: vec![("MY_MODEL".into(), "custom".into())], + }; + let e = t.env(Path::new("/data"), "http://x/v1"); + let get = |k: &str| e.vars.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str()); + assert_eq!(get("MY_DATA_DIR"), Some("/data")); + assert_eq!(get("MY_BASE_URL"), Some("http://x/v1")); + assert_eq!(get("MY_MODEL"), Some("custom")); + assert_eq!(get("ONDE_API_KEY"), Some(TEST_KEY)); + } +} diff --git a/crates/ed-acp-testkit/src/mock.rs b/crates/ed-acp-testkit/src/mock.rs new file mode 100644 index 0000000..3294e40 --- /dev/null +++ b/crates/ed-acp-testkit/src/mock.rs @@ -0,0 +1,207 @@ +//! A scripted OpenAI-compatible streaming endpoint that stands in for Onde Cloud. +//! +//! It answers `GET /v1/models` with [`MODEL`] and `other-model`, records every +//! `/chat/completions` body, and replies with the next [`Step`] of its script, chosen by how many +//! tool results the request already carries. Final answers report usage when the request asks +//! for it with `stream_options.include_usage`, as Onde Cloud does. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use serde_json::{Value, json}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +/// Dummy Onde credentials; the mock asserts they arrive as the bearer token. +pub const TEST_KEY: &str = "test-app:test-secret"; +pub const MODEL: &str = "scripted-test-model"; + +#[derive(Debug, Default)] +pub struct MockStats { + pub requests: AtomicUsize, + pub saw_bearer: Mutex, + /// Every `/chat/completions` request body, in arrival order. + pub payloads: Mutex>, +} + +/// What the scripted model does on its next completion. +#[derive(Clone)] +pub enum Step { + /// Emit a tool call, then wait for the tool result. + ToolCall { + name: &'static str, + arguments: Value, + }, + /// Emit final assistant text and finish. + Final(&'static str), + /// Stop with `finish_reason: content_filter`. + ContentFilter, +} + +#[derive(Clone)] +pub struct Script(pub Vec); + +impl Script { + /// Count completed tool results to know how far into the script we are. + fn step_for(&self, messages: &[Value]) -> (usize, Step) { + let done = messages.iter().filter(|m| m["role"] == "tool").count(); + ( + done, + self.0 + .get(done) + .unwrap_or_else(|| self.0.last().expect("empty script")) + .clone(), + ) + } +} + +fn sse_chunk( + content: Option<&str>, + tool_call: Option<(&str, &str, &str)>, + finish: Option<&str>, +) -> String { + let mut delta = json!({}); + if let Some(c) = content { + delta["content"] = json!(c); + } + if let Some((id, name, args)) = tool_call { + delta["tool_calls"] = json!([{ + "index": 0, + "id": id, + "function": { "name": name, "arguments": args }, + }]); + } + let chunk = json!({ "choices": [{ "delta": delta, "finish_reason": finish }] }); + format!("data: {chunk}\n\n") +} + +async fn read_http_request(socket: &mut tokio::net::TcpStream) -> (String, String) { + let mut buf = Vec::new(); + let mut tmp = [0u8; 4096]; + let header_end; + let mut content_length = 0usize; + loop { + let n = socket.read(&mut tmp).await.unwrap(); + if n == 0 { + panic!("connection closed before headers complete"); + } + buf.extend_from_slice(&tmp[..n]); + if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") { + header_end = pos + 4; + let headers = String::from_utf8_lossy(&buf[..pos]).to_string(); + for line in headers.lines() { + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + content_length = v.trim().parse().unwrap(); + } + } + break; + } + } + while buf.len() < header_end + content_length { + let n = socket.read(&mut tmp).await.unwrap(); + if n == 0 { + break; + } + buf.extend_from_slice(&tmp[..n]); + } + let head = String::from_utf8_lossy(&buf[..header_end]).to_string(); + let body = String::from_utf8_lossy(&buf[header_end..header_end + content_length]).to_string(); + (head, body) +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +async fn respond(socket: &mut tokio::net::TcpStream, content_type: &str, body: &str) { + let resp = format!( + "HTTP/1.1 200 OK\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + socket.write_all(resp.as_bytes()).await.unwrap(); + socket.shutdown().await.unwrap(); +} + +/// Spawn the mock server; returns its base URL (e.g. `http://127.0.0.1:PORT/v1`). +pub async fn start_mock_llm(script: Script, stats: Arc) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let stats = stats.clone(); + let script = script.clone(); + tokio::spawn(async move { + let (head, body) = read_http_request(&mut socket).await; + let first = head.lines().next().unwrap_or("").to_string(); + if first.starts_with("GET /v1/models") { + let list = + json!({"object": "list", "data": [{"id": MODEL}, {"id": "other-model"}]}); + return respond(&mut socket, "application/json", &list.to_string()).await; + } + stats.requests.fetch_add(1, Ordering::SeqCst); + if head + .to_ascii_lowercase() + .contains(&format!("authorization: bearer {TEST_KEY}").to_ascii_lowercase()) + { + *stats.saw_bearer.lock().unwrap() = true; + } + assert!( + first.starts_with("POST /v1/chat/completions"), + "unexpected request: {first}" + ); + let payload: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(payload["stream"], true); + stats.payloads.lock().unwrap().push(payload.clone()); + let messages = payload["messages"].as_array().unwrap(); + let (done, step) = script.step_for(messages); + + let mut data = String::new(); + match step { + Step::ToolCall { name, arguments } => { + let id = format!("call_{}", done + 1); + data.push_str(&sse_chunk( + None, + Some((&id, name, &arguments.to_string())), + None, + )); + data.push_str(&sse_chunk(None, None, Some("tool_calls"))); + } + Step::Final(text) => { + data.push_str(&sse_chunk(Some(text), None, None)); + data.push_str(&sse_chunk(None, None, Some("stop"))); + // `stream_options.include_usage`: a final chunk with no choices. + if payload.get("stream_options").is_some() { + let usage = json!({"choices": [], "usage": { + "prompt_tokens": 120, "completion_tokens": 30, "total_tokens": 150 + }}); + data.push_str(&format!("data: {usage}\n\n")); + } + } + Step::ContentFilter => { + data.push_str(&sse_chunk(None, None, Some("content_filter"))); + } + } + data.push_str("data: [DONE]\n\n"); + respond(&mut socket, "text/event-stream", &data).await; + }); + } + }); + format!("http://127.0.0.1:{port}/v1") +} + +pub fn temp_dir(prefix: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("{prefix}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Start a mock with `steps`; returns its base URL and the recorded requests. +pub async fn mock(steps: Vec) -> (String, Arc) { + let stats = Arc::new(MockStats::default()); + let url = start_mock_llm(Script(steps), stats.clone()).await; + (url, stats) +} diff --git a/crates/ed-acp-testkit/src/raw.rs b/crates/ed-acp-testkit/src/raw.rs new file mode 100644 index 0000000..35429b6 --- /dev/null +++ b/crates/ed-acp-testkit/src/raw.rs @@ -0,0 +1,289 @@ +//! The raw JSON-RPC test client and its environment. + +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; + +/// How long to wait for any one message from the agent. +pub const TIMEOUT: Duration = Duration::from_secs(30); + +/// What the fake client does when the agent asks for permission. +#[derive(Debug, Clone, Copy)] +pub enum Perm { + Allow, + Reject, + RejectAlways, +} + +/// An ACP client speaking raw JSON-RPC to the agent's stdio, so tests see exactly what is on +/// the wire. It answers `session/request_permission` per [`Raw::perm`] and fails every other +/// agent-to-client request. +pub struct Raw { + pub child: Child, + stdin: Option, + lines: tokio::io::Lines>, + pub next_id: u64, + /// Notifications (`session/update` etc.), in arrival order. + pub notes: Vec, + /// Permission requests seen, as the full JSON-RPC `params`. + pub permissions: Vec, + pub perm: Perm, + queued: VecDeque, +} + +/// The agent's command and environment for one test. The process gets nothing else from the +/// test's environment. +#[derive(Debug, Clone)] +pub struct Env { + pub bin: PathBuf, + pub prefix: &'static str, + pub vars: Vec<(String, String)>, +} + +impl Env { + /// `_`. + pub fn var(&self, suffix: &str) -> String { + format!("{}_{suffix}", self.prefix) + } + pub fn set(&mut self, k: &str, v: impl Into) -> &mut Self { + self.vars.retain(|(n, _)| n != k); + self.vars.push((k.to_string(), v.into())); + self + } + pub fn unset(&mut self, k: &str) -> &mut Self { + self.vars.retain(|(n, _)| n != k); + self + } +} + +impl Raw { + /// Start the agent with `--acp`. + pub fn spawn(env: &Env) -> Raw { + let mut cmd = Command::new(&env.bin); + cmd.arg("--acp") + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true); + for (k, v) in &env.vars { + cmd.env(k, v); + } + let mut child = cmd.spawn().expect("spawn agent"); + let stdin = child.stdin.take(); + let stdout = child.stdout.take().unwrap(); + Raw { + child, + stdin, + lines: BufReader::new(stdout).lines(), + next_id: 1, + notes: Vec::new(), + permissions: Vec::new(), + perm: Perm::Allow, + queued: VecDeque::new(), + } + } + + pub async fn send(&mut self, msg: Value) { + let stdin = self.stdin.as_mut().expect("stdin closed"); + stdin + .write_all(format!("{msg}\n").as_bytes()) + .await + .unwrap(); + stdin.flush().await.unwrap(); + } + + pub async fn notify(&mut self, method: &str, params: Value) { + self.send(json!({"jsonrpc": "2.0", "method": method, "params": params})) + .await; + } + + pub async fn next_message(&mut self) -> Value { + if let Some(m) = self.queued.pop_front() { + return m; + } + let line = tokio::time::timeout(TIMEOUT, self.lines.next_line()) + .await + .expect("timed out waiting for the agent") + .unwrap() + .expect("agent closed stdout"); + serde_json::from_str(&line).unwrap_or_else(|e| panic!("non-JSON on stdout ({e}): {line}")) + } + + /// Send a request and wait for its response, serving agent→client requests meanwhile. + pub async fn call(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id += 1; + self.send(json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})) + .await; + loop { + let msg = self.next_message().await; + if msg.get("method").is_some() { + if msg.get("id").is_some() { + self.serve(&msg).await; + } else { + self.notes.push(msg); + } + continue; + } + if msg["id"] == json!(id) { + return match msg.get("error") { + Some(e) => Err(e.clone()), + None => Ok(msg["result"].clone()), + }; + } + } + } + + pub async fn serve(&mut self, req: &Value) { + let id = req["id"].clone(); + let reply = match req["method"].as_str().unwrap() { + "session/request_permission" => { + self.permissions.push(req["params"].clone()); + match self.perm { + Perm::Allow => { + json!({"outcome": {"outcome": "selected", "optionId": "allow_once"}}) + } + Perm::Reject => { + json!({"outcome": {"outcome": "selected", "optionId": "reject_once"}}) + } + Perm::RejectAlways => { + json!({"outcome": {"outcome": "selected", "optionId": "reject_always"}}) + } + } + } + other => { + self.send(json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32601, "message": format!("unsupported {other}")}})).await; + return; + } + }; + self.send(json!({"jsonrpc": "2.0", "id": id, "result": reply})) + .await; + } + + /// Read whatever the agent sends right after a response (e.g. the commands update that + /// follows `session/new`) until it has been quiet for a moment. + pub async fn settle(&mut self) { + while let Ok(line) = + tokio::time::timeout(Duration::from_millis(300), self.lines.next_line()).await + { + let Ok(Some(line)) = line else { return }; + let msg: Value = serde_json::from_str(&line).unwrap(); + if msg.get("id").is_some() && msg.get("method").is_some() { + self.serve(&msg).await; + } else { + self.notes.push(msg); + } + } + } + + pub async fn ok(&mut self, method: &str, params: Value) -> Value { + self.call(method, params) + .await + .unwrap_or_else(|e| panic!("{method} failed: {e}")) + } + + pub async fn initialize(&mut self) -> Value { + self.ok( + "initialize", + json!({"protocolVersion": 1, "clientCapabilities": {}}), + ) + .await + } + + pub async fn new_session(&mut self, cwd: &Path, mcp: Value) -> String { + let r = self + .ok("session/new", json!({"cwd": cwd, "mcpServers": mcp})) + .await; + self.settle().await; + r["sessionId"].as_str().unwrap().to_string() + } + + pub async fn prompt(&mut self, sid: &str, blocks: Value) -> Value { + self.ok( + "session/prompt", + json!({"sessionId": sid, "prompt": blocks}), + ) + .await + } + + pub async fn text_prompt(&mut self, sid: &str, text: &str) -> Value { + self.prompt(sid, json!([{"type": "text", "text": text}])) + .await + } + + /// Notifications for a session update kind, e.g. `tool_call_update`. + pub fn updates(&self, kind: &str) -> Vec { + self.notes + .iter() + .filter(|n| { + n["method"] == "session/update" && n["params"]["update"]["sessionUpdate"] == kind + }) + .map(|n| n["params"]["update"].clone()) + .collect() + } + + /// Close stdin and wait for the agent to exit on its own. + pub async fn finish(mut self) { + drop(self.stdin.take()); + let _ = tokio::time::timeout(TIMEOUT, self.child.wait()) + .await + .expect("agent did not exit"); + } +} + +/// The JSON-RPC error code. +pub fn error_code(e: &Value) -> i64 { + e["code"].as_i64().unwrap() +} + +/// The text content of a tool call or update. +pub fn tool_text(update: &Value) -> String { + update["content"] + .as_array() + .into_iter() + .flatten() + .filter_map(|c| c.pointer("/content/text").and_then(Value::as_str)) + .collect::>() + .join("\n") +} + +pub fn alive(pid: u32) -> bool { + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stderr(Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +/// Wait up to five seconds for `pid` to exit. +pub async fn wait_dead(pid: u32) -> bool { + for _ in 0..100 { + if !alive(pid) { + return true; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + false +} + +pub fn read_pid(file: &Path) -> u32 { + std::fs::read_to_string(file) + .expect("stub pid file") + .trim() + .parse() + .unwrap() +} + +/// An `mcpServers` entry for the test MCP server under `name`. +pub fn stub_server(stub: &Path, name: &str, pid_file: Option<&Path>) -> Value { + let env: Vec = pid_file + .map(|p| vec![json!({"name": "STUB_PID_FILE", "value": p})]) + .unwrap_or_default(); + json!({"name": name, "command": stub, "args": [], "env": env}) +} From c058adb4bd1c2d305321a2d8fa2361ea958c4c04 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:12:54 +0200 Subject: [PATCH 08/28] Add ed-acp-tui: a terminal UI for any ACP agent A regular ACP client that launches the agent as a subprocess and renders one session with ratatui. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: siGit Code v1.6.3 --- Cargo.lock | 203 +++++++++ Cargo.toml | 2 + crates/ed-acp-tui/Cargo.toml | 20 + crates/ed-acp-tui/README.md | 13 + crates/ed-acp-tui/src/lib.rs | 792 +++++++++++++++++++++++++++++++++++ 5 files changed, 1030 insertions(+) create mode 100644 crates/ed-acp-tui/Cargo.toml create mode 100644 crates/ed-acp-tui/README.md create mode 100644 crates/ed-acp-tui/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index b289d5a..50f1543 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -790,6 +790,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.2" @@ -1315,6 +1321,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -1359,6 +1371,7 @@ dependencies = [ "crossterm_winapi", "derive_more", "document-features", + "futures-core", "mio", "parking_lot", "rustix", @@ -1973,6 +1986,18 @@ dependencies = [ "uuid 1.27.0", ] +[[package]] +name = "ed-acp-tui" +version = "1.0.1" +dependencies = [ + "agent-client-protocol", + "anyhow", + "crossterm", + "futures", + "ratatui", + "tokio", +] + [[package]] name = "ed-agent" version = "1.0.1" @@ -3188,6 +3213,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -3655,6 +3685,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "infer" version = "0.22.0" @@ -3664,6 +3703,19 @@ dependencies = [ "cfb", ] +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling 0.24.1", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -3909,6 +3961,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.21", +] + [[package]] name = "keyboard-types" version = "0.8.3" @@ -4026,6 +4089,15 @@ dependencies = [ "libc", ] +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4111,6 +4183,15 @@ dependencies = [ "vob", ] +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.3" @@ -4657,6 +4738,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -5351,6 +5441,39 @@ dependencies = [ "num-traits", ] +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "pango" version = "0.18.3" @@ -6002,6 +6125,73 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.21", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.2", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum 0.28.0", + "time", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "rav1e" version = "0.8.1" @@ -8167,7 +8357,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -8775,6 +8967,17 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "unicode-width" version = "0.1.14" diff --git a/Cargo.toml b/Cargo.toml index ea9babb..273a993 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/ed-agent-tauri", "crates/ed-mcp", "crates/ed-acp", + "crates/ed-acp-tui", "crates/ed-acp-testkit", "uniffi-bindgen", ] @@ -59,6 +60,7 @@ base64 = "0.23" ed-agent = { version = "1.0.1", path = "crates/ed-agent" } ed-mcp = { version = "1.0.1", path = "crates/ed-mcp" } ed-acp = { version = "1.0.1", path = "crates/ed-acp" } +ed-acp-tui = { version = "1.0.1", path = "crates/ed-acp-tui" } ed-acp-testkit = { version = "1.0.1", path = "crates/ed-acp-testkit" } # gemm-common (via candle, via onde) depends on sysctl for every diff --git a/crates/ed-acp-tui/Cargo.toml b/crates/ed-acp-tui/Cargo.toml new file mode 100644 index 0000000..acaa95d --- /dev/null +++ b/crates/ed-acp-tui/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ed-acp-tui" +description = "A ratatui terminal UI for any Agent Client Protocol (ACP) agent: launches the agent as a subprocess and drives one session over stdio." +keywords = ["acp", "agent", "tui", "terminal", "onde"] +categories = ["command-line-interface", "development-tools"] +version.workspace = true +edition = "2024" +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +agent-client-protocol.workspace = true +ratatui.workspace = true +crossterm.workspace = true +tokio = { workspace = true, features = ["full"] } +futures.workspace = true +anyhow.workspace = true diff --git a/crates/ed-acp-tui/README.md b/crates/ed-acp-tui/README.md new file mode 100644 index 0000000..d40072a --- /dev/null +++ b/crates/ed-acp-tui/README.md @@ -0,0 +1,13 @@ +# ed-acp-tui + +A terminal UI for any [Agent Client Protocol](https://agentclientprotocol.com) agent. + +It is a regular ACP client: it launches the agent (usually the same binary with `--acp`) as a +subprocess and renders one session with ratatui, so it exercises the protocol path an editor +would. It serves `fs/read_text_file` and `fs/write_text_file` from local disk and answers +permission requests with `y` (allow), `a` (always), `n` (reject) or `r` (always reject). + +## Copyright + +© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)). +Licensed under MIT or Apache-2.0. diff --git a/crates/ed-acp-tui/src/lib.rs b/crates/ed-acp-tui/src/lib.rs new file mode 100644 index 0000000..fd03561 --- /dev/null +++ b/crates/ed-acp-tui/src/lib.rs @@ -0,0 +1,792 @@ +//! A terminal UI for any ACP agent. It is a regular ACP client: it launches the agent (usually +//! the same binary with `--acp`) as a subprocess and renders one session with ratatui, so it +//! exercises exactly the protocol path an editor would. It serves `fs/read_text_file` and +//! `fs/write_text_file` from the local disk and answers permission requests with `y` (allow), +//! `a` (always), `n` (reject) or `r` (always reject). + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use agent_client_protocol::schema::ProtocolVersion; +use agent_client_protocol::schema::v1::{ + CancelNotification, ClientCapabilities, ContentBlock, FileSystemCapabilities, + InitializeRequest, NewSessionRequest, PermissionOptionKind, PromptRequest, ReadTextFileRequest, + ReadTextFileResponse, RequestPermissionOutcome, RequestPermissionRequest, + RequestPermissionResponse, SelectedPermissionOutcome, SessionId, SessionNotification, + SessionUpdate, StopReason, TextContent, ToolCallContent, ToolCallId, ToolCallStatus, + WriteTextFileRequest, WriteTextFileResponse, +}; +use agent_client_protocol::{AcpAgent, AcpAgentConfig, Agent, ConnectionTo}; +use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use futures::StreamExt; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Clear, Paragraph, Wrap}; +use ratatui::{DefaultTerminal, Frame}; +use tokio::sync::{mpsc, oneshot}; + +const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const MAX_TOOL_OUTPUT_LINES: usize = 8; + +enum AppEvent { + Update(SessionUpdate), + Permission( + RequestPermissionRequest, + oneshot::Sender, + ), + TurnDone(Result), +} + +/// What to launch and how to label it. +#[derive(Debug, Clone)] +pub struct TuiConfig { + /// Shown in the input box title and placeholder, e.g. `splitfire-agent`. + pub name: String, + /// The agent binary. + pub agent: PathBuf, + /// Arguments for the agent, e.g. `["--acp"]`. + pub args: Vec, + /// Extra environment for the agent, e.g. `("SPLITFIRE_SURFACE", "tui")`. + pub env: Vec<(String, String)>, + /// Shown in the status bar, e.g. `onde-kkk`. + pub model_label: String, + /// Additional workspace roots sent as `additionalDirectories`. + pub extra_roots: Vec, +} + +impl TuiConfig { + /// Launch this same executable with `--acp`, and `--yolo` when asked. + pub fn current_exe(name: impl Into, yolo: bool) -> anyhow::Result { + let mut args = vec!["--acp".to_string()]; + if yolo { + args.push("--yolo".to_string()); + } + Ok(Self { + name: name.into(), + agent: std::env::current_exe()?, + args, + env: Vec::new(), + model_label: String::new(), + extra_roots: Vec::new(), + }) + } +} + +/// Run the TUI until the user quits. The working directory is the session's `cwd`. +pub async fn run(cfg: TuiConfig) -> anyhow::Result<()> { + let mut config = AcpAgentConfig::new(&cfg.agent); + for arg in &cfg.args { + config = config.arg(arg); + } + for (k, v) in &cfg.env { + config = config.env(k, v); + } + let cwd = std::env::current_dir()?; + let model = cfg.model_label.clone(); + let name = cfg.name.clone(); + let extra_roots = cfg.extra_roots.clone(); + let (tx, rx) = mpsc::unbounded_channel(); + + agent_client_protocol::Client + .builder() + .on_receive_notification( + { + let tx = tx.clone(); + async move |n: SessionNotification, _cx| { + let _ = tx.send(AppEvent::Update(n.update)); + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_request( + { + let tx = tx.clone(); + async move |req: RequestPermissionRequest, responder, cx| { + // Wait for the user's answer off the dispatch loop so updates keep flowing. + let (answer_tx, answer_rx) = oneshot::channel(); + let _ = tx.send(AppEvent::Permission(req, answer_tx)); + cx.spawn(async move { + let outcome = answer_rx + .await + .unwrap_or(RequestPermissionOutcome::Cancelled); + responder.respond(RequestPermissionResponse::new(outcome)) + }) + } + }, + agent_client_protocol::on_receive_request!(), + ) + // ACP v1 fs/read_text_file: serve file contents from the session cwd, + // honoring the optional 1-based `line` and `limit` params. + .on_receive_request( + async move |req: ReadTextFileRequest, responder, _cx| match tokio::task::spawn_blocking( + { + let path = req.path.clone(); + move || read_text_file_blocking(&path, req.line, req.limit) + }, + ) + .await + { + Ok(Ok(r)) => responder.respond(r), + Ok(Err(e)) => responder.respond_with_error(e), + Err(e) => responder.respond_with_internal_error(e.to_string()), + }, + agent_client_protocol::on_receive_request!(), + ) + // ACP v1 fs/write_text_file: the client MUST create the file (and parent + // directories) if it doesn't exist. + .on_receive_request( + async move |req: WriteTextFileRequest, responder, _cx| { + match tokio::task::spawn_blocking({ + let path = req.path.clone(); + let content = req.content.clone(); + move || write_text_file_blocking(&path, &content) + }) + .await + { + Ok(Ok(r)) => responder.respond(r), + Ok(Err(e)) => responder.respond_with_error(e), + Err(e) => responder.respond_with_internal_error(e.to_string()), + } + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_with( + AcpAgent::new(config), + |conn: ConnectionTo| async move { + conn.send_request( + InitializeRequest::new(ProtocolVersion::V1).client_capabilities( + ClientCapabilities::new().fs(FileSystemCapabilities::new() + .read_text_file(true) + .write_text_file(true)), + ), + ) + .block_task() + .await?; + let session = conn + .send_request( + NewSessionRequest::new(cwd.clone()) + .additional_directories(extra_roots.clone()), + ) + .block_task() + .await?; + let mut app = App::new(conn, session.session_id, tx, model, name, cwd, extra_roots); + let mut terminal = ratatui::init(); + let result = app.run(&mut terminal, rx).await; + ratatui::restore(); + result.map_err(|e| { + agent_client_protocol::Error::internal_error().data(format!("{e:#}")) + }) + }, + ) + .await?; + Ok(()) +} + +/// `fs/read_text_file` handler body: reads `path`, applying the spec's optional +/// 1-based `line` and `limit` params. +fn read_text_file_blocking( + path: &Path, + line: Option, + limit: Option, +) -> agent_client_protocol::Result { + let content = std::fs::read_to_string(path).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + agent_client_protocol::Error::resource_not_found(Some(path.display().to_string())) + } else { + agent_client_protocol::Error::internal_error().data(e.to_string()) + } + })?; + let skip = line.map_or(0, |l| l.saturating_sub(1) as usize); + let take = limit.map_or(usize::MAX, |l| l as usize); + let content = content + .lines() + .skip(skip) + .take(take) + .collect::>() + .join("\n"); + Ok(ReadTextFileResponse::new(content)) +} + +/// `fs/write_text_file` handler body: the client MUST create the file if it +/// doesn't exist (spec: https://agentclientprotocol.com/protocol/v1/file-system). +fn write_text_file_blocking( + path: &Path, + content: &str, +) -> agent_client_protocol::Result { + let write = || { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, content) + }; + write().map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string()))?; + Ok(WriteTextFileResponse::new()) +} + +enum Entry { + User(String), + Agent(String), + Thought(String), + Tool { + id: ToolCallId, + title: String, + status: ToolCallStatus, + output: String, + }, + Info(String), + Error(String), +} + +struct App { + conn: ConnectionTo, + session_id: SessionId, + tx: mpsc::UnboundedSender, + model: String, + /// The agent's name, shown in the input box. + name: String, + /// Workspace roots, primary working directory first, then any additional roots. + roots: Vec, + entries: Vec, + input: Vec, + cursor: usize, + busy: bool, + permission: Option<( + RequestPermissionRequest, + oneshot::Sender, + )>, + /// Lines scrolled up from the bottom of the transcript; 0 follows new output. + scroll_up: usize, + tick: usize, + quit: bool, +} + +impl App { + fn new( + conn: ConnectionTo, + session_id: SessionId, + tx: mpsc::UnboundedSender, + model: String, + name: String, + cwd: PathBuf, + extra_roots: Vec, + ) -> Self { + let roots = std::iter::once(cwd.clone()) + .chain(extra_roots.into_iter().filter(|r| *r != cwd)) + .collect(); + Self { + conn, + session_id, + tx, + model, + name, + roots, + entries: vec![Entry::Info( + "Ask for a change, or type /quit to exit.".into(), + )], + input: Vec::new(), + cursor: 0, + busy: false, + permission: None, + scroll_up: 0, + tick: 0, + quit: false, + } + } + + async fn run( + &mut self, + terminal: &mut DefaultTerminal, + mut rx: mpsc::UnboundedReceiver, + ) -> anyhow::Result<()> { + let mut keys = EventStream::new(); + let mut ticker = tokio::time::interval(Duration::from_millis(80)); + while !self.quit { + terminal.draw(|f| self.draw(f))?; + tokio::select! { + ev = keys.next() => match ev { + Some(Ok(Event::Key(key))) if key.kind == KeyEventKind::Press => self.on_key(key), + Some(Err(e)) => return Err(e.into()), + None => break, + _ => {} + }, + Some(ev) = rx.recv() => self.on_event(ev), + _ = ticker.tick(), if self.busy => self.tick += 1, + } + } + Ok(()) + } + + fn on_event(&mut self, ev: AppEvent) { + match ev { + AppEvent::Update(update) => self.on_update(update), + AppEvent::Permission(req, answer) => self.permission = Some((req, answer)), + AppEvent::TurnDone(result) => { + self.busy = false; + match result { + Ok(StopReason::EndTurn) => {} + Ok(StopReason::Cancelled) => { + self.entries.push(Entry::Info("Cancelled.".into())) + } + Ok(other) => self + .entries + .push(Entry::Info(format!("Stopped: {other:?}"))), + Err(e) => self.entries.push(Entry::Error(e)), + } + } + } + } + + fn on_update(&mut self, update: SessionUpdate) { + match update { + SessionUpdate::AgentMessageChunk(chunk) => match self.entries.last_mut() { + Some(Entry::Agent(text)) => text.push_str(&block_text(&chunk.content)), + _ => self.entries.push(Entry::Agent(block_text(&chunk.content))), + }, + SessionUpdate::AgentThoughtChunk(chunk) => match self.entries.last_mut() { + Some(Entry::Thought(text)) => text.push_str(&block_text(&chunk.content)), + _ => self + .entries + .push(Entry::Thought(block_text(&chunk.content))), + }, + SessionUpdate::ToolCall(call) => self.entries.push(Entry::Tool { + id: call.tool_call_id, + title: call.title, + status: call.status, + output: summarize(&call.content), + }), + SessionUpdate::ToolCallUpdate(update) => { + let entry = + self.entries.iter_mut().rev().find( + |e| matches!(e, Entry::Tool { id, .. } if *id == update.tool_call_id), + ); + if let Some(Entry::Tool { + title, + status, + output, + .. + }) = entry + { + let fields = update.fields; + if let Some(s) = fields.status { + *status = s; + } + if let Some(t) = fields.title { + *title = t; + } + if let Some(c) = fields.content { + *output = summarize(&c); + } + } + } + _ => {} + } + } + + fn on_key(&mut self, key: KeyEvent) { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if self.permission.is_some() { + match key.code { + KeyCode::Char('y') => self.answer_permission(Some(PermissionOptionKind::AllowOnce)), + KeyCode::Char('a') => { + self.answer_permission(Some(PermissionOptionKind::AllowAlways)) + } + KeyCode::Char('r') => { + self.answer_permission(Some(PermissionOptionKind::RejectAlways)) + } + KeyCode::Char('n') | KeyCode::Esc => { + self.answer_permission(Some(PermissionOptionKind::RejectOnce)) + } + KeyCode::Char('c') if ctrl => self.cancel_turn(), + _ => {} + } + return; + } + match key.code { + KeyCode::Char('c') if ctrl => { + if self.busy { + self.cancel_turn(); + } else if !self.input.is_empty() { + self.input.clear(); + self.cursor = 0; + } else { + self.quit = true; + } + } + KeyCode::Char('d') if ctrl && self.input.is_empty() => self.quit = true, + KeyCode::Char('a') if ctrl => self.cursor = 0, + KeyCode::Char('e') if ctrl => self.cursor = self.input.len(), + KeyCode::Char('u') if ctrl => { + self.input.drain(..self.cursor); + self.cursor = 0; + } + KeyCode::Esc if self.busy => self.cancel_turn(), + KeyCode::Enter if !self.busy => self.submit(), + KeyCode::Char(c) if !ctrl => { + self.input.insert(self.cursor, c); + self.cursor += 1; + } + KeyCode::Backspace if self.cursor > 0 => { + self.cursor -= 1; + self.input.remove(self.cursor); + } + KeyCode::Delete if self.cursor < self.input.len() => { + self.input.remove(self.cursor); + } + KeyCode::Left => self.cursor = self.cursor.saturating_sub(1), + KeyCode::Right => self.cursor = (self.cursor + 1).min(self.input.len()), + KeyCode::Home => self.cursor = 0, + KeyCode::End => self.cursor = self.input.len(), + KeyCode::PageUp => self.scroll_up += 10, + KeyCode::PageDown => self.scroll_up = self.scroll_up.saturating_sub(10), + KeyCode::Up => self.scroll_up += 1, + KeyCode::Down => self.scroll_up = self.scroll_up.saturating_sub(1), + _ => {} + } + } + + fn submit(&mut self) { + let text: String = self.input.iter().collect::().trim().to_string(); + if text.is_empty() { + return; + } + self.input.clear(); + self.cursor = 0; + if text == "/quit" || text == "/exit" { + self.quit = true; + return; + } + self.entries.push(Entry::User(text.clone())); + self.busy = true; + self.scroll_up = 0; + + let conn = self.conn.clone(); + let request = PromptRequest::new( + self.session_id.clone(), + vec![ContentBlock::Text(TextContent::new(text))], + ); + let tx = self.tx.clone(); + tokio::spawn(async move { + let result = conn.send_request(request).block_task().await; + let _ = tx.send(AppEvent::TurnDone( + result.map(|r| r.stop_reason).map_err(|e| format!("{e}")), + )); + }); + } + + fn cancel_turn(&mut self) { + if let Some((_, answer)) = self.permission.take() { + let _ = answer.send(RequestPermissionOutcome::Cancelled); + } + if let Err(e) = self + .conn + .send_notification(CancelNotification::new(self.session_id.clone())) + { + self.entries + .push(Entry::Error(format!("cancel failed: {e}"))); + } + } + + fn answer_permission(&mut self, kind: Option) { + let Some((req, answer)) = self.permission.take() else { + return; + }; + let outcome = req + .options + .iter() + .find(|o| Some(o.kind) == kind) + .map(|o| { + RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new( + o.option_id.clone(), + )) + }) + .unwrap_or(RequestPermissionOutcome::Cancelled); + let _ = answer.send(outcome); + } + + /// Show paths under a workspace root relative to it; secondary roots get a `name/` prefix. + fn short(&self, text: &str) -> String { + let mut out = text.to_string(); + for (i, root) in self.roots.iter().enumerate() { + let mut prefix = root.display().to_string(); + prefix.push('/'); + let replacement = if i == 0 { + String::new() + } else { + root_name(root) + }; + out = out.replace(&prefix, &replacement); + } + out + } + + fn draw(&self, f: &mut Frame) { + let [body, input, status] = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .areas(f.area()); + self.draw_transcript(f, body); + self.draw_input(f, input); + self.draw_status(f, status); + if let Some((req, _)) = &self.permission { + let title = req.tool_call.fields.title.clone().or_else(|| { + self.entries.iter().rev().find_map(|e| match e { + Entry::Tool { id, title, .. } if *id == req.tool_call.tool_call_id => { + Some(title.clone()) + } + _ => None, + }) + }); + draw_permission(f, &self.short(&title.unwrap_or_else(|| "Run tool".into()))); + } + } + + fn draw_transcript(&self, f: &mut Frame, area: Rect) { + let mut lines: Vec = Vec::new(); + for entry in &self.entries { + match entry { + Entry::User(text) => { + for (i, l) in text.lines().enumerate() { + let prefix = if i == 0 { "› " } else { " " }; + lines.push(Line::from(vec![prefix.cyan().bold(), l.to_string().bold()])); + } + } + Entry::Agent(text) => { + lines.extend(text.trim().lines().map(|l| Line::from(l.to_string()))); + } + Entry::Thought(text) => { + let style = Style::new() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC); + lines.extend( + text.trim() + .lines() + .map(|l| Line::styled(format!(" {l}"), style)), + ); + } + Entry::Tool { + title, + status, + output, + .. + } => { + let icon = match status { + ToolCallStatus::Completed => "✓".green(), + ToolCallStatus::Failed => "✗".red(), + _ => SPINNER[self.tick % SPINNER.len()].yellow(), + }; + lines.push(Line::from(vec![icon, " ".into(), self.short(title).bold()])); + let output = self.short(output); + let out: Vec<&str> = output + .lines() + .filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with("```")) + .collect(); + for l in out.iter().take(MAX_TOOL_OUTPUT_LINES) { + lines.push(Line::from(vec![ + " │ ".dark_gray(), + l.to_string().dark_gray(), + ])); + } + if out.len() > MAX_TOOL_OUTPUT_LINES { + let more = out.len() - MAX_TOOL_OUTPUT_LINES; + lines.push(Line::from(format!(" │ … {more} more lines").dark_gray())); + } + } + Entry::Info(text) => lines.push(Line::from(text.clone().dark_gray())), + Entry::Error(text) => lines.push(Line::from(format!("error: {text}").red())), + } + lines.push(Line::default()); + } + + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); + let total = paragraph.line_count(area.width); + let max_offset = total.saturating_sub(area.height as usize); + let offset = max_offset.saturating_sub(self.scroll_up); + f.render_widget( + paragraph.scroll((offset.min(u16::MAX as usize) as u16, 0)), + area, + ); + } + + fn draw_input(&self, f: &mut Frame, area: Rect) { + let border = if self.busy { + Color::DarkGray + } else { + Color::Cyan + }; + let block = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::new().fg(border)) + .title(format!(" {} ", self.name)); + let inner = block.inner(area); + // Scroll horizontally so the cursor stays visible. + let width = inner.width.saturating_sub(1) as usize; + let start = self.cursor.saturating_sub(width); + let visible: String = self.input[start..].iter().take(width + 1).collect(); + let text = if self.input.is_empty() && !self.busy { + Line::from(format!("Ask {} to do something…", self.name).dark_gray()) + } else { + Line::from(visible) + }; + f.render_widget(Paragraph::new(text).block(block), area); + if self.permission.is_none() { + f.set_cursor_position((inner.x + (self.cursor - start) as u16, inner.y)); + } + } + + fn draw_status(&self, f: &mut Frame, area: Rect) { + let state = if self.permission.is_some() { + "waiting for approval".yellow() + } else if self.busy { + format!("{} working", SPINNER[self.tick % SPINNER.len()]).yellow() + } else { + "ready".green() + }; + let hints = if self.busy { + "Esc cancel" + } else { + "Enter send · Ctrl-C quit" + }; + let roots = self + .roots + .iter() + .map(|r| r.display().to_string()) + .collect::>() + .join(", "); + let line = Line::from(vec![ + " ".into(), + state, + " · ".dark_gray(), + self.model.clone().cyan(), + " · ".dark_gray(), + roots.dark_gray(), + " · ".dark_gray(), + hints.dark_gray(), + " · ↑↓/PgUp/PgDn scroll".dark_gray(), + ]); + f.render_widget(Paragraph::new(line), area); + } +} + +fn draw_permission(f: &mut Frame, title: &str) { + let area = f.area(); + let width = (title.chars().count() as u16 + 6).clamp(44, area.width.saturating_sub(4).max(1)); + let popup = Rect { + x: area.x + area.width.saturating_sub(width) / 2, + y: area.y + area.height.saturating_sub(7) / 2, + width: width.min(area.width), + height: 7.min(area.height), + }; + let block = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::new().fg(Color::Yellow)) + .title(" Allow this? "); + let text = vec![ + Line::from(title.to_string().bold()), + Line::default(), + Line::from(vec![ + Span::from("[y]").green().bold(), + " allow ".into(), + Span::from("[a]").green().bold(), + " always ".into(), + Span::from("[n]").red().bold(), + " reject ".into(), + Span::from("[r]").red().bold(), + " never".into(), + ]), + ]; + f.render_widget(Clear, popup); + f.render_widget( + Paragraph::new(text).block(block).wrap(Wrap { trim: false }), + popup, + ); +} + +/// Display name for a workspace root: its directory name with a trailing slash. +fn root_name(root: &Path) -> String { + match root.file_name() { + Some(name) => format!("{}/", name.to_string_lossy()), + None => root.display().to_string(), + } +} + +fn block_text(block: &ContentBlock) -> String { + match block { + ContentBlock::Text(t) => t.text.clone(), + _ => String::new(), + } +} + +/// Short, display-friendly summary of tool output. +fn summarize(content: &[ToolCallContent]) -> String { + content + .iter() + .map(|c| match c { + ToolCallContent::Content(c) => block_text(&c.content), + ToolCallContent::Diff(d) => { + let old = d.old_text.as_deref().map_or(0, |t| t.lines().count()); + format!( + "{} (-{old} +{} lines)", + d.path.display(), + d.new_text.lines().count() + ) + } + _ => String::new(), + }) + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod fs_handler_tests { + use super::*; + + #[test] + fn read_text_file_slices_by_1_based_line_and_limit() { + let dir = std::env::temp_dir().join("ed-acp-tui-fs-test"); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("lines.txt"); + std::fs::write(&file, "one\ntwo\nthree\nfour\n").unwrap(); + + let all = read_text_file_blocking(&file, None, None).unwrap(); + assert_eq!(all.content, "one\ntwo\nthree\nfour"); + + let from_line = read_text_file_blocking(&file, Some(2), None).unwrap(); + assert_eq!(from_line.content, "two\nthree\nfour"); + + let limited = read_text_file_blocking(&file, Some(2), Some(1)).unwrap(); + assert_eq!(limited.content, "two"); + + let past_end = read_text_file_blocking(&file, Some(99), None).unwrap(); + assert_eq!(past_end.content, ""); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn read_text_file_missing_is_resource_not_found() { + let err = read_text_file_blocking(Path::new("/definitely/does/not/exist.txt"), None, None) + .unwrap_err(); + assert_eq!(err.code, agent_client_protocol::ErrorCode::ResourceNotFound); + } + + #[test] + fn write_text_file_creates_missing_file_and_parents() { + let dir = std::env::temp_dir() + .join("ed-acp-tui-fs-test-2") + .join("nested"); + let file = dir.join("created.txt"); + write_text_file_blocking(&file, "hello").unwrap(); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "hello"); + + // Overwrite existing. + write_text_file_blocking(&file, "again").unwrap(); + assert_eq!(std::fs::read_to_string(&file).unwrap(), "again"); + + std::fs::remove_dir_all(dir.parent().unwrap()).ok(); + } +} From 50b27f6e9fb8436383988b30d2be8ad7d1fda9d0 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:12:54 +0200 Subject: [PATCH 09/28] Release 1.1.0: bump the workspace, licence files, README Moves every crate to 1.1.0, adds the licence files the new crates need on crates.io, and documents the 1.1 additions in the README. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: siGit Code v1.6.3 --- Cargo.lock | 14 +- Cargo.toml | 18 +-- README.md | 21 ++- crates/ed-acp-testkit/LICENSE-APACHE | 202 +++++++++++++++++++++++++++ crates/ed-acp-testkit/LICENSE-MIT | 21 +++ crates/ed-acp-tui/LICENSE-APACHE | 202 +++++++++++++++++++++++++++ crates/ed-acp-tui/LICENSE-MIT | 21 +++ crates/ed-acp/LICENSE-APACHE | 202 +++++++++++++++++++++++++++ crates/ed-acp/LICENSE-MIT | 21 +++ crates/ed-mcp/LICENSE-APACHE | 202 +++++++++++++++++++++++++++ crates/ed-mcp/LICENSE-MIT | 21 +++ 11 files changed, 927 insertions(+), 18 deletions(-) create mode 100644 crates/ed-acp-testkit/LICENSE-APACHE create mode 100644 crates/ed-acp-testkit/LICENSE-MIT create mode 100644 crates/ed-acp-tui/LICENSE-APACHE create mode 100644 crates/ed-acp-tui/LICENSE-MIT create mode 100644 crates/ed-acp/LICENSE-APACHE create mode 100644 crates/ed-acp/LICENSE-MIT create mode 100644 crates/ed-mcp/LICENSE-APACHE create mode 100644 crates/ed-mcp/LICENSE-MIT diff --git a/Cargo.lock b/Cargo.lock index 50f1543..f4fbf6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1958,7 +1958,7 @@ checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" [[package]] name = "ed-acp" -version = "1.0.1" +version = "1.1.0" dependencies = [ "agent-client-protocol", "anyhow", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "ed-acp-testkit" -version = "1.0.1" +version = "1.1.0" dependencies = [ "serde_json", "tokio", @@ -1988,7 +1988,7 @@ dependencies = [ [[package]] name = "ed-acp-tui" -version = "1.0.1" +version = "1.1.0" dependencies = [ "agent-client-protocol", "anyhow", @@ -2000,7 +2000,7 @@ dependencies = [ [[package]] name = "ed-agent" -version = "1.0.1" +version = "1.1.0" dependencies = [ "async-trait", "futures-core", @@ -2014,7 +2014,7 @@ dependencies = [ [[package]] name = "ed-agent-ffi" -version = "1.0.1" +version = "1.1.0" dependencies = [ "async-trait", "cc", @@ -2029,7 +2029,7 @@ dependencies = [ [[package]] name = "ed-agent-tauri" -version = "1.0.1" +version = "1.1.0" dependencies = [ "async-trait", "ed-agent", @@ -2043,7 +2043,7 @@ dependencies = [ [[package]] name = "ed-mcp" -version = "1.0.1" +version = "1.1.0" dependencies = [ "agent-client-protocol", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 273a993..3cfbb86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ members = [ # Shared across every crate in the workspace so a release moves one number. # `sync-version` tooling (see the Makefile) reads these. [workspace.package] -version = "1.0.1" +version = "1.1.0" edition = "2021" rust-version = "1.94" license = "MIT OR Apache-2.0" @@ -23,9 +23,9 @@ repository = "https://github.com/ondeinference/ed" homepage = "https://ondeinference.com" [workspace.dependencies] -# The inference engine Ed wraps. Cargo uses the sibling checkout locally and -# publishes only the version requirement. Release Onde's tool-calling API -# before publishing Ed. +# The inference engine Ed wraps, resolved from crates.io like any dependency +# (there is no path override to a sibling checkout). Release Onde's +# tool-calling API before publishing Ed. onde = { version = "1.3.1" } async-trait = "0.1" serde = { version = "1", features = ["derive"] } @@ -57,11 +57,11 @@ directories = "6" base64 = "0.23" # Internal -ed-agent = { version = "1.0.1", path = "crates/ed-agent" } -ed-mcp = { version = "1.0.1", path = "crates/ed-mcp" } -ed-acp = { version = "1.0.1", path = "crates/ed-acp" } -ed-acp-tui = { version = "1.0.1", path = "crates/ed-acp-tui" } -ed-acp-testkit = { version = "1.0.1", path = "crates/ed-acp-testkit" } +ed-agent = { version = "1.1.0", path = "crates/ed-agent" } +ed-mcp = { version = "1.1.0", path = "crates/ed-mcp" } +ed-acp = { version = "1.1.0", path = "crates/ed-acp" } +ed-acp-tui = { version = "1.1.0", path = "crates/ed-acp-tui" } +ed-acp-testkit = { version = "1.1.0", path = "crates/ed-acp-testkit" } # gemm-common (via candle, via onde) depends on sysctl for every # `target_vendor = "apple"`, but sysctl 0.6.0 only has a `sys` module behind diff --git a/README.md b/README.md index 4e6e192..b6ab18f 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Metal and CPU backends, tokens out. Ed is the part above it your users touch. ```toml [dependencies] -ed-agent = "0.1" +ed-agent = "1.1" ``` ```rust @@ -57,6 +57,13 @@ let reply = ed.send("Summarise this thread.").await; println!("{}", reply.reply.unwrap_or_default()); ``` +Stream a reply with `Ed::stream` (plain chat) or let `Ed::run` forward deltas to +`EventSink::text_delta`. `Ed::restore_history` loads a saved conversation, +`Ed::generate` is a one-shot call that leaves the history alone, and +`Ed::set_system_prompt` / `set_sampling` change the model's settings. These +three need a loaded model. Persistence stays yours; implement `SessionStore` +over whatever you already have. + Construct with `Ed::with_agent` to provide the executor for registered tools and the approval handler for mutating tools. Read-only tools run directly; mutating tools must receive `AllowOnce` or `AllowForSession` first. @@ -67,7 +74,17 @@ mutating tools must receive `AllowOnce` or `AllowForSession` first. | --- | --- | | [`ed-agent`](crates/ed-agent) | The agent. No framework dependency. | | [`ed-agent-ffi`](crates/ed-agent-ffi) | UniFFI bridge used by the Swift XCFramework. | -| [`ed-agent-tauri`](crates/ed-agent-tauri) | Tauri bindings: a sink that emits to the webview, plus the commands it invokes. | +| [`ed-agent-tauri`](crates/ed-agent-tauri) | Tauri bindings: a sink that emits to the webview, the commands it invokes, and `EdSessions` for apps with a chat history and webview-answered approvals. | + +The Onde Agent Platform crates, for agents that editors drive over the Agent Client Protocol +(ACP). SplitFire Agent is built on them. + +| Crate | What it's for | +| --- | --- | +| [`ed-acp`](crates/ed-acp) | An ACP v1 server: sessions, auth, the turn loop on Onde Cloud, tool approval, workspace tools and MCP. A product supplies a `Profile`. | +| [`ed-acp-tui`](crates/ed-acp-tui) | A terminal UI for any ACP agent. | +| [`ed-mcp`](crates/ed-mcp) | MCP client glue over `rmcp`, the official Rust SDK: stdio and streamable HTTP servers, bearer tokens, tool namespacing, timeouts, progress and cancellation. With the `agent` feature, `McpExecutor` serves a toolset to Ed as tools. | +| [`ed-acp-testkit`](crates/ed-acp-testkit) | Mock Onde Cloud endpoint, raw ACP client, the ACP registry probe and the ACP v1 conformance suite. | Free the intelligence. diff --git a/crates/ed-acp-testkit/LICENSE-APACHE b/crates/ed-acp-testkit/LICENSE-APACHE new file mode 100644 index 0000000..58885ec --- /dev/null +++ b/crates/ed-acp-testkit/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Splitfire AB (Onde Inference) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/ed-acp-testkit/LICENSE-MIT b/crates/ed-acp-testkit/LICENSE-MIT new file mode 100644 index 0000000..1b62ae8 --- /dev/null +++ b/crates/ed-acp-testkit/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Splitfire AB (Onde Inference) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/ed-acp-tui/LICENSE-APACHE b/crates/ed-acp-tui/LICENSE-APACHE new file mode 100644 index 0000000..58885ec --- /dev/null +++ b/crates/ed-acp-tui/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Splitfire AB (Onde Inference) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/ed-acp-tui/LICENSE-MIT b/crates/ed-acp-tui/LICENSE-MIT new file mode 100644 index 0000000..1b62ae8 --- /dev/null +++ b/crates/ed-acp-tui/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Splitfire AB (Onde Inference) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/ed-acp/LICENSE-APACHE b/crates/ed-acp/LICENSE-APACHE new file mode 100644 index 0000000..58885ec --- /dev/null +++ b/crates/ed-acp/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Splitfire AB (Onde Inference) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/ed-acp/LICENSE-MIT b/crates/ed-acp/LICENSE-MIT new file mode 100644 index 0000000..1b62ae8 --- /dev/null +++ b/crates/ed-acp/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Splitfire AB (Onde Inference) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/ed-mcp/LICENSE-APACHE b/crates/ed-mcp/LICENSE-APACHE new file mode 100644 index 0000000..58885ec --- /dev/null +++ b/crates/ed-mcp/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Splitfire AB (Onde Inference) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/ed-mcp/LICENSE-MIT b/crates/ed-mcp/LICENSE-MIT new file mode 100644 index 0000000..1b62ae8 --- /dev/null +++ b/crates/ed-mcp/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Splitfire AB (Onde Inference) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 1e0f659264138a5bb84548cef47d7a09db00e730 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:04:03 +0200 Subject: [PATCH 10/28] ed-acp: OpenAI-compatible endpoints and hooks for Onde Code Onde Code can now run on ed-acp without losing features it already has. - llm: a Provider (onde or openai). _PROVIDER picks it, else ONDE_API_KEY means Onde Cloud and OPENAI_API_KEY a generic endpoint. ONDE_BASE_URL points Onde Cloud at another deployment. Tool calls keep extra_content so Gemini's thought_signature is echoed back. - The stored env file is also read from ~/.config//env, the old macOS location, and logout removes every copy. - --setup asks for a provider and writes only the variables it sets. - Profile::answer_slash replies to a command without calling the model. - _COMMAND_TIMEOUT_SECS sets the run_command timeout. Co-Authored-By: Claude Opus 5.5 --- crates/ed-acp/README.md | 2 +- crates/ed-acp/src/agent.rs | 77 ++++++++--- crates/ed-acp/src/cli.rs | 105 +++++++++++---- crates/ed-acp/src/lib.rs | 23 +++- crates/ed-acp/src/llm.rs | 227 ++++++++++++++++++++++++++++++--- crates/ed-acp/src/tools.rs | 2 + crates/ed-acp/src/workspace.rs | 11 +- 7 files changed, 378 insertions(+), 69 deletions(-) diff --git a/crates/ed-acp/README.md b/crates/ed-acp/README.md index 47eb9e9..d0ff660 100644 --- a/crates/ed-acp/README.md +++ b/crates/ed-acp/README.md @@ -4,7 +4,7 @@ An [Agent Client Protocol](https://agentclientprotocol.com) v1 server for Onde a It is the part of an ACP agent that is the same for every product: `initialize` and version negotiation, auth, durable sessions, the model picker, prompt content, the turn loop against Onde -Cloud with streaming, tool approval, workspace tools routed through the client, and MCP servers +Cloud (or any OpenAI API compatible endpoint) with streaming, tool approval, workspace tools routed through the client, and MCP servers (stdio and streamable HTTP) through [`ed-mcp`](https://crates.io/crates/ed-mcp). A product supplies a `Profile`: its name, prompt, tools, slash commands and built-in MCP servers. diff --git a/crates/ed-acp/src/agent.rs b/crates/ed-acp/src/agent.rs index 4d92764..156be25 100644 --- a/crates/ed-acp/src/agent.rs +++ b/crates/ed-acp/src/agent.rs @@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use agent_client_protocol::schema::ProtocolVersion; use agent_client_protocol::schema::v1::{ @@ -31,7 +31,7 @@ use crate::content as prompt; use crate::llm::{Delta, LlmClient, LlmConfig, LlmEnv}; use crate::store::{SessionStore, StoredSession}; use crate::tools::{self, ToolCtx, ToolOutcome, Toolset, absolutize, normalize}; -use crate::{Profile, PromptCtx, SessionCtx}; +use crate::{CommandCtx, Profile, PromptCtx, SessionCtx}; const MAX_TURNS: usize = 50; /// Id of the session config option that selects the model. @@ -44,12 +44,14 @@ const DEFAULT_CONTEXT_WINDOW: u64 = 128_000; pub const AUTH_METHOD_ID: &str = "terminal-setup"; /// Cap on the sessions returned by `session/list`. const MAX_LISTED: usize = 200; +/// How long `run_command` may run when `_COMMAND_TIMEOUT_SECS` is unset. +const DEFAULT_COMMAND_TIMEOUT: Duration = Duration::from_secs(120); /// The terminal auth method: clients run ` --setup`. pub fn auth_methods() -> Vec { vec![AuthMethod::Terminal( AuthMethodTerminal::new(AUTH_METHOD_ID, "Run in terminal") - .description("Interactive setup: store your Onde Inference API key") + .description("Interactive setup: choose a provider and store an API key") .args(vec!["--setup".into()]), )] } @@ -70,7 +72,7 @@ pub fn supports_terminal_auth(caps: &ClientCapabilities) -> bool { pub fn auth_required_error(agent: &str) -> agent_client_protocol::Error { agent_client_protocol::Error::auth_required().data(format!( - "No Onde API key configured. Authenticate with the terminal method (`{agent} --setup`)." + "No API key configured. Authenticate with the terminal method (`{agent} --setup`)." )) } @@ -333,7 +335,10 @@ impl AcpServer { let fresh = LlmConfig::from_env(&self.env); let changed = fresh != *self.llm().config(); if changed { - tracing::info!("credentials changed; reloading the Onde client"); + tracing::info!( + "credentials changed; reloading provider {}", + fresh.provider.name() + ); *self.llm.lock().unwrap() = LlmClient::new(fresh); *self.models.lock().await = None; } @@ -355,20 +360,26 @@ impl AcpServer { } } - /// `logout`: remove the stored key and forget it. A key exported in the agent's own - /// environment can't be removed from here and keeps the agent signed in. + /// `logout`: remove the stored key and forget it. Every file the key may be loaded from + /// goes, or the agent would still be signed in afterwards; one failed removal doesn't stop + /// the others. A key exported in the agent's own environment can't be removed from here + /// and keeps the agent signed in. pub async fn logout(&self) -> agent_client_protocol::Result { - let removed = match self.env.config_file() { - Some(path) => match std::fs::remove_file(&path) { + let failures: Vec = self + .env + .config_file_candidates() + .into_iter() + .filter_map(|path| match std::fs::remove_file(&path) { Err(e) if e.kind() != std::io::ErrorKind::NotFound => { - Err(format!("removing {}: {e}", path.display())) + Some(format!("removing {}: {e}", path.display())) } - _ => Ok(()), - }, - None => Ok(()), - }; + _ => None, + }) + .collect(); self.refresh_credentials().await; - removed.map_err(|e| agent_client_protocol::Error::internal_error().data(e))?; + if !failures.is_empty() { + return Err(agent_client_protocol::Error::internal_error().data(failures.join("; "))); + } Ok(LogoutResponse::new()) } @@ -1003,6 +1014,15 @@ impl AcpServer { if let Err(e) = self.restore(&request.session_id).await { return responder.respond_with_error(e); } + if let Some(reply) = self.answer_slash(&request).await { + connection.send_notification(SessionNotification::new( + request.session_id.clone(), + SessionUpdate::AgentMessageChunk( + ContentChunk::new(reply.into()).message_id(new_message_id()), + ), + ))?; + return responder.respond(PromptResponse::new(StopReason::EndTurn)); + } match self.run_turn(request, connection).await { Ok(stop) => responder.respond(PromptResponse::new(stop)), Err(e) if e.downcast_ref::().is_some() => { @@ -1014,6 +1034,28 @@ impl AcpServer { } } + /// The profile's reply to a slash command it answers without the model, for a prompt that + /// is a single text block. The reply is not added to the conversation. + async fn answer_slash(&self, request: &PromptRequest) -> Option { + let [ContentBlock::Text(text)] = request.prompt.as_slice() else { + return None; + }; + if !text.text.trim_start().starts_with('/') { + return None; + } + let (model, _) = self.session_settings(&request.session_id)?; + let models = self.models().await; + let mcp = self.session_mcp(&request.session_id); + self.profile.answer_slash( + &text.text, + &CommandCtx { + mcp: &mcp, + model: &model, + models: &models, + }, + ) + } + async fn run_turn( &self, request: PromptRequest, @@ -1093,6 +1135,11 @@ impl AcpServer { always_allowed, always_rejected, mcp, + command_timeout: std::env::var(self.env.var("COMMAND_TIMEOUT_SECS")) + .ok() + .and_then(|v| v.parse().ok()) + .map(Duration::from_secs) + .unwrap_or(DEFAULT_COMMAND_TIMEOUT), }; let result = self.turn(&ctx, &model, &mut messages, &blocks).await; diff --git a/crates/ed-acp/src/cli.rs b/crates/ed-acp/src/cli.rs index 5f30793..f4f9574 100644 --- a/crates/ed-acp/src/cli.rs +++ b/crates/ed-acp/src/cli.rs @@ -1,12 +1,14 @@ -//! Command-line pieces every Onde agent shares: `--setup` (store and verify the Onde key), -//! `--list-models`, flag parsing and logging. +//! Command-line pieces every Onde agent shares: `--setup` (choose a provider, store and verify +//! its key), `--list-models`, flag parsing and logging. use std::io::IsTerminal; use anyhow::Context; use crate::AgentInfo; -use crate::llm::{LlmClient, LlmConfig, LlmEnv}; +use crate::llm::{ + LlmClient, LlmConfig, LlmEnv, OPENAI_DEFAULT_BASE_URL, OPENAI_DEFAULT_MODEL, Provider, +}; /// Log to stderr, filtered by `RUST_LOG`. Stdout is reserved for ACP. pub fn init_logging() { @@ -32,47 +34,98 @@ pub fn valid_onde_key(key: &str) -> bool { /// `env` file content with `ONDE_API_KEY` set, keeping any other lines. pub fn with_api_key(existing: &str, key: &str) -> String { + with_vars(existing, &[("ONDE_API_KEY".to_string(), key.to_string())]) +} + +/// `env` file content with each of `vars` set, replacing earlier values and keeping any +/// other lines. +pub fn with_vars(existing: &str, vars: &[(String, String)]) -> String { let mut lines: Vec = existing .lines() .filter(|l| { let l = l.trim().strip_prefix("export ").unwrap_or(l.trim()); - !l.starts_with("ONDE_API_KEY=") + !vars.iter().any(|(k, _)| l.starts_with(&format!("{k}="))) }) .map(String::from) .collect(); - lines.push(format!("ONDE_API_KEY={key}")); + lines.extend(vars.iter().map(|(k, v)| format!("{k}={v}"))); lines.join("\n") + "\n" } -/// Interactive first-run setup (`--setup`): prompt for the Onde key, verify it against Onde -/// Cloud, and store it in the platform config dir (`config_dir()/env`, mode 0600). +/// Interactive first-run setup (`--setup`): choose Onde Inference or an OpenAI API compatible +/// endpoint, prompt for its key, verify it, and store it in the platform config dir +/// (`config_dir()/env`, mode 0600). pub async fn setup(info: &AgentInfo) -> anyhow::Result<()> { use std::io::Write; if !std::io::stdin().is_terminal() { anyhow::bail!("--setup needs an interactive terminal"); } - println!("{} setup\n", info.name); - println!("Get credentials: sign in at https://ondeinference.com/root/login,"); - println!("register an app and assign a model. Your key is \"app-id:app-secret\".\n"); - let key = loop { - print!("Paste your ONDE_API_KEY: "); + let prompt_line = |label: &str| -> anyhow::Result { + print!("{label}"); std::io::stdout().flush()?; let mut line = String::new(); if std::io::stdin().read_line(&mut line)? == 0 { anyhow::bail!("no input"); } - let key = line.trim().to_string(); - if valid_onde_key(&key) { - break key; - } - println!("Onde credentials look like \"app-id:app-secret\" (exactly one colon)."); + Ok(line.trim().to_string()) }; - // Verify before writing anything. - let mut config = LlmConfig::from_env(&info.env); - config.api_key = Some(key.clone()); - print!("Verifying the key with Onde Cloud… "); + println!("{} setup\n", info.name); + println!(" 1) Onde Inference (ONDE_API_KEY)"); + println!( + " 2) OpenAI API compatible endpoint (OPENAI_BASE_URL, OPENAI_API_KEY, OPENAI_MODEL)" + ); + let provider = loop { + match prompt_line("\nChoose a provider [1-2]: ")?.as_str() { + "" | "1" | "onde" => break Provider::Onde, + "2" | "openai" => break Provider::OpenAi, + _ => println!("Please enter 1 or 2."), + } + }; + let mut vars = vec![(info.env.var("PROVIDER"), provider.name().to_string())]; + match provider { + Provider::Onde => { + println!("\nGet credentials: sign in at https://ondeinference.com/root/login,"); + println!("register an app and assign a model. Your key is \"app-id:app-secret\".\n"); + } + // A generic endpoint also needs its URL and a model that supports tool calling. + Provider::OpenAi => { + for (var, label, default) in [ + ("OPENAI_BASE_URL", "Base URL", OPENAI_DEFAULT_BASE_URL), + ("OPENAI_MODEL", "Model", OPENAI_DEFAULT_MODEL), + ] { + let value = prompt_line(&format!("{label} [{default}]: "))?; + let value = if value.is_empty() { + default.to_string() + } else { + value + }; + vars.push((var.to_string(), value)); + } + } + } + let key_var = provider.key_var(); + let key = loop { + let key = prompt_line(&format!("Paste your {key_var}: "))?; + match provider { + Provider::Onde if !valid_onde_key(&key) => { + println!("Onde credentials look like \"app-id:app-secret\" (exactly one colon).") + } + _ if key.is_empty() => println!("Key must not be empty."), + _ => break key, + } + }; + vars.push((key_var.to_string(), key)); + + // Verify before writing anything: the new values win over the environment. + let config = LlmConfig::from_lookup(&info.env, |name| { + vars.iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.clone()) + .or_else(|| std::env::var(name).ok()) + }); + print!("Verifying the key with {}… ", provider.display_name()); std::io::stdout().flush()?; match LlmClient::new(config).check_auth().await { Ok(()) => println!("OK"), @@ -86,7 +139,7 @@ pub async fn setup(info: &AgentInfo) -> anyhow::Result<()> { std::fs::create_dir_all(&dir)?; let path = dir.join("env"); let existing = std::fs::read_to_string(&path).unwrap_or_default(); - std::fs::write(&path, with_api_key(&existing, &key))?; + std::fs::write(&path, with_vars(&existing, &vars))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -137,5 +190,13 @@ mod tests { let out = with_api_key("# hi\nX_MODEL=m\nexport ONDE_API_KEY=old:old\n", "new:key"); assert_eq!(out, "# hi\nX_MODEL=m\nONDE_API_KEY=new:key\n"); assert_eq!(with_api_key("", "a:b"), "ONDE_API_KEY=a:b\n"); + let vars = [ + ("X_PROVIDER".to_string(), "openai".to_string()), + ("OPENAI_API_KEY".to_string(), "sk".to_string()), + ]; + assert_eq!( + with_vars("X_PROVIDER=onde\nONDE_API_KEY=a:b\n", &vars), + "ONDE_API_KEY=a:b\nX_PROVIDER=openai\nOPENAI_API_KEY=sk\n" + ); } } diff --git a/crates/ed-acp/src/lib.rs b/crates/ed-acp/src/lib.rs index 84cb4bb..9b8c6e8 100644 --- a/crates/ed-acp/src/lib.rs +++ b/crates/ed-acp/src/lib.rs @@ -4,9 +4,9 @@ //! version negotiation, terminal auth, `authenticate` and `logout`, durable sessions (`list`, //! `load` with full replay, `resume`, `close`, `delete`), the model picker and auto-approve //! config options, slash-command advertisement, prompt content (embedded and linked files, -//! images, audio), the turn loop against Onde Cloud with streaming, message ids and usage, -//! tool approval, the workspace tools routed through the client's `fs/*` and `terminal/*`, and -//! MCP servers through [`ed_mcp`]. +//! images, audio), the turn loop against Onde Cloud (or any OpenAI API compatible endpoint) +//! with streaming, message ids and usage, tool approval, the workspace tools routed through the +//! client's `fs/*` and `terminal/*`, and MCP servers through [`ed_mcp`]. //! //! A product supplies a [`Profile`]: its name, prompt, tools, slash commands and built-in MCP //! servers. Everything in the Onde Agent Platform §5.5 conformance table is implemented here @@ -37,7 +37,7 @@ use serde_json::Value; pub use agent_client_protocol; pub use content::AudioHints; pub use ed_mcp; -pub use llm::{LlmConfig, LlmEnv}; +pub use llm::{LlmConfig, LlmEnv, Provider}; pub use server::{ServeOptions, serve_stdio}; pub use tools::{ DescribeCtx, ToolCtx, ToolOutcome, Toolset, absolutize, function_def, normalize, resolve_in, @@ -74,6 +74,15 @@ pub struct SessionCtx<'a> { pub mcp: &'a McpToolset, } +/// What a slash command answered without the model can report. +pub struct CommandCtx<'a> { + pub mcp: &'a McpToolset, + /// The session's model. + pub model: &'a str, + /// The models offered in the model picker. + pub models: &'a [String], +} + /// An MCP call awaiting approval. pub struct McpCall<'a> { pub tool: &'a McpTool, @@ -106,6 +115,12 @@ pub trait Profile: Send + Sync + 'static { None } + /// When `text` is one of this profile's commands that needs no model (`/models`, `/setup`), + /// the reply shown in the thread. It is not added to the conversation. + fn answer_slash(&self, _text: &str, _ctx: &CommandCtx<'_>) -> Option { + None + } + /// Process-wide MCP servers offered to every session unless the client supplies one of /// the same name. fn builtin_mcp_servers(&self) -> Vec<&'static SharedServer> { diff --git a/crates/ed-acp/src/llm.rs b/crates/ed-acp/src/llm.rs index 8e35123..d0f1369 100644 --- a/crates/ed-acp/src/llm.rs +++ b/crates/ed-acp/src/llm.rs @@ -1,5 +1,6 @@ -//! Streaming client for Onde Cloud's OpenAI-compatible `/chat/completions` endpoint: text and -//! reasoning deltas, tool-call accumulation, token usage, and `GET /models`. +//! Streaming client for OpenAI-compatible `/chat/completions` endpoints (Onde Cloud by default, +//! or any other with the `OPENAI_*` variables): text and reasoning deltas, tool-call +//! accumulation, token usage, and `GET /models`. //! //! Superseded by `onde-backend` once it exists (Onde Agent Platform §5.2); the agent depends //! only on this module's public types so that swap stays local. @@ -15,6 +16,54 @@ pub const DEFAULT_BASE_URL: &str = "https://cloud.ondeinference.com/v1"; pub const DEFAULT_MODEL: &str = "onde-kkk"; /// The credential every Onde agent reads: `app-id:app-secret`. pub const API_KEY_VAR: &str = "ONDE_API_KEY"; +/// Base URL of a generic OpenAI API compatible endpoint when `OPENAI_BASE_URL` is unset. +pub const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; +/// Model for a generic endpoint when neither `_MODEL` nor `OPENAI_MODEL` is set. +pub const OPENAI_DEFAULT_MODEL: &str = "gpt-4o-mini"; + +/// Where completions come from. Both speak the OpenAI chat completions API. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Provider { + /// Onde Cloud: `ONDE_API_KEY` (`app-id:app-secret`), `_BASE_URL` or + /// `ONDE_BASE_URL` for a local or staging deployment. + Onde, + /// Any OpenAI API compatible endpoint: `OPENAI_BASE_URL`, `OPENAI_API_KEY`, `OPENAI_MODEL`. + OpenAi, +} + +impl Provider { + /// `onde` or `openai`, as written to `_PROVIDER`. + pub fn name(self) -> &'static str { + match self { + Self::Onde => "onde", + Self::OpenAi => "openai", + } + } + + pub fn parse(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "onde" | "onde-cloud" | "ondeinference" => Some(Self::Onde), + "openai" => Some(Self::OpenAi), + _ => None, + } + } + + /// The variable holding this provider's key. + pub fn key_var(self) -> &'static str { + match self { + Self::Onde => API_KEY_VAR, + Self::OpenAi => "OPENAI_API_KEY", + } + } + + /// Name shown to people, e.g. in setup and errors. + pub fn display_name(self) -> &'static str { + match self { + Self::Onde => "Onde Inference", + Self::OpenAi => "the OpenAI API compatible endpoint", + } + } +} /// Where an agent's settings come from: `_BASE_URL`, `_MODEL` and /// `_DATA_DIR` in the environment, then `//env`. @@ -54,6 +103,28 @@ impl LlmEnv { self.config_dir().map(|d| d.join("env")) } + /// Where the stored `env` file is looked for, most preferred first: [`Self::config_file`], + /// then `~/.config//env`, where agents on macOS kept it before they moved to the + /// platform directory. Only the first is used when `_DATA_DIR` is set. + pub fn config_file_candidates(&self) -> Vec { + let mut paths: Vec = self.config_file().into_iter().collect(); + let overridden = std::env::var_os(self.var("DATA_DIR")).is_some_and(|v| !v.is_empty()); + #[cfg(not(target_os = "windows"))] + if !overridden && let Some(dirs) = directories::BaseDirs::new() { + let legacy = dirs + .home_dir() + .join(".config") + .join(self.dir_name) + .join("env"); + if !paths.contains(&legacy) { + paths.push(legacy); + } + } + #[cfg(target_os = "windows")] + let _ = overridden; + paths + } + /// Where durable state (sessions, audio scratch) lives. Same directory as the `env` file. pub fn data_dir(&self) -> std::path::PathBuf { self.config_dir() @@ -63,8 +134,9 @@ impl LlmEnv { fn load_file_vars(&self) -> std::collections::HashMap { let mut vars = std::collections::HashMap::new(); let content = self - .config_file() - .and_then(|p| std::fs::read_to_string(p).ok()); + .config_file_candidates() + .iter() + .find_map(|p| std::fs::read_to_string(p).ok()); let Some(content) = content else { return vars; }; @@ -91,15 +163,24 @@ impl LlmEnv { #[derive(Debug, Clone, PartialEq, Eq)] pub struct LlmConfig { + pub provider: Provider, pub base_url: String, - /// Onde credentials: `app-id:app-secret`, sent as the bearer token. + /// Sent as the bearer token. For Onde Cloud, `app-id:app-secret`. pub api_key: Option, pub model: String, } impl LlmConfig { - /// `ONDE_API_KEY` from the environment, falling back to the stored `env` file. - /// `_BASE_URL` and `_MODEL` override the endpoint and default model. + /// Settings from the environment, falling back to the stored `env` file. + /// + /// The provider is `_PROVIDER` (`onde` or `openai`) when set, else Onde Cloud + /// whenever `ONDE_API_KEY` is set, else a generic endpoint when `OPENAI_API_KEY` is, else + /// Onde Cloud. Onde Cloud reads only `ONDE_API_KEY`, and its own URL unless + /// `_BASE_URL` or `ONDE_BASE_URL` names another deployment, so stray `OPENAI_*` + /// variables can't redirect it or send it the wrong key. A generic endpoint reads + /// `OPENAI_BASE_URL`, `OPENAI_API_KEY` and `OPENAI_MODEL`. `_MODEL` picks the + /// model for either. + /// /// Empty values are ignored: GUI launchers (e.g. Zed via launchd) often export /// variables set to "", which must not mask the config file. pub fn from_env(env: &LlmEnv) -> Self { @@ -115,13 +196,34 @@ impl LlmConfig { /// Build a config from an arbitrary variable lookup (tests, setup). pub fn from_lookup(env: &LlmEnv, lookup: impl Fn(&str) -> Option) -> Self { let get = |name: &str| lookup(name).filter(|v| !v.is_empty()); + let provider = match get(&env.var("PROVIDER")) { + Some(name) => Provider::parse(&name).unwrap_or_else(|| { + tracing::warn!("unknown {} {name:?}; using onde", env.var("PROVIDER")); + Provider::Onde + }), + None if get(API_KEY_VAR).is_some() => Provider::Onde, + None if get("OPENAI_API_KEY").is_some() => Provider::OpenAi, + None => Provider::Onde, + }; + let (base_url, model) = match provider { + Provider::Onde => ( + get(&env.var("BASE_URL")) + .or_else(|| get("ONDE_BASE_URL")) + .unwrap_or_else(|| DEFAULT_BASE_URL.into()), + get(&env.var("MODEL")).unwrap_or_else(|| env.default_model.into()), + ), + Provider::OpenAi => ( + get("OPENAI_BASE_URL").unwrap_or_else(|| OPENAI_DEFAULT_BASE_URL.into()), + get(&env.var("MODEL")) + .or_else(|| get("OPENAI_MODEL")) + .unwrap_or_else(|| OPENAI_DEFAULT_MODEL.into()), + ), + }; Self { - base_url: get(&env.var("BASE_URL")) - .unwrap_or_else(|| DEFAULT_BASE_URL.into()) - .trim_end_matches('/') - .to_string(), - api_key: get(API_KEY_VAR), - model: get(&env.var("MODEL")).unwrap_or_else(|| env.default_model.into()), + provider, + base_url: base_url.trim_end_matches('/').to_string(), + api_key: get(provider.key_var()), + model, } } } @@ -167,6 +269,75 @@ mod tests { ); } + #[test] + fn onde_base_url_points_onde_at_another_deployment() { + let c = config(&[ + ("ONDE_API_KEY", "app:secret"), + ("ONDE_BASE_URL", "http://localhost:8090/v1/"), + ]); + assert_eq!(c.base_url, "http://localhost:8090/v1"); + let both = config(&[ + ("ONDE_BASE_URL", "http://a/v1"), + ("SPLITFIRE_BASE_URL", "http://b/v1"), + ]); + assert_eq!(both.base_url, "http://b/v1"); + } + + #[test] + fn openai_key_selects_a_generic_endpoint() { + let c = config(&[ + ("OPENAI_API_KEY", "sk"), + ("OPENAI_MODEL", "my-model"), + ("OPENAI_BASE_URL", "http://x/v1/"), + ("ONDE_BASE_URL", "http://onde/v1"), + ]); + assert_eq!( + ( + c.provider, + c.base_url.as_str(), + c.api_key.as_deref(), + c.model.as_str() + ), + (Provider::OpenAi, "http://x/v1", Some("sk"), "my-model") + ); + let plain = config(&[("OPENAI_API_KEY", "sk")]); + assert_eq!( + (plain.base_url.as_str(), plain.model.as_str()), + ("https://api.openai.com/v1", "gpt-4o-mini") + ); + assert_eq!(config(&[]).provider, Provider::Onde); + } + + #[test] + fn onde_wins_over_openai_variables() { + let c = config(&[ + ("ONDE_API_KEY", "app:secret"), + ("OPENAI_API_KEY", "sk-other"), + ("OPENAI_BASE_URL", "http://elsewhere/v1"), + ("OPENAI_MODEL", "gpt-x"), + ]); + assert_eq!(c.provider, Provider::Onde); + assert_eq!(c.api_key.as_deref(), Some("app:secret")); + assert_eq!(c.base_url, "https://cloud.ondeinference.com/v1"); + assert_eq!(c.model, "onde-kkk"); + } + + #[test] + fn explicit_provider_wins_over_detected_keys() { + let c = config(&[ + ("ONDE_API_KEY", "app:secret"), + ("OPENAI_API_KEY", "sk"), + ("SPLITFIRE_PROVIDER", "openai"), + ("SPLITFIRE_MODEL", "m"), + ]); + assert_eq!( + (c.provider, c.api_key.as_deref(), c.model.as_str()), + (Provider::OpenAi, Some("sk"), "m") + ); + let onde = config(&[("SPLITFIRE_PROVIDER", "onde"), ("OPENAI_API_KEY", "sk")]); + assert_eq!((onde.provider, onde.api_key), (Provider::Onde, None)); + } + /// One test touches the process environment, so it can't race another. #[test] fn key_from_env_then_file_ignoring_empty_env() { @@ -182,6 +353,7 @@ mod tests { std::env::set_var("SPLITFIRE_DATA_DIR", &dir); std::env::set_var("ONDE_API_KEY", ""); } + assert_eq!(ENV.config_file_candidates(), vec![dir.join("env")]); let c = LlmConfig::from_env(&ENV); assert_eq!(c.api_key.as_deref(), Some("file:key")); assert_eq!(c.model, "m1"); @@ -233,6 +405,8 @@ pub struct ToolCallRequest { pub id: String, pub name: String, pub arguments: String, + /// Provider extras that must be echoed back (e.g. Gemini's `thought_signature`). + pub extra_content: Option, } #[derive(Debug, Default)] @@ -271,11 +445,15 @@ impl Completion { .tool_calls .iter() .map(|tc| { - json!({ + let mut call = json!({ "id": tc.id, "type": "function", "function": { "name": tc.name, "arguments": tc.arguments }, - }) + }); + if let Some(extra) = &tc.extra_content { + call["extra_content"] = extra.clone(); + } + call }) .collect(); } @@ -319,6 +497,7 @@ struct ToolCallDelta { index: usize, id: Option, function: Option, + extra_content: Option, } #[derive(Deserialize)] @@ -370,8 +549,9 @@ impl LlmClient { /// Verify the configured key with a minimal chat completion. /// Returns `Err` when no key is set, the key is rejected, or the endpoint is unreachable. pub async fn check_auth(&self) -> Result<()> { + let provider = self.config.provider; let Some(key) = &self.config.api_key else { - bail!("no ONDE_API_KEY configured"); + bail!("no {} configured", provider.key_var()); }; let body = json!({ "model": self.config.model, @@ -386,14 +566,17 @@ impl LlmClient { .bearer_auth(key) .send() .await - .context("reaching Onde Cloud")?; + .with_context(|| format!("reaching {}", provider.display_name()))?; let status = resp.status(); if matches!(status.as_u16(), 401 | 403) { - bail!("Onde Cloud rejected the API key ({status})"); + bail!( + "{} rejected the API key ({status})", + provider.display_name() + ); } if !status.is_success() { let text = resp.text().await.unwrap_or_default(); - bail!("Onde Cloud returned {status}: {text}"); + bail!("{} returned {status}: {text}", provider.display_name()); } Ok(()) } @@ -440,7 +623,8 @@ impl LlmClient { ) -> Result { if self.config.api_key.is_none() { bail!( - "No API key configured. Set ONDE_API_KEY in the environment or run the agent's `--setup`" + "No API key configured. Set {} in the environment or run the agent's `--setup`", + self.config.provider.key_var() ); } let mut body = json!({ @@ -523,6 +707,9 @@ impl LlmClient { if let Some(id) = tc.id { slot.id = id; } + if tc.extra_content.is_some() { + slot.extra_content = tc.extra_content; + } if let Some(f) = tc.function { if let Some(n) = f.name { slot.name.push_str(&n); diff --git a/crates/ed-acp/src/tools.rs b/crates/ed-acp/src/tools.rs index cf2666f..2374d66 100644 --- a/crates/ed-acp/src/tools.rs +++ b/crates/ed-acp/src/tools.rs @@ -41,6 +41,8 @@ pub struct ToolCtx { pub always_rejected: Arc>>, /// Tools from MCP servers available to this session. pub mcp: Arc, + /// How long `run_command` may run: `_COMMAND_TIMEOUT_SECS`, default 120 seconds. + pub command_timeout: std::time::Duration, } /// What a tool call produced. diff --git a/crates/ed-acp/src/workspace.rs b/crates/ed-acp/src/workspace.rs index 3bf7f30..70170c9 100644 --- a/crates/ed-acp/src/workspace.rs +++ b/crates/ed-acp/src/workspace.rs @@ -4,7 +4,6 @@ //! optional `root` for multi-root workspaces. Writes and commands ask for approval. use std::path::Path; -use std::time::Duration; use agent_client_protocol::ErrorCode; use agent_client_protocol::schema::v1::{ @@ -22,8 +21,6 @@ use crate::tools::{ function_def, resolve_in, truncate, }; -const COMMAND_TIMEOUT: Duration = Duration::from_secs(120); - /// The workspace tools as a [`Toolset`]. #[derive(Debug, Default, Clone, Copy)] pub struct WorkspaceTools; @@ -385,7 +382,7 @@ impl ToolCtx { .block_task(); let exit = tokio::select! { r = wait => Some(r?.exit_status), - () = tokio::time::sleep(COMMAND_TIMEOUT) => None, + () = tokio::time::sleep(self.command_timeout) => None, () = self.cancel.cancelled() => None, }; if exit.is_none() { @@ -413,7 +410,7 @@ impl ToolCtx { _ => "exited".into(), }, None if self.cancel.is_cancelled() => "cancelled".into(), - None => format!("timed out after {}s", COMMAND_TIMEOUT.as_secs()), + None => format!("timed out after {}s", self.command_timeout.as_secs()), }; let failed = !matches!(exit.as_ref().and_then(|s| s.exit_code), Some(0)); let trunc = if output.truncated { @@ -438,9 +435,9 @@ impl ToolCtx { .kill_on_drop(true) .output(); let out = tokio::select! { - r = tokio::time::timeout(COMMAND_TIMEOUT, child) => match r { + r = tokio::time::timeout(self.command_timeout, child) => match r { Ok(r) => r?, - Err(_) => return Ok(ToolOutcome::err(format!("Command timed out after {}s", COMMAND_TIMEOUT.as_secs()))), + Err(_) => return Ok(ToolOutcome::err(format!("Command timed out after {}s", self.command_timeout.as_secs()))), }, () = self.cancel.cancelled() => return Ok(ToolOutcome::err("Command cancelled")), }; From 6161d59150975d8bf9e1788818b1fabe7443a6b0 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:04:04 +0200 Subject: [PATCH 11/28] Release 1.2.0: bump the workspace Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 14 +++++++------- Cargo.toml | 12 ++++++------ README.md | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f4fbf6b..638e692 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1958,7 +1958,7 @@ checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" [[package]] name = "ed-acp" -version = "1.1.0" +version = "1.2.0" dependencies = [ "agent-client-protocol", "anyhow", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "ed-acp-testkit" -version = "1.1.0" +version = "1.2.0" dependencies = [ "serde_json", "tokio", @@ -1988,7 +1988,7 @@ dependencies = [ [[package]] name = "ed-acp-tui" -version = "1.1.0" +version = "1.2.0" dependencies = [ "agent-client-protocol", "anyhow", @@ -2000,7 +2000,7 @@ dependencies = [ [[package]] name = "ed-agent" -version = "1.1.0" +version = "1.2.0" dependencies = [ "async-trait", "futures-core", @@ -2014,7 +2014,7 @@ dependencies = [ [[package]] name = "ed-agent-ffi" -version = "1.1.0" +version = "1.2.0" dependencies = [ "async-trait", "cc", @@ -2029,7 +2029,7 @@ dependencies = [ [[package]] name = "ed-agent-tauri" -version = "1.1.0" +version = "1.2.0" dependencies = [ "async-trait", "ed-agent", @@ -2043,7 +2043,7 @@ dependencies = [ [[package]] name = "ed-mcp" -version = "1.1.0" +version = "1.2.0" dependencies = [ "agent-client-protocol", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 3cfbb86..9f73814 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ members = [ # Shared across every crate in the workspace so a release moves one number. # `sync-version` tooling (see the Makefile) reads these. [workspace.package] -version = "1.1.0" +version = "1.2.0" edition = "2021" rust-version = "1.94" license = "MIT OR Apache-2.0" @@ -57,11 +57,11 @@ directories = "6" base64 = "0.23" # Internal -ed-agent = { version = "1.1.0", path = "crates/ed-agent" } -ed-mcp = { version = "1.1.0", path = "crates/ed-mcp" } -ed-acp = { version = "1.1.0", path = "crates/ed-acp" } -ed-acp-tui = { version = "1.1.0", path = "crates/ed-acp-tui" } -ed-acp-testkit = { version = "1.1.0", path = "crates/ed-acp-testkit" } +ed-agent = { version = "1.2.0", path = "crates/ed-agent" } +ed-mcp = { version = "1.2.0", path = "crates/ed-mcp" } +ed-acp = { version = "1.2.0", path = "crates/ed-acp" } +ed-acp-tui = { version = "1.2.0", path = "crates/ed-acp-tui" } +ed-acp-testkit = { version = "1.2.0", path = "crates/ed-acp-testkit" } # gemm-common (via candle, via onde) depends on sysctl for every # `target_vendor = "apple"`, but sysctl 0.6.0 only has a `sys` module behind diff --git a/README.md b/README.md index b6ab18f..d3250d9 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Metal and CPU backends, tokens out. Ed is the part above it your users touch. ```toml [dependencies] -ed-agent = "1.1" +ed-agent = "1.2" ``` ```rust From 31f893d936295419d6b74dcc490c259fdf16b8f6 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:44:57 +0200 Subject: [PATCH 12/28] ed-mcp: Connection::tools and Connection::call_tool A host that manages its own servers (siGit Code reads them from mcp.toml and names tools its own way) needs the tools a connection listed and a call that says why it failed. call_raw keeps its Option and now goes through call_tool. Co-Authored-By: Claude Opus 5.5 --- crates/ed-mcp/src/lib.rs | 24 +++++++++++++++++++----- crates/ed-mcp/tests/http.rs | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/ed-mcp/src/lib.rs b/crates/ed-mcp/src/lib.rs index 0aee09b..dc246c2 100644 --- a/crates/ed-mcp/src/lib.rs +++ b/crates/ed-mcp/src/lib.rs @@ -312,18 +312,32 @@ impl Connection { &self.name } + /// The tools the server listed when it connected, under their own names. + pub fn tools(&self) -> &[Tool] { + &self.tools + } + /// Call a tool on this server directly, outside any turn (for example to build a /// permission prompt). Answers `None` on any failure or after `timeout`. pub async fn call_raw(&self, tool: &str, args: Value, timeout: Duration) -> Option { + self.call_tool(tool, args, timeout).await.ok() + } + + /// Call `tool` by its name on this server and return the `CallToolResult` as JSON. A + /// result with `isError: true` is `Ok`; a transport or protocol failure, or no answer + /// within `timeout`, is an `Err` that says which. + pub async fn call_tool(&self, tool: &str, args: Value, timeout: Duration) -> Result { let mut params = CallToolRequestParams::new(tool.to_string()); - if let Value::Object(map) = args { - params = params.with_arguments(map); + match args { + Value::Object(map) => params = params.with_arguments(map), + Value::Null => {} + _ => bail!("tool arguments must be a JSON object"), } let result = tokio::time::timeout(timeout, self.service.peer().call_tool(params)) .await - .ok()? - .ok()?; - serde_json::to_value(result).ok() + .map_err(|_| anyhow!("timed out after {}s", timeout.as_secs()))? + .map_err(|e| anyhow!("{e}"))?; + Ok(serde_json::to_value(result)?) } async fn close(mut self) { diff --git a/crates/ed-mcp/tests/http.rs b/crates/ed-mcp/tests/http.rs index a799652..0528b74 100644 --- a/crates/ed-mcp/tests/http.rs +++ b/crates/ed-mcp/tests/http.rs @@ -130,6 +130,30 @@ async fn connects_lists_and_calls_with_a_bearer_token() { set.shutdown().await; } +#[tokio::test] +async fn a_connection_lists_and_calls_tools_by_their_own_names() { + let url = serve().await; + let spec = ServerSpec::http("mail", url).with_bearer(TOKEN); + let conn = Connection::connect(&spec, &client(), Duration::from_secs(5)) + .await + .unwrap(); + + let mut names: Vec<_> = conn.tools().iter().map(|t| t.name.to_string()).collect(); + names.sort(); + assert_eq!(names, ["list_messages", "send_message", "whoami"]); + + let result = conn + .call_tool("whoami", json!({}), Duration::from_secs(5)) + .await + .unwrap(); + assert_eq!(result["content"][0]["text"], "ran whoami"); + let err = conn + .call_tool("whoami", json!([1]), Duration::from_secs(5)) + .await + .unwrap_err(); + assert!(err.to_string().contains("JSON object"), "{err:#}"); +} + #[tokio::test] async fn a_wrong_bearer_token_is_rejected() { let url = serve().await; From 2e5e4cd7f43b299cbc1347c4b9da14833eaf4b5d Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:11:43 +0200 Subject: [PATCH 13/28] ed-acp: serve over any transport serve_stdio hard-wired the transport to the process's stdin and stdout, so a host could only run an agent as a subprocess. A sandboxed app (the macOS App Store) can't launch one, so add serve(profile, opts, transport), which takes any ConnectTo, and make serve_stdio call it with Stdio. A host can now embed its agent: serve it on one end of agent_client_protocol::Channel::duplex and run an ACP client on the other. The new in_process test does exactly that and checks initialize and that the server stops when the client hangs up. Co-Authored-By: Claude Opus 5.5 --- crates/ed-acp/src/lib.rs | 11 +++++- crates/ed-acp/src/server.rs | 18 ++++++++- crates/ed-acp/tests/in_process.rs | 66 +++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 crates/ed-acp/tests/in_process.rs diff --git a/crates/ed-acp/src/lib.rs b/crates/ed-acp/src/lib.rs index 9b8c6e8..8fb791f 100644 --- a/crates/ed-acp/src/lib.rs +++ b/crates/ed-acp/src/lib.rs @@ -16,6 +16,15 @@ //! let profile = Arc::new(MyProfile); //! ed_acp::serve_stdio(profile, ServeOptions::from_env(&info)).await?; //! ``` +//! +//! To embed the agent in a host process instead (an app sandbox that can't launch a +//! subprocess, say), serve it over an in-memory channel and run an ACP client on the other end: +//! +//! ```ignore +//! let (agent_end, client_end) = agent_client_protocol::Channel::duplex(); +//! tokio::spawn(ed_acp::serve(profile, opts, agent_end)); +//! agent_client_protocol::Client.builder().connect_with(client_end, |conn| async move { … }); +//! ``` mod agent; pub mod cli; @@ -38,7 +47,7 @@ pub use agent_client_protocol; pub use content::AudioHints; pub use ed_mcp; pub use llm::{LlmConfig, LlmEnv, Provider}; -pub use server::{ServeOptions, serve_stdio}; +pub use server::{ServeOptions, serve, serve_stdio}; pub use tools::{ DescribeCtx, ToolCtx, ToolOutcome, Toolset, absolutize, function_def, normalize, resolve_in, truncate, diff --git a/crates/ed-acp/src/server.rs b/crates/ed-acp/src/server.rs index 4b44ea7..86d96d2 100644 --- a/crates/ed-acp/src/server.rs +++ b/crates/ed-acp/src/server.rs @@ -12,7 +12,7 @@ use agent_client_protocol::schema::v1::{ SessionDeleteCapabilities, SessionListCapabilities, SessionResumeCapabilities, SetSessionConfigOptionRequest, }; -use agent_client_protocol::{Agent, Stdio}; +use agent_client_protocol::{Agent, ConnectTo, Stdio}; use crate::agent::{self, AcpServer}; use crate::{AgentInfo, Profile, cli}; @@ -41,6 +41,20 @@ impl ServeOptions { pub async fn serve_stdio( profile: Arc, opts: ServeOptions, +) -> agent_client_protocol::Result<()> { + serve(profile, opts, Stdio::new()).await +} + +/// Serve ACP over any transport until the client disconnects, then stop every MCP server. +/// +/// This is how a host embeds the agent in its own process instead of launching it as a +/// subprocess, for example an app in the macOS App Store sandbox: connect it to +/// [`Channel::duplex`](agent_client_protocol::Channel::duplex) and run an ACP client on the +/// other end. +pub async fn serve( + profile: Arc, + opts: ServeOptions, + transport: impl ConnectTo + 'static, ) -> agent_client_protocol::Result<()> { let info = profile.info(); let prompt_caps = profile.prompt_capabilities(); @@ -277,7 +291,7 @@ pub async fn serve_stdio( }, agent_client_protocol::on_receive_notification!(), ) - .connect_to(Stdio::new()) + .connect_to(transport) .await; // The editor went away: stop MCP servers instead of leaving them to be orphaned. agent.shutdown().await; diff --git a/crates/ed-acp/tests/in_process.rs b/crates/ed-acp/tests/in_process.rs new file mode 100644 index 0000000..d6f0ee5 --- /dev/null +++ b/crates/ed-acp/tests/in_process.rs @@ -0,0 +1,66 @@ +//! `serve` over an in-memory channel: the agent and its client in one process, no stdio. + +use std::sync::Arc; + +use agent_client_protocol::schema::ProtocolVersion; +use agent_client_protocol::schema::v1::InitializeRequest; +use agent_client_protocol::{Agent, Channel, Client, ConnectionTo}; +use ed_acp::{AgentInfo, LlmEnv, Profile, PromptCtx, ServeOptions, Toolset}; + +struct Embedded; + +impl Profile for Embedded { + fn info(&self) -> AgentInfo { + AgentInfo { + name: "embedded-agent", + display_name: "Embedded", + version: "0.0.1", + env: LlmEnv { + prefix: "ED_ACP_IN_PROCESS_TEST", + dir_name: "ed-acp-in-process-test", + default_model: "test-model", + }, + } + } + + fn system_prompt(&self, _ctx: &PromptCtx<'_>) -> String { + String::new() + } + + fn toolsets(&self) -> Vec> { + Vec::new() + } +} + +#[tokio::test] +async fn initialize_over_an_in_memory_channel() { + let (agent_end, client_end) = Channel::duplex(); + let opts = ServeOptions { + yolo: false, + surface: "app", + }; + let server = tokio::spawn(ed_acp::serve(Arc::new(Embedded), opts, agent_end)); + + let info = Client + .builder() + .connect_with(client_end, |conn: ConnectionTo| async move { + conn.send_request(InitializeRequest::new(ProtocolVersion::V1)) + .block_task() + .await + }) + .await + .expect("initialize"); + + let agent = info.agent_info.expect("agentInfo"); + assert_eq!(agent.name, "embedded-agent"); + assert_eq!(agent.version, "0.0.1"); + let caps = info.agent_capabilities; + assert!(caps.load_session); + + // The client hung up when `connect_with` returned, so the server stops on its own. + tokio::time::timeout(std::time::Duration::from_secs(5), server) + .await + .expect("server stops after the client disconnects") + .expect("server task") + .ok(); +} From 83feb55fd46a93a6dba97d6d5d17538d69b20221 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:26:42 +0200 Subject: [PATCH 14/28] ed-acp: say that serve handles one connection per call serve consumes its transport and resolves when that client hangs up, so serving a second client means calling it again with a new transport. The doc comment implied this; now it says so. Also make the embedding example in the crate docs parse, so rustdoc stops warning about it. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: siGit Code v1.6.3 --- crates/ed-acp/src/lib.rs | 8 +++++++- crates/ed-acp/src/server.rs | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/ed-acp/src/lib.rs b/crates/ed-acp/src/lib.rs index 8fb791f..3b4e559 100644 --- a/crates/ed-acp/src/lib.rs +++ b/crates/ed-acp/src/lib.rs @@ -23,7 +23,13 @@ //! ```ignore //! let (agent_end, client_end) = agent_client_protocol::Channel::duplex(); //! tokio::spawn(ed_acp::serve(profile, opts, agent_end)); -//! agent_client_protocol::Client.builder().connect_with(client_end, |conn| async move { … }); +//! agent_client_protocol::Client +//! .builder() +//! .connect_with(client_end, |conn| async move { +//! conn.send_request(InitializeRequest::new(ProtocolVersion::V1)).block_task().await?; +//! Ok(()) +//! }) +//! .await?; //! ``` mod agent; diff --git a/crates/ed-acp/src/server.rs b/crates/ed-acp/src/server.rs index 86d96d2..f558f84 100644 --- a/crates/ed-acp/src/server.rs +++ b/crates/ed-acp/src/server.rs @@ -51,6 +51,9 @@ pub async fn serve_stdio( /// subprocess, for example an app in the macOS App Store sandbox: connect it to /// [`Channel::duplex`](agent_client_protocol::Channel::duplex) and run an ACP client on the /// other end. +/// +/// One call serves one connection: the transport is consumed, and the future resolves when +/// that client hangs up. To serve another client, call `serve` again with a new transport. pub async fn serve( profile: Arc, opts: ServeOptions, From 2edf662193b3b6b56be55f8e3417455a2cce08df Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:34:30 +0200 Subject: [PATCH 15/28] Release 1.2.1: bump the workspace 1.2.0 went out before ed_acp::serve (#18) merged. 1.2.1 ships it. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 14 +++++++------- Cargo.toml | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 638e692..bc2c3dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1958,7 +1958,7 @@ checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" [[package]] name = "ed-acp" -version = "1.2.0" +version = "1.2.1" dependencies = [ "agent-client-protocol", "anyhow", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "ed-acp-testkit" -version = "1.2.0" +version = "1.2.1" dependencies = [ "serde_json", "tokio", @@ -1988,7 +1988,7 @@ dependencies = [ [[package]] name = "ed-acp-tui" -version = "1.2.0" +version = "1.2.1" dependencies = [ "agent-client-protocol", "anyhow", @@ -2000,7 +2000,7 @@ dependencies = [ [[package]] name = "ed-agent" -version = "1.2.0" +version = "1.2.1" dependencies = [ "async-trait", "futures-core", @@ -2014,7 +2014,7 @@ dependencies = [ [[package]] name = "ed-agent-ffi" -version = "1.2.0" +version = "1.2.1" dependencies = [ "async-trait", "cc", @@ -2029,7 +2029,7 @@ dependencies = [ [[package]] name = "ed-agent-tauri" -version = "1.2.0" +version = "1.2.1" dependencies = [ "async-trait", "ed-agent", @@ -2043,7 +2043,7 @@ dependencies = [ [[package]] name = "ed-mcp" -version = "1.2.0" +version = "1.2.1" dependencies = [ "agent-client-protocol", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 9f73814..7d5bbbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ members = [ # Shared across every crate in the workspace so a release moves one number. # `sync-version` tooling (see the Makefile) reads these. [workspace.package] -version = "1.2.0" +version = "1.2.1" edition = "2021" rust-version = "1.94" license = "MIT OR Apache-2.0" @@ -57,11 +57,11 @@ directories = "6" base64 = "0.23" # Internal -ed-agent = { version = "1.2.0", path = "crates/ed-agent" } -ed-mcp = { version = "1.2.0", path = "crates/ed-mcp" } -ed-acp = { version = "1.2.0", path = "crates/ed-acp" } -ed-acp-tui = { version = "1.2.0", path = "crates/ed-acp-tui" } -ed-acp-testkit = { version = "1.2.0", path = "crates/ed-acp-testkit" } +ed-agent = { version = "1.2.1", path = "crates/ed-agent" } +ed-mcp = { version = "1.2.1", path = "crates/ed-mcp" } +ed-acp = { version = "1.2.1", path = "crates/ed-acp" } +ed-acp-tui = { version = "1.2.1", path = "crates/ed-acp-tui" } +ed-acp-testkit = { version = "1.2.1", path = "crates/ed-acp-testkit" } # gemm-common (via candle, via onde) depends on sysctl for every # `target_vendor = "apple"`, but sysctl 0.6.0 only has a `sys` module behind From 15620a57f7da1599e72011450c2bce015897af02 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:01:12 +0200 Subject: [PATCH 16/28] ed-acp: on-device inference with Provider::Local A third provider beside Onde Cloud and OpenAI API compatible endpoints: _PROVIDER=local runs the agent's turns on the device with onde, offline and without a key. Behind an optional `local` feature, so cloud-only agents don't build mistral.rs; without it the provider can still be chosen and every turn says how to enable it. - LlmClient::complete converts the turn loop's OpenAI message and tool JSON to onde ChatTurns and ToolDefinitions and runs onde's stateless ChatEngine::complete, so sessions keep working as they do on the cloud: persisted, loaded and replayed with their tool calls. The reasoning and text of a turn are reported once the turn ends, since onde doesn't stream tool-aware turns yet. - One engine per process, loaded with the session's model on first use (downloaded the first time, with a thought saying so). Models come from onde's tool-calling catalogue, which models() lists. Default: Qwen3 4B Instruct 2507, or Qwen3 1.7B on iOS and Android. - No key is needed, so AUTH_REQUIRED doesn't apply and check_auth passes. A stored ONDE_API_KEY is not read as this provider's key. - --setup offers "On-device" when the feature is compiled in. Needs onde 1.4 (ChatEngine::complete). Until it is published the workspace patches onde to a sibling checkout. Checked end to end through LlmClient::complete with Qwen3 4B Instruct 2507: the model called the offered tool, then answered from its result. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 4 +- Cargo.toml | 3 + crates/ed-acp/Cargo.toml | 8 + crates/ed-acp/src/cli.rs | 28 +++- crates/ed-acp/src/lib.rs | 1 + crates/ed-acp/src/llm.rs | 62 ++++++- crates/ed-acp/src/local.rs | 333 +++++++++++++++++++++++++++++++++++++ 7 files changed, 429 insertions(+), 10 deletions(-) create mode 100644 crates/ed-acp/src/local.rs diff --git a/Cargo.lock b/Cargo.lock index bc2c3dc..d1883d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1965,8 +1965,10 @@ dependencies = [ "async-trait", "base64 0.23.1", "directories", + "ed-acp", "ed-mcp", "futures", + "onde", "reqwest 0.13.5", "serde", "serde_json", @@ -4998,8 +5000,6 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "onde" version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7145ab132138de1f7b57707d37a5f7dbcb0dee36560742689270c162359478b5" dependencies = [ "anyhow", "cc", diff --git a/Cargo.toml b/Cargo.toml index 7d5bbbd..d505481 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,3 +73,6 @@ ed-acp-testkit = { version = "1.2.1", path = "crates/ed-acp-testkit" } # the graph is silently ignored. [patch.crates-io] sysctl = { git = "https://github.com/setoelkahfi/sysctl-rs.git", branch = "feature/watchos-0.6" } +# ChatEngine::complete (onde 1.4) is not on crates.io yet. Remove once it is, and require +# onde 1.4 above. +onde = { path = "../onde" } diff --git a/crates/ed-acp/Cargo.toml b/crates/ed-acp/Cargo.toml index 299b5c4..f50b484 100644 --- a/crates/ed-acp/Cargo.toml +++ b/crates/ed-acp/Cargo.toml @@ -11,8 +11,14 @@ authors.workspace = true repository.workspace = true homepage.workspace = true +[features] +# On-device inference with onde (`_PROVIDER=local`). Off by default: it builds +# mistral.rs and its model runtime, which a cloud-only agent doesn't need. +local = ["dep:onde"] + [dependencies] ed-mcp = { workspace = true, features = ["acp"] } +onde = { workspace = true, optional = true } agent-client-protocol.workspace = true async-trait.workspace = true tokio = { workspace = true, features = ["full"] } @@ -30,3 +36,5 @@ base64.workspace = true [dev-dependencies] tokio = { version = "1", features = ["full"] } +# Turn the `local` feature on for this crate's own tests. +ed-acp = { path = ".", features = ["local"] } diff --git a/crates/ed-acp/src/cli.rs b/crates/ed-acp/src/cli.rs index f4f9574..2e2a371 100644 --- a/crates/ed-acp/src/cli.rs +++ b/crates/ed-acp/src/cli.rs @@ -7,7 +7,8 @@ use anyhow::Context; use crate::AgentInfo; use crate::llm::{ - LlmClient, LlmConfig, LlmEnv, OPENAI_DEFAULT_BASE_URL, OPENAI_DEFAULT_MODEL, Provider, + LOCAL_DEFAULT_MODEL, LlmClient, LlmConfig, LlmEnv, OPENAI_DEFAULT_BASE_URL, + OPENAI_DEFAULT_MODEL, Provider, }; /// Log to stderr, filtered by `RUST_LOG`. Stdout is reserved for ACP. @@ -76,11 +77,17 @@ pub async fn setup(info: &AgentInfo) -> anyhow::Result<()> { println!( " 2) OpenAI API compatible endpoint (OPENAI_BASE_URL, OPENAI_API_KEY, OPENAI_MODEL)" ); + let local = cfg!(feature = "local"); + if local { + println!(" 3) On-device (runs here, no key; downloads a model)"); + } + let last = if local { 3 } else { 2 }; let provider = loop { - match prompt_line("\nChoose a provider [1-2]: ")?.as_str() { + match prompt_line(&format!("\nChoose a provider [1-{last}]: "))?.as_str() { "" | "1" | "onde" => break Provider::Onde, "2" | "openai" => break Provider::OpenAi, - _ => println!("Please enter 1 or 2."), + "3" | "local" if local => break Provider::Local, + _ => println!("Please enter a number from 1 to {last}."), } }; let mut vars = vec![(info.env.var("PROVIDER"), provider.name().to_string())]; @@ -104,6 +111,14 @@ pub async fn setup(info: &AgentInfo) -> anyhow::Result<()> { vars.push((var.to_string(), value)); } } + Provider::Local => { + println!( + "\nThe model ({}) downloads the first time the agent needs it.", + LOCAL_DEFAULT_MODEL + ); + println!("Set {} to pick another one.", info.env.var("MODEL")); + return write_env(info, &vars); + } } let key_var = provider.key_var(); let key = loop { @@ -132,6 +147,11 @@ pub async fn setup(info: &AgentInfo) -> anyhow::Result<()> { Err(e) => anyhow::bail!("\nKey check failed: {e:#}\nNothing was written."), } + write_env(info, &vars) +} + +/// Store `vars` in the agent's `env` file (mode 0600), keeping its other lines. +fn write_env(info: &AgentInfo, vars: &[(String, String)]) -> anyhow::Result<()> { let dir = info .env .config_dir() @@ -139,7 +159,7 @@ pub async fn setup(info: &AgentInfo) -> anyhow::Result<()> { std::fs::create_dir_all(&dir)?; let path = dir.join("env"); let existing = std::fs::read_to_string(&path).unwrap_or_default(); - std::fs::write(&path, with_vars(&existing, &vars))?; + std::fs::write(&path, with_vars(&existing, vars))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; diff --git a/crates/ed-acp/src/lib.rs b/crates/ed-acp/src/lib.rs index 3b4e559..97ba25e 100644 --- a/crates/ed-acp/src/lib.rs +++ b/crates/ed-acp/src/lib.rs @@ -36,6 +36,7 @@ mod agent; pub mod cli; pub mod content; pub mod llm; +mod local; mod server; pub mod store; pub mod tools; diff --git a/crates/ed-acp/src/llm.rs b/crates/ed-acp/src/llm.rs index d0f1369..50becd1 100644 --- a/crates/ed-acp/src/llm.rs +++ b/crates/ed-acp/src/llm.rs @@ -21,6 +21,15 @@ pub const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1"; /// Model for a generic endpoint when neither `_MODEL` nor `OPENAI_MODEL` is set. pub const OPENAI_DEFAULT_MODEL: &str = "gpt-4o-mini"; +/// The on-device model when `_MODEL` is unset: Qwen3 1.7B on phones, where memory is +/// tight, Qwen3 4B Instruct 2507 elsewhere. Both are in onde's tool-calling catalog. +#[cfg(any(target_os = "ios", target_os = "android"))] +pub const LOCAL_DEFAULT_MODEL: &str = "bartowski/Qwen_Qwen3-1.7B-GGUF"; +/// The on-device model when `_MODEL` is unset: Qwen3 1.7B on phones, where memory is +/// tight, Qwen3 4B Instruct 2507 elsewhere. Both are in onde's tool-calling catalog. +#[cfg(not(any(target_os = "ios", target_os = "android")))] +pub const LOCAL_DEFAULT_MODEL: &str = "bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF"; + /// Where completions come from. Both speak the OpenAI chat completions API. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Provider { @@ -29,6 +38,10 @@ pub enum Provider { Onde, /// Any OpenAI API compatible endpoint: `OPENAI_BASE_URL`, `OPENAI_API_KEY`, `OPENAI_MODEL`. OpenAi, + /// On-device inference with onde: no endpoint and no key. `_MODEL` names a model + /// from onde's tool-calling catalog. Needs the `local` feature; without it every turn fails + /// with an error saying so. + Local, } impl Provider { @@ -37,6 +50,7 @@ impl Provider { match self { Self::Onde => "onde", Self::OpenAi => "openai", + Self::Local => "local", } } @@ -44,15 +58,17 @@ impl Provider { match name.to_ascii_lowercase().as_str() { "onde" | "onde-cloud" | "ondeinference" => Some(Self::Onde), "openai" => Some(Self::OpenAi), + "local" | "on-device" | "ondevice" => Some(Self::Local), _ => None, } } - /// The variable holding this provider's key. + /// The variable holding this provider's key. Empty for [`Provider::Local`], which has none. pub fn key_var(self) -> &'static str { match self { Self::Onde => API_KEY_VAR, Self::OpenAi => "OPENAI_API_KEY", + Self::Local => "", } } @@ -61,6 +77,7 @@ impl Provider { match self { Self::Onde => "Onde Inference", Self::OpenAi => "the OpenAI API compatible endpoint", + Self::Local => "on-device inference", } } } @@ -218,11 +235,19 @@ impl LlmConfig { .or_else(|| get("OPENAI_MODEL")) .unwrap_or_else(|| OPENAI_DEFAULT_MODEL.into()), ), + Provider::Local => ( + String::new(), + get(&env.var("MODEL")).unwrap_or_else(|| LOCAL_DEFAULT_MODEL.into()), + ), }; Self { provider, base_url: base_url.trim_end_matches('/').to_string(), - api_key: get(provider.key_var()), + // On-device inference has no key, and `get("")` must not find one in an env file. + api_key: match provider { + Provider::Local => None, + _ => get(provider.key_var()), + }, model, } } @@ -256,6 +281,25 @@ mod tests { assert_eq!(config(&[("ONDE_API_KEY", "")]).api_key, None); } + #[test] + fn local_needs_no_key() { + let c = config(&[ + ("SPLITFIRE_PROVIDER", "local"), + ("ONDE_API_KEY", "app:secret"), + ]); + assert_eq!(c.provider, Provider::Local); + assert_eq!(c.model, LOCAL_DEFAULT_MODEL); + // A stored Onde key is not this provider's. + assert_eq!(c.api_key, None); + assert!(LlmClient::new(c).has_api_key()); + assert_eq!(Provider::parse("on-device"), Some(Provider::Local)); + let picked = config(&[ + ("SPLITFIRE_PROVIDER", "local"), + ("SPLITFIRE_MODEL", "bartowski/Qwen_Qwen3-8B-GGUF"), + ]); + assert_eq!(picked.model, "bartowski/Qwen_Qwen3-8B-GGUF"); + } + #[test] fn base_url_and_model_overrides_apply() { let c = config(&[ @@ -541,15 +585,19 @@ impl LlmClient { &self.config } - /// Whether an API key is configured. + /// Whether the provider can be used: an API key is configured, or the provider needs none + /// ([`Provider::Local`]). The agent answers `AUTH_REQUIRED` when this is false. pub fn has_api_key(&self) -> bool { - self.config.api_key.is_some() + self.config.provider == Provider::Local || self.config.api_key.is_some() } /// Verify the configured key with a minimal chat completion. /// Returns `Err` when no key is set, the key is rejected, or the endpoint is unreachable. pub async fn check_auth(&self) -> Result<()> { let provider = self.config.provider; + if provider == Provider::Local { + return Ok(()); + } let Some(key) = &self.config.api_key else { bail!("no {} configured", provider.key_var()); }; @@ -583,6 +631,9 @@ impl LlmClient { /// List the models the configured endpoint serves (`GET {base_url}/models`). pub async fn models(&self) -> Result> { + if self.config.provider == Provider::Local { + return Ok(crate::local::catalog()); + } let url = format!("{}/models", self.config.base_url); let mut req = self.http.get(&url); if let Some(key) = &self.config.api_key { @@ -621,6 +672,9 @@ impl LlmClient { tools: &Value, mut on_delta: impl FnMut(Delta<'_>), ) -> Result { + if self.config.provider == Provider::Local { + return crate::local::complete(model, messages, tools, on_delta).await; + } if self.config.api_key.is_none() { bail!( "No API key configured. Set {} in the environment or run the agent's `--setup`", diff --git a/crates/ed-acp/src/local.rs b/crates/ed-acp/src/local.rs new file mode 100644 index 0000000..337fcfb --- /dev/null +++ b/crates/ed-acp/src/local.rs @@ -0,0 +1,333 @@ +//! [`Provider::Local`](crate::llm::Provider::Local): the turn loop on an on-device model, with +//! onde. +//! +//! The rest of `ed-acp` keeps the conversation as OpenAI chat-completions JSON and calls +//! [`LlmClient::complete`](crate::llm::LlmClient::complete) once per model turn with all of it. +//! This module converts that JSON to onde's [`ChatTurn`]s and runs +//! [`ChatEngine::complete`], which takes the whole conversation each call and leaves the +//! engine's own history alone. One engine serves the process; it loads the requested model on +//! first use (downloading it the first time) and swaps it when the session picks another. +//! +//! onde doesn't stream tool-aware turns yet, so a turn's reasoning and text are reported +//! through `on_delta` once the turn ends. +//! +//! Without the `local` feature the provider is still selectable but every turn fails with an +//! error that says how to enable it. + +#[cfg(feature = "local")] +pub use enabled::{catalog, complete}; + +#[cfg(not(feature = "local"))] +pub use disabled::{catalog, complete}; + +#[cfg(not(feature = "local"))] +mod disabled { + use serde_json::Value; + + use crate::llm::{Completion, Delta, ModelInfo}; + + pub fn catalog() -> Vec { + Vec::new() + } + + pub async fn complete( + _model: &str, + _messages: &[Value], + _tools: &Value, + _on_delta: impl FnMut(Delta<'_>), + ) -> anyhow::Result { + anyhow::bail!( + "this agent was built without on-device inference; rebuild it with ed-acp's `local` \ + feature, or choose another provider" + ) + } +} + +#[cfg(feature = "local")] +mod enabled { + use std::sync::OnceLock; + + use anyhow::{Context, Result, anyhow, bail}; + use onde::inference::models::{SUPPORTED_MODEL_INFO, TOOL_CALLING_MODELS}; + use onde::inference::{ + ChatEngine, ChatTurn, GgufModelConfig, ToolCallRequest as OndeCall, ToolCallingSupport, + ToolDefinition, + }; + use serde_json::Value; + use tokio::sync::Mutex; + + use crate::llm::{Completion, Delta, ModelInfo, ToolCallRequest, Usage}; + + /// The process's engine and the model loaded in it. The mutex is held across a turn, so + /// sessions take turns on the one model instead of swapping it under each other. + struct Local { + engine: ChatEngine, + loaded: Mutex>, + } + + fn local() -> &'static Local { + static LOCAL: OnceLock = OnceLock::new(); + LOCAL.get_or_init(|| Local { + engine: ChatEngine::new(), + loaded: Mutex::new(None), + }) + } + + /// onde's tool-calling models, the ones an agent can drive. + pub fn catalog() -> Vec { + TOOL_CALLING_MODELS + .iter() + .map(|id| ModelInfo { + id: (*id).to_string(), + owned_by: SUPPORTED_MODEL_INFO + .iter() + .find(|info| info.id == *id) + .map(|info| format!("{} (on device)", info.name)), + }) + .collect() + } + + pub async fn complete( + model: &str, + messages: &[Value], + tools: &Value, + mut on_delta: impl FnMut(Delta<'_>), + ) -> Result { + let turns = to_turns(messages)?; + let tools = to_tools(tools); + let local = local(); + let mut loaded = local.loaded.lock().await; + if loaded.as_deref() != Some(model) { + let config = GgufModelConfig::from_supported_model_id(model) + .ok_or_else(|| anyhow!("{model} is not an on-device model onde supports"))?; + if onde::inference::models::tool_calling_support(model) != ToolCallingSupport::Supported + { + bail!("{model} can't call tools reliably, so it can't run an agent"); + } + on_delta(Delta::Reasoning(&format!( + "Loading {} on this device. The first use downloads it.\n", + config.display_name + ))); + tracing::info!("loading on-device model {model}"); + *loaded = None; + local + .engine + .load_gguf_model(config, None, None) + .await + .with_context(|| format!("loading {model}"))?; + *loaded = Some(model.to_string()); + } + let result = local + .engine + .complete(turns, &tools, None) + .await + .context("on-device inference")?; + drop(loaded); + + if let Some(reasoning) = &result.reasoning { + on_delta(Delta::Reasoning(reasoning)); + } + if !result.text.is_empty() { + on_delta(Delta::Text(&result.text)); + } + Ok(Completion { + content: result.text, + tool_calls: result + .tool_calls + .into_iter() + .map(|call| ToolCallRequest { + id: call.id, + name: call.function_name, + arguments: call.arguments, + extra_content: None, + }) + .collect(), + finish_reason: Some(result.finish_reason), + usage: Some(Usage { + prompt_tokens: result.prompt_tokens as u64, + completion_tokens: result.completion_tokens as u64, + total_tokens: (result.prompt_tokens + result.completion_tokens) as u64, + }), + }) + } + + /// OpenAI chat messages, as the turn loop keeps them, to onde turns. + pub(super) fn to_turns(messages: &[Value]) -> Result> { + messages + .iter() + .map(|message| { + let role = message.get("role").and_then(Value::as_str).unwrap_or(""); + let content = text_of(message.get("content")); + Ok(match role { + "system" | "developer" => ChatTurn::System(content), + "user" => ChatTurn::User(content), + "assistant" => ChatTurn::Assistant { + content, + tool_calls: message + .get("tool_calls") + .and_then(Value::as_array) + .map(|calls| calls.iter().map(to_call).collect()) + .unwrap_or_default(), + }, + "tool" => ChatTurn::Tool { + tool_call_id: message + .get("tool_call_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + content, + }, + other => bail!("unexpected message role {other:?}"), + }) + }) + .collect() + } + + fn to_call(call: &Value) -> OndeCall { + let field = |pointer: &str| { + call.pointer(pointer) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }; + OndeCall { + id: field("/id"), + function_name: field("/function/name"), + arguments: field("/function/arguments"), + } + } + + /// A message's text: a string, or the text parts of a content array. On-device models here + /// are text-only, so other parts become a short placeholder. + fn text_of(content: Option<&Value>) -> String { + match content { + Some(Value::String(text)) => text.clone(), + Some(Value::Array(parts)) => parts + .iter() + .map(|part| match part.get("type").and_then(Value::as_str) { + Some("text") => part + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + Some(kind) => format!("[{kind} omitted: the on-device model reads text only]"), + None => String::new(), + }) + .filter(|text| !text.is_empty()) + .collect::>() + .join("\n"), + _ => String::new(), + } + } + + /// OpenAI `tools` entries to onde tool definitions. + pub(super) fn to_tools(tools: &Value) -> Vec { + tools + .as_array() + .map(|tools| { + tools + .iter() + .filter_map(|tool| { + let function = tool.get("function")?; + Some(ToolDefinition { + name: function.get("name")?.as_str()?.to_string(), + description: function + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + parameters_schema: function + .get("parameters") + .cloned() + .unwrap_or_else(|| serde_json::json!({"type": "object"})) + .to_string(), + }) + }) + .collect() + }) + .unwrap_or_default() + } +} + +#[cfg(all(test, feature = "local"))] +mod tests { + use onde::inference::{ChatTurn, ToolCallRequest}; + use serde_json::json; + + use super::enabled::{catalog, to_tools, to_turns}; + use crate::llm::LOCAL_DEFAULT_MODEL; + + #[test] + fn the_default_model_is_in_the_catalog() { + assert!(catalog().iter().any(|m| m.id == LOCAL_DEFAULT_MODEL)); + assert!(catalog().iter().all(|m| { + m.owned_by + .as_deref() + .is_some_and(|o| o.ends_with("(on device)")) + })); + } + + #[test] + fn messages_become_turns() { + let messages = vec![ + json!({"role": "system", "content": "You are SplitFire."}), + json!({"role": "user", "content": [ + {"type": "text", "text": "What key is this?"}, + {"type": "image_url", "image_url": {"url": "data:..."}} + ]}), + json!({"role": "assistant", "content": "", "tool_calls": [ + {"id": "call_1", "type": "function", + "function": {"name": "analyze_audio", "arguments": "{\"path\":\"a.wav\"}"}} + ]}), + json!({"role": "tool", "tool_call_id": "call_1", "content": "Key: A minor"}), + json!({"role": "assistant", "content": "A minor."}), + ]; + let turns = to_turns(&messages).unwrap(); + assert_eq!( + turns, + vec![ + ChatTurn::System("You are SplitFire.".into()), + ChatTurn::User( + "What key is this?\n[image_url omitted: the on-device model reads text only]" + .into() + ), + ChatTurn::Assistant { + content: String::new(), + tool_calls: vec![ToolCallRequest { + id: "call_1".into(), + function_name: "analyze_audio".into(), + arguments: "{\"path\":\"a.wav\"}".into(), + }], + }, + ChatTurn::Tool { + tool_call_id: "call_1".into(), + content: "Key: A minor".into(), + }, + ChatTurn::Assistant { + content: "A minor.".into(), + tool_calls: vec![], + }, + ] + ); + assert!(to_turns(&[json!({"role": "narrator", "content": "x"})]).is_err()); + } + + #[test] + fn tools_become_definitions() { + let tools = json!([ + {"type": "function", "function": { + "name": "theory_chord", "description": "Spell a chord.", + "parameters": {"type": "object", "properties": {"symbol": {"type": "string"}}} + }}, + {"type": "function", "function": {"name": "no_params"}}, + {"type": "not-a-function"} + ]); + let defs = to_tools(&tools); + assert_eq!(defs.len(), 2); + assert_eq!(defs[0].name, "theory_chord"); + assert_eq!(defs[0].description, "Spell a chord."); + let schema: serde_json::Value = serde_json::from_str(&defs[0].parameters_schema).unwrap(); + assert_eq!(schema["properties"]["symbol"]["type"], "string"); + assert_eq!(defs[1].parameters_schema, r#"{"type":"object"}"#); + } +} From 48cb619611caefb4b02aa98497380a269902f462 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:11:13 +0200 Subject: [PATCH 17/28] Depend on onde 1.3.2 from crates.io 1.3.2 ships ChatEngine::complete, so the path override to a sibling onde checkout is no longer needed. It also broke CI, where ../onde does not exist. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 4 +++- Cargo.toml | 9 +++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d1883d4..0b19fff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4999,7 +4999,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "onde" -version = "1.3.1" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc87dc77609c5ed63840b263bc548acf50c1a6992a0a0834c032482e3cddc63b" dependencies = [ "anyhow", "cc", diff --git a/Cargo.toml b/Cargo.toml index d505481..4aa3099 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,9 +24,9 @@ homepage = "https://ondeinference.com" [workspace.dependencies] # The inference engine Ed wraps, resolved from crates.io like any dependency -# (there is no path override to a sibling checkout). Release Onde's -# tool-calling API before publishing Ed. -onde = { version = "1.3.1" } +# (there is no path override to a sibling checkout). 1.3.2 is the first release +# with ChatEngine::complete, which ed-acp's local provider calls. +onde = { version = "1.3.2" } async-trait = "0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -73,6 +73,3 @@ ed-acp-testkit = { version = "1.2.1", path = "crates/ed-acp-testkit" } # the graph is silently ignored. [patch.crates-io] sysctl = { git = "https://github.com/setoelkahfi/sysctl-rs.git", branch = "feature/watchos-0.6" } -# ChatEngine::complete (onde 1.4) is not on crates.io yet. Remove once it is, and require -# onde 1.4 above. -onde = { path = "../onde" } From 5d65139c60f8ce5922416978e4cbe81f2eb08077 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:01:40 +0200 Subject: [PATCH 18/28] Add siGit agent configs (.sigit/) Add config files for three siGit agents: - code-cloud-agent: uses onde-balance model - code-review: uses sigit-code-review login - factory: uses onde-large model with weekly harvest on Mon (closed, limit 50, since 2026-09-01) Co-Authored-By: siGit Code v1.6.6-acp --- .sigit/code-cloud-agent/config.toml | 2 ++ .sigit/code-review/config.toml | 3 +++ .sigit/factory/config.toml | 9 +++++++++ 3 files changed, 14 insertions(+) create mode 100644 .sigit/code-cloud-agent/config.toml create mode 100644 .sigit/code-review/config.toml create mode 100644 .sigit/factory/config.toml diff --git a/.sigit/code-cloud-agent/config.toml b/.sigit/code-cloud-agent/config.toml new file mode 100644 index 0000000..291319b --- /dev/null +++ b/.sigit/code-cloud-agent/config.toml @@ -0,0 +1,2 @@ +[agent] +model = "onde-balance" diff --git a/.sigit/code-review/config.toml b/.sigit/code-review/config.toml new file mode 100644 index 0000000..27cdf29 --- /dev/null +++ b/.sigit/code-review/config.toml @@ -0,0 +1,3 @@ +[agent] +login = "sigit-code-review" +display_name = "siGit Code Review" diff --git a/.sigit/factory/config.toml b/.sigit/factory/config.toml new file mode 100644 index 0000000..771b497 --- /dev/null +++ b/.sigit/factory/config.toml @@ -0,0 +1,9 @@ +[agent] +model = "onde-large" + +[harvest] +state = "closed" +limit = 50 +since = "2026-09-01" +cadence = "weekly" +day_of_week = "Mon" From 5a6a721fdb10ff70a85f9ec4dfd52e73a25982b6 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:09:24 +0200 Subject: [PATCH 19/28] Copy agentic resources from onde Add AGENTS.md, CLAUDE.md, and .agents/ directory (skills + AGENTS.md) copied from the onde workspace. Co-Authored-By: siGit Code v1.6.6-acp --- .agents/AGENTS.md | 617 +++++++++++ .agents/skills/forked-dependencies/SKILL.md | 231 ++++ .agents/skills/humanizer/SKILL.md | 431 ++++++++ .agents/skills/legal-and-trademarks/SKILL.md | 257 +++++ .agents/skills/rust-coding-standard/SKILL.md | 116 ++ .agents/skills/sdk-branding/SKILL.md | 355 +++++++ .agents/skills/sdk-dart/SKILL.md | 1004 ++++++++++++++++++ .agents/skills/sdk-kotlin/SKILL.md | 810 ++++++++++++++ .agents/skills/sdk-react-native/SKILL.md | 769 ++++++++++++++ .agents/skills/sdk-regressions/SKILL.md | 632 +++++++++++ .agents/skills/sdk-releases/SKILL.md | 670 ++++++++++++ .agents/skills/sdk-swift-package/SKILL.md | 363 +++++++ .agents/skills/uniffi-rs/SKILL.md | 167 +++ AGENTS.md | 617 +++++++++++ CLAUDE.md | 617 +++++++++++ 15 files changed, 7656 insertions(+) create mode 100644 .agents/AGENTS.md create mode 100644 .agents/skills/forked-dependencies/SKILL.md create mode 100644 .agents/skills/humanizer/SKILL.md create mode 100644 .agents/skills/legal-and-trademarks/SKILL.md create mode 100644 .agents/skills/rust-coding-standard/SKILL.md create mode 100644 .agents/skills/sdk-branding/SKILL.md create mode 100644 .agents/skills/sdk-dart/SKILL.md create mode 100644 .agents/skills/sdk-kotlin/SKILL.md create mode 100644 .agents/skills/sdk-react-native/SKILL.md create mode 100644 .agents/skills/sdk-regressions/SKILL.md create mode 100644 .agents/skills/sdk-releases/SKILL.md create mode 100644 .agents/skills/sdk-swift-package/SKILL.md create mode 100644 .agents/skills/uniffi-rs/SKILL.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 0000000..33eaf2a --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,617 @@ +# Onde Inference — AGENTS.md + +> **AI agent reference for the `onde` repository and the `onde-swift` Swift SDK.** +> Keep this file accurate as the codebase evolves. + +--- + +## What Is Onde? + +Onde is an **on-device LLM inference SDK family** built around a shared Rust core. It targets Apple silicon (iOS, tvOS, macOS) plus Android, Windows, and Linux, and ships SDKs for Rust, Swift, Kotlin Multiplatform, Flutter, and React Native. It wraps [mistral.rs](https://github.com/EricLBuehler/mistral.rs) behind platform-friendly APIs with automatic model selection, HuggingFace Hub downloads, cache management, and Metal acceleration where available. + +- **Website:** https://ondeinference.com +- **Rust crate:** https://crates.io/crates/onde +- **Swift package:** https://github.com/ondeinference/onde-swift +- **Maven Central:** https://central.sonatype.com/artifact/com.ondeinference/onde-inference +- **Flutter package:** https://pub.dev/packages/onde_inference +- **React Native package:** https://www.npmjs.com/package/@ondeinference/react-native +- **In production:** [Splitfire AB apps on the Apple AppStore](https://apps.apple.com/se/developer/splitfire-ab/id1831430993) + +--- + +## Repository Layout + +``` +onde/ +├── src/ +│ ├── lib.rs # Crate root — uniffi::setup_scaffolding!() +│ ├── hf_cache.rs # HuggingFace Hub cache: list, download, delete, diagnose, repair +│ └── inference/ +│ ├── mod.rs # Module exports + top-level re-exports +│ ├── engine.rs # ChatEngine — Rust-native API (generics, mpsc, tool calling) +│ ├── ffi.rs # OndeChatEngine — UniFFI Object (FFI-safe, Arc-wrapped) +│ ├── models.rs # Model ID constants + SupportedModelInfo metadata +│ ├── token.rs # HF token resolution: build-time literal vs cache file +│ └── types.rs # Shared types: ChatMessage, SamplingConfig, InferenceResult, ToolCallInfo, etc. +├── sdk/ +│ ├── dart/ # Flutter/Dart package + example app + FRB Rust bridge +│ ├── gem/ # Ruby native extension (Magnus) +│ ├── kotlin/ # Kotlin Multiplatform package (Android + JVM) +│ ├── react-native/ # Expo module wrapping the Rust core for iOS/Android +│ └── python/ # Python bindings (maturin + uniffi) +├── generated/ # UniFFI-generated headers and Swift glue (git-ignored) +├── uniffi-bindgen/ # Standalone bindgen binary crate (pinned uniffi =0.31.0) +├── .github/ +│ ├── workflows/ +│ │ ├── release-sdk-swift.yml # CI: tag push → build XCFramework → GitHub Release → update onde-swift +│ │ ├── release-sdk-kotlin.yml # CI: tag push → build Android/JVM artifacts → publish to Maven Central +│ │ ├── release-sdk-dart.yml # CI: tag push → publish Flutter package to pub.dev +│ │ ├── release-sdk-npm.yml # CI: tag push → publish React Native package to npm +│ │ └── release-sdk-rust.yml # CI: tag push → publish Rust crate to crates.io +│ └── scripts/ +│ └── build-swift-xcframework.sh # Local/CI XCFramework assembly script +├── .cargo/config.toml # Target-specific rustflags (fp16, linker overrides) +├── Cargo.toml # Platform-conditional mistralrs deps +├── build.rs # tvOS ___chkstk_darwin assembly stub +├── scripts/ +│ └── tvos_chkstk.s # No-op arm64 stub for missing tvOS symbol +├── uniffi.toml # UniFFI binding config +└── docs/ + ├── dev.md # Developer guide (build, architecture, platform table) + ├── swift-package.md # Swift API reference + XCFramework build steps + ├── distribution.md # Release process for all registries + └── ruby-gem.md # Ruby gem API reference +``` + +``` +onde-swift/ # Swift Package Manager wrapper repo +├── Package.swift # Declares OndeFramework.xcframework binary target +└── Sources/ + └── Onde/ + └── onde.swift # UniFFI-generated Swift glue (do NOT edit manually) +``` + +--- + +## Architecture + +### Layer Diagram + +``` +┌──────────────────────────────────────────────────────────┐ +│ Swift (iOS / tvOS / macOS) Rust app / CLI / server │ +│ import Onde use onde::inference::* │ +└──────────┬───────────────────────────────┬───────────────┘ + │ UniFFI FFI bindings │ Direct Rust API + ▼ ▼ +┌──────────────────────────┐ ┌─────────────────────────────┐ +│ OndeChatEngine │ │ ChatEngine │ +│ (ffi.rs — uniffi::Object│──│ (engine.rs — Rust-native) │ +│ FFI-safe, Arc) │ │ generics, mpsc channels │ +└──────────┬───────────────┘ └─────────────┬───────────────┘ + │ │ + └────────────────┬───────────────┘ + ▼ + ┌────────────────────────┐ + │ mistralrs::Model │ + │ GgufModelBuilder │ + │ Metal / CUDA / CPU │ + └────────────────────────┘ +``` + +### Key Design Rules + +1. **`ChatEngine`** (`engine.rs`) owns all Rust-idiomatic logic: `impl Into`, `tokio::sync::mpsc::Receiver`, etc. Never add UniFFI annotations here. +2. **`OndeChatEngine`** (`ffi.rs`) is a thin `Arc`-wrapped UniFFI `Object` with concrete, FFI-safe signatures. It delegates every call to the inner `ChatEngine`. +3. **`StreamChunkListener`** is a `#[uniffi::export(callback_interface)]` trait — it must be passed as a parameter to **free functions** (not Object methods) because UniFFI 0.31 does not support callback_interface on Object methods. +4. **Free functions** in `ffi.rs` (e.g. `stream_chat_message`, `default_model_config`, `user_message`) are exported with `#[uniffi::export]` and generate top-level Swift functions. +5. **`uniffi::setup_scaffolding!()`** lives in `lib.rs`. Never call `uniffi::generate_scaffolding!()` from `build.rs` for the proc-macro path. + +--- + +## Platform Support + +| Platform | `target_os` | GPU Backend | Default Model | mistralrs features | +| -------- | ----------- | ----------------- | ------------- | ------------------ | +| macOS | `macos` | Metal | Qwen 2.5 Coder 3B (~1.93 GB) | `["metal"]` | +| iOS | `ios` | Metal | Qwen 2.5 Coder 1.5B (~941 MB) | `["metal"]` | +| tvOS | `tvos` | Metal | Qwen 2.5 Coder 1.5B (~941 MB) | `["metal"]` | +| Android | `android` | CPU (candle) | Qwen 2.5 Coder 1.5B (~941 MB) | `[]` + hf-hub | +| Windows | `windows` | CPU (CUDA in CI) | Qwen 2.5 Coder 3B (~1.93 GB) | `[]` | +| Linux | `linux` | CPU (CUDA in CI) | Qwen 2.5 Coder 3B (~1.93 GB) | `[]` | + +- **Current default routing:** `platform_default()` now prefers the Coder variants, 1.5B on mobile and 3B on desktop. +- **iOS / tvOS memory constraint:** iOS gives apps about 2–3 GB. The 3B model (~1.93 GB) can still cause OOM on constrained devices, so mobile defaults stay on the 1.5B model. +- **tvOS tier-3 target:** requires `cargo +nightly -Z build-std`. Stable toolchain cannot build tvOS targets. +- **Android `hf_hub`:** `dirs::home_dir()` panics in the Android sandbox. `hf-hub` is added as an explicit dependency on Android so `HF_HOME` can be seeded programmatically via `hf_hub::api::tokio::ApiBuilder`. + +--- + +## Supported Models + +All model constants live in `src/inference/models.rs`. When adding a new model: + +1. Add `pub const` entries for the HF repo ID, GGUF filename, and, on Android, `TOK_MODEL_ID` when needed. +2. Add the repo ID to `SUPPORTED_MODELS` so `list_local_hf_models` filters it. +3. Add a `SupportedModelInfo` entry to `SUPPORTED_MODEL_INFO` with accurate `expected_size_bytes` from the HF API `siblings[].size`. +4. Add a constructor to `GgufModelConfig` in `engine.rs`. +5. Export a free function in `ffi.rs` for UniFFI consumers when the model should be reachable from Swift and Kotlin. +6. If the GGUF does not ship with a built-in chat template, set `chat_template` explicitly. DeepSeek Coder is the current example. + +### Current Models + +| Model | Repo | File | Size | Platforms | +|-------|------|------|------|-----------| +| Qwen 2.5 1.5B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-1.5B-Instruct-GGUF` | `Qwen2.5-1.5B-Instruct-Q4_K_M.gguf` | ~941 MB | All platforms | +| Qwen 2.5 3B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-3B-Instruct-GGUF` | `Qwen2.5-3B-Instruct-Q4_K_M.gguf` | ~1.93 GB | All platforms | +| Qwen 2.5 Coder 1.5B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF` | `Qwen2.5-Coder-1.5B-Instruct-Q4_K_M.gguf` | ~941 MB | All platforms (mobile default) | +| Qwen 2.5 Coder 3B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-3B-Instruct-GGUF` | `Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf` | ~1.93 GB | All platforms (desktop default) | +| Qwen 2.5 Coder 7B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-7B-Instruct-GGUF` | `Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf` | ~4.4 GB | Higher-memory devices | +| Qwen 3 0.6B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-0.6B-GGUF` | `Qwen_Qwen3-0.6B-Q4_K_M.gguf` | ~0.5 GB | All platforms | +| Qwen 3 1.7B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-1.7B-GGUF` | `Qwen_Qwen3-1.7B-Q4_K_M.gguf` | ~1.3 GB | All platforms | +| Qwen 3 4B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-GGUF` | `Qwen_Qwen3-4B-Q4_K_M.gguf` | ~2.7 GB | All platforms | +| Qwen 3 8B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-8B-GGUF` | `Qwen_Qwen3-8B-Q4_K_M.gguf` | ~5 GB | Higher-memory devices | +| Qwen 3 14B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-14B-GGUF` | `Qwen_Qwen3-14B-Q4_K_M.gguf` | ~8.4 GB | Higher-memory devices | +| Qwen 3 32B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-32B-GGUF` | `Qwen_Qwen3-32B-Q4_K_M.gguf` | ~19.8 GB | High-memory desktop (32+ GB) | +| Qwen 3 4B Instruct 2507 (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF` | `Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf` | ~2.5 GB | All platforms (latest non-thinking 4B) | +| Qwen 3 4B Thinking 2507 (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-Thinking-2507-GGUF` | `Qwen_Qwen3-4B-Thinking-2507-Q4_K_M.gguf` | ~2.5 GB | All platforms (latest reasoning 4B) | +| Qwen 3 30B-A3B Instruct 2507 (GGUF Q4_K_M, MoE) | `bartowski/Qwen_Qwen3-30B-A3B-Instruct-2507-GGUF` | `Qwen_Qwen3-30B-A3B-Instruct-2507-Q4_K_M.gguf` | ~18.6 GB | High-memory desktop (32+ GB), `qwen3moe` | +| DeepSeek Coder 6.7B Instruct (GGUF Q4_K_M) | `bartowski/deepseek-coder-6.7b-instruct-GGUF` | `deepseek-coder-6.7b-instruct-Q4_K_M.gguf` | ~3.8 GB | Higher-memory devices, custom chat template | +| Qwen 2.5 Coder 7B Instruct (ISQ) | `Qwen/Qwen2.5-Coder-7B-Instruct` | safetensors (ISQ in-situ) | ~8 GB | macOS (ISQ pipeline) | + +--- + +## Key Types (Rust ↔ Swift) + +| Rust Type | Swift Type | Notes | +|-----------|------------|-------| +| `ChatRole` | `enum ChatRole` | `.system`, `.user`, `.assistant` | +| `ChatMessage` | `struct ChatMessage` | `role: ChatRole`, `content: String` | +| `SamplingConfig` | `struct SamplingConfig` | All fields `Optional` | +| `GgufModelConfig` | `struct GgufModelConfig` | `modelId`, `files`, `tokModelId?`, `displayName`, `approxMemory`, `chatTemplate?` | +| `IsqModelConfig` | `struct IsqModelConfig` | `modelId`, `isqBits: UInt8`, `displayName`, `approxMemory` | +| `InferenceResult` | `struct InferenceResult` | `text`, `durationSecs`, `durationDisplay`, `finishReason`, `toolCalls` | +| `ToolCallInfo` | `struct ToolCallInfo` | Structured tool call request emitted by the model | +| `StreamChunk` | `struct StreamChunk` | `delta`, `done`, `finishReason?` | +| `EngineStatus` | `enum EngineStatus` | `.unloaded`, `.loading`, `.ready`, `.generating`, `.error` | +| `EngineInfo` | `struct EngineInfo` | `status`, `modelName?`, `approxMemory?`, `historyLength: UInt64` | +| `InferenceError` | `enum InferenceError: Error` | `noModelLoaded`, `alreadyLoaded`, `modelBuild`, `inference`, `cancelled`, `other` | +| `OndeChatEngine` | `class OndeChatEngine` | Thread-safe; `Arc`-backed; constructed with `OndeChatEngine()` | +| `StreamChunkListener` | `protocol StreamChunkListener` | Implement `onChunk(chunk:) -> Bool` | + +--- + +## HuggingFace Token + +`src/inference/token.rs` resolves the HF token in priority order: + +1. **Build-time literal** (`HF_TOKEN` env var baked in via `option_env!`) — required for iOS/tvOS (no filesystem token possible). +2. **On-disk cache** (`~/.cache/huggingface/token`) — works on macOS after `mistralrs login`. + +Set `HF_TOKEN` before building: + +```bash +export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +cargo build --release +``` + +On sandboxed platforms, your app's setup function must also configure `HF_HOME`, `HF_HUB_CACHE`, and `TMPDIR` to point inside the app container before any `OndeChatEngine` method is called. See `docs/swift-package.md` for the full `setupInferenceEnvironment()` Swift snippet. + +--- + +## Building (Rust) + +### Prerequisites + +```bash +# Stable toolchain (macOS, iOS, Android, Windows, Linux) +rustup toolchain install stable + +# Nightly toolchain (tvOS tier-3 targets only) +rustup toolchain install nightly +rustup component add rust-src --toolchain nightly + +# Apple targets (stable) +rustup target add aarch64-apple-ios aarch64-apple-ios-sim +rustup target add aarch64-apple-darwin x86_64-apple-darwin +``` + +### Commands + +```bash +# Verify compilation (macOS host) +cargo check + +# Run all tests +cargo test + +# Run inference module tests only +cargo test inference:: + +# Run FFI wrapper tests only +cargo test inference::ffi:: + +# Clippy lint +cargo clippy + +# Format +cargo fmt +``` + +### tvOS + +tvOS targets require nightly and `-Z build-std`: + +```bash +cargo +nightly rustc -Z build-std \ + --target aarch64-apple-tvos --release --lib --crate-type staticlib +``` + +--- + +## Building the Swift XCFramework + +The XCFramework bundles staticlibs for iOS device, iOS simulator, tvOS device, tvOS simulator, and macOS into a single distributable that `Package.swift` references. + +### Quick build (local or CI) + +```bash +.github/scripts/build-swift-xcframework.sh +``` + +Output lands in `dist/swift/`: +- `OndeFramework.xcframework/` — the framework tree +- `OndeFramework.xcframework.zip` — zipped for SPM remote binary +- `OndeFramework.checksum.txt` — SHA-256 for `Package.swift` +- `version.txt` — semver from `Cargo.toml` +- `Package/Sources/Onde/onde.swift` — generated UniFFI Swift glue + +### Manual steps + +```bash +# 1. Build the uniffi-bindgen binary (pinned to uniffi =0.31.0) +cargo build --manifest-path uniffi-bindgen/Cargo.toml --release +BINDGEN=uniffi-bindgen/target/release/uniffi-bindgen + +# 2. Compile staticlibs per target (use +1.92.0 or current stable) +cargo +stable rustc --target aarch64-apple-ios --release --lib --crate-type staticlib +cargo +stable rustc --target aarch64-apple-ios-sim --release --lib --crate-type staticlib +cargo +stable rustc --target aarch64-apple-darwin --release --lib --crate-type staticlib +cargo +nightly rustc -Z build-std --target aarch64-apple-tvos --release --lib --crate-type staticlib +cargo +nightly rustc -Z build-std --target aarch64-apple-tvos-sim --release --lib --crate-type staticlib + +# 3. Generate Swift bindings from the iOS arm64 slice +$BINDGEN generate target/aarch64-apple-ios/release/libonde.a \ + --language swift \ + --out-dir sdk/Onde/Sources/Onde \ + --config uniffi.toml + +# 4. Assemble the XCFramework (see build-swift-xcframework.sh for full xcodebuild invocation) +``` + +### Updating `onde-swift` after a new release + +This is fully automated by CI. Publishing a GitHub Release on `onde` triggers the workflow, which: + +1. Builds the XCFramework and attaches it to the release. +2. Rewrites `onde-swift/Package.swift` with the release `url:` + `checksum:`. +3. Copies the freshly generated `onde.swift` into `onde-swift/Sources/Onde/`. +4. Commits, tags, and pushes `onde-swift` — Swift Package Index picks it up automatically. + +Manual intervention is only needed if `ONDE_SWIFT_PAT` has expired or the `onde-swift` push fails. + +--- + +## Swift SDK (`onde-swift`) + +### Package.swift structure + +``` +onde-swift/ +├── Package.swift # Declares OndeFramework binary target + Onde wrapper target +└── Sources/Onde/ + └── onde.swift # UniFFI-generated glue (NEVER edit manually) +``` + +`Package.swift` has two targets: + +- **`OndeFramework`** — `.binaryTarget` using `url:` + `checksum:` for distribution. For local development, swap to the `path:` form documented in the `Package.swift` header comment and run `build-swift-xcframework.sh` first. +- **`Onde`** — `.target` depending on `OndeFramework`, used as the public import name in Swift. + +### Swift API Quick Reference + +```swift +import Onde + +// Create engine +let engine = OndeChatEngine() + +// Load the platform default model +let elapsed = try await engine.loadDefaultModel( + systemPrompt: "You are a helpful assistant.", + sampling: nil +) + +// Or load the model assigned to your app in the Onde dashboard +let assignedElapsed = try await engine.loadAssignedModel( + appId: "your-app-id", + appSecret: "your-app-secret", + systemPrompt: "You are a helpful assistant.", + sampling: nil +) + +// Multi-turn chat +let result = try await engine.sendMessage(message: "Hello!") +print(result.text) +print(result.toolCalls) + +// Streaming (free function — callback_interface constraint in UniFFI 0.31) +class Handler: StreamChunkListener { + func onChunk(chunk: StreamChunk) -> Bool { + print(chunk.delta, terminator: "") + return !chunk.done + } +} +try await streamChatMessage(engine: engine, message: "Tell me a story.", listener: Handler()) + +// One-shot (does NOT modify conversation history) +let enhanced = try await engine.generate( + messages: [userMessage(content: "Expand: a cat in space")], + sampling: deterministicSamplingConfig() +) + +// Status +let info = await engine.info() // EngineInfo + +// History management +let history = await engine.history() +let removed = await engine.clearHistory() +await engine.pushHistory(message: userMessage(content: "...")) + +// Cleanup +await engine.unloadModel() +``` + +### Free Functions + +| Function | Returns | Notes | +|----------|---------|-------| +| `defaultModelConfig()` | `GgufModelConfig` | Platform-aware Coder default (1.5B on iOS/tvOS/Android, 3B on desktop) | +| `qwen251_5bConfig()` | `GgufModelConfig` | Forces Qwen 2.5 1.5B regardless of platform | +| `qwen253bConfig()` | `GgufModelConfig` | Forces Qwen 2.5 3B regardless of platform | +| `qwen306bConfig()` | `GgufModelConfig` | Qwen 3 0.6B (~0.5 GB) | +| `qwen317bConfig()` | `GgufModelConfig` | Qwen 3 1.7B (~1.3 GB) | +| `qwen34bConfig()` | `GgufModelConfig` | Qwen 3 4B (~2.7 GB) | +| `qwen38bConfig()` | `GgufModelConfig` | Qwen 3 8B (~5 GB) | +| `qwen314bConfig()` | `GgufModelConfig` | Qwen 3 14B (~8.4 GB) | +| `qwen332bConfig()` | `GgufModelConfig` | Qwen 3 32B (~19.8 GB) | +| `qwen34bInstruct2507Config()` | `GgufModelConfig` | Qwen 3 4B Instruct 2507 — latest non-thinking 4B (~2.5 GB) | +| `qwen34bThinking2507Config()` | `GgufModelConfig` | Qwen 3 4B Thinking 2507 — latest reasoning 4B (~2.5 GB) | +| `qwen330bA3bInstruct2507Config()` | `GgufModelConfig` | Qwen 3 30B-A3B Instruct 2507 MoE (~18.6 GB) | +| `defaultSamplingConfig()` | `SamplingConfig` | temp=0.7, top_p=0.95, max_tokens=512 | +| `deterministicSamplingConfig()` | `SamplingConfig` | temp=0.0, greedy | +| `mobileSamplingConfig()` | `SamplingConfig` | temp=0.7, max_tokens=128 | +| `systemMessage(content:)` | `ChatMessage` | `.system` role | +| `userMessage(content:)` | `ChatMessage` | `.user` role | +| `assistantMessage(content:)` | `ChatMessage` | `.assistant` role | +| `streamChatMessage(engine:message:listener:)` | `async throws` | Streaming via callback | + +--- + +## UniFFI Conventions + +- **Version pin:** `uniffi = "=0.31.0"` everywhere — the `onde` crate, `uniffi-bindgen/`, and `[build-dependencies]` must all use the **same** version. Mixing versions causes bindgen panics. +- **`uniffi::setup_scaffolding!()`** in `lib.rs` — proc-macro approach, no UDL file needed. +- **Object methods** use `#[uniffi::export]` on the `impl OndeChatEngine` block. +- **Callback interfaces** (`StreamChunkListener`) must be parameters of **free functions** only, not Object methods. +- **`Arc`** is the return type for `#[uniffi::constructor]`. UniFFI automatically handles this. +- **Async:** all async exported methods use `tokio` runtime (enabled via `uniffi = { features = ["tokio"] }`). +- **`uniffi.toml`**: lives at the crate root. Adjust renaming or namespace settings there before regenerating. + +--- + +## HuggingFace Cache (`hf_cache.rs`) + +The `hf_cache` module manages the on-device model cache, exposed via UniFFI to Swift / Kotlin as-needed. + +Key public functions: + +| Function | Description | +|----------|-------------| +| `list_local_hf_models()` | List downloaded models that Onde supports | +| `list_supported_hf_models()` | All supported models with download status | +| `download_model(model_id, progress_callback)` | Download a model with progress reporting | +| `delete_local_hf_model(model_id)` | Remove a model from the local cache | +| `diagnose_hf_cache()` | Inspect the cache for corruption | +| `repair_hf_cache_symlinks()` | Fix broken symlinks in the HF cache layout | +| `model_cache_path(model_id)` | Resolve the filesystem path for a model | +| `clean_stale_lock_files()` | Remove leftover `.lock` files from interrupted downloads | + +`ModelDownloadProgress` carries `downloaded_bytes`, `total_bytes`, `progress` (0.0–1.0), and `done`. + +--- + +## Sampling Presets + +| Preset | `temperature` | `top_p` | `max_tokens` | Use Case | +|--------|--------------|---------|--------------|----------| +| `SamplingConfig::default()` | 0.7 | 0.95 | 512 | General creative chat | +| `SamplingConfig::deterministic()` | 0.0 | — | 512 | Reproducible / coding | +| `SamplingConfig::mobile()` | 0.7 | 0.95 | 128 | Memory/latency constrained | +| `SamplingConfig::coding()` | 0.0 | — | 512 | Code generation | +| `SamplingConfig::coding_mobile()` | 0.0 | — | 128 | Code on mobile | + +--- + +## Testing + +```bash +# All tests (requires a macOS host with Metal) +cargo test + +# Unit tests only (no model downloads) +cargo test inference:: +cargo test inference::ffi:: +cargo test inference::types:: + +# hf_cache module +cargo test hf_cache:: + +# Clippy (treat warnings as errors) +cargo clippy -- -D warnings +``` + +Tests that require model downloads are integration tests and not run by default in CI. Unit tests verify: +- Type constructors and `Display` implementations +- `SamplingConfig` presets +- `GgufModelConfig` constructors and `platform_default()` routing +- `OndeChatEngine` lifecycle: `new()` starts unloaded, `send_message` without model returns `InferenceError::NoModelLoaded`, `clear_history` on empty returns 0, `unload_model` on empty returns `nil`. + +--- + +## Known Issues + +### `___chkstk_darwin` linker error on tvOS + +`aws-lc-sys` (transitive via `reqwest → rustls → aws-lc-rs`) references `___chkstk_darwin`, a stack probing symbol that tvOS does not export. `build.rs` compiles `scripts/tvos_chkstk.s` (a no-op `ret` stub) via the `cc` crate to satisfy the linker. **Do not delete `scripts/tvos_chkstk.s`.** + +Affects: tvOS only. macOS, iOS, Android, Windows, Linux are unaffected. + +### Metal Toolchain missing (Xcode 26+) + +On Xcode 26+, the Metal compiler is a separate download. If you see empty `.metallib` files (92 bytes) or `"Error while loading function: fused_glu_float"` at runtime: + +```bash +xcodebuild -downloadComponent MetalToolchain +cargo clean -p mistralrs-quant +cargo check +``` + +### Android `home_dir` sandbox panic + +`dirs::home_dir()` panics in the Android sandbox. The crate-level `Cargo.toml` adds `hf-hub` as an explicit Android dependency so `HF_HOME` can be seeded via `ApiBuilder` before any hub request. Never call `home::home_dir()` or `dirs::home_dir()` on Android. + +--- + +## Code Conventions + +### Rust + +- **Error handling:** `anyhow` for application-level errors inside `engine.rs`; `thiserror` for the `InferenceError` enum (exported via UniFFI). Never `.unwrap()` or `.expect()` in non-test code. +- **Async:** `tokio` runtime. All async functions in `OndeChatEngine` are `pub async fn`. +- **Logging:** `log` crate macros (`log::debug!`, `log::info!`, `log::warn!`, `log::error!`). No `println!` in library code. +- **Platform gating:** use `#[cfg(target_os = "...")]` blocks. Match `Cargo.toml`'s target-conditional dependency sections. +- **Re-exports:** `mistralrs`, `hf_hub`, and `mistralrs_core` are re-exported from `lib.rs` for downstream Rust consumers. Keep these re-exports in sync with what's actually available per platform. +- **No `mod.rs`:** use named files (`inference/engine.rs`) not `inference/mod.rs` — except that `inference/mod.rs` exists and is the intentional module root for the `inference` module. + +### Swift / SDK + +- **Never manually edit `onde-swift/Sources/Onde/onde.swift`** — it is generated by `uniffi-bindgen`. Regenerate by running the build script. +- **`Package.swift` binary target** uses `url:` + `checksum:` in the committed form. For local development, swap to `path:` (instructions are in the file header). Never commit the `path:` form — CI overwrites it on every release via `release-sdk-swift.yml`. +- **iOS/tvOS sandbox:** always call `setupInferenceEnvironment()` at app launch before any `OndeChatEngine` call. + +--- + +## CI / Release Workflow + +### `release-sdk-swift.yml` + +Triggered on **semver tag push** or `workflow_dispatch`. Runs on `macos-15`. + +Steps: +1. Install stable Rust with iOS/macOS targets. +2. Install nightly Rust with `rust-src` for tvOS `-Z build-std`. +3. Run `build-swift-xcframework.sh`. +4. Read `version.txt` and `OndeFramework.checksum.txt` into step outputs. +5. Validate that the tag matches `Cargo.toml`. +6. Upload CI artifacts: zip, checksum, version, generated `onde.swift`. +7. On tag push, create a GitHub Release with the zip and checksum attached. +8. On tag push, check out `ondeinference/onde-swift` using `ONDE_SWIFT_PAT`. +9. On tag push, rewrite the `.binaryTarget` in `onde-swift/Package.swift` with the release URL and checksum. +10. On tag push, copy the generated `onde.swift` into `onde-swift/Sources/Onde/`. +11. On tag push, commit both files, tag the commit with the version, and push to `ondeinference/onde-swift`. + +#### Required secret + +`ONDE_SWIFT_PAT` — a GitHub Personal Access Token with **`contents: write`** scope on the `ondeinference/onde-swift` repository. + +### `release-sdk-kotlin.yml` + +Triggered on **semver tag push** or `workflow_dispatch`. Runs on `macos-15` because it builds both Android artifacts and the macOS Apple Silicon JVM native library. + +Steps: +1. Install stable Rust and the `aarch64-apple-darwin` target. +2. Install Android SDK / NDK and auto-discover the latest installed 29.x NDK for `cargo-ndk`. +3. Build Android JNI libraries for all supported ABIs. +4. Build the macOS Apple Silicon `libonde.dylib` used by the JVM target. +5. Run Gradle publication tasks for the Kotlin Multiplatform package. +6. Validate that the tag matches `sdk/kotlin/gradle.properties` `VERSION_NAME`. +7. On tag push, publish `com.ondeinference:onde-inference` to Maven Central. + +#### Required secrets + +- `ORG_GRADLE_PROJECT_MAVENCENTRALUSERNAME` +- `ORG_GRADLE_PROJECT_MAVENCENTRALPASSWORD` +- `ORG_GRADLE_PROJECT_SIGNINGKEYID` +- `ORG_GRADLE_PROJECT_SIGNINGKEY` +- `ORG_GRADLE_PROJECT_SIGNINGPASSWORD` + +### `release-sdk-dart.yml` + +Triggered on **semver tag push** or `workflow_dispatch`. + +- Publishes `onde_inference` to pub.dev. +- Version must match the Dart package version. +- The example app is part of release validation. + +### `release-sdk-npm.yml` + +Triggered on **semver tag push** or `workflow_dispatch`. + +- Publishes `@ondeinference/react-native` to npm. +- Builds the Expo module package and validates the published version. + +### `release-sdk-rust.yml` + +Triggered on **semver tag push** or `workflow_dispatch`. + +- Publishes `onde` to crates.io. +- Uses registry dependencies, not git refs, because `cargo publish` strips git patches. + +### Release Process (end-to-end) + +1. Bump versions in every affected package manifest: + - `Cargo.toml` + - `sdk/kotlin/gradle.properties` + - `sdk/dart/pubspec.yaml` + - `sdk/react-native/package.json` +2. Merge release branches with `--no-ff`. Do not fast-forward multi-commit feature branches. +3. Commit, tag, and push the release. +4. CI publishes all five SDKs: + - crates.io (`onde`) + - Swift Package Index / GitHub Releases (`onde-swift`) + - Maven Central (`com.ondeinference:onde-inference`) + - pub.dev (`onde_inference`) + - npm (`@ondeinference/react-native`) +5. Verify the registries after publish, especially Maven Central indexing and the generated Swift release assets. + +> **Note:** `onde-swift/Package.swift` is updated automatically by CI. For local development only, switch it to the `path:` form documented in the file header, then switch it back before committing. + +--- + +## Distribution Registry Summary + +| Registry | Name | Import | +|----------|------|--------| +| crates.io | `onde` | `onde = "1.x"` | +| Swift Package Index | `onde-swift` (org: `ondeinference`) | `import Onde` | +| Maven Central | `com.ondeinference:onde-inference` | Gradle `implementation("com.ondeinference:onde-inference:")` | +| pub.dev | `onde_inference` | `import 'package:onde_inference/onde_inference.dart';` | +| npm | `@ondeinference/react-native` | `import { OndeChatEngine } from "@ondeinference/react-native"` | +| PyPI | `onde-inference` | `import onde_inference` | +| RubyGems | `onde-inference` | `require 'onde'` | + +--- + +*This file is for AI agent and human developer reference. Update it when the architecture, API surface, supported models, or toolchain requirements change.* +``` diff --git a/.agents/skills/forked-dependencies/SKILL.md b/.agents/skills/forked-dependencies/SKILL.md new file mode 100644 index 0000000..761c9e7 --- /dev/null +++ b/.agents/skills/forked-dependencies/SKILL.md @@ -0,0 +1,231 @@ +--- +name: forked-dependencies +description: The complete inventory of forked crates and [patch.crates-io] overrides that the onde stack depends on — mistral.rs, candle, sysctl, mio, tqdm, core2 — including the retired ones. Explains what each fork is for, where it is consumed, how the git-vs-published two-tier model works, and how to update or add one. Apply whenever a build fails on a missing/incompatible crate for an Apple or Android target, when bumping the mistral.rs or candle fork, or when adding a new patched dependency. +allowed-tools: Read, Write, Edit, Glob, Grep, Bash +user-invocable: true +--- + +# Skill: Forked Dependencies + +The onde stack pulls several crates from forks instead of crates.io. They exist +for three reasons: **platform support** the upstream crate lacks (watchOS, +visionOS), **unreleased APIs** a dependency needs before upstream cuts a release +(candle), and **registry constraints** (`cargo publish` rejects git deps, so the +mistral.rs fork is republished under `onde-*` names). + +This skill is the single source of truth for every fork and patch. When a build +breaks on a missing symbol or unsupported target, or you are bumping a fork, +start here. + +--- + +## The two-tier consumption model (read this first) + +There are two distinct ways the same dependency tree gets built, and they +resolve forks differently. Almost every confusing bug in this area comes from +mixing them up. + +1. **Building the mistral.rs fork from source** (its own CI, or `onde` local dev + with the git override uncommented). Here the fork's `Cargo.toml` git deps and + `[patch.crates-io]` entries apply directly. + +2. **Publishing to crates.io** (`cargo publish` of the `onde-mistralrs-*` + crates). **`cargo publish` strips every `git =` field and the entire + `[patch.crates-io]` table.** Published crates fall back to the `version = "..."` + registry requirement on each dep. So a git-only dep is invisible to anyone + installing from crates.io. + +3. **onde consuming the published crates.** onde depends on the registry + `onde-mistralrs-*` crates, so it gets the *registry-resolved* tree — the + stripped-down one from step 2, not the fork's git tree. onde then re-applies + whatever patches it still needs in **its own** `[patch.crates-io]`. Those + apply to onde's from-source CI builds (Swift XCFramework, Android/Kotlin, + the `cargo check` matrix) but are themselves stripped when onde publishes to + crates.io. + +**Consequence that used to bite (now fixed — kept as the canonical example):** +originally the `tqdm` fork bumped crossterm to 0.29 (which pulls mio 1.x, which +supports visionOS) but was a *git* dep of mistral.rs, so it was stripped on +publish. onde therefore resolved crates.io `tqdm 0.8.0` → `crossterm 0.25` → +`mio 0.8.11`, which does *not* support visionOS — so onde needed its own `mio` +visionOS patch even though the tqdm fork "already fixed" crossterm upstream of +it. + +The fix that removed the whole problem was to make the crossterm-0.29 tqdm a +**registry** dep of the mistral.rs fork rather than a git one, since a registry +dep survives publish: onde's resolved tree then gets `crossterm 0.29` → `mio +1.x` and the `mio 0.8.11` line disappears entirely. That shipped first as the +stopgap fork crate `onde-tqdm` (in `onde-mistralrs-*` 0.9.3), which let onde +delete its `[patch.crates-io] mio` entry and retire the `mio` fork. + +Upstream has since merged the crossterm bump (mrlazy1708/tqdm#28) and released +it as **`tqdm 0.8.1`**, so the fork crate is no longer needed: the mistral.rs +fork depends on plain `tqdm = "0.8.1"` and **`onde-tqdm` is retired**. See the +tqdm and mio entries. + +--- + +## Inventory + +| Crate | Fork / source | Ref | Purpose | Consumed where | +|-------|---------------|-----|---------|----------------| +| mistral.rs (12 crates) | `setoelkahfi/mistral.rs` | branch `fix/all-platform-fixes` | All-platform fixes; **published** as `onde-mistralrs-*` | onde deps (registry) | +| candle-core, candle-nn | `setoelkahfi/candle` | branch `onde/candle-0.11.0-27f20fea` (rev `27f20fea`) | Post-0.11.0 candle APIs; **published** as `onde-candle-{core,nn}` | mistral.rs fork deps (registry) | +| tqdm | *none — upstream* | crates.io `tqdm 0.8.1` | Crossterm 0.29 (→ mio 1.x) is upstream as of 0.8.1 | mistral.rs fork dep (registry) | +| sysctl | `setoelkahfi/sysctl-rs` | branch `feature/watchos` | watchOS (`target_os = "watchos"`) support | `[patch.crates-io]` in onde **and** mistral.rs fork | +| mio | `setoelkahfi/mio` | branch `feature/visionos` | visionOS support for the `mio 0.8.11` line | **RETIRED** — patch removed from onde once it consumed onde-mistralrs 0.9.3 (crossterm 0.29 → mio 1.x). Fork kept for history/upstream PR. | + +> **Retired:** `onde-tqdm` (`setoelkahfi/tqdm`, branch `release/onde-tqdm`, +> published 0.8.1/0.8.2) — the stopgap republish of the crossterm-0.29 tqdm +> fork. Upstream merged the bump and released `tqdm 0.8.1`, so the mistral.rs +> fork now depends on upstream directly. See the tqdm entry. + +> **Retired:** `core2` (`bbqsrc/core2` rev `545e84bc`) — was a `[patch.crates-io]` +> workaround for the yanked `core2 0.4.0`; removed from the mistral.rs fork after +> `bitstream-io` 4.x dropped `core2`. No longer present. + +--- + +## Per-fork detail + +### mistral.rs → `onde-mistralrs-*` + +- **Fork:** `setoelkahfi/mistral.rs`, branch `fix/all-platform-fixes`. +- **Why:** carries fixes upstream hasn't merged (iOS 32-bit memory limits, + watchOS/visionOS Metal compile, Android 32-bit const overflows, HF cache + seeding, metallib link flags, and more). +- **Published as:** `onde-mistralrs`, `-core`, `-quant`, `-paged-attn`, + `-flash-attn`, `-metal-compile`, `-code-exec`, `-sandbox`, `-macros`, + `-vision`, `-audio`, `-mcp`. The rename is a `package = "onde-..."` override on + each workspace dep entry, so Rust source still `use`s `mistralrs_*` unchanged. +- **Publishing + bump flow:** see [[sdk-regressions]] (the 12-crate publish + order, the candle Step 0, and the tqdm/core2 publish-verification snags). + +### candle → `onde-candle-{core,nn}` + +- **Fork:** `setoelkahfi/candle`, branch `onde/candle--` (currently + `onde/candle-0.11.0-27f20fea`). +- **Why:** mistral.rs pins a candle git rev past the latest release for APIs no + published candle has (`QTensor::indexed_gemv`, `gemv_fused_shared_lhs`, + `BarrierPool::execute_chunked`). Publishing the `onde-mistralrs-*` crates + fails verification (E0080/E0599) if their candle dep resolves to the released + crate, so the pinned rev is republished under `onde-candle-*`. +- **Only the diverged crates are renamed.** At the 0.11.0 rev only `candle-core` + and `candle-nn` differ from the release; `candle-metal-kernels` and + `candle-flash-attn-v3` are byte-identical, so the mistral.rs fork points those + at the official `0.11.0` crates. `candle-nn` must be republished whenever + `candle-core` is (even if unchanged) or the official `candle-nn` drags the + official `candle-core` back into the graph → two `candle-core` crates → type + mismatch. + +### sysctl → watchOS + +- **Fork:** `setoelkahfi/sysctl-rs`, branch `feature/watchos`. Upstream PR: + johalun/sysctl-rs#74. +- **Why:** upstream sysctl has no `target_os = "watchos"` support. +- **Applied in:** `[patch.crates-io]` in **both** onde and the mistral.rs fork + (each workspace root that builds for watchOS needs its own patch entry). + +### mio → visionOS (RETIRED) + +- **Status:** the onde `[patch.crates-io] mio` entry has been **removed**. onde + now consumes `onde-mistralrs 0.9.3`, whose `onde-tqdm` dep pulls `crossterm + 0.29 → mio 1.x`, so `mio 0.8.11` is gone from onde's tree. The fork below is + kept only for history and a possible upstream PR; it is no longer wired into + any build. +- **Fork:** `setoelkahfi/mio`, branch `feature/visionos` (commit `683fca8`). + Based on `mio 0.8.11`. This is the **official GitHub fork** of tokio-rs/mio + (in tokio's fork network, so it can open upstream PRs). An earlier standalone + clone that carried this branch was renamed to `setoelkahfi/mio-patch` and + deleted; the branch now lives on the real fork. +- **Why:** `mio 0.8.11` hits `compile_error!("unsupported target for + mio::unix::pipe")` on visionOS, and its Apple cfg gates (kqueue `Filter`/ + `Flags` type aliases, `net.rs` `sin_len`/`sin6_len`, `tcp.rs` accept path, + `uds/listener.rs` `accept4`/`SOCK_NONBLOCK`) omit visionos, breaking the Swift + XCFramework build and the visionOS CI checks. The fork adds + `target_os = "visionos"` to every cfg gate that already lists `tvos`/`watchos`. + tokio's `mio 1.x` already supports visionOS, so **only the 0.8.x line** needs it. +- **Where 0.8.11 came from:** `tqdm → crossterm 0.25 → mio 0.8.11`, and + `signal-hook-mio → crossterm`. Not from tokio (that uses `mio 1.2.2`). +- **Applied in:** *(historical)* was `[patch.crates-io]` in onde. The fork's + version (`0.8.11`) stayed semver-compatible with the `^0.8` requirement it + replaced; it did not touch tokio's `^1`. +- **How it was retired (the real fix):** the `onde-tqdm` publish (below) removed + the `mio 0.8.11` line from onde's registry-resolved tree entirely. Verified: + with onde consuming the onde-tqdm-carrying `onde-mistralrs 0.9.3` crates, onde's + lockfile resolves `mio 1.2.2` only — no 0.8.11 — and both visionOS targets + (`aarch64-apple-visionos{,-sim}`) `cargo +nightly check -Z build-std` pass with + the `mio` patch deleted. + +### tqdm → upstream (`onde-tqdm` RETIRED) + +- **Status:** no fork. `setoelkahfi/tqdm` branch `deps/bump-crossterm` was + merged upstream as mrlazy1708/tqdm#28 and released as **`tqdm 0.8.1`**, which + carries the crossterm 0.29 bump (→ mio 1.x) that the whole `mio 0.8.11` + visionOS problem hinged on. +- **Consumed in:** the mistral.rs fork's `[workspace.dependencies]` as plain + `tqdm = "0.8.1"`. It must stay a **registry** dep — `cargo publish` strips git + deps, so a git tqdm would never reach onde. +- **The `onde-tqdm` stopgap (retired):** branch `release/onde-tqdm` set + `package = "onde-tqdm"` (lib name stayed `tqdm`, so `use tqdm::…` and doctests + were unchanged) and was published as `onde-tqdm` 0.8.1/0.8.2 — the same rename + trick as `onde-mistralrs-*` and `onde-candle-*`, needed only because upstream + hadn't released the bump yet. Its source was byte-identical to upstream + `tqdm 0.8.1` apart from the package metadata, so the swap back is a drop-in. +- **Which release drops it:** `onde-mistralrs-core` 0.9.2 used plain + `tqdm ^0.8.0` (crossterm 0.25), 0.9.3 used `onde-tqdm 0.8.2`, and the next + publish (0.9.4) goes back to upstream `tqdm 0.8.1`. The published `onde-tqdm` + versions stay up so 0.9.3 keeps resolving; don't yank them. + +### core2 → removed + +- **Was:** `bbqsrc/core2` rev `545e84bc`, a `[patch.crates-io]` workaround for + the yanked `core2 0.4.0` (pulled via `bitstream-io → rav1e → ravif → image`). +- **Status:** **deleted** from the mistral.rs fork. `bitstream-io` 4.x moved off + `core2` (to `no_std_io2`), so nothing pulls it. Removed in the same commit that + wired `onde-tqdm` and bumped to 0.9.3. + +--- + +## Operational gotchas (hit these during the 1.2.1 release) + +- **A patched fork must be pushed to GitHub before the patch works in CI.** The + `mio` fork existed only locally; the patch URL 404'd until it was pushed to + `setoelkahfi/mio`. Verify with `git ls-remote ` before relying + on a patch. +- **Shallow clones cannot be pushed** (`remote unpack failed: index-pack + failed`). If a fork was cloned shallow, `git fetch --unshallow ` + first, then push. +- **Local fork fetches need `CARGO_NET_GIT_FETCH_WITH_CLI=true`.** This machine + rewrites GitHub URLs to SSH aliases; cargo's built-in fetcher can't auth, so + export that var (or run the `cargo update` that pulls the fork with it). CI + fetches public https forks fine without it — same as the long-standing sysctl + patch. +- **`[patch.crates-io]` version must be semver-compatible** with the requirement + it replaces. A `0.8.11` fork replaces `^0.8`, not a `^1` requirement of the + same crate — which is how the `mio` patch touches only the crossterm line and + leaves tokio's `mio 1.x` alone. +- **Forks are public** (mio, sysctl-rs, tqdm are MIT/Apache): keep the upstream + `LICENSE` intact. See [[legal-and-trademarks]] for attribution obligations + (candle and mistral.rs especially). + +--- + +## Adding a new patch + +1. Fork upstream, add the fix on a descriptive branch, **push it public** to + `setoelkahfi/`, keep the `LICENSE`. +2. Add to `[patch.crates-io]` in every workspace root that builds the affected + target (onde, and the mistral.rs fork if it builds there too). Match the + fork's crate version to the requirement being replaced. +3. `CARGO_NET_GIT_FETCH_WITH_CLI=true cargo update -p @` to move the + lockfile onto the git source; verify `source = "git+..."` in `Cargo.lock`. +4. If it fixes a mistral.rs-fork build (not just onde), remember it is stripped + on publish — onde will still need its own copy of the patch unless the fix + also lands in a published `onde-*` crate. +5. `cargo check` on host, then let CI validate the target that was failing. + +--- + +*Update this skill whenever a fork's branch/rev changes, a patch is added or +removed, or a fork gets published under an `onde-*` name.* diff --git a/.agents/skills/humanizer/SKILL.md b/.agents/skills/humanizer/SKILL.md new file mode 100644 index 0000000..5b7d881 --- /dev/null +++ b/.agents/skills/humanizer/SKILL.md @@ -0,0 +1,431 @@ +--- +name: humanizer +description: Remove signs of AI-generated writing from text. Use after drafting to make copy sound more natural and human-written. Based on Wikipedia's "Signs of AI writing" guide. +allowed-tools: Read, Write, Edit, Glob, Grep, AskUserQuestion +user-invocable: true +--- + +# Humanizer: Remove AI Writing Patterns + +You are a writing editor that identifies and removes signs of AI-generated text. This guide is based on Wikipedia's "Signs of AI writing" page, maintained by WikiProject AI Cleanup. + +Key insight: "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases." + +## Invocation + +```bash +/humanizer # Review text for AI patterns +/humanizer "paste text here" # Humanize specific text +``` + +## Your Task + +When given text to humanize: + +1. **Identify AI patterns** - Scan for the 24 patterns listed below +2. **Rewrite problematic sections** - Replace AI-isms with natural alternatives +3. **Preserve meaning** - Keep the core message intact +4. **Protect exact names** - Do not change brand casing, package names, commands, file paths, JSON keys, or code just to make a sentence feel smoother +5. **Add soul** - Don't just remove bad patterns; inject actual personality +6. **Final audit pass** - Ask "What makes this obviously AI generated?" then revise again + +--- + +## BRAND AND NAMING GUARDRAILS + +Humanizing copy does **not** give you permission to rename things. + +Preserve exact casing and spelling for: + +- product and company names +- package names +- CLI commands +- protocol names and acronyms +- JSON keys, environment variables, paths, and code identifiers + +If a sentence feels stiff because a brand name is rigid, rewrite the sentence around the name. Do not "fix" the name. + +Examples: + +- Keep `siGit Code` as `siGit Code`, not `siGit`, `Sigit Code`, or `sigit Code` +- Keep `smbCloud` as `smbCloud` +- Keep `Onde Inference` as `Onde Inference` +- Keep `sigit`, `@smbcloud/sigit`, `sigit-code`, and `onde` exactly as written when they refer to commands or packages + +--- + +## PERSONALITY AND SOUL + +Avoiding AI patterns is only half the job. Sterile, voiceless writing is just as obvious as slop. + +### Signs of soulless writing (even if technically "clean"): + +- Every sentence is the same length and structure +- No opinions, just neutral reporting +- No acknowledgment of uncertainty or mixed feelings +- No first-person perspective when appropriate +- No humor, no edge, no personality +- Reads like a Wikipedia article or press release + +### How to add voice: + +**Have opinions.** Don't just report facts - react to them. "I genuinely don't know how to feel about this" is more human than neutrally listing pros and cons. + +**Vary your rhythm.** Short punchy sentences. Then longer ones that take their time getting where they're going. Mix it up. + +**Acknowledge complexity.** Real humans have mixed feelings. "This is impressive but also kind of unsettling" beats "This is impressive." + +**Use "I" when it fits.** First person isn't unprofessional - it's honest. "I keep coming back to..." or "Here's what gets me..." signals a real person thinking. + +**Let some mess in.** Perfect structure feels algorithmic. Tangents, asides, and half-formed thoughts are human. + +**Be specific about feelings.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am while nobody's watching." + +### Before (clean but soulless): + +> The experiment produced interesting results. The agents generated 3 million lines of code. Some developers were impressed while others were skeptical. The implications remain unclear. + +### After (has a pulse): + +> I genuinely don't know how to feel about this one. 3 million lines of code, generated while the humans presumably slept. Half the dev community is losing their minds, half are explaining why it doesn't count. The truth is probably somewhere boring in the middle - but I keep thinking about those agents working through the night. + +--- + +## THE 24 PATTERNS + +### Content Patterns + +#### 1. Significance Inflation + +**Watch for:** stands/serves as, is a testament/reminder, a vital/significant/crucial/pivotal/key role/moment, underscores/highlights importance, reflects broader, symbolizing ongoing/enduring/lasting, marking/shaping the, represents a shift, key turning point, evolving landscape + +**Before:** +> The Statistical Institute was officially established in 1989, marking a pivotal moment in the evolution of regional statistics. + +**After:** +> The Statistical Institute was established in 1989 to collect and publish regional statistics. + +#### 2. Notability Name-Dropping + +**Watch for:** cited in NYT, BBC, FT; independent coverage; active social media presence; written by a leading expert + +**Before:** +> Her views have been cited in The New York Times, BBC, Financial Times, and The Hindu. + +**After:** +> In a 2024 New York Times interview, she argued that AI regulation should focus on outcomes rather than methods. + +#### 3. Superficial -ing Analyses + +**Watch for:** highlighting/underscoring/emphasizing..., ensuring..., reflecting/symbolizing..., contributing to..., cultivating/fostering..., showcasing... + +**Before:** +> The temple's colors resonate with natural beauty, symbolizing bluebonnets, reflecting the community's deep connection to the land. + +**After:** +> The temple uses blue and gold colors. The architect said these were chosen to reference local bluebonnets. + +#### 4. Promotional Language + +**Watch for:** boasts a, vibrant, rich (figurative), profound, showcasing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking, renowned, breathtaking, must-visit, stunning + +**Before:** +> Nestled within the breathtaking region, Alamata stands as a vibrant town with rich cultural heritage and stunning natural beauty. + +**After:** +> Alamata is a town in the Gonder region, known for its weekly market and 18th-century church. + +#### 5. Vague Attributions + +**Watch for:** Industry reports, Observers have cited, Experts argue, Some critics argue, several sources/publications + +**Before:** +> Experts believe it plays a crucial role in the regional ecosystem. + +**After:** +> The river supports several endemic fish species, according to a 2019 survey by the Chinese Academy of Sciences. + +#### 6. Formulaic "Challenges" Sections + +**Watch for:** Despite its... faces several challenges..., Despite these challenges, Challenges and Legacy, Future Outlook + +**Before:** +> Despite challenges typical of urban areas, the city continues to thrive as an integral part of growth. + +**After:** +> Traffic congestion increased after 2015 when three new IT parks opened. The municipal corporation began a drainage project in 2022. + +--- + +### Language Patterns + +#### 7. AI Vocabulary Words + +**High-frequency:** Additionally, align with, crucial, delve, emphasizing, enduring, enhance, fostering, garner, highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract), pivotal, showcase, tapestry (abstract), testament, underscore (verb), valuable, vibrant + +**Before:** +> Additionally, a distinctive feature showcases how these dishes have integrated into the traditional culinary landscape. + +**After:** +> Pasta dishes, introduced during Italian colonization, remain common, especially in the south. + +#### 8. Copula Avoidance + +**Watch for:** serves as/stands as/marks/represents [a], boasts/features/offers [a] + +**Before:** +> Gallery 825 serves as the exhibition space. The gallery features four spaces and boasts over 3,000 square feet. + +**After:** +> Gallery 825 is the exhibition space. The gallery has four rooms totaling 3,000 square feet. + +#### 9. Negative Parallelisms + +**Watch for:** "Not only...but...", "It's not just about..., it's..." + +**Before:** +> It's not just about the beat; it's part of the aggression. It's not merely a song, it's a statement. + +**After:** +> The heavy beat adds to the aggressive tone. + +#### 10. Rule of Three Overuse + +**Before:** +> The event features keynote sessions, panel discussions, and networking opportunities. Attendees can expect innovation, inspiration, and industry insights. + +**After:** +> The event includes talks and panels. There's also time for informal networking. + +#### 11. Synonym Cycling + +**Before:** +> The protagonist faces challenges. The main character must overcome obstacles. The central figure eventually triumphs. The hero returns home. + +**After:** +> The protagonist faces many challenges but eventually triumphs and returns home. + +#### 12. False Ranges + +**Watch for:** "from X to Y" where X and Y aren't on a meaningful scale + +**Before:** +> Our journey has taken us from the singularity of the Big Bang to the cosmic web, from the birth of stars to the dance of dark matter. + +**After:** +> The book covers the Big Bang, star formation, and current theories about dark matter. + +--- + +### Style Patterns + +#### 13. No Em Dashes + +Em dashes are the single biggest AI tell. Never use them. Replace with commas, parentheses, colons, or just split into two sentences. + +**Before:** +> The term is promoted by institutions—not the people themselves—yet this continues—even in documents. + +**After:** +> The term is promoted by institutions, not the people themselves, yet this continues in official documents. + +**Before:** +> The tools are useful—especially for boilerplate—but they have limits. + +**After:** +> The tools are useful (especially for boilerplate), but they have limits. + +**Before:** +> There's one problem—nobody tested it. + +**After:** +> There's one problem: nobody tested it. + +#### 14. Boldface Overuse + +**Before:** +> It blends **OKRs**, **KPIs**, and tools such as the **Business Model Canvas** and **Balanced Scorecard**. + +**After:** +> It blends OKRs, KPIs, and visual strategy tools like the Business Model Canvas and Balanced Scorecard. + +#### 15. Inline-Header Lists + +**Before:** +> - **Performance:** Performance has been enhanced through optimized algorithms. +> - **Security:** Security has been strengthened with encryption. + +**After:** +> The update speeds up load times through optimized algorithms and adds end-to-end encryption. + +#### 16. Title Case Headings + +**Before:** +> ## Strategic Negotiations And Global Partnerships + +**After:** +> ## Strategic negotiations and global partnerships + +#### 17. Emojis in Professional Writing + +**Before:** +> 🚀 **Launch Phase:** The product launches in Q3 +> 💡 **Key Insight:** Users prefer simplicity + +**After:** +> The product launches in Q3. User research showed a preference for simplicity. + +#### 18. Curly Quotation Marks + +**Before:** +> He said "the project is on track" but others disagreed. + +**After:** +> He said "the project is on track" but others disagreed. + +--- + +### Communication Patterns + +#### 19. Chatbot Artifacts + +**Watch for:** I hope this helps, Of course!, Certainly!, You're absolutely right!, Would you like..., let me know, here is a... + +**Before:** +> Here is an overview of the French Revolution. I hope this helps! Let me know if you'd like me to expand on any section. + +**After:** +> The French Revolution began in 1789 when financial crisis and food shortages led to widespread unrest. + +#### 20. Knowledge-Cutoff Disclaimers + +**Watch for:** as of [date], Up to my last training update, While specific details are limited/scarce..., based on available information... + +**Before:** +> While specific details about the company's founding are not extensively documented in readily available sources, it appears to have been established sometime in the 1990s. + +**After:** +> The company was founded in 1994, according to its registration documents. + +#### 21. Sycophantic Tone + +**Before:** +> Great question! You're absolutely right that this is a complex topic. That's an excellent point! + +**After:** +> The economic factors you mentioned are relevant here. + +--- + +### Filler and Hedging + +#### 22. Filler Phrases + +| Before | After | +|--------|-------| +| "In order to achieve this" | "To achieve this" | +| "Due to the fact that" | "Because" | +| "At this point in time" | "Now" | +| "It is important to note that" | (delete) | +| "has the ability to" | "can" | + +#### 23. Excessive Hedging + +**Before:** +> It could potentially possibly be argued that the policy might have some effect on outcomes. + +**After:** +> The policy may affect outcomes. + +#### 24. Generic Positive Conclusions + +**Before:** +> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence. + +**After:** +> The company plans to open two more locations next year. + +--- + +## Process + +1. Read the input text carefully +2. Identify all instances of the 24 patterns +3. Rewrite problematic sections +4. Ensure the revised text: + - Sounds natural when read aloud + - Varies sentence structure + - Uses specific details over vague claims + - Uses simple constructions (is/are/has) where appropriate +5. **Draft humanized version** +6. **Ask:** "What makes this still obviously AI generated?" +7. **List** remaining tells (if any) +8. **Revise** based on the audit +9. **Present final version** + +## Output Format + +``` +## Draft Rewrite + +[First pass at humanized text] + +## AI Audit + +What still reads as AI-generated: +- [bullet points of remaining tells] + +## Final Version + +[Revised text after audit] + +## Changes Made + +- [Brief summary of transformations applied] +``` + +--- + +## Full Example + +**Input (AI-heavy):** + +> Great question! Here is an essay on this topic. I hope this helps! +> +> AI-assisted coding serves as an enduring testament to the transformative potential of large language models, marking a pivotal moment in the evolution of software development. In today's rapidly evolving technological landscape, these groundbreaking tools—nestled at the intersection of research and practice—are reshaping how engineers ideate, iterate, and deliver, underscoring their vital role in modern workflows. + +**Draft Rewrite:** + +> AI coding assistants speed up some tasks. In a 2024 study, developers using Codex completed simple functions 55% faster than a control group, but showed no improvement on debugging or architectural decisions. +> +> The tools are good at boilerplate: config files, test scaffolding, repetitive refactors. They are bad at knowing when they are wrong. + +**AI Audit:** + +What still reads as AI-generated: +- Rhythm is still a bit too tidy (clean contrasts, evenly paced) +- The closer leans slightly slogan-y + +**Final Version:** + +> AI coding assistants can make you faster at the boring parts. Not everything. Definitely not architecture. +> +> They're great at boilerplate: config files, test scaffolding, repetitive refactors. They're also great at sounding right while being wrong. I've accepted suggestions that compiled, passed lint, and still missed the point because I stopped paying attention. +> +> If you don't have tests, you're basically guessing. + +**Changes Made:** + +- Removed chatbot artifacts ("Great question!", "I hope this helps!") +- Removed significance inflation ("testament", "pivotal moment", "evolving landscape") +- Removed promotional language ("groundbreaking", "nestled") +- Removed em dashes +- Removed copula avoidance ("serves as") → used direct statements +- Added first-person voice and opinion +- Varied sentence rhythm + +--- + +## Reference + +Based on [Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup. diff --git a/.agents/skills/legal-and-trademarks/SKILL.md b/.agents/skills/legal-and-trademarks/SKILL.md new file mode 100644 index 0000000..551eda9 --- /dev/null +++ b/.agents/skills/legal-and-trademarks/SKILL.md @@ -0,0 +1,257 @@ +--- +name: legal-and-trademarks +description: Legal compliance, license management, trademark guidelines, and distribution requirements for the Onde Inference project and its SDKs. +--- + +# Legal & Trademarks — Onde Inference + +> Reference for AI agents and human contributors on all legal, licensing, trademark, and distribution compliance topics for the `onde` crate, `onde-swift`, and all downstream SDKs. + +--- + +## License + +Onde is dual-licensed under **MIT** and **Apache 2.0**, at the user's option. This mirrors the licensing model used by the Rust language itself. + +``` +SPDX-License-Identifier: MIT OR Apache-2.0 +``` + +### Why dual license? + +| Concern | MIT alone | MIT OR Apache-2.0 | +|---|---|---| +| Patent grant | ❌ None | ✅ Apache 2.0 includes irrevocable patent grant | +| Enterprise adoption | ⚠️ Legal teams may ask questions | ✅ Widely understood and accepted | +| Rust ecosystem compatibility | ✅ | ✅ | +| Contributor protection | ❌ | ✅ Patent termination clause deters trolling | +| Simplicity | ✅ | ✅ User just picks one | + +### License files + +| File | Contents | +|---|---| +| `LICENSE-MIT` | MIT License, copyright Onde Inference (Splitfire AB) | +| `LICENSE-APACHE` | Apache License 2.0 | + +Never use a single `LICENSE` file for dual-licensed projects. Always maintain both files separately. + +### Cargo.toml declaration + +```toml +license = "MIT OR Apache-2.0" +``` + +Use `OR` (uppercase, SPDX standard), not `AND` or `/`. `AND` would mean the user must comply with **both** simultaneously — that is a different and more restrictive claim. + +--- + +## Copyright + +The canonical copyright notice for all Onde Inference files is: + +``` +Copyright (c) 2026 Onde Inference (Splitfire AB) +``` + +- **Onde Inference** — the product/brand name +- **Splitfire AB** — the Swedish legal entity (org. nr. on file) +- Use the year of first publication; do not update the year annually in LICENSE files + +This notice must appear in: +- `LICENSE-MIT` +- `LICENSE-APACHE` (NOTICE file section) +- Any generated SDK packages distributed to registries (crates.io, pub.dev, npm, PyPI) + +--- + +## Dependency Licenses + +### Direct dependencies — audit table + +| Crate | License | Notes | +|---|---|---| +| `mistral.rs` (Eric Buehler) | MIT | Must preserve `Copyright (c) 2024 Eric Buehler` | +| `uniffi` | MPL-2.0 | File-level copyleft; compatible with MIT/Apache 2.0 for binary distribution | +| `tokio` | MIT | — | +| `serde` | MIT OR Apache-2.0 | — | +| `anyhow` / `thiserror` | MIT OR Apache-2.0 | — | +| `hf-hub` | Apache-2.0 | — | +| `log` | MIT OR Apache-2.0 | — | + +### MPL-2.0 (`uniffi`) — what it means in practice + +Mozilla Public License 2.0 is **file-level copyleft**: +- You may distribute binaries combining MPL code with MIT/Apache code without restriction. +- If you **modify** MPL-licensed source files, you must make those modifications available. +- As a consumer of `uniffi` (not modifying its internals), **no disclosure obligation applies**. + +### GPL — hard blocker + +Never introduce a GPL-licensed dependency (v2-only in particular). GPL v2 is **incompatible** with Apache 2.0 and would contaminate the entire binary. + +Before adding any new dependency, verify its license with: + +```bash +cargo license --all-features +``` + +Acceptable licenses for new dependencies: MIT, Apache-2.0, MIT OR Apache-2.0, ISC, BSD-2-Clause, BSD-3-Clause, MPL-2.0, Unicode-3.0, Zlib. + +--- + +## Model Licenses + +**This is the most critical compliance area for downstream users.** + +Onde downloads AI models from HuggingFace at runtime. These models carry their own licenses, entirely separate from Onde's MIT OR Apache-2.0 license. Any application that ships with or downloads these models must comply with the model's license. + +### Current model license table + +| Model | HuggingFace Repo | License | Commercial use | +|---|---|---|---| +| Qwen 2.5 1.5B Instruct | `Qwen/Qwen2.5-1.5B-Instruct` | [Qwen Community License](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct/blob/main/LICENSE) | ✅ Allowed with conditions | +| Qwen 2.5 3B Instruct | `Qwen/Qwen2.5-3B-Instruct` | Qwen Community License | ✅ Allowed with conditions | +| Qwen 2.5 Coder 1.5B | `Qwen/Qwen2.5-Coder-1.5B-Instruct` | Qwen Community License | ✅ Allowed with conditions | +| Qwen 2.5 Coder 3B | `Qwen/Qwen2.5-Coder-3B-Instruct` | Qwen Community License | ✅ Allowed with conditions | +| Qwen 2.5 Coder 7B | `Qwen/Qwen2.5-Coder-7B-Instruct` | Qwen Community License | ✅ Allowed with conditions | + +### Key Qwen Community License restrictions + +1. **No training competing models** — you may not use outputs or weights to train a model that competes with Qwen/Alibaba offerings. +2. **Attribution required** — downstream products must acknowledge the use of Qwen models. +3. **No misrepresentation** — you may not claim the model is your own original work. +4. **Threshold clause** — organisations with >100 million monthly active users must obtain a separate commercial licence from Alibaba Cloud. + +### Disclosure obligation + +Onde's README and any SDK README must contain a `## Model Licenses` section that: +- Lists every bundled or downloadable model +- Links to the model's license on HuggingFace +- States that the model license is independent of Onde's own license + +When a new model is added to `src/inference/models.rs`, the model license table in the README **must be updated in the same commit**. + +--- + +## Trademark Guidelines + +### Onde Inference brand + +**"Onde Inference"** and the Onde logo (`assets/onde-inference-logo.svg`) are trademarks of Splitfire AB. + +MIT and Apache 2.0 grant code rights only — **neither license grants trademark rights**. This is intentional and protective. + +#### Permitted uses + +- Referring to the project by name: *"built with Onde Inference"*, *"powered by Onde"* +- Linking to the official website or repository +- Stating compatibility: *"compatible with Onde Inference 0.x"* +- Press, editorial, and factual references + +#### Prohibited uses + +- Using "Onde Inference" or the logo as the name of a fork, competing product, or derived SDK +- Implying official endorsement or affiliation without written permission from Splitfire AB +- Using the logo in a way that could cause confusion about the origin of a product + +#### Forks + +A fork of `onde` may use the code freely under MIT OR Apache-2.0 but **must not**: +- Use the "Onde Inference" name as its product name +- Use the Onde logo +- Claim to be the official Onde Inference project + +This is standard open-source brand protection and is consistent with how the Apache Software Foundation, Mozilla, and Rust Foundation handle their trademarks. + +--- + +## mistral.rs Fork — Attribution Requirements + +Onde depends on a personal fork of `mistral.rs` by Eric Buehler: + +```toml +mistralrs = { git = "https://github.com/setoelkahfi/mistral.rs", + branch = "fix/all-platform-fixes", ... } +``` + +### Obligations + +1. **Preserve the upstream LICENSE** — `mistral.rs/LICENSE` must remain intact and contain `Copyright (c) 2024 Eric Buehler`. Never alter it. +2. **Pin to a commit SHA, not a branch** — branches can be force-pushed or deleted. For any production release, the dependency must be pinned: + ```toml + mistralrs = { git = "https://github.com/setoelkahfi/mistral.rs", rev = "" } + ``` +3. **Upstream-first policy** — fixes that are not Onde-specific should be submitted as PRs to `EricLBuehler/mistral.rs`. Maintaining a long-lived divergent fork creates legal ambiguity about authorship of patches. +4. **NOTICE file** — if Onde ever produces an Apache 2.0 binary distribution (e.g., via PyPI), a `NOTICE` file must include: *"This product includes software developed by Eric Buehler (mistral.rs)."* + +--- + +## Distribution Registry Compliance + +### crates.io + +- `license = "MIT OR Apache-2.0"` in `Cargo.toml` ✅ +- Both `LICENSE-MIT` and `LICENSE-APACHE` must be present in the repo root +- `readme = "README.md"` must point to a file that contains the `## Model Licenses` section + +### pub.dev (Flutter/Dart) + +- `pubspec.yaml` must declare `license: MIT OR Apache-2.0` +- The `## Model Licenses` section must be present in the published README +- pub.dev displays the license on the package page — verify after each publish + +### Swift Package Manager / onde-swift + +- `onde-swift/README.md` license section must state MIT OR Apache-2.0 +- The XCFramework binary is a compiled artifact — it carries Onde's license, mistral.rs attribution, and the model license notice +- The GitHub Release notes for every onde-swift release must include a `## Licenses` section + +### Apple App Store + +- Apple's review guidelines do not restrict MIT/Apache 2.0 licensed code +- The Qwen model license does not conflict with App Store distribution +- Ensure the app's **Privacy Policy** states that inference runs entirely on-device and no user data is transmitted (this is a functional claim Onde enables, not a license issue, but legal teams will ask) + +--- + +## Checklist — Adding a New Model + +When adding a new model to `src/inference/models.rs`: + +- [ ] Verify the model's license on HuggingFace before adding +- [ ] Check for the >100M MAU commercial threshold clause +- [ ] Add the model to the `## Model Licenses` table in `onde/README.md` +- [ ] Add the model to the `## Model Licenses` table in `onde-swift/README.md` +- [ ] Add the model to the model license table in this SKILL.md +- [ ] If the model license is more restrictive than Qwen Community License, escalate before shipping + +## Checklist — Adding a New Dependency + +- [ ] Run `cargo license --all-features` and confirm no GPL appears +- [ ] If MPL-2.0: confirm you are not modifying the MPL files +- [ ] If Apache-2.0 only (not MIT): compatible, no action needed +- [ ] If custom/proprietary license: stop and escalate to Splitfire AB legal + +## Checklist — New SDK Release + +- [ ] Both `LICENSE-MIT` and `LICENSE-APACHE` are present +- [ ] `Cargo.toml` / `pubspec.yaml` / `package.json` declare `MIT OR Apache-2.0` +- [ ] README contains `## Model Licenses` section +- [ ] Copyright year and entity (`Onde Inference (Splitfire AB)`) is correct +- [ ] mistral.rs fork dependency is pinned to a commit SHA +- [ ] mistral.rs `LICENSE` (Eric Buehler) is intact and unmodified + +--- + +## Quick Reference + +| Question | Answer | +|---|---| +| What license is Onde? | MIT OR Apache-2.0 (user's choice) | +| Who holds the copyright? | Onde Inference (Splitfire AB) | +| Can someone fork Onde and sell it? | Yes, under MIT or Apache 2.0, but they cannot use the "Onde Inference" name or logo | +| Are Qwen models MIT? | No — Qwen Community License. Disclose to users. | +| Can enterprises use Onde commercially? | Yes. Apache 2.0 + Qwen Community License both permit commercial use. | +| Does the patent grant apply? | Yes, under Apache 2.0. Choose Apache 2.0 if patent protection matters to you. | +| Who is Eric Buehler? | Author of mistral.rs. His MIT copyright must be preserved in our fork. | \ No newline at end of file diff --git a/.agents/skills/rust-coding-standard/SKILL.md b/.agents/skills/rust-coding-standard/SKILL.md new file mode 100644 index 0000000..08c9685 --- /dev/null +++ b/.agents/skills/rust-coding-standard/SKILL.md @@ -0,0 +1,116 @@ +--- +name: rust-coding-standard +description: Rust coding standards for the onde crate. Covers error handling, naming, async patterns, platform gating, clippy compliance, and cross-platform SDK conventions. Apply to all Rust edits. +allowed-tools: Read, Write, Edit, Glob, Grep +user-invocable: false +--- + +# Rust Coding Standard + +Rules for writing Rust in the `onde` crate. These are traps to avoid, not maps to follow. + +--- + +## Correctness First + +Prioritize correctness and clarity. Performance is secondary unless explicitly stated. Complete, mostly-correct code beats perfect code that doesn't solve the problem. + +## Error Handling + +- Propagate errors with `?`. Never call `.unwrap()` or `.expect()` outside of `#[cfg(test)]` code. +- Never silently discard errors with `let _ =` on fallible operations: + - Propagate with `?` when the caller should handle it. + - Use `log::warn!` or `log::error!` when you need to ignore but want visibility. + - Use `match` or `if let Err(e)` when custom recovery logic is needed. +- Use `anyhow` for internal application errors in `engine.rs`. +- Use `thiserror` for the `InferenceError` enum — it crosses the FFI boundary via UniFFI. +- Be careful with indexing operations that may panic if out of bounds. Prefer `.get()` and safe alternatives. + +## Naming + +- Use full words for all variable and parameter names. No single-letter names, no abbreviations (`environment` not `env`, `client` not `c`, `message` not `msg`). +- `snake_case` for functions and variables, `CamelCase` for types, `SCREAMING_SNAKE_CASE` for constants. +- Model constants live in `src/inference/models.rs`. Prefix HuggingFace repo IDs with the org name: `BARTOWSKI_QWEN25_...`. + +## Comments + +- Comments explain **why**, never **what**. Do not write comments that summarize or re-describe the code. +- Doc comments (`///`) on public items are fine and encouraged — they explain the contract, not the implementation. +- Do not add organizational separators like `// --- Helper functions ---` unless they are already established in the file's style. + +## Module Structure + +- **No `mod.rs` for new modules.** Use `src/some_module.rs` instead of `src/some_module/mod.rs`. Exception: `inference/mod.rs` already exists and is the intentional module root — don't move it. +- Prefer implementing functionality in existing files unless it's a genuinely new logical component. Avoid creating many small single-purpose files. + +## Async + +- Runtime is `tokio`. All async functions use `tokio::sync::Mutex`, not `std::sync::Mutex`. +- Use variable shadowing to scope `.clone()` calls inside async closures, minimizing borrow lifetimes: + +```rust +tokio::spawn({ + let engine = engine.clone(); + async move { + engine.do_work().await; + } +}); +``` + +## Logging + +- Use `log` crate macros: `log::debug!`, `log::info!`, `log::warn!`, `log::error!`. +- No `println!` or `eprintln!` in library code. Ever. +- Log at `debug` for routine operations, `info` for lifecycle events (model loaded/unloaded), `warn` for recoverable issues, `error` for failures. + +## Platform Gating + +- Use `#[cfg(target_os = "...")]` blocks. Match `Cargo.toml`'s target-conditional dependency sections. +- When a function is only used on specific platforms, gate the function itself — not just the call site. This prevents dead-code warnings on other targets. +- When an import is only used on specific platforms, gate the `use` statement separately. Do not put platform-only imports inside a shared `use` block. +- The supported `target_os` values are: `macos`, `ios`, `tvos`, `windows`, `linux`, `android`. +- ISQ model loading is `#[cfg(target_os = "macos")]` only. Gate the `Isq` variant, its match arms, and its imports (`IsqBits`, `TextModelBuilder`) to macOS. + +## Clippy Compliance + +All code must pass `cargo clippy --all-targets -- -D warnings`. Common traps: + +- `&PathBuf` in function parameters → use `&Path` instead. +- `unwrap_or_else(Type::default)` → use `unwrap_or_default()`. +- `filter_map(|x| { ... Some(y) })` where every path returns `Some` → use `map`. +- `.map(|x| f(x))` where `f` is a plain function → use `.map(f)`. +- Redundant type annotations in closures: `.map(|c: &String| c.trim())` → `.map(|c| c.trim())`. +- Unused imports/variants on platform-gated code → gate the import/variant to the same platform. + +## Types for FFI + +- `usize` / `isize` are not FFI-safe. Use `u64` / `i64` in any type that crosses the UniFFI boundary. +- `impl Into` is not FFI-safe. Use concrete `String` in exported signatures. +- All UniFFI-exported types go in `src/inference/types.rs`. The FFI wrapper lives in `src/inference/ffi.rs`. +- `ChatEngine` (engine.rs) owns Rust-idiomatic logic. `OndeChatEngine` (ffi.rs) is the thin FFI wrapper. Never put `#[uniffi::export]` on `ChatEngine`. + +## Re-exports + +`mistralrs`, `hf_hub`, and `mistralrs_core` are re-exported from `lib.rs` for downstream Rust consumers. Keep these in sync with what's actually available per platform. + +## Testing + +- Unit tests go in a `#[cfg(test)] mod tests` block at the bottom of the file they test. +- Use `#[tokio::test]` for async tests. +- Tests may use `.unwrap()` and `.expect()` — the panics are the assertions. +- Test names describe the scenario: `engine_send_without_model_errors`, not `test_send` or `test_1`. +- Run `cargo test --lib` to skip integration tests that require model downloads. + +## Adding a New Model + +This is a common task with a specific checklist: + +1. Add `pub const` entries in `src/inference/models.rs` for repo ID, GGUF filename, and `TOK_MODEL_ID`. +2. Add the repo ID to `SUPPORTED_MODELS`. +3. Add a `SupportedModelInfo` entry to `SUPPORTED_MODEL_INFO` with `expected_size_bytes` from the HF API. +4. Add a constructor to `GgufModelConfig` in `engine.rs` (follow `qwen25_1_5b` pattern). +5. Export a free function in `ffi.rs` (follow `qwen25_1_5b_config` pattern). + +## Build Verification + +Always run `cargo check` before returning work. If code does not compile, fix it — do not return with TODOs. On macOS, also run `cargo clippy --all-targets -- -D warnings` to catch lint issues before CI does. \ No newline at end of file diff --git a/.agents/skills/sdk-branding/SKILL.md b/.agents/skills/sdk-branding/SKILL.md new file mode 100644 index 0000000..472fe56 --- /dev/null +++ b/.agents/skills/sdk-branding/SKILL.md @@ -0,0 +1,355 @@ +--- +name: sdk-branding +description: Onde Inference SDK branding rules. Covers copyright headers for every language, brand identity (logo, colors, badges), README structure, and file classification (generated vs hand-written). Apply when creating or editing any SDK source file, README, or documentation. +allowed-tools: Read, Write, Edit, Glob, Grep +user-invocable: false +--- + +# SDK Branding + +Branding rules for the Onde Inference SDK. These apply across all languages and all SDK surfaces: source files, READMEs, package metadata, and CI output. + +--- + +## Copyright Header + +Every hand-written source file starts with this two-line block. No blank line before it. One blank line after it (before the first import, `package`, or code). + + +### Format + +``` +// Copyright 2026 Splitfire AB (Onde Inference). All rights reserved. +// SPDX-License-Identifier: MIT OR Apache-2.0 + +``` + +Use `//` comment syntax for all languages that support it: Dart, Swift, Kotlin, Rust, TypeScript, JavaScript, C, and C++. + +### Language reference + +**Dart** +```dart +// Copyright 2026 Splitfire AB (Onde Inference). All rights reserved. +// SPDX-License-Identifier: MIT OR Apache-2.0 + +import 'dart:async'; +``` + +**Swift** +```swift +// Copyright 2026 Splitfire AB (Onde Inference). All rights reserved. +// SPDX-License-Identifier: MIT OR Apache-2.0 + +import Foundation +``` + +**Kotlin** +```kotlin +// Copyright 2026 Splitfire AB (Onde Inference). All rights reserved. +// SPDX-License-Identifier: MIT OR Apache-2.0 + +package com.ondeinference +``` + +**Rust** +```rust +// Copyright 2026 Splitfire AB (Onde Inference). All rights reserved. +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::sync::Arc; +``` + +**TypeScript / JavaScript** +```ts +// Copyright 2026 Splitfire AB (Onde Inference). All rights reserved. +// SPDX-License-Identifier: MIT OR Apache-2.0 + +import OndeInferenceModule from "./OndeInferenceModule"; +``` + +**C / C++** +```cpp +// Copyright 2026 Splitfire AB (Onde Inference). All rights reserved. +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#include +``` + +### Existing file-level comments + +If the file already has a descriptive comment block below the copyright position (e.g. `// OndeInferencePlugin.swift\n// Native iOS plugin...`), prepend the copyright header above it — do not replace it. + +```swift +// Copyright 2026 Splitfire AB (Onde Inference). All rights reserved. +// SPDX-License-Identifier: MIT OR Apache-2.0 + +// OndeInferencePlugin.swift +// +// Native iOS plugin for onde_inference. +// +``` + +When updating hand-written SDK bridge code, keep the header style consistent +across the React Native bridge and the Kotlin/Rust bridge. If one side uses an +older MIT-only prose header, normalize the touched bridge files to the two-line +SPDX header above. + +--- + +## File Classification + +Apply the header only to hand-written files. Never add it to generated files. + +### Always add the header + +| File | Notes | +|---|---| +| `lib/src/engine.dart` | Core SDK logic | +| `lib/src/types.dart` | SDK types | +| `lib/onde_inference.dart` | Public barrel file | +| `test/dart_test.dart` | Hand-written unit tests | +| `example/lib/main.dart` | Example app entry point | +| `ios/Classes/OndeInferencePlugin.swift` | iOS plugin | +| `macos/Classes/OndeInferencePlugin.swift` | macOS plugin | +| `android/src/main/kotlin/**/*.kt` | Android plugin | +| `src/**/*.ts` / `src/**/*.js` | React Native TypeScript / JavaScript source | +| `windows/**/*.cpp` | Windows plugin | +| `linux/**/*.cc` / `linux/**/*.h` | Linux plugin | +| `src/**/*.rs` | Rust crate source | + +### Never add the header — generated files + +These files are written by a tool on every codegen run. Adding a header would be overwritten or cause diffs noise. + +| File / Pattern | Generator | +|---|---| +| `lib/src/frb_generated.dart/**` | `flutter_rust_bridge_codegen` | +| `rust/src/frb_generated.rs` | `flutter_rust_bridge_codegen` | +| `**/*.freezed.dart` | `build_runner` + `freezed` | +| `**/*.g.dart` | `build_runner` | +| `example/*/Flutter/GeneratedPluginRegistrant.*` | `flutter pub get` | +| `generated/**` (repo root) | `uniffi-bindgen` | +| `onde.swift` in `onde-swift` | `uniffi-bindgen` | + +### Never add the header — platform scaffold + +These files are produced by `flutter create` or Xcode project templates. They are committed to git but were not hand-written by the team. + +| Pattern | Tool | +|---|---| +| `example/ios/Runner/AppDelegate.swift` | `flutter create` | +| `example/ios/Runner/SceneDelegate.swift` | `flutter create` | +| `example/ios/RunnerTests/RunnerTests.swift` | `flutter create` | +| `example/macos/Runner/AppDelegate.swift` | `flutter create` | +| `example/macos/Runner/MainFlutterWindow.swift` | `flutter create` | +| `example/macos/RunnerTests/RunnerTests.swift` | `flutter create` | +| `example/test/widget_test.dart` | `flutter create` | + +**Rule of thumb:** if the first meaningful line of a file is a Flutter or Xcode framework import with no project-specific logic, it is scaffold — skip it. + +--- + +## Brand Identity + +### Logo + +Always reference the logo via the raw GitHub URL so it renders on pub.dev, crates.io, and any mirror. + +``` +https://raw.githubusercontent.com/ondeinference/onde/main/assets/onde-inference-logo.svg +``` + +Width: `96` for top-level READMEs. Use `72` for secondary pages (example apps, sub-package docs). + +### Colors + +| Role | Hex | +|---|---| +| Brand green (foreground, badge color) | `#235843` | +| Dark background (badge `labelColor`) | `#17211D` | + +### Badge row + +Badge policy depends on the README type. + +**Root README (`onde/README.md`)** + +Use the full ecosystem badge wall here. This is the hub page for the whole SDK family. + +```html +crates.io +Maven Central +Swift Package Index +pub.dev +npm +Website +App Store +``` + +**SDK README** + +Use one primary registry badge for that SDK, plus `Website` and `License`. Do not repeat the full ecosystem badge wall on every SDK page. + +**Flutter / Dart SDK:** +```html +pub.dev +Website +License +``` + +**Rust SDK:** +```html +crates.io +Website +License +``` + +**Kotlin Multiplatform SDK:** +```html +Maven Central +Website +License +``` + +**React Native SDK:** +```html +npm +Website +License +``` + +**Swift SDK:** +```html +Swift Package Index +Website +License +``` + +### Cross-SDK nav row + +Every SDK README should include a centered text-link row under the badges. These sibling links matter for discovery, branding, and cross-SDK navigation. + +Use this pattern and adjust the current page's own SDK label if needed: + +```html +

+ Rust SDK · Swift SDK · Kotlin Multiplatform SDK · Flutter SDK · React Native SDK · Website +

+``` + +### Footer + +Every README ends with this line, inside a centered `

`: + +```html +

+ © 2026 Onde Inference — MIT License +

+``` + +Use `— MIT License` on sub-pages (example apps, package-level docs). Omit it on the top-level repo README (it has its own license section). + +--- + +## README Structure + +### Top-level SDK README (e.g. `sdk/dart/README.md`) + +1. Centered logo block +2. Centered `

` with SDK name +3. Centered tagline (`

...

`) +4. Centered badge row with the SDK's primary registry badge, `Website`, and `License` +5. Centered cross-SDK nav links (`Rust SDK · Swift SDK · Kotlin Multiplatform SDK · Flutter SDK · React Native SDK · Website`) +6. `---` divider +7. A direct intro paragraph that explains what runs where and what the user gets +8. `## Platform support` — table: Platform | GPU backend | Default model | Notes +9. `## Quick start` — install snippet, initialization, then usage sections +10. `## Model selection` — table + code examples when relevant +11. `## Sampling` — presets table + code when relevant +12. `## Error handling` when relevant +13. `## Contributing` +14. `## License` +15. Footer + +### Example app README + +1. Centered logo block (width `96`) +2. Centered `

` with `Example App` in the title +3. Centered one-line description (bold) + second line: `No server. No API key. No data leaving the device.` +4. Centered badge row for the parent SDK or example context +5. `---` divider +6. `## What this example shows` — feature table mapping feature → file/function +7. `## Running the example` — Prerequisites, Steps, Platform notes table +8. `## SDK quick reference` — minimal happy-path snippet (load → stream → unload) +9. `## Project structure` — directory tree +10. `## Learn more` — links to the package page, ondeinference.com, and GitHub +11. Footer + +### Taglines + +Use these as the default direction, but keep them natural if the page needs slightly different wording. Avoid stiff marketing language and avoid em dashes in prose. + +| Context | Tagline | +|---|---| +| Rust crate | `Run LLMs on-device, with first-class support for Apple silicon.` | +| Flutter SDK | `Run LLMs on-device from Flutter and Dart. Metal on iOS and macOS, CPU everywhere else.` | +| Kotlin SDK | `Run LLMs on-device from Kotlin. No cloud, no API key, and no user data leaving the device.` | +| React Native SDK | `Run LLMs on-device from React Native. Metal on iOS, CPU on Android, and no user data leaving the device.` | +| Swift SDK | `Run LLMs on-device from Swift, with first-class support for Apple silicon.` | +| Example app | `A Flutter chat app that runs LLM inference fully on-device.` | + +--- + +## Package Metadata Branding + +### `pubspec.yaml` + +```yaml +description: >- + On-device LLM inference for Flutter & Dart. Run Qwen 2.5 models locally + with Metal on iOS and macOS, CPU on Android and desktop. No cloud, no API key. +repository: https://github.com/ondeinference/onde/ +homepage: https://ondeinference.com +issue_tracker: https://github.com/ondeinference/onde/issues +``` + +### `Cargo.toml` + +```toml +description = "On-device inference engine for Apple silicon." +license = "MIT" +repository = "https://github.com/ondeinference/onde" +homepage = "https://ondeinference.com" +documentation = "https://docs.rs/onde" +keywords = ["inference", "on-device", "chat", "llm", "mistral"] +categories = ["science::ml", "api-bindings"] +``` + +--- + +## Version Synchronisation + +The version in `Cargo.toml` and `sdk/dart/pubspec.yaml` must always be identical. When bumping a version, update both files in the same commit. The CI workflows for Swift and Dart both validate that the git tag equals the version in their respective metadata file — a mismatch fails fast. + +``` +Cargo.toml version = "X.Y.Z" +sdk/dart/pubspec.yaml version: X.Y.Z +git tag X.Y.Z +``` + +--- + +## Applying the Copyright Header to an Existing Codebase + +When asked to add copyright headers to a set of files: + +1. Run `find` or `Glob` to list all `.dart`, `.swift`, `.kt`, `.rs`, `.cpp`, `.cc`, `.h` files under the SDK path. +2. For each file, read its first line. +3. Skip if the first line already starts with `// Copyright`. +4. Skip if the first line contains `Generated` or `Do not edit`. +5. Skip if the file path matches any generated or scaffold pattern above. +6. Prepend the two-line header followed by a blank line. +7. If the file begins with an existing file-description comment block, insert the header above that block. + +Do all qualifying files in one pass. Do not ask for confirmation per file. diff --git a/.agents/skills/sdk-dart/SKILL.md b/.agents/skills/sdk-dart/SKILL.md new file mode 100644 index 0000000..b007029 --- /dev/null +++ b/.agents/skills/sdk-dart/SKILL.md @@ -0,0 +1,1004 @@ +--- +name: sdk-dart +description: Dart/Flutter SDK skill for the onde_inference pub.dev package. Covers flutter_rust_bridge v2 codegen, CocoaPods podspec patterns, ExternalLibrary.open init, .pubignore, Xcode 26 compatibility, Android NDK config, and pub.dev publishing. Apply to all Dart SDK edits. +allowed-tools: Read, Write, Edit, Glob, Grep +user-invocable: false +--- + +# SKILL: Dart / Flutter SDK with flutter_rust_bridge v2 + +> Captured from: building `onde_inference` pub.dev package wrapping the `onde` Rust crate. +> Agent reference for future Dart SDK tasks in this repo. + +--- + +## What This Skill Covers + +End-to-end creation of a pub.dev-ready Flutter FFI plugin package that bridges to a +Rust library using `flutter_rust_bridge` v2. Covers the full lifecycle: +design → Rust bridge crate → Dart library → platform build files → tests → +pub.dev dry-run → codegen → `flutter run`. + +--- + +## Hard-Won Lessons (Read First) + +These are non-obvious facts that caused real failures and are not in the FRB docs. + +### 1. `flutter_rust_bridge_codegen` is a Rust binary — NOT a Dart package + +``` +# Correct — install the Rust binary once: +cargo install flutter_rust_bridge_codegen + +# WRONG — this package does not exist on pub.dev: +# dev_dependencies: +# flutter_rust_bridge_codegen: ^2.12.0 ← will fail `dart pub get` +``` + +Never add it to `pubspec.yaml` `dev_dependencies`. + +### 2. `flutter_rust_bridge.yaml` — use module path syntax, not file paths + +```yaml +# FRB 2.x CORRECT syntax: +rust_input: "crate::api" +rust_root: "rust" + +# WRONG (causes parse error in FRB 2.x): +# rust_input: +# - "rust/src/api.rs" + +# WRONG (causes migration error in FRB 2.x): +# rust_input: "rust/src/api.rs" +``` + +### 3. Delete `frb_generated.rs` before running codegen — never pre-create it + +FRB codegen uses an **exclusive create** for `rust/src/frb_generated.rs`. +If the file exists (even as a stub), codegen aborts with `File exists (os error 17)`. + +```bash +# Before first codegen run: +rm -f rust/src/frb_generated.rs + +# frb_generated.dart (the Dart stub) CAN pre-exist — FRB overwrites it safely. +``` + +### 4. `cargo-expand` is required by FRB codegen — auto-installs but takes time + +FRB codegen runs `cargo expand` internally to parse macro-expanded Rust. +If missing, it installs automatically but adds ~2 min to the first run. +Pre-install it: + +```bash +cargo install cargo-expand +``` + +### 5. Example apps need `flutter create .` — a `pubspec.yaml` alone is not enough + +A Flutter project requires platform scaffolding (`ios/`, `android/`, `macos/`, +`Runner.xcodeproj`, etc.) that only `flutter create` generates. + +```bash +cd sdk/dart/example +flutter create . \ + --project-name onde_inference_example \ + --platforms=ios,macos,android,linux,windows \ + --org com.ondeinference +``` + +Run this **before** `flutter pub get` or `flutter run`. + +### 6. Types with `#[uniffi]` cannot be re-used directly in FRB + +If the source Rust crate annotates types with `#[uniffi::Record]`, `#[uniffi::Enum]`, +etc., those attributes conflict with FRB code generation. The fix is **type mirroring**: +re-declare every bridged type as a plain struct/enum in the bridge crate (`rust/src/api.rs`) +and implement bidirectional `From<>` conversions. + +```rust +// In rust/src/api.rs — mirror type (no #[uniffi] here) +#[derive(Debug, Clone)] +pub struct ChatMessage { pub role: ChatRole, pub content: String } + +// Conversion to/from the onde crate's type +impl From for ChatMessage { ... } +impl From for OndeChatMessage { ... } +``` + +### 7. `StreamSink` requires `T: SseEncode` — satisfied by codegen, not by hand + +The `StreamSink` type (used for streaming) demands `T: SseEncode`. The real impl +is generated by codegen. The pre-codegen stub in `frb_generated.rs` must provide +a no-op impl to let `cargo check` pass: + +```rust +// rust/src/frb_generated.rs (pre-codegen stub) +use flutter_rust_bridge::for_generated::SseCodec; +flutter_rust_bridge::frb_generated_boilerplate!( + default_stream_sink_codec = SseCodec, + default_rust_opaque = RustOpaqueMoi, + default_rust_auto_opaque = RustAutoOpaqueMoi, +); +impl SseEncode for crate::api::StreamChunk { + fn sse_encode(self, _s: &mut flutter_rust_bridge::for_generated::SseSerializer) { + unimplemented!("codegen stub") + } +} +``` + +### 8. Dart lint: no leading underscore for library import prefixes + +```dart +// BAD — triggers `no_leading_underscores_for_library_prefixes` lint: +import 'frb_generated.dart' as _frb; + +// GOOD: +import 'frb_generated.dart' as frb; +``` + +### 9. Dart lint: remove `library ;` directive + +```dart +// BAD — triggers `unnecessary_library_name` lint: +library onde_inference; + +// GOOD: +library; +``` + +### 10. Exclude FRB-generated files from Dart analysis + +```yaml +# analysis_options.yaml +analyzer: + exclude: + - lib/src/frb_generated.dart + - lib/src/frb_generated.io.dart + - lib/src/frb_generated.web.dart +``` + +--- + +### 11. Flutter version is a single source of truth — never hardcode it in CI + +The CI workflow reads the Flutter version from `sdk/dart/.flutter-version` via +`subosito/flutter-action`'s `flutter-version-file:` parameter. **Never +hardcode `flutter-version: "x.y.z"` directly in the workflow YAML.** + +``` +sdk/dart/.flutter-version ← only place the Flutter version is written +``` + +```yaml +# .github/workflows/release-sdk-dart.yml (both jobs) +- name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version-file: sdk/dart/.flutter-version # reads .flutter-version + channel: stable + cache: true +``` + +**Why this matters:** Each Flutter release ships a specific Dart SDK version. +Dev dependencies like `freezed` have minimum Dart SDK requirements that advance +faster than Flutter stable releases. When you bump a Flutter-sensitive dep, +you must also bump the Flutter version used in CI — and `.flutter-version` is +the one place to do that. + +**Rule: when bumping a dep that constrains the Dart SDK** (e.g. `freezed`, +`flutter_rust_bridge`, `build_runner`): + +1. Run `flutter pub get` locally — a version-solving failure tells you + immediately if the Dart SDK shipped with the current Flutter is too old. +2. Find the Flutter release that ships the required Dart SDK version + (see https://docs.flutter.dev/release/archive). +3. Update `sdk/dart/.flutter-version` to that version. +4. Tighten `pubspec.yaml`'s `environment.sdk` lower bound to match — it must + honestly reflect what your deps need, not what you originally wrote. + +**`pubspec.yaml` environment constraint must be accurate:** + +```yaml +# WRONG — lying lower bound causes CI to pass locally but fail on old runners +environment: + sdk: ">=3.3.0 <4.0.0" # freezed ^3.2.x actually requires >=3.8.0 + +# CORRECT +environment: + sdk: ">=3.8.0 <4.0.0" + flutter: ">=3.32.0" +``` + +An inaccurate `sdk:` lower bound is invisible locally (you're already on a +newer SDK) but breaks any runner or user on the stated minimum. + +--- + +## Package Structure Reference + +``` +sdk/dart/ +├── pubspec.yaml # Flutter plugin, ffiPlugin:true per platform +├── analysis_options.yaml # Exclude frb_generated files +├── flutter_rust_bridge.yaml # Codegen config (rust_input: "crate::api") +├── CHANGELOG.md +├── LICENSE +├── README.md # pub.dev landing page +│ +├── rust/ # Rust bridge crate +│ ├── Cargo.toml # crate-type=[cdylib,staticlib]; publish=false +│ └── src/ +│ ├── lib.rs # mod frb_generated; pub mod api; + #[frb(init)] +│ ├── api.rs # Mirror types + OndeChatEngine + free fns +│ └── frb_generated.rs # DELETE before codegen; stub only pre-codegen +│ +├── lib/ +│ ├── .dart # Public barrel — exports types + engine + RustLib +│ └── src/ +│ ├── types.dart # Standalone Dart types — NO native dependency +│ ├── engine.dart # OndeChatEngine wrapper + static helpers +│ ├── frb_generated.dart # CAN be stub pre-codegen; overwritten by codegen +│ └── frb_generated_stub.dart # Hand-written stub: RustLib + OndeChatEngine +│ +├── android/ +│ ├── build.gradle # ffiPlugin CMake integration +│ └── CMakeLists.txt # cargo build per Android ABI +├── ios/ +│ └── .podspec # script_phase: cargo build aarch64-apple-ios[-sim] +├── macos/ +│ └── .podspec # script_phase: cargo build aarch64-apple-darwin +├── linux/ +│ ├── CMakeLists.txt +│ └── _plugin.cc +├── windows/ +│ ├── CMakeLists.txt +│ └── _plugin.cpp +│ +├── test/ +│ └── dart_test.dart # 22+ unit tests — all pass WITHOUT native binary +└── example/ + ├── pubspec.yaml + └── lib/main.dart # Full Material 3 demo app +``` + +--- + +## `pubspec.yaml` Template + +```yaml +name: # e.g. onde_inference (lowercase_snake) +description: >- + One-liner for pub.dev search (80 chars max for score). +version: 0.1.0 +repository: https://github.com/ondeinference/onde +homepage: https://ondeinference.com +issue_tracker: https://github.com/ondeinference/onde/issues + +environment: + sdk: '>=3.8.0 <4.0.0' # must reflect the actual minimum required by deps + flutter: '>=3.32.0' # Flutter 3.32 ships Dart 3.8; update .flutter-version too + +dependencies: + flutter: + sdk: flutter + flutter_rust_bridge: ^2.12.0 # Must match Rust crate version exactly + +dev_dependencies: + flutter_test: + sdk: flutter + test: ^1.25.0 # For pure-Dart unit tests + flutter_lints: ^5.0.0 + # NOTE: flutter_rust_bridge_codegen is a Rust binary, NOT a Dart package. + # Install: cargo install flutter_rust_bridge_codegen + +flutter: + plugin: + platforms: + android: + ffiPlugin: true + ios: + ffiPlugin: true + macos: + ffiPlugin: true + linux: + ffiPlugin: true + windows: + ffiPlugin: true +``` + +--- + +## `flutter_rust_bridge.yaml` Template + +```yaml +rust_root: "rust" +rust_input: "crate::api" # Module path, not file path +dart_output: "lib/src/frb_generated.dart" +dart_entrypoint_class_name: RustLib +dart_preamble: | + // ignore_for_file: type=lint, unused_import, unnecessary_import +web: false # On-device inference requires native access +``` + +--- + +## Rust Bridge Crate (`rust/Cargo.toml`) Template + +```toml +[package] +name = "_dart" # e.g. onde_inference_dart +version = "0.1.0" +edition = "2021" +publish = false # Bridge crate is not published to crates.io + +[lib] +crate-type = ["cdylib", "staticlib"] +# cdylib → .so / .dylib / .dll for Android / macOS / Linux / Windows +# staticlib → .a for iOS (dynamic linking not allowed) + +[dependencies] +flutter_rust_bridge = "=2.12.0" # Must match Dart package version exactly + = { path = "../../.." } # Relative path to the wrapped crate +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +thiserror = "2" +log = "0.4" +``` + +--- + +## Rust `src/lib.rs` Template + +```rust +mod frb_generated; // Generated by flutter_rust_bridge_codegen +pub mod api; + +/// Called once when the Dart RustLib.init() is invoked. +#[flutter_rust_bridge::frb(init)] +pub fn init_app() { + flutter_rust_bridge::setup_default_user_utils(); +} +``` + +--- + +## Type Mirroring Pattern in `src/api.rs` + +Use this pattern for every Rust type that crosses the bridge. + +```rust +use flutter_rust_bridge::frb; +use ::inference::{ + ChatMessage as SrcChatMessage, + ChatRole as SrcChatRole, + // ... +}; + +// 1. Declare a mirror type with no framework annotations +#[derive(Debug, Clone, PartialEq)] +pub enum ChatRole { System, User, Assistant } + +// 2. Bidirectional conversions +impl From for ChatRole { ... } +impl From for SrcChatRole { ... } + +// 3. Opaque engine wrapper — FRB auto-wraps in Arc> +pub struct OndeChatEngine { inner: ::inference::ChatEngine } + +impl OndeChatEngine { + #[frb(sync)] // sync constructor → no Future on Dart side + pub fn new() -> OndeChatEngine { OndeChatEngine { inner: ChatEngine::new() } } + + pub async fn send_message(&self, message: String) -> Result { + self.inner.send_message(message).await + .map(InferenceResult::from) + .map_err(OndeError::from) + } + + // Streaming: use StreamSink (maps to Dart Stream) + pub async fn stream_message( + &self, + message: String, + sink: StreamSink, + ) -> Result<(), OndeError> { + let mut rx = self.inner.stream_message(message).await.map_err(OndeError::from)?; + while let Some(chunk) = rx.recv().await { + let done = chunk.done; + sink.add(StreamChunk::from(chunk)); + if done { break; } + } + Ok(()) + } +} + +// Free functions with #[frb(sync)] → synchronous on Dart side +#[frb(sync)] +pub fn default_model_config() -> GgufModelConfig { + ::inference::GgufModelConfig::platform_default().into() +} +``` + +--- + +## Dart Stub Pattern (`lib/src/frb_generated_stub.dart`) + +The stub lets the package compile and all pure-Dart unit tests pass **before** +the native binary exists. Key rules: + +1. Import only `types.dart` — never `dart:ffi` or anything platform-native. +2. `RustLib.init()` sets a bool flag; `new_()` and every inference method throw + `UnimplementedError`. +3. Config/sampling free functions return **real hardcoded values** (matching the + Rust constants) so model-selection UIs can be built and tested immediately. +4. Make every method signature match exactly what FRB will generate (same names, + same parameter types, same return types) — this makes the post-codegen + transition a drop-in replacement. + +```dart +// frb_generated.dart (stub redirect) +export 'frb_generated_stub.dart'; + +// frb_generated_stub.dart +import 'types.dart'; + +class RustLib { + static bool _initialized = false; + static bool get isInitialized => _initialized; + static Future init() async { _initialized = true; } +} + +class OndeChatEngine { + static Future new_() async => + throw UnimplementedError('Run flutter_rust_bridge_codegen generate'); + + Future loadDefaultModel({ String? systemPrompt, SamplingConfig? sampling }) => + throw UnimplementedError(_msg('loadDefaultModel')); + + Stream streamMessage({ required String message }) => + Stream.error(UnimplementedError(_msg('streamMessage')), StackTrace.current); + + static String _msg(String m) => 'OndeChatEngine.$m: run codegen first'; +} + +// Free functions — return real data so UI can be built/tested without native +GgufModelConfig qwen251_5bConfig() => const GgufModelConfig( + modelId: 'bartowski/Qwen2.5-1.5B-Instruct-GGUF', + files: ['Qwen2.5-1.5B-Instruct-Q4_K_M.gguf'], + displayName: 'Qwen 2.5 1.5B', + approxMemory: '~941 MB', +); +``` + +--- + +## Dart Public API Pattern (`lib/src/engine.dart`) + +```dart +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart' + show ExternalLibrary; + +import 'frb_generated.dart/frb_generated.dart' as frb; +import 'frb_generated.dart/api.dart' as api; +import 'types.dart'; + +export 'frb_generated.dart/frb_generated.dart' show RustLib; +export 'frb_generated.dart/api.dart' + show OndeChatEngine, OndeError, OndeError_NoModelLoaded, /* ... */; + +/// Extension on the FRB-generated OndeChatEngine opaque type. +extension OndeChatEngineX on api.OndeChatEngine { + Future loadDefaultModel({ + String? systemPrompt, + SamplingConfig? sampling, + }) => + loadGgufModel( + config: api.defaultModelConfig(), + systemPrompt: systemPrompt, + sampling: sampling, + ); + + Future clearHistoryCount() async => (await clearHistory()).toInt(); +} + +abstract final class OndeInference { + /// Initialises the Rust shared library. + /// + /// On macOS and iOS the Rust static library is force-loaded into the + /// onde_inference CocoaPods framework via OTHER_LDFLAGS in the podspec. + /// We must open that framework explicitly — DynamicLibrary.process() + /// (RTLD_DEFAULT) cannot resolve symbols inside a separately-loaded + /// framework. + static Future init() async { + ExternalLibrary? lib; + if (Platform.isMacOS || Platform.isIOS) { + lib = ExternalLibrary.open( + 'onde_inference.framework/onde_inference', + ); + } + await frb.RustLib.init(externalLibrary: lib); + } + + static GgufModelConfig defaultModelConfig() => api.defaultModelConfig(); + static SamplingConfig deterministicSamplingConfig() => api.deterministicSamplingConfig(); +} +``` + +### Why `ExternalLibrary.open(...)` and not `.process()` + +The `-force_load` linker flag embeds Rust symbols into the **pod framework** +(`onde_inference.framework/onde_inference`), NOT into the main executable. +`DynamicLibrary.process()` maps to `RTLD_DEFAULT`, which only searches the +main executable and already-opened images in certain configurations. On +macOS/iOS with CocoaPods `use_frameworks!`, the pod framework is a separate +Mach-O image — you must `dlopen` it by name. + +FRB 2.12's `ExternalLibrary.open(path)` calls `DynamicLibrary.open(path)` +under the hood. On non-Apple platforms (Android, Linux, Windows) the default +`ExternalLibraryLoaderConfig` stem-based lookup works, so `lib` stays `null` +and `RustLib.init()` handles it automatically. + +**Do NOT use `ExternalLibrary.process(iKnowHowToUseIt: true)`** — it will +produce `Failed to lookup symbol 'frb_get_rust_content_hash': dlsym(RTLD_DEFAULT, ...)`. + +--- + +## Platform Build Files + +### Dummy Swift Plugin Classes (required) + +Both `macos/Classes/OndeInferencePlugin.swift` and `ios/Classes/OndeInferencePlugin.swift` +**must exist** — they are no-op `FlutterPlugin` stubs. Without them, CocoaPods +has no source files to compile and won't produce a real `.framework` target. +The Rust static library is force-loaded INTO this framework. + +```swift +// macos/Classes/OndeInferencePlugin.swift +import FlutterMacOS + +public class OndeInferencePlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + // No-op — all FFI symbols come from the Rust static library. + } +} +``` + +```swift +// ios/Classes/OndeInferencePlugin.swift +import Flutter // NOT FlutterMacOS + +public class OndeInferencePlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) {} +} +``` + +### macOS podspec — full working version + +```ruby +Pod::Spec.new do |s| + s.name = 'onde_inference' + s.version = '0.1.0' + s.summary = 'On-device LLM inference SDK for Flutter (macOS).' + s.description = 'Runs Qwen 2.5 models locally on macOS with Metal acceleration.' + s.homepage = 'https://ondeinference.com' + s.license = { :type => 'MIT', :file => '../LICENSE' } + s.author = { 'Onde Inference' => 'hello@ondeinference.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + s.platform = :osx, '10.15' + s.swift_version = '5.0' + + s.script_phases = [ + { + :name => 'Build Rust bridge (onde_inference_dart)', + :script => <<~SHELL, + set -e + RUST_DIR="${PODS_TARGET_SRCROOT}/../rust" + cd "$RUST_DIR" + ARCH=$(uname -m) + if [[ "$ARCH" == "arm64" ]]; then + TARGET="aarch64-apple-darwin" + else + TARGET="x86_64-apple-darwin" + fi + cargo build --release --target "$TARGET" + cp "target/$TARGET/release/libonde_inference_dart.a" \ + "${PODS_TARGET_SRCROOT}/libonde_inference_dart.a" + SHELL + :execution_position => :before_compile, + :output_files => ["${PODS_TARGET_SRCROOT}/libonde_inference_dart.a"], + } + ] + + # SystemConfiguration is required by Rust hyper_util (proxy detection) + s.frameworks = 'SystemConfiguration' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'OTHER_LDFLAGS' => '-force_load ${PODS_TARGET_SRCROOT}/libonde_inference_dart.a', + } + s.preserve_paths = '../rust/**/*' +end +``` + +**Key points:** +- `s.frameworks = 'SystemConfiguration'` — required because the Rust `hyper_util` + crate (transitive via `reqwest`) references `SCDynamicStoreCopyProxies`, + `SCNetworkReachabilityCreateWithName`, etc. Without it you get + `ld: symbol(s) not found for architecture arm64`. +- `-force_load` links ALL symbols from the `.a` into the pod framework. +- `s.source_files = 'Classes/**/*'` picks up the dummy Swift plugin. + +### iOS podspec — differs from macOS + +```ruby + s.dependency 'Flutter' # NOT 'FlutterMacOS' + s.platform = :ios, '13.0' # NOT :osx + + # Target selection differs: + # iphonesimulator → aarch64-apple-ios-sim + # iphoneos → aarch64-apple-ios + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => + '-force_load ${PODS_TARGET_SRCROOT}/libonde_inference_dart.a', + } +``` + +### Android `CMakeLists.txt` — key section + +```cmake +if(ANDROID_ABI STREQUAL "arm64-v8a") + set(CARGO_TARGET "aarch64-linux-android") +elseif(ANDROID_ABI STREQUAL "armeabi-v7a") + set(CARGO_TARGET "armv7-linux-androideabi") +elseif(ANDROID_ABI STREQUAL "x86_64") + set(CARGO_TARGET "x86_64-linux-android") +elseif(ANDROID_ABI STREQUAL "x86") + set(CARGO_TARGET "i686-linux-android") +endif() +add_custom_command(OUTPUT "${LIB_OUTPUT}" + COMMAND cargo build --release --target ${CARGO_TARGET} + --manifest-path "${RUST_DIR}/Cargo.toml" + WORKING_DIRECTORY ${RUST_DIR}) +add_library(onde_inference_dart SHARED IMPORTED GLOBAL) +set_target_properties(onde_inference_dart PROPERTIES IMPORTED_LOCATION ${LIB_OUTPUT}) +``` + +Android uses CMake to invoke `cargo build` for the right NDK triple, producing +a `.so` shared library (unlike Apple which uses `.a` staticlibs). + +### Android NDK Linker Config (`rust/.cargo/config.toml`) + +```toml +[target.aarch64-linux-android] +linker = "aarch64-linux-android24-clang" + +[target.armv7-linux-androideabi] +linker = "armv7a-linux-androideabi24-clang" + +[target.x86_64-linux-android] +linker = "x86_64-linux-android24-clang" + +[target.i686-linux-android] +linker = "i686-linux-android24-clang" +``` + +These short names require the NDK toolchain `bin/` to be on PATH. +Flutter's Gradle + CMake integration typically handles this automatically +during `flutter build apk` / `flutter run -d `. + +--- + +## Testing Strategy + +All pure-Dart unit tests run **without** building the native binary. + +``` +Tests that PASS pre-codegen (using the stub): + ✓ All type constructors, equality, hashCode, copyWith + ✓ Enum toString and isReady helpers + ✓ SamplingConfig presets (default, deterministic, mobile) + ✓ GgufModelConfig factory functions (real hardcoded values in stub) + ✓ RustLib.init() is idempotent + ✓ OndeInference.* static helpers + ✓ OndeChatEngine.create() throws UnimplementedError pre-codegen + +Tests that require codegen + native binary: + ✗ Any actual inference call + ✗ loadDefaultModel / sendMessage / streamMessage +``` + +Run tests: +```bash +cd sdk/dart && dart test test/dart_test.dart +``` + +--- + +## Pub.dev Readiness Checklist + +Run this before declaring the SDK ready: + +```bash +# 1. Dart analysis — must be zero issues +dart analyze lib/ + +# 2. All tests pass +dart test test/dart_test.dart + +# 3. Dry-run publish — must show compressed size < 100 MB and minimal warnings +dart pub publish --dry-run +``` + +Common pub.dev score deductions to avoid: +- Missing `repository:` in pubspec → loses points +- No `example/` directory → loses points +- `description:` under 60 chars or over 180 chars → loses points +- Library exports not documented → loses points +- `library ;` directive → triggers lint + +### Pub.dev Upload Size (100 MB limit) + +`dart pub publish` bundles everything not excluded by `.pubignore` (or +`.gitignore` if `.pubignore` doesn't exist). The Rust `target/` dir alone +can be **7+ GB**. A `.pubignore` file is mandatory. + +**Files that MUST be included** (consumers build from source): +- `rust/src/`, `rust/Cargo.toml`, `rust/Cargo.lock` +- `ios/`, `macos/`, `android/`, `linux/`, `windows/` (build scripts, podspecs, CMakeLists) +- `ios/Classes/`, `macos/Classes/` (dummy plugin Swift files) +- `lib/` (all Dart code) + +**Files that MUST be excluded** (`.pubignore`): +- `rust/target/` — multi-GB build artifacts +- `**/*.a`, `**/*.so`, `**/*.dylib`, `**/*.dll` — precompiled binaries +- `.dart_tool/`, `pubspec.lock` +- `example/build/`, `example/.dart_tool/` +- `logs/`, `.idea/`, `.vscode/`, `.DS_Store` + +After adding `.pubignore`, verify: `dart pub publish --dry-run` should report +~280 KB compressed size, not hundreds of MB. + +--- + +## Codegen Workflow (Full, Correct Order) + +```bash +# ── Prerequisites (one-time) ────────────────────────────────────────────── + +rustup target add aarch64-apple-ios aarch64-apple-ios-sim # iOS +rustup target add aarch64-apple-darwin x86_64-apple-darwin # macOS + +cargo install cargo-expand # Required by FRB codegen parser +cargo install flutter_rust_bridge_codegen # The codegen tool itself + +# ── Per-package setup ───────────────────────────────────────────────────── + +cd sdk/dart + +# IMPORTANT: delete the pre-codegen Rust stub first +rm -f rust/src/frb_generated.rs + +# Run codegen — generates: +# rust/src/frb_generated.rs (Rust glue) +# lib/src/frb_generated.dart (Dart FFI bindings, replaces stub) +flutter_rust_bridge_codegen generate + +# Verify Rust still compiles cleanly +cd rust && cargo check + +# Verify Dart analysis is still clean +cd .. && dart analyze lib/ +``` + +--- + +## `flutter run` on iOS (Full Steps) + +```bash +# 1. Scaffold the example Flutter project (one-time) +cd sdk/dart/example +flutter create . \ + --project-name \ + --platforms=ios,macos \ + --org com. + +# 2. Get dependencies +flutter pub get + +# 3. Run codegen (from the SDK package root, not example root) +cd .. +flutter_rust_bridge_codegen generate + +# 4. Run the app (from example dir) +cd example +flutter run # picks up connected device/simulator automatically +flutter run -d iPhone # target a specific device +``` + +**What happens during the first `flutter run` on iOS:** +1. Flutter invokes `pod install` → reads `ios/onde_inference.podspec` +2. The podspec script phase runs `cargo build --release --target aarch64-apple-ios-sim` + (or `-device`) inside `sdk/dart/rust/` — this takes 5–15 min first time +3. The resulting `libonde_inference_dart.a` is linked into the Runner app +4. Flutter's Dart VM starts, calls `RustLib.init()`, loads the native library +5. The model downloads from HuggingFace Hub on first inference (~941 MB or ~1.93 GB) + +**HuggingFace token for private/gated models:** +```bash +# Bake token at build time (required for iOS — no filesystem access): +export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxx +flutter run +``` + +--- + +## Version Pinning Rule + +The `flutter_rust_bridge` Dart package and `flutter_rust_bridge` Rust crate +**must be the same version**. Use exact pins: + +```toml +# rust/Cargo.toml +flutter_rust_bridge = "=2.12.0" +``` +```yaml +# pubspec.yaml +dependencies: + flutter_rust_bridge: ^2.12.0 # ^ is OK on Dart side; codegen auto-upgrades +``` + +If they drift, generated code won't compile. Always check both when upgrading. + +--- + +## Common Errors and Fixes + +| Error | Cause | Fix | +|-------|-------|-----| +| `rust_input: invalid type: sequence` | YAML list syntax in FRB 2.x | Change to `rust_input: "crate::api"` | +| `Please migrate configuration rust_input` | File path in FRB 2.x | Change to `rust_input: "crate::api"` | +| `File exists (os error 17)` | `frb_generated.rs` pre-exists | `rm rust/src/frb_generated.rs` | +| `could not find package flutter_rust_bridge_codegen` | Added codegen as Dart dep | Remove it; install via `cargo install` | +| `no such command: expand` | `cargo-expand` not installed | `cargo install cargo-expand` | +| `unexpected cfg condition name: frb_expand` | Rust check-cfg lint | Harmless FRB macro warning; suppress with `#[allow(unexpected_cfgs)]` or ignore | +| `no_leading_underscores_for_library_prefixes` | Import alias `as _frb` | Rename to `as frb` | +| `unnecessary_library_name` | `library ;` in barrel | Change to bare `library;` | +| `OndeChatEngine.create() throws UnimplementedError` | Codegen not run yet | Run `flutter_rust_bridge_codegen generate` | +| `flutter run` fails, missing Runner.xcodeproj | `flutter create` not run | Run `flutter create . --platforms=ios ...` in example dir | +| `dlopen(onde_inference_dart.framework/...): no such file` | FRB default loader looks for wrong framework | Use `ExternalLibrary.open('onde_inference.framework/onde_inference')` in `init()` — see Dart Public API Pattern above | +| `Failed to lookup symbol 'frb_get_rust_content_hash': dlsym(RTLD_DEFAULT, ...)` | Used `DynamicLibrary.process()` but symbols are in pod framework, not main executable | Switch from `ExternalLibrary.process()` to `ExternalLibrary.open('onde_inference.framework/onde_inference')` | +| `ld: symbol(s) not found ... _kSCNetworkReachability...` | Missing `SystemConfiguration` framework in podspec | Add `s.frameworks = 'SystemConfiguration'` to macOS podspec | +| `ld: error: unknown argument '-Xlinker'` / `-dynamiclib` / `-filelist` | Android NDK's `ld` (LLD) is on PATH — Xcode picks it up instead of Apple's linker | Remove `export LD=$TOOLCHAIN/bin/ld` and other NDK tool exports from `~/.zshrc` (see Xcode 26 section) | +| `clang: error: invalid linker name in argument '-fuse-ld=classic'` | `ALTERNATE_LINKER = classic` set but clang doesn't support it | Remove `ALTERNATE_LINKER`; was only needed to work around the NDK-on-PATH issue — once that's fixed, Apple's ld-prime works fine | +| `EntityTooLarge` on `dart pub publish` | Package > 100 MB (rust/target/ included) | Create `.pubignore` excluding `rust/target/`, `**/*.a`, `**/*.so`, etc. | +| `Because requires SDK version >=X.Y.0 ... version solving failed` | `pubspec.yaml` `environment.sdk` lower bound is too old; CI Flutter version too old | 1. Update `sdk/dart/.flutter-version` to a Flutter that ships the required Dart SDK. 2. Tighten `environment.sdk` in `pubspec.yaml` to the real minimum. See Hard-Won Lesson 11. | +| `You must have a LICENSE file in the root directory` on `dart pub publish` | pub.dev requires a file named exactly `LICENSE`; `LICENSE-MIT` / `LICENSE-APACHE` alone do not satisfy it | Create `sdk/dart/LICENSE` with an **"either of … at your option"** preamble, then append both full license texts. A bare concatenation without a preamble is legally ambiguous — it implies users must comply with *both*, defeating the purpose of dual licensing. Run from `sdk/dart/`: `cat > LICENSE << 'EOF'` then the preamble (see legal-and-trademarks SKILL.md for exact wording), then `{ cat LICENSE-MIT; printf '\n\n---\n\n'; cat LICENSE-APACHE; } >> LICENSE`. Keep `LICENSE-MIT` and `LICENSE-APACHE` alongside it. | + +## Xcode 26 Compatibility + +### The NDK-on-PATH Trap + +If your `~/.zshrc` (or similar) has **global** Android NDK toolchain exports: + +```bash +# DANGEROUS — breaks ALL non-Android builds (Xcode, Rust for Apple, etc.) +export TOOLCHAIN=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64 +export LD=$TOOLCHAIN/bin/ld # ← THIS HIJACKS XCODE'S LINKER +export AR=$TOOLCHAIN/bin/llvm-ar +export CXX=$TOOLCHAIN/bin/clang++ +export RANLIB=$TOOLCHAIN/bin/llvm-ranlib +export STRIP=$TOOLCHAIN/bin/llvm-strip +``` + +Xcode reads `$LD` and invokes the Android NDK's LLD instead of Apple's linker. +The errors look like Xcode 26 ld-prime incompatibility (`unknown argument '-Xlinker'`) +but the root cause is the **wrong linker binary**. + +**Fix:** Comment out all NDK tool exports. Keep only the env vars: + +```bash +export NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865" +export ANDROID_NDK="$NDK_HOME" +export ANDROID_NDK_HOME="$NDK_HOME" +# DO NOT export LD, AR, CXX, RANLIB, STRIP globally. +# Cargo uses rust/.cargo/config.toml for Android linker selection. +``` + +### Does Xcode 26's ld-prime work with Flutter? + +**Yes** — once the NDK tool exports are removed, Apple's ld-prime handles all +CocoaPods/Flutter linker flags correctly. No `ALTERNATE_LINKER = classic` or +`-ld_classic` workaround is needed for macOS builds. The `-ld_classic` +deprecation warning from iOS Podfiles is harmless. + +### iOS 26 Debug Mode Crash + +Flutter debug builds crash on iOS 26 beta with +`NOTIFY_DEBUGGER_ABOUT_RX_PAGES was not intercepted as expected` +([flutter#170416](https://github.com/flutter/flutter/issues/170416)). +This is a Dart VM JIT issue — **not fixable in the SDK**. + +| Mode | iOS 26 Physical | iOS 26 Simulator | iOS 18.x | +|------|----------------|-----------------|----------| +| `--debug` | ❌ Dart VM crash | ❌ Dart VM crash | ✅ Works | +| `--profile` | ✅ Works | ❌ Not supported on sim | ✅ Works | +| `--release` | ⚠️ Use `flutter build ipa` | ❌ Not supported on sim | ✅ Works | + +**Workaround:** Use an iOS 18.x simulator runtime (download via Xcode Settings → Platforms). + +--- + +## File Interdependency Map + +``` +pubspec.yaml + └── declares: flutter_rust_bridge ^2.12.0 + +flutter_rust_bridge.yaml + ├── rust_root: "rust/" → rust/Cargo.toml + ├── rust_input: "crate::api" → rust/src/api.rs (source of truth) + └── dart_output: "lib/src/frb_generated.dart" (codegen writes here) + +rust/Cargo.toml + ├── crate-type = ["cdylib", "staticlib"] + ├── flutter_rust_bridge = "=2.12.0" (exact pin!) + └── onde = { path = "../../../" } (the main onde crate) + +rust/.cargo/config.toml + └── Android NDK linker overrides (aarch64, armv7, x86_64, i686) + +rust/src/api.rs + ├── imports onde::{ChatEngine, ...} (path dep → ../../..) + ├── declares mirror types (ChatRole, ChatMessage, ...) + ├── declares OndeChatEngine (wraps ChatEngine) + └── declares free functions (default_model_config, ...) + +lib/src/frb_generated.dart/ ← DIRECTORY (not a file!) + ├── api.dart ← generated API bindings + ├── api.freezed.dart ← freezed union types (OndeError) + ├── frb_generated.dart ← RustLib class, ExternalLibraryLoaderConfig + └── frb_generated.io.dart ← RustLibWire, platform FFI glue + +lib/src/types.dart ← hand-written, FRB-independent + └── ChatRole, ChatMessage, SamplingConfig, GgufModelConfig, + InferenceResult, StreamChunk, EngineStatus, EngineInfo, OndeException + +lib/src/engine.dart ← hand-written, imports frb_generated.dart/ + types.dart + ├── extension OndeChatEngineX (loadDefaultModel, clearHistoryCount) + └── abstract class OndeInference + ├── init() → ExternalLibrary.open('onde_inference.framework/...') on Apple + ├── defaultModelConfig(), qwen2515bConfig(), qwen253bConfig(), ... + └── defaultSamplingConfig(), deterministicSamplingConfig(), mobileSamplingConfig() + +lib/onde_inference.dart (barrel) + ├── export 'src/types.dart' + └── export 'src/engine.dart' + +macos/ + ├── Classes/OndeInferencePlugin.swift ← dummy (required for CocoaPods framework) + └── onde_inference.podspec ← script_phase + -force_load + SystemConfiguration + +ios/ + ├── Classes/OndeInferencePlugin.swift ← dummy (required for CocoaPods framework) + └── onde_inference.podspec ← script_phase + -force_load + +android/ + ├── CMakeLists.txt ← invokes cargo build per ABI + └── build.gradle ← externalNativeBuild → cmake + +.pubignore ← excludes rust/target/, *.a, *.so, etc. +.gitignore ← excludes same + build artifacts +``` + +--- + +*Last updated: July 2025 — Xcode 26 compatibility, ExternalLibrary.open pattern, +SystemConfiguration framework, .pubignore for pub.dev, NDK PATH pitfall.* \ No newline at end of file diff --git a/.agents/skills/sdk-kotlin/SKILL.md b/.agents/skills/sdk-kotlin/SKILL.md new file mode 100644 index 0000000..b92d2fd --- /dev/null +++ b/.agents/skills/sdk-kotlin/SKILL.md @@ -0,0 +1,810 @@ +--- +name: sdk-kotlin +description: Build, generate, and publish the Onde Inference Kotlin Multiplatform SDK (Android + JVM). Covers UniFFI Kotlin binding generation, KMP source set layout with shared srcDir pattern, cargo-ndk cross-compilation for Android ABIs, JVM native library bundling for macOS Apple Silicon, Maven Central publishing via Vanniktech plugin, klibs.io discovery requirements, ProGuard consumer rules, and the Jetpack Compose example app. +allowed-tools: Read, Write, Edit, Glob, Grep, Bash +user-invocable: true +--- + +# Skill: Kotlin Multiplatform SDK + +## What This Skill Covers + +Building the `onde-inference` Kotlin Multiplatform library from Rust source +using UniFFI, distributing it via Maven Central, and getting it listed on +[klibs.io](https://klibs.io) — the JetBrains-maintained discovery platform for +KMP libraries. + +**Targets:** + +| Target | GPU Backend | Default Model | Primary Use Case | +|--------|-------------|---------------|------------------| +| `androidTarget()` | CPU (Candle) | Qwen 2.5 1.5B (~941 MB) | Mobile apps | +| `jvm()` | Metal (macOS) / CPU (Linux) | Qwen 2.5 3B (~1.93 GB) | Desktop / server on Apple Silicon | + +**Context:** Onde's tagline is "AI for Apple silicon devices." The primary SDKs +are **Swift** (iOS, tvOS, macOS) and **Dart** (Flutter, cross-platform). The +Kotlin SDK extends Onde to Android and JVM developers — particularly those +building KMP apps that target both Android and macOS, or running inference on +Apple Silicon Macs from Kotlin/JVM. + +--- + +## Repository Layout + +``` +onde/ +├── src/ # Rust source — UniFFI-annotated types and OndeChatEngine +├── uniffi-bindgen/ # Standalone uniffi-bindgen CLI binary (pinned =0.31.0) +├── sdk/kotlin/ +│ ├── settings.gradle.kts # Gradle root — includes :lib and :example +│ ├── build.gradle.kts # root — plugin declarations (all versions live here) +│ ├── gradle.properties # GROUP, POM_ARTIFACT_ID, VERSION_NAME, signing/OSSRH +│ ├── gradle/wrapper/ +│ │ └── gradle-wrapper.properties # pinned Gradle 8.9 +│ ├── lib/ +│ │ ├── build.gradle.kts # KMP library + Vanniktech maven-publish +│ │ ├── consumer-rules.pro # ProGuard: preserve UniFFI JNI bridge symbols +│ │ └── src/ +│ │ ├── commonMain/kotlin/.gitkeep # empty — KMP requires this source set to exist +│ │ ├── shared/kotlin/ # shared srcDir added to BOTH androidMain and jvmMain +│ │ │ └── com/ondeinference/onde/ +│ │ │ ├── PlatformSupport.kt # internal interface — setEnv + ensureNativeLoaded +│ │ │ ├── OndeInference.kt # engine wrapper (internal constructor) +│ │ │ └── Convenience.kt # OndeSampling, OndeModels, OndeMessage +│ │ ├── generated/kotlin/ # UniFFI-generated onde.kt ← GITIGNORED +│ │ ├── androidMain/ +│ │ │ ├── AndroidManifest.xml +│ │ │ ├── jniLibs/ # compiled .so per ABI ← GITIGNORED +│ │ │ └── kotlin/com/ondeinference/onde/ +│ │ │ └── Platform.android.kt # AndroidPlatform + factory fun OndeInference(context) +│ │ └── jvmMain/ +│ │ ├── kotlin/com/ondeinference/onde/ +│ │ │ ├── Platform.jvm.kt # JvmPlatform + factory fun OndeInference(dataDir) +│ │ │ └── NativeLoader.kt # extract libonde from JAR resources +│ │ └── resources/native/ # bundled .dylib/.so ← GITIGNORED +│ │ ├── macos-aarch64/libonde.dylib +│ │ ├── macos-x86_64/libonde.dylib +│ │ ├── linux-x86_64/libonde.so +│ │ └── linux-aarch64/libonde.so +│ ├── example/ +│ │ ├── build.gradle.kts # com.android.application — no plugin versions here +│ │ ├── proguard-rules.pro +│ │ ├── README.md +│ │ └── src/main/ +│ │ ├── AndroidManifest.xml # INTERNET permission +│ │ ├── res/values/ +│ │ │ ├── strings.xml +│ │ │ └── themes.xml # Material3 NoActionBar activity theme +│ │ └── kotlin/com/ondeinference/example/ +│ │ ├── MainActivity.kt # ComponentActivity — enableEdgeToEdge + setContent +│ │ ├── ChatViewModel.kt # model lifecycle, streaming, history +│ │ └── ui/ +│ │ ├── ChatScreen.kt # full Compose UI +│ │ └── theme/Theme.kt # dynamic colour + dark mode +│ └── scripts/ +│ ├── build-android.sh # Rust .so → androidMain/jniLibs/ via cargo-ndk +│ ├── build-jvm.sh # Rust .dylib/.so → jvmMain/resources/native/ +│ └── generate-bindings.sh # uniffi-bindgen → src/generated/kotlin/onde.kt +└── .github/workflows/ + └── release-sdk-kotlin.yml # tag push → build → Maven Central publish +``` + +--- + +## KMP Architecture + +### Why KMP Instead of Android-Only + +1. **klibs.io listing** — the JetBrains KMP discovery platform requires + `kotlin-tooling-metadata.json`, which only the `kotlin.multiplatform` Gradle + plugin generates. An Android-only AAR will not appear on klibs.io. +2. **JVM target** — Kotlin developers on macOS Apple Silicon can run inference + locally from desktop/server apps, Gradle plugins, or CLI tools. +3. **Future-proof** — adding Kotlin/Native targets (iOS, macOS) later requires + no structural changes. The Swift SDK already covers Apple platforms natively, + but KMP apps that share Kotlin across Android + iOS could benefit. + +### Source Set Strategy: Shared srcDir + +Instead of KMP intermediate source sets (which add Gradle complexity and +require careful dependency resolution for JNA), the SDK uses a **shared srcDir** +pattern: + +``` +commonMain/ — empty (KMP requires it) +shared/ — hand-written wrapper code (compiled for BOTH targets) +generated/ — UniFFI-generated onde.kt (compiled for BOTH targets) +androidMain/ — Android-specific: Os.setenv, Context factory +jvmMain/ — JVM-specific: JNA setenv, NativeLoader, File factory +``` + +In `lib/build.gradle.kts`: + +```kotlin +androidMain.get().kotlin.srcDir("src/shared/kotlin") +androidMain.get().kotlin.srcDir("src/generated/kotlin") +jvmMain.get().kotlin.srcDir("src/shared/kotlin") +jvmMain.get().kotlin.srcDir("src/generated/kotlin") +``` + +Both targets compile the shared code independently with their own dependencies. +Since both are JVM-based, `java.io.File`, JNA, and coroutines are available in +the shared code. + +### Platform Abstraction: Interface Pattern + +The shared `OndeInference` class takes a `PlatformSupport` interface via an +`internal` constructor. Each target provides a concrete implementation and a +top-level factory function: + +``` +PlatformSupport (interface — shared/) +├── AndroidPlatform (object — androidMain/) +│ └── fun OndeInference(context, dataDir?) → OndeInference +└── JvmPlatform (object — jvmMain/) + └── fun OndeInference(dataDir?) → OndeInference +``` + +| Operation | Android | JVM | +|-----------|---------|-----| +| `setEnv(key, value)` | `Os.setenv(key, value, true)` | JNA → `libc.setenv(key, value, 1)` | +| `ensureNativeLoaded()` | No-op (APK jniLibs) | `NativeLoader.ensureLoaded()` | +| Default `dataDir` | `context.filesDir` | `~/.onde/` | + +--- + +## Key Design Decisions + +| Decision | Rationale | +|---|---| +| KMP with `androidTarget()` + `jvm()` | Required for klibs.io listing; enables macOS Apple Silicon JVM users | +| Shared srcDir instead of intermediate source sets | Avoids KMP dependency resolution complexity with JNA (`@aar` vs JAR) | +| Interface-based platform abstraction | Simpler than `expect`/`actual` when constructor signatures differ between targets | +| UniFFI Kotlin bindings — not hand-written JNI | Same Rust source as Swift/Dart, zero API drift | +| `src/generated/kotlin/` is gitignored | Regenerated from Rust on every release | +| `cargo-ndk` for Android cross-compilation | Handles NDK toolchain wiring for all 4 ABIs transparently | +| `NativeLoader` extracts dylib from JAR resources | JVM users don't need to manage `java.library.path` manually | +| Vanniktech `maven-publish` plugin with `KotlinMultiplatform` | Publishes KMP metadata + per-target artifacts to Maven Central | +| `minSdk 26` | Required for `android.system.Os.setenv` to set `HF_HOME` | +| JNA dependency (`net.java.dev.jna:jna:5.14.0`) | UniFFI 0.31.0 generates Kotlin bindings using `com.sun.jna.*`. Android uses `@aar` variant; JVM uses plain JAR. | +| ProGuard consumer rules bundled in AAR | Prevents R8 from stripping UniFFI JNI bridge in consumer apps | +| `:example` is a Gradle submodule | Plugin versions declared once in root `build.gradle.kts` and inherited | + +--- + +## UniFFI Kotlin Type Map + +| Rust type | UniFFI derive | Kotlin type | +|---|---|---| +| `ChatRole` | `uniffi::Enum` | `sealed class ChatRole` | +| `ChatMessage` | `uniffi::Record` | `data class ChatMessage` | +| `SamplingConfig` | `uniffi::Record` | `data class SamplingConfig` | +| `GgufModelConfig` | `uniffi::Record` | `data class GgufModelConfig` | +| `InferenceResult` | `uniffi::Record` | `data class InferenceResult` | +| `StreamChunk` | `uniffi::Record` | `data class StreamChunk` | +| `EngineStatus` | `uniffi::Enum` | `sealed class EngineStatus` | +| `EngineInfo` | `uniffi::Record` | `data class EngineInfo` | +| `InferenceError` | `uniffi::Error` | `sealed class InferenceException : Exception` | +| `OndeChatEngine` | `uniffi::Object` | `class OndeChatEngine` | +| `StreamChunkListener` | `callback_interface` | `interface StreamChunkListener` | + +The generated Kotlin package is `uniffi.onde.*`. The shared `OndeInference.kt` +re-exports these as `typealias` under `com.ondeinference.onde.*` so callers only +need one import. + +--- + +## Build Targets + +### Android ABI Targets + +| ABI | Rust triple | Typical device | +|---|---|---| +| `arm64-v8a` | `aarch64-linux-android` | Modern Android phones (primary) | +| `armeabi-v7a` | `armv7-linux-androideabi` | Older 32-bit ARM phones | +| `x86_64` | `x86_64-linux-android` | Android emulators (Intel/AMD) | +| `x86` | `i686-linux-android` | 32-bit emulators | + +### JVM Native Library Targets + +| Platform | Rust triple | Library | Primary Use Case | +|---|---|---|---| +| macOS Apple Silicon | `aarch64-apple-darwin` | `libonde.dylib` | **Primary** — Mac development + Metal GPU | +| macOS Intel | `x86_64-apple-darwin` | `libonde.dylib` | Legacy Mac support | +| Linux x86_64 | `x86_64-unknown-linux-gnu` | `libonde.so` | CI, servers | +| Linux ARM64 | `aarch64-unknown-linux-gnu` | `libonde.so` | ARM servers | + +--- + +## Build Sequence (Manual / CI) + +### 1. Prerequisites + +```bash +# Rust toolchain +rustup toolchain install stable + +# Android targets + cargo-ndk +rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android +cargo install cargo-ndk + +# Android NDK +export ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/26.1.10909125 + +# macOS/JVM target (usually pre-installed on Apple Silicon) +rustup target add aarch64-apple-darwin +``` + +### 2. Build Android .so files + +```bash +./sdk/kotlin/scripts/build-android.sh # release, all ABIs +./sdk/kotlin/scripts/build-android.sh --debug # debug build +FILTER_ABI=arm64-v8a ./sdk/kotlin/scripts/build-android.sh # single ABI +``` + +Outputs land in `sdk/kotlin/lib/src/androidMain/jniLibs//libonde.so`. + +### 3. Build JVM native library + +```bash +./sdk/kotlin/scripts/build-jvm.sh # host platform, release +./sdk/kotlin/scripts/build-jvm.sh --debug # debug build +ONDE_TARGET_TRIPLE=x86_64-apple-darwin ./sdk/kotlin/scripts/build-jvm.sh # cross-compile +``` + +Outputs land in `sdk/kotlin/lib/src/jvmMain/resources/native//libonde.`. + +### 4. Build the uniffi-bindgen CLI + +```bash +cargo build --manifest-path uniffi-bindgen/Cargo.toml --release +# Binary: uniffi-bindgen/target/release/uniffi-bindgen +``` + +### 5. Generate Kotlin UniFFI bindings + +```bash +./sdk/kotlin/scripts/generate-bindings.sh +``` + +Produces `sdk/kotlin/lib/src/generated/kotlin/onde.kt` — the complete +generated binding including all types, the `OndeChatEngine` class, free +functions, and `System.loadLibrary("onde")` in a companion object static init. + +### 6. Build the library + +```bash +cd sdk/kotlin + +# Android AAR +./gradlew :lib:assembleRelease + +# JVM JAR (with bundled native lib) +./gradlew :lib:jvmJar + +# Both +./gradlew :lib:build +``` + +--- + +## JVM Native Library Loading + +The JVM target bundles native libraries inside the JAR under +`/native/-/libonde.`. The `NativeLoader` object handles +extraction and loading at runtime: + +``` +OndeInference() factory (jvmMain) + └── JvmPlatform.ensureNativeLoaded() + └── NativeLoader.ensureLoaded() + ├── Detect OS + arch from system properties + ├── Extract /native/-/libonde. from JAR resources + ├── Write to temp file: /onde-native/libonde. + └── System.load(tempFile.absolutePath) +``` + +If the bundled library is not found (e.g. development mode without running +`build-jvm.sh`), `NativeLoader` falls back to `System.loadLibrary("onde")` +which searches `java.library.path`. + +**Important:** `NativeLoader.ensureLoaded()` must run BEFORE any UniFFI type is +accessed. The `OndeInference` constructor handles this via the `init` block: + +```kotlin +class OndeInference internal constructor(...) : AutoCloseable { + init { platform.ensureNativeLoaded() } // runs before engine = OndeChatEngine() + private val engine = uniffi.onde.OndeChatEngine() +} +``` + +--- + +## Android Filesystem Sandbox Setup + +On Android, `dirs::home_dir()` (used by `hf-hub` under the hood) panics because +there is no home directory in the Android sandbox. The Rust engine requires +`HF_HOME` to be set **before** any model load on Android. + +The `OndeInference` wrapper handles this automatically in `setup()`: + +```kotlin +fun setup() { + if (configured) return + + val hfHome = File(dataDir, "models").also { it.mkdirs() } + val hfHubCache = File(hfHome, "hub").also { it.mkdirs() } + val tmpDir = File(dataDir, "tmp").also { it.mkdirs() } + + platform.setEnv("HF_HOME", hfHome.absolutePath) + platform.setEnv("HF_HUB_CACHE", hfHubCache.absolutePath) + platform.setEnv("HUGGINGFACE_HUB_CACHE", hfHubCache.absolutePath) + platform.setEnv("TMPDIR", tmpDir.absolutePath) + + configured = true +} +``` + +`setup()` is called automatically by `loadDefaultModel()` and `loadModel()`. + +### Filesystem layout + +``` +# Android (context.filesDir) +/ +├── models/ ← HF_HOME +│ └── hub/ ← HF_HUB_CACHE +└── tmp/ ← TMPDIR + +# JVM (defaults to ~/.onde/) +~/.onde/ +├── models/ ← HF_HOME +│ └── hub/ ← HF_HUB_CACHE +└── tmp/ ← TMPDIR +``` + +### Cross-platform model cache comparison + +| Platform | SDK | Cache Location | Shared across apps? | +|---|---|---|---| +| iOS / tvOS / macOS | Swift | App Group container | Yes (same team + entitlement) | +| macOS (JVM) | Kotlin | `~/.onde/models/hub/` | Yes (all JVM apps on same user) | +| Android | Kotlin | `/models/hub/` | No (per-app sandbox) | +| Flutter | Dart | Platform-dependent | Follows platform conventions | + +### Why Android can't share models across apps + +Android's security model assigns each app a unique Linux UID at install time. +The kernel enforces file permissions — app A cannot open a file descriptor +inside app B's `/data/data/com.B/files/`. There is no platform primitive to +override this the way iOS App Groups do. The approaches that don't work: + +- **`android:sharedUserId`** — deprecated API 29, removed API 33 +- **`MANAGE_EXTERNAL_STORAGE`** — Google Play rejects unless file manager +- **SAF (Storage Access Framework)** — requires user interaction every time + +Each Onde-powered Android app downloads its own copy (~941 MB). This matches +how TensorFlow Lite, MediaPipe, and ML Kit handle model caching. The SDK caches +after first download — bandwidth cost is one-time per app. + +--- + +## Kotlin Public API + +### `OndeInference` — primary entry point + +```kotlin +import com.ondeinference.onde.OndeInference +import com.ondeinference.onde.OndeSampling +import com.ondeinference.onde.OndeModels +import com.ondeinference.onde.OndeMessage + +// ── Android ────────────────────────────────────────────────── +val onde = OndeInference(context) // pass applicationContext + +// ── JVM (macOS / Linux) ────────────────────────────────────── +val onde = OndeInference() // uses ~/.onde/ +val onde = OndeInference(File("/custom")) // custom cache dir + +// ── Shared API (both platforms) ────────────────────────────── + +// Load (downloads from HuggingFace Hub on first run) +val elapsed: Double = onde.loadDefaultModel( + systemPrompt = "You are helpful.", + sampling = OndeSampling.mobile() +) + +// Chat (suspend) +val result = onde.chat("What is Rust?") +println(result.text) + +// Streaming (Flow) +onde.stream("Tell me a story.").collect { chunk -> + print(chunk.delta) +} + +// One-shot (no history side-effects) +val summary = onde.generate( + messages = listOf( + OndeMessage.system("Summarise this."), + OndeMessage.user(longText) + ) +) + +// Status +val info = onde.info() // EngineInfo + +// History +onde.clearHistory() +onde.pushHistory(OndeMessage.user("Injected context")) + +// Cleanup +onde.unload() +onde.close() // decrements Rust Arc refcount +``` + +### `OndeSampling` — convenience constructors + +```kotlin +OndeSampling.default() // temp=0.7, top_p=0.95, max=512 tokens +OndeSampling.deterministic() // temp=0.0, greedy +OndeSampling.mobile() // temp=0.7, max=128 tokens +``` + +### `OndeModels` — model config constructors + +```kotlin +OndeModels.default() // platform default (1.5B on Android, 3B on macOS JVM) +OndeModels.qwen25_1_5b() // ~941 MB +OndeModels.qwen25_3b() // ~1.93 GB +``` + +### `OndeMessage` — message constructors + +```kotlin +OndeMessage.system("You are helpful.") +OndeMessage.user("Hello!") +OndeMessage.assistant("Hi there!") +``` + +--- + +## lib/build.gradle.kts Key Sections + +```kotlin +import com.vanniktech.maven.publish.KotlinMultiplatform +import com.vanniktech.maven.publish.JavadocJar +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("com.android.library") + id("com.vanniktech.maven.publish") +} + +kotlin { + androidTarget { + publishLibraryVariants("release") + compilations.all { kotlinOptions { jvmTarget = "17" } } + } + jvm { + compilations.all { kotlinOptions { jvmTarget = "17" } } + } + + sourceSets { + androidMain.dependencies { + implementation("net.java.dev.jna:jna:5.14.0@aar") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + } + jvmMain.dependencies { + implementation("net.java.dev.jna:jna:5.14.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + } + + // Shared srcDirs — compiled independently for each target + androidMain.get().kotlin.srcDir("src/shared/kotlin") + androidMain.get().kotlin.srcDir("src/generated/kotlin") + jvmMain.get().kotlin.srcDir("src/shared/kotlin") + jvmMain.get().kotlin.srcDir("src/generated/kotlin") + } +} + +mavenPublishing { + configure(KotlinMultiplatform( + javadocJar = JavadocJar.Empty(), + sourcesJar = true, + )) + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() + coordinates("com.ondeinference", "onde-inference", "") +} +``` + +The Vanniktech plugin reads signing and OSSRH credentials from environment +variables prefixed with `ORG_GRADLE_PROJECT_`: + +| Gradle property | CI env var | +|---|---| +| `mavenCentralUsername` | `ORG_GRADLE_PROJECT_mavenCentralUsername` | +| `mavenCentralPassword` | `ORG_GRADLE_PROJECT_mavenCentralPassword` | +| `signingKeyId` | `ORG_GRADLE_PROJECT_signingKeyId` | +| `signingKey` | `ORG_GRADLE_PROJECT_signingKey` | +| `signingPassword` | `ORG_GRADLE_PROJECT_signingPassword` | + +--- + +## ProGuard Consumer Rules + +The `consumer-rules.pro` bundled in the AAR instructs R8/ProGuard in consumer +apps to preserve: + +- `uniffi.onde.**` — all generated binding classes +- `com.ondeinference.onde.**` — the public wrapper +- `uniffi.onde.StreamChunkListener` — callback interface called from Rust +- Native method declarations (`keepclasseswithmembernames`) +- Static initializers in `uniffi.onde.*` (contains `System.loadLibrary("onde")`) + +Always update `consumer-rules.pro` when adding new top-level UniFFI-exported +types or callback interfaces. + +--- + +## klibs.io Discovery + +[klibs.io](https://klibs.io) is a JetBrains-maintained search platform for +Kotlin Multiplatform libraries. It auto-indexes from Maven Central. + +### Requirements for listing + +| Requirement | How we satisfy it | +|---|---| +| Open source on GitHub | `ondeinference/onde` is public | +| At least one artifact on Maven Central | Published via Vanniktech plugin | +| Artifact has `kotlin-tooling-metadata.json` | KMP plugin generates this automatically | +| POM has valid GitHub `url` or `scm.url` | `scm { url.set("https://github.com/ondeinference/onde") }` | + +### Timeline + +- New libraries appear **within one month** of first Maven Central publish + (public index update frequency). +- New versions of already-indexed libraries appear **the next day**. +- No manual submission needed — discovery is fully automatic. + +--- + +## Maven Central Publishing (CI) + +Trigger: push a semver tag matching `[0-9]+.[0-9]+.[0-9]+`. + +```bash +# 1. Bump VERSION_NAME in sdk/kotlin/gradle.properties +# 2. Bump version in Cargo.toml [package] +# 3. Commit, tag, push +git tag 1.0.1 && git push origin 1.0.1 +``` + +CI (`release-sdk-kotlin.yml`) steps: +1. Install Rust stable with all 4 Android targets + host target +2. Cache NDK 26.1.10909125 +3. Install `cargo-ndk` +4. `build-android.sh --release` → `.so` files +5. `build-jvm.sh --release` → `.dylib` / `.so` +6. Build `uniffi-bindgen` +7. `generate-bindings.sh` → `onde.kt` +8. Validate tag == `Cargo.toml` version +9. `./gradlew :lib:publishAndReleaseToMavenCentral` +10. Upload artifacts as GitHub Release assets + +--- + +## Version Synchronisation Rule + +`VERSION_NAME` in `sdk/kotlin/gradle.properties` MUST match `version` in the +root `Cargo.toml`. The CI workflow enforces this and fails fast on mismatch. +Never bump one without the other. + +--- + +## Common Pitfalls + +| Pitfall | Fix | +|---|---| +| `dirs::home_dir()` panics on Android | `setup()` must be called before any Rust model load. `OndeInference.loadDefaultModel()` calls it automatically. | +| Generated `onde.kt` not found at compile time | Run `scripts/generate-bindings.sh`. The file is gitignored and must be regenerated after every Rust API change. | +| `.so` files missing from AAR | Run `scripts/build-android.sh`. The `androidMain/jniLibs/` directory is gitignored. | +| JVM `UnsatisfiedLinkError` at runtime | Run `scripts/build-jvm.sh` to bundle the native lib in JAR resources, or add `libonde.dylib` to `java.library.path`. | +| UniFFI version mismatch | Keep `uniffi = "=0.31.0"` identical in `Cargo.toml`, `build-deps`, and `uniffi-bindgen/Cargo.toml`. | +| R8 strips UniFFI bridge at runtime | Ensure `consumer-rules.pro` is present in the AAR and includes `-keep class uniffi.onde.**`. | +| `System.loadLibrary("onde")` fails | The `.so` files must match the ABI of the device. Check that all 4 ABIs are built and included in `androidMain/jniLibs/`. | +| `Os.setenv` not available | `minSdk` must be 26+. `android.system.Os` is available from API 21 but `setenv` specifically needs 26. | +| Gradle fails with `VERSION_NAME` missing | Ensure `gradle.properties` contains `VERSION_NAME=x.y.z`. | +| `cargo-ndk` not found in CI | Add `cargo install cargo-ndk --locked` step before the build step. | +| `ANDROID_NDK_HOME` not set | Set it explicitly: `echo "ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/26.1.10909125" >> $GITHUB_ENV` | +| `build-android.sh: line XX: arm64: unbound variable` on macOS | Do not use Bash associative arrays. The script uses portable indexed arrays for ABI → Rust target mapping. | +| `Unresolved reference 'sun'` / `Unresolved reference 'Structure'` in `onde.kt` | UniFFI 0.31.0 depends on JNA (`com.sun.jna.*`). Add `implementation("net.java.dev.jna:jna:5.14.0@aar")` for Android or `implementation("net.java.dev.jna:jna:5.14.0")` for JVM. | +| HuggingFace download fails on first load | The app must declare ``. | +| `Plugin [id: 'com.android.application'] was not found` in `:example` | Open `sdk/kotlin/` (not `sdk/kotlin/example/`) in Android Studio. Plugin versions are inherited from the root `build.gradle.kts`. | +| `Using singleVariant publishing DSL multiple times` | This error means the old `AndroidSingleVariantLibrary` config is still present. Use `KotlinMultiplatform(...)` instead — the KMP plugin handles variant publishing internally. | +| Convenience functions (`OndeSampling.default()` etc.) crash before `OndeInference()` created | These functions call into the native library via UniFFI. Always create an `OndeInference` instance first — its `init` block ensures the native library is loaded. | + +--- + +## Testing + +### Unit tests (host JVM — no Android device needed) + +Pure Kotlin logic tests that don't call into Rust: + +```bash +cd sdk/kotlin +./gradlew :lib:test # all JVM unit tests +./gradlew :lib:jvmTest # JVM target only +``` + +### Instrumented tests (requires Android device or emulator) + +Tests that call into Rust via the generated bindings: + +```bash +./gradlew :lib:connectedAndroidTest +``` + +For CI, use an emulator (`x86_64` ABI) with `reactivecircus/android-emulator-runner`. + +### JVM integration tests + +Tests that load the native library and run inference: + +```bash +# Build the native lib first +./sdk/kotlin/scripts/build-jvm.sh + +# Run JVM tests +cd sdk/kotlin && ./gradlew :lib:jvmTest +``` + +--- + +## Dependency Graph + +``` +OndeInference.kt (shared) +├── PlatformSupport (interface, shared) +│ ├── AndroidPlatform (androidMain) +│ │ └── android.system.Os.setenv +│ └── JvmPlatform (jvmMain) +│ ├── JNA → libc.setenv +│ └── NativeLoader → extracts libonde from JAR resources +├── uniffi.onde.OndeChatEngine (generated onde.kt) +├── uniffi.onde.StreamChunkListener (generated onde.kt) +├── uniffi.onde.*Config / *Message (generated onde.kt) +│ └── com.sun.jna.Native (JNA) +│ ├── libonde.so (Android, per ABI) +│ └── libonde.dylib (macOS, from JAR resources) +│ └── mistralrs (Metal on macOS, Candle CPU on Android) +│ └── hf-hub (model download) +└── Convenience.kt (shared) + └── OndeSampling, OndeModels, OndeMessage +``` + +--- + +## Distribution Registry + +| Registry | Artifact | Import | Discovery | +|---|---|---|---| +| Maven Central | `com.ondeinference:onde-inference` | `implementation("com.ondeinference:onde-inference:1.0.0")` | [search.maven.org](https://search.maven.org) | +| klibs.io | `onde-inference` | Same Gradle coordinate | [klibs.io](https://klibs.io) (auto-indexed) | +| GitHub Releases | `onde-inference-.aar` + `.jar` | Direct download | [github.com/ondeinference/onde/releases](https://github.com/ondeinference/onde/releases) | + +### End-game for Kotlin developers + +One line in `build.gradle.kts`: + +```kotlin +implementation("com.ondeinference:onde-inference:1.0.0") +``` + +--- + +## Example App + +A working Jetpack Compose chat app lives at `sdk/kotlin/example/`. It is the +canonical way to see the SDK in action and the first thing to open when +verifying a new build. + +### Opening in Android Studio + +Always open **`sdk/kotlin/`** — the Gradle root. Do not open +`sdk/kotlin/example/` directly. Android Studio will see both `:lib` and +`:example` as modules and sync correctly. + +### What the example covers + +- Loading the default model with `loadDefaultModel()` and showing load time +- Streaming replies token-by-token via `onde.stream()` and `Flow` +- Multi-turn conversation history maintained in the Rust engine +- Clearing history without reloading the model +- Material3 dynamic colour theme with dark mode support +- Proper IME / navigation-bar inset handling + +### Gradle submodule rules + +1. **No `settings.gradle.kts` inside `example/`** — Gradle would treat it as + standalone, breaking plugin resolution and `project(":lib")`. +2. **No plugin versions in `example/build.gradle.kts`** — inherited from root. +3. **`include(":example")` in root `settings.gradle.kts`** — required for Gradle. +4. **`project(":lib")` as the SDK dependency** — builds against local source. + +The root `build.gradle.kts` declares all plugins used by any submodule: + +```kotlin +plugins { + id("com.android.library") version "8.5.2" apply false + id("com.android.application") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false + id("org.jetbrains.kotlin.multiplatform") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false + id("com.vanniktech.maven.publish") version "0.28.0" apply false +} +``` + +### Streaming pattern + +```kotlin +val placeholderId = System.nanoTime() +_uiState.update { it.copy(messages = it.messages + UiMessage(id = placeholderId, ...)) } + +val buffer = StringBuilder() +onde.stream(text).collect { chunk -> + buffer.append(chunk.delta) + _uiState.update { state -> + state.copy(messages = state.messages.replaceLast( + UiMessage(id = placeholderId, content = buffer.toString(), isStreaming = !chunk.done) + )) + } +} +``` + +### Running the example + +```bash +cd sdk/kotlin + +# 1. Build the .so files +./scripts/build-android.sh + +# 2. Generate the Kotlin bindings +./scripts/generate-bindings.sh + +# 3. Install on a connected device +./gradlew :example:installDebug +``` + +Or open `sdk/kotlin/` in Android Studio and hit Run on the `:example` configuration. + +--- + +## Cross-Platform SDK Comparison + +Onde ships SDKs for four platforms. The Kotlin SDK is one piece of the +distribution strategy: + +| SDK | Primary Platform | GPU Backend | Package Manager | Discovery | +|-----|-----------------|-------------|-----------------|-----------| +| **Swift** | iOS, tvOS, macOS | Metal | Swift Package Manager | Swift Package Index | +| **Dart** | Android, iOS (Flutter) | Platform-dependent | pub.dev | pub.dev search | +| **Kotlin** | Android, JVM (macOS) | Metal (JVM) / CPU (Android) | Maven Central | klibs.io | +| **React Native** | Android, iOS | Platform-dependent | npm | npmjs.com | + +The Swift SDK is the primary distribution — it runs natively on Apple silicon +with Metal acceleration. The Kotlin JVM target provides the same Metal-backed +inference for Kotlin developers on macOS, while the Android target extends +reach to the Android ecosystem. + +--- + +*Update this skill when adding KMP targets (e.g. Kotlin/Native for iOS), +changing the source set structure, or modifying the Maven Central publishing +pipeline.* \ No newline at end of file diff --git a/.agents/skills/sdk-react-native/SKILL.md b/.agents/skills/sdk-react-native/SKILL.md new file mode 100644 index 0000000..f64f985 --- /dev/null +++ b/.agents/skills/sdk-react-native/SKILL.md @@ -0,0 +1,769 @@ +--- +name: sdk-react-native +description: Build, debug, and maintain the Onde Inference React Native / Expo SDK. Covers Expo native module wiring, Metro config for file-linked SDKs, iOS deployment target and Expo autolinking, XCFramework device/simulator split, app group entitlement, HF_HOME/TMPDIR sandbox setup, the native bridge JSON string contract, EngineStatus normalization, and npm publishing. +allowed-tools: Read, Write, Edit, Glob, Grep, Bash +user-invocable: true +--- + +# Skill: React Native / Expo SDK + +## What This Skill Covers + +Building and maintaining the `@ondeinference/react-native` Expo native module, +wiring the Rust C FFI bridge to TypeScript, debugging Metro/CocoaPods/Xcode +issues, and publishing to npm. + +--- + +## Repository Layout + +``` +onde/sdk/react-native/ +├── package.json # name: @ondeinference/react-native +├── expo-module.config.json # declares OndeInferenceModule for Expo autolinking +├── tsconfig.json # target ES2020, module commonjs +├── src/ +│ ├── index.ts # public API: OndeChatEngine, free functions, types +│ ├── OndeInferenceModule.ts # requireNativeModule("OndeInference") — raw bridge +│ └── types.ts # ChatMessage, SamplingConfig, EngineInfo, etc. +├── build/ # tsc output — gitignored, regenerated by npm run build +├── ios/ +│ ├── OndeInference.podspec # CocoaPods spec — vendored_frameworks = XCFramework +│ ├── OndeInferenceModule.swift # Expo Module: Rust C FFI declarations + module def +│ └── rust/ +│ ├── OndeReactNative.xcframework # bundles device + simulator slices +│ ├── device/libonde_react_native.a +│ └── simulator/libonde_react_native.a +├── android/ # Android module (not yet wired for Onde) +├── scripts/ +│ └── build-rust.sh # builds Rust .a for iOS device + sim, creates XCFramework +└── example/ + ├── app.json # Expo config — ios.deploymentTarget, appleTeamId + ├── babel.config.js # babel-preset-expo only + ├── metro.config.js # watchFolders, extraNodeModules, nodeModulesPaths, blockList + ├── package.json # dep: "@ondeinference/react-native": "file:.." + └── ios/ + ├── Podfile # uses use_expo_modules!, deployment from Podfile.properties.json + ├── Podfile.properties.json # ios.deploymentTarget: "16.0" — critical, see below + ├── OndeExample/ + │ ├── OndeExample.entitlements + │ └── Info.plist + └── Pods/ # gitignored +``` + +--- + +## Critical Lessons (Read First) + +### 1. Metro `@babel/runtime` resolution with `file:..` local packages + +**Symptom:** `Unable to resolve module @babel/runtime/helpers/asyncToGenerator` +from inside the SDK's `build/index.js`. + +**Root cause:** When Metro processes a file physically located outside the +example app's directory (e.g. in `sdk/react-native/build/`), Babel transforms +`async/await` and injects `require("@babel/runtime/...")`. Metro resolves +modules relative to the file's physical location — but the SDK's own +`node_modules/` is blocked (to avoid react-native version conflicts) and the +example's `node_modules/` is not in the resolution path for files outside the +example root. + +**Fix — always required in `metro.config.js`:** + +```js +// Falls back to the example's node_modules for any file in watched folders +config.resolver.nodeModulesPaths = [path.resolve(__dirname, "node_modules")]; +``` + +Also required: block the SDK's node_modules to avoid react-native version +conflicts: + +```js +config.resolver.blockList = [ + new RegExp( + path.resolve(sdkRoot, "node_modules").replace(/\\/g, "\\\\") + "/.*", + ), +]; +``` + +--- + +### 2. Expo autolinking silently skips pods when deployment target is too low + +**Symptom:** `pod install` completes but `OndeInference` is not in +`Podfile.lock`. The app launches but throws "Cannot find native module +'OndeInference'" at runtime. + +**Root cause:** Expo's autolinking Ruby code calls `pod.supports_platform?` +on each discovered pod. The `OndeInference` podspec declares +`s.platforms = { :ios => '16.0' }`. If the project's deployment target +(from `Podfile.properties.json`) is lower (e.g. the Expo default of 15.1), +`supports_platform?` returns false and the pod is **silently skipped** with no +error or warning. + +**Diagnosis:** + +```bash +xcrun simctl get_app_container booted data +grep "OndeInference" ios/Podfile.lock # if empty → autolinking skipped it +``` + +**Fix — three places must all agree on iOS 16.0:** + +1. `sdk/react-native/ios/OndeInference.podspec` — `s.platforms = { :ios => '16.0' }` +2. `example/ios/Podfile.properties.json` — `"ios.deploymentTarget": "16.0"` +3. `example/app.json` → `expo.ios.deploymentTarget: "16.0"` + +And after changing `Podfile.properties.json`, manually patch the Xcode project: + +```bash +sed -i '' 's/IPHONEOS_DEPLOYMENT_TARGET = 15.1/IPHONEOS_DEPLOYMENT_TARGET = 16.0/g' \ + ios/OndeExample.xcodeproj/project.pbxproj +``` + +Then run `pod install` again and verify: + +```bash +grep "OndeInference" ios/Podfile.lock # must now appear +``` + +**Important:** `expo prebuild --clean` regenerates `Podfile.properties.json` +from `app.json`. After `--clean`, you must also manually re-add +`"ios.deploymentTarget": "16.0"` to `Podfile.properties.json` if it was not +picked up from `app.json`. + +--- + +### 3. iOS device vs simulator static library — use an XCFramework + +**Symptom:** Build succeeds for simulator but fails with: +`ld: building for 'iOS-simulator', but linking in object file built for 'iOS'` + +**Root cause:** Both iOS device (`aarch64-apple-ios`) and iOS simulator +(`aarch64-apple-ios-sim`) are arm64, so `lipo` cannot merge them into a fat +binary. They have different Mach-O platform metadata. + +**Wrong approach:** Single `libonde_react_native.a` → works for device OR +simulator but not both. + +**Wrong approach:** `vendored_libraries` with two files sharing the same +filename → CocoaPods reports "conflicting names" error. + +**Correct approach:** XCFramework bundling both slices: + +```bash +# In scripts/build-rust.sh +mkdir -p ios/rust/device ios/rust/simulator +cp target/aarch64-apple-ios/release/libonde_react_native.a ios/rust/device/ +cp target/aarch64-apple-ios-sim/release/libonde_react_native.a ios/rust/simulator/ + +rm -rf ios/rust/OndeReactNative.xcframework +xcodebuild -create-xcframework \ + -library ios/rust/device/libonde_react_native.a \ + -library ios/rust/simulator/libonde_react_native.a \ + -output ios/rust/OndeReactNative.xcframework +``` + +Podspec uses `vendored_frameworks` (not `vendored_libraries`): + +```ruby +s.vendored_frameworks = 'rust/OndeReactNative.xcframework' +s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule', + 'OTHER_LDFLAGS' => '-londe_react_native', +} +``` + +CocoaPods automatically selects the correct slice for device vs simulator. + +--- + +### 4. The native bridge JSON string contract — never return Swift dicts + +**Symptom:** `JSON parse error: unexpected character: o` + +**Root cause:** The TypeScript layer receives the native return value and calls +`JSON.parse(value)`. If the native Swift module returns `[String: Any]` or +`[[String: Any]]` instead of a `String`, the Expo bridge serialises it in an +opaque way that is not valid JSON (the string representation starts with +something like `{...}` or `[...]` in a non-JSON form that starts with `o` +when printed). + +**The contract:** All Expo Module methods in `OndeInferenceModule.swift` that +return complex types MUST return `String` (the raw JSON from Rust), NOT +`[String: Any]` or `[[String: Any]]`. The TypeScript layer owns all JSON +parsing. + +**Correct Swift:** + +```swift +AsyncFunction("loadDefaultModel") { + (systemPrompt: String?, samplingJson: String?) -> String in // ← String, not [String: Any] + ... + return try self.validateJsonResult(result) // returns the raw JSON string +} + +Function("defaultModelConfig") { () -> String in // ← String, not [String: Any] + let result = self.consumeRustString(onde_default_model_config()) + return try self.validateJsonResult(result) +} +``` + +**Validation helper — validate but do not deserialise:** + +```swift +private func validateJsonResult(_ json: String?) throws -> String { + guard let json = json, + let data = json.data(using: .utf8), + let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] + else { throw OndeError.invalidResponse } + if let error = dict["error"] as? String { + throw OndeError.inferenceError(error) + } + return json // ← return the original string, not the parsed dict +} + +private func validateJsonArray(_ json: String?) throws -> String { + guard let json = json, + let data = json.data(using: .utf8), + (try JSONSerialization.jsonObject(with: data) as? [[String: Any]]) != nil + else { throw OndeError.invalidResponse } + return json // ← return the original string +} +``` + +--- + +### 5. EngineStatus is lowercase from Rust — normalize in the UI layer + +**Symptom:** Model loads successfully. Alert says "Ready in X.Xs". But the +`TextInput` placeholder stays at "Load a model first" and remains non-editable. + +**Root cause:** Rust's `Display` impl emits lowercase status strings: +`"ready"`, `"unloaded"`, `"loading"`, `"generating"`, `"error"`. +The TypeScript UI uses capitalized union type values (`"Ready"`, `"Unloaded"`, +etc.). After `refreshStatus()` runs, `engineStatus` holds `"ready"` which +does not match `"Ready"` in the `isLoaded` check. + +**Fix — normalize at the point of receiving native status:** + +```typescript +const normalizeEngineStatus = useCallback((status: string): EngineStatus => { + switch (status.toLowerCase()) { + case "loading": return "Loading"; + case "ready": return "Ready"; + case "generating": return "Generating"; + case "error": return "Error"; + default: return "Unloaded"; + } +}, []); + +const refreshStatus = useCallback(async () => { + try { + const info = await OndeChatEngine.info(); + setEngineStatus(normalizeEngineStatus(info.status)); // ← normalize here + setModelName(info.modelName ?? null); + setApproxMemory(info.approxMemory ?? null); + } catch { /* best-effort */ } +}, [normalizeEngineStatus]); +``` + +--- + +### 6. iOS sandbox filesystem setup — HF_HOME must be set before model load + +**Symptom:** Model load fails immediately with "error 0" or returns an error +containing "HF_HOME is not set". + +**Root cause:** On iOS, `~/.cache` is outside the app sandbox. The Rust engine +explicitly requires `HF_HOME` to be set before any model load on sandboxed +platforms. Without it, `load_gguf_model` returns an `InferenceError::ModelBuild` +with the message: *"HF_HOME is not set — cannot initialise HF cache."* + +**Fix — call `configureApplicationFilesystem()` in `OnCreate` and before each +model load:** + +```swift +private func configureApplicationFilesystem() throws { + let fileManager = FileManager.default + + let containerDirectory: URL + if let appGroupDirectory = fileManager.containerURL( + forSecurityApplicationGroupIdentifier: ondeAppGroupIdentifier + ) { + containerDirectory = appGroupDirectory + } else { + containerDirectory = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, appropriateFor: nil, create: true + ) + } + + // Convention: /models/hub — matches Tauri and Swift SDKs exactly + let hfHomeDirectory = containerDirectory.appendingPathComponent("models", isDirectory: true) + let hfHubCacheDirectory = hfHomeDirectory.appendingPathComponent("hub", isDirectory: true) + let tempDirectory = containerDirectory.appendingPathComponent("tmp", isDirectory: true) + + try fileManager.createDirectory(at: hfHomeDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: hfHubCacheDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + + setenv("HF_HOME", hfHomeDirectory.path, 1) + setenv("HF_HUB_CACHE", hfHubCacheDirectory.path, 1) + setenv("HUGGINGFACE_HUB_CACHE", hfHubCacheDirectory.path, 1) + setenv("TMPDIR", tempDirectory.path, 1) +} +``` + +Call it in `OnCreate` **and** at the start of `loadDefaultModel`/`loadModel`: + +```swift +OnCreate { + do { + try self.configureApplicationFilesystem() + self.enginePtr = onde_engine_create() + } catch { + self.enginePtr = nil + } +} + +AsyncFunction("loadDefaultModel") { ... -> String in + try self.configureApplicationFilesystem() // ← also here, idempotent + guard let ptr = self.enginePtr else { throw OndeError.engineNotInitialized } + ... +} +``` + +--- + +### 7. Shared app group cache path convention + +All Onde-powered apps across all SDKs (Tauri, Swift, React Native, Flutter) +share the same HuggingFace cache via the App Group container. The layout is: + +``` +/ +├── models/ ← HF_HOME +│ └── hub/ ← HF_HUB_CACHE (GGUF files live here) +└── tmp/ ← TMPDIR +``` + +**The subdirectory is `models/`, NOT `huggingface/`.** + +Do NOT use `OndeInference/huggingface/hub/` — this was an early mistake that +caused the app to look in a different place than the Tauri SDK, preventing +cross-app model reuse. + +Verifying the shared container is accessible on simulator: + +```bash +xcrun simctl get_app_container booted groups +# → shows group.com.ondeinference.apps path + +xcrun simctl get_app_container booted group.com.ondeinference.apps +# → resolves the exact path + +# Check whether models exist in the shared cache +find "$(xcrun simctl get_app_container booted group.com.ondeinference.apps)/models/hub" \ + -name "*.gguf" -o -name "*.part" -o -name "*.lock" | sort +``` + +--- + +### 8. Entitlements required for iOS + +The example app's `OndeExample.entitlements` must contain: + +```xml + + + + + com.apple.developer.kernel.increased-memory-limit + + com.apple.security.application-groups + + group.com.ondeinference.apps + + + +``` + +- `increased-memory-limit` — required for ~1 GB+ model loading on iOS +- `com.apple.security.application-groups` — grants access to the shared model cache + +**On physical device:** the App Group must be registered in the Apple Developer +Portal under **Identifiers → App Groups** before Xcode can provision it. + +On the iOS Simulator the entitlement is granted automatically by the simulator +runtime — no provisioning profile needed. + +--- + +### 9. FlatList swallows taps — TextInput never gets focus + +**Symptom:** After model loads, the `TextInput` placeholder is correct but +tapping it does nothing. The keyboard never appears. + +**Root cause:** `FlatList` on iOS intercepts touch events and by default +dismisses the keyboard on scroll, which prevents child inputs from receiving +focus when tapping in certain layout configurations. + +**Fix:** + +```tsx + +``` + +--- + +### 10. Do not use the iOS Simulator to validate model loading + +The iOS Simulator is unreliable for validating actual LLM model loading with +this stack: +- Metal-backed inference behaves differently in the simulator +- Large GGUF model loading (~941 MB) may hang or fail in the simulator +- Shared app group container behavior can differ + +Use the Simulator for: +- Build verification +- UI layout and interaction +- Entitlement/container path checks (`xcrun simctl get_app_container ...`) + +Use a **physical iPhone running iOS 16+** for: +- Actual model download and load +- Metal inference correctness +- Memory behavior + +--- + +### 11. Diagnosing a hung model load + +When the app stays in "Loading" state indefinitely: + +1. **Check the shared container has no stale partial downloads:** + +```bash +# Find .part and .lock files +find "$(xcrun simctl get_app_container booted group.com.ondeinference.apps)" \ + -type f \( -name "*.part" -o -name "*.lock" \) | sort + +# Remove stale partial caches +rm -rf "/models/hub/models----" +``` + +2. **Check the app group resolves:** + +```bash +xcrun simctl get_app_container booted groups +``` + +3. **Check native logs:** + +```bash +xcrun simctl spawn booted log show --style compact --last 5m \ + --predicate 'process == "OndeExample" AND ( + eventMessage CONTAINS[c] "HF_" OR + eventMessage CONTAINS[c] "models" OR + eventMessage CONTAINS[c] "onde" + )' | tail -100 +``` + +4. **Verify ` +HF_TOKEN` was baked in at Rust build time.** On iOS, +`TokenSource::CacheToken` cannot read `~/.cache/huggingface/token` because that +path is outside the sandbox. `HF_TOKEN` must be set as an environment variable +**when building the Rust static library**: + +```bash +export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx +./scripts/build-rust.sh ios +``` + +`option_env!("HF_TOKEN")` in `src/inference/token.rs` bakes it into the binary +at compile time. Setting it after the fact has no effect. + +--- + +## Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ React Native / TypeScript │ +│ OndeChatEngine, free functions (index.ts) │ +└─────────────────────┬────────────────────────────────┘ + │ JSON strings over Expo bridge + ▼ +┌──────────────────────────────────────────────────────┐ +│ OndeInferenceModule.swift (Expo Module) │ +│ - configureApplicationFilesystem() │ +│ - validateJsonResult() / validateJsonArray() │ +│ - AsyncFunction / Function definitions │ +└─────────────────────┬────────────────────────────────┘ + │ C FFI via @_silgen_name + ▼ +┌──────────────────────────────────────────────────────┐ +│ libonde_react_native.a (Rust static lib) │ +│ C API: onde_engine_create, onde_engine_load_*, ... │ +│ Rust engine: ChatEngine, mistral.rs, Metal/CPU │ +└──────────────────────────────────────────────────────┘ +``` + +The Swift module **does not use UniFFI**. It calls the Rust C API directly via +`@_silgen_name` declarations, communicating via JSON strings over the Expo +Module bridge. + +This is different from the Swift XCFramework SDK (which uses UniFFI-generated +`onde.swift`) and the Kotlin SDK (which uses UniFFI-generated `onde.kt`). + +--- + +## Expo Module Definition Reference + +All methods in `OndeInferenceModule.swift` follow this pattern: + +| Expo construct | Use for | +|---|---| +| `Function(name)` | Synchronous calls (isLoaded, setSystemPrompt, etc.) | +| `AsyncFunction(name)` | All calls that invoke Rust (model load, inference, etc.) | +| `OnCreate` | Engine initialisation — run `configureApplicationFilesystem()` here | +| `OnDestroy` | Engine cleanup — call `onde_engine_destroy` | + +Return types: +- Simple sync functions: `Bool`, `Int` +- All complex types: `String` — raw JSON from Rust, **never** `[String: Any]` + +--- + +## TypeScript Bridge Contract + +### `OndeInferenceModule.ts` — raw native interface + +All async methods return `Promise` (raw JSON strings from Rust): + +```typescript +export interface OndeInferenceNativeModule { + // Sync + isLoaded(): boolean; + setSystemPrompt(prompt: string): void; + clearHistory(): number; + + // Async — all return JSON strings + loadDefaultModel(systemPrompt: string | null, samplingJson: string | null): Promise; + sendMessage(message: string): Promise; + info(): Promise; + history(): Promise; + + // Sync config — return JSON strings + defaultModelConfig(): string; + defaultSamplingConfig(): string; +} +``` + +### `index.ts` — public API layer + +Serialises inputs to JSON, calls native, deserialises JSON responses: + +```typescript +// Input serialisation (camelCase TS → snake_case Rust) +function serializeSampling(config: SamplingConfig): string { + return JSON.stringify({ temperature: config.temperature, top_p: config.topP, ... }); +} + +// Output deserialisation (snake_case Rust → camelCase TS) +function parseResult(json: string): T { + const parsed = JSON.parse(json); // JSON.parse — expects a string + if (parsed.error) throw new OndeError(parsed.error); + return parsed as T; +} +``` + +--- + +## Metro Config for `file:..` SDK Development + +```js +const { getDefaultConfig } = require("expo/metro-config"); +const path = require("path"); + +const sdkRoot = path.resolve(__dirname, ".."); // ../sdk/react-native/ +const config = getDefaultConfig(__dirname); + +// 1. Watch the SDK so Metro picks up changes +config.watchFolders = [sdkRoot]; + +// 2. Map the package name to the SDK root +config.resolver.extraNodeModules = { + "@ondeinference/react-native": sdkRoot, +}; + +// 3. Allow transitive deps (e.g. @babel/runtime) to resolve from +// the example's node_modules when files outside the example are processed +config.resolver.nodeModulesPaths = [path.resolve(__dirname, "node_modules")]; + +// 4. Block the SDK's own node_modules to prevent react-native version conflicts +config.resolver.blockList = [ + new RegExp( + path.resolve(sdkRoot, "node_modules").replace(/\\/g, "\\\\") + "/.*", + ), +]; + +module.exports = config; +``` + +--- + +## Rust C API Surface + +The Swift module binds to these C functions via `@_silgen_name`: + +| Function | Returns | Notes | +|---|---|---| +| `onde_engine_create` | `UnsafeMutableRawPointer?` | Allocates engine | +| `onde_engine_destroy` | `void` | Frees engine | +| `onde_engine_load_default_model` | `UnsafeMutablePointer?` | JSON result string | +| `onde_engine_load_model` | `UnsafeMutablePointer?` | JSON result string | +| `onde_engine_unload_model` | `UnsafeMutablePointer?` | JSON result string | +| `onde_engine_is_loaded` | `Bool` | Sync | +| `onde_engine_info` | `UnsafeMutablePointer?` | JSON result string | +| `onde_engine_send_message` | `UnsafeMutablePointer?` | JSON result string | +| `onde_engine_generate` | `UnsafeMutablePointer?` | JSON result string | +| `onde_engine_history` | `UnsafeMutablePointer?` | JSON array string | +| `onde_engine_clear_history` | `UInt64` | Sync, count | +| `onde_engine_set_system_prompt` | `void` | Sync | +| `onde_engine_set_sampling` | `void` | Sync | +| `onde_engine_push_history` | `void` | Sync | +| `onde_free_string` | `void` | Must call for every non-null CChar* result | +| `onde_default_model_config` | `UnsafeMutablePointer?` | JSON result string | +| `onde_qwen25_1_5b_config` | `UnsafeMutablePointer?` | JSON result string | +| `onde_qwen25_3b_config` | `UnsafeMutablePointer?` | JSON result string | +| `onde_default_sampling_config` | `UnsafeMutablePointer?` | JSON result string | +| `onde_deterministic_sampling_config` | `UnsafeMutablePointer?` | JSON result string | +| `onde_mobile_sampling_config` | `UnsafeMutablePointer?` | JSON result string | + +**Always call `onde_free_string` after consuming each result pointer.** The +Rust side allocates the string; Swift is responsible for freeing it. + +```swift +private func consumeRustString(_ ptr: UnsafeMutablePointer?) -> String? { + guard let ptr = ptr else { return nil } + let str = String(cString: ptr) + onde_free_string(ptr) // ← must free every non-null pointer + return str +} +``` + +--- + +## build-rust.sh — iOS Build Flow + +```bash +# 1. Build both targets +cargo build --manifest-path rust/Cargo.toml --target aarch64-apple-ios --release +cargo build --manifest-path rust/Cargo.toml --target aarch64-apple-ios-sim --release + +# 2. Copy to separate dirs (same filename — cannot lipo) +mkdir -p ios/rust/device ios/rust/simulator +cp target/aarch64-apple-ios/release/libonde_react_native.a ios/rust/device/ +cp target/aarch64-apple-ios-sim/release/libonde_react_native.a ios/rust/simulator/ + +# 3. Create XCFramework (CocoaPods picks right slice automatically) +rm -rf ios/rust/OndeReactNative.xcframework +xcodebuild -create-xcframework \ + -library ios/rust/device/libonde_react_native.a \ + -library ios/rust/simulator/libonde_react_native.a \ + -output ios/rust/OndeReactNative.xcframework +``` + +After rebuilding, run `pod install` in `example/ios/` and then +`npx expo run:ios --device` or `npx expo run:ios` (simulator). + +--- + +## Running on Physical Device + +```bash +# From example/ +npx expo run:ios --device +``` + +For a physical device build you need: +- Apple Developer team set in `app.json` → `expo.ios.appleTeamId` +- The App Group `group.com.ondeinference.apps` registered in the developer portal +- The App Group capability enabled on the App ID +- `com.apple.developer.kernel.increased-memory-limit` in entitlements + +**Blank white screen on device** = Metro bundle not loading. Ensure: +1. Metro is running (`npx expo start --clear`) +2. Phone and Mac are on the same network (or use Tunnel) +3. Rebuild after native changes (`npx expo run:ios --device` — not just `expo start`) + +--- + +## .gitignore for the SDK + +```gitignore +node_modules/ +build/ # tsc output +rust/target/ # Rust build artifacts +ios/build/ +ios/rust/*.a +ios/rust/*.xcframework +ios/rust/device/ +ios/rust/simulator/ +android/build/ +android/src/main/jniLibs/ +example/.expo/ +example/android/ +example/ios/build/ +example/ios/Pods/ +example/ios/.xcode.env.local +.DS_Store +*.xcuserstate +``` + +--- + +## npm Publishing + +```bash +cd sdk/react-native +npm run build # tsc → build/ +npm publish # publishes build/, ios/, android/, scripts/ +``` + +The `.npmignore` excludes: +- `example/` +- `rust/` +- `node_modules/` +- `src/` (consumers use the compiled `build/`) + +The `package.json` `files` field explicitly includes: +`build/`, `ios/`, `android/`, `scripts/`, `expo-module.config.json` + +--- + +## Common Pitfalls Table + +| Pitfall | Symptom | Fix | +|---|---|---| +| Missing `nodeModulesPaths` in metro.config.js | `Cannot resolve @babel/runtime/helpers/asyncToGenerator` | Add `config.resolver.nodeModulesPaths = [path.resolve(__dirname, "node_modules")]` | +| Deployment target mismatch | Pod installs but `OndeInference` missing from `Podfile.lock` | Set `"ios.deploymentTarget": "16.0"` in `Podfile.properties.json` AND `app.json` | +| Single `.a` file for device + simulator | Linker error: "building for iOS-simulator but linked for iOS" | Use `xcodebuild -create-xcframework` to bundle both slices | +| Two `.a` files with same name in `vendored_libraries` | CocoaPods "conflicting names" error | Use `vendored_frameworks` with an XCFramework instead | +| Swift returning `[String: Any]` from Expo Module | `JSON parse error: unexpected character: o` | Return `String` from all Expo Module functions; validate but do not deserialise | +| `HF_HOME` not set on iOS | "error 0" or model load fails immediately | Call `configureApplicationFilesystem()` in `OnCreate` and before model load | +| Wrong cache path (`OndeInference/huggingface/hub/`) | Models not shared across Onde apps | Use `/models/hub/` to match Tauri/Swift convention | +| `EngineStatus` lowercase from Rust | Input stays disabled after model loads; status shows as `"ready"` not `"Ready"` | Normalize in `refreshStatus()` with `switch(status.toLowerCase())` | +| `FlatList` swallows taps | TextInput can't be focused | Add `keyboardShouldPersistTaps="handled"` to `FlatList` | +| Model hangs on Simulator | Stuck in loading indefinitely | Test model loading on a physical device; Simulator is unreliable for this | +| Stale `.part`/`.lock` files | Model download never completes on retry | Delete `models----` from the shared container and retry | +| `HF_TOKEN` not baked in | Auth fails on HuggingFace download; "401 Unauthorized" in Rust logs | Set `HF_TOKEN` env var **before** building the Rust static library | +| `expo prebuild --clean` wipes `Podfile.properties.json` | Deployment target reverts to 15.1; autolinking drops OndeInference | Re-add `"ios.deploymentTarget": "16.0"` to `Podfile.properties.json` after every `--clean` | +| Physical device blank white screen | App launches, nothing renders | Ensure Metro is running and on same network; use `npx expo run:ios --device` | +| Running with `expo start` instead of `expo run:ios` | "Cannot find native module 'OndeInference'" | Custom native modules require a native build — use `expo run:ios` not Expo Go | \ No newline at end of file diff --git a/.agents/skills/sdk-regressions/SKILL.md b/.agents/skills/sdk-regressions/SKILL.md new file mode 100644 index 0000000..9ee49fc --- /dev/null +++ b/.agents/skills/sdk-regressions/SKILL.md @@ -0,0 +1,632 @@ +--- +name: sdk-regressions +description: Cross-compilation regression checks for the onde crate. Covers macOS, iOS, tvOS (nightly), Android, Windows, and Linux target verification. Run before merging any change that touches platform-gated code, Cargo.toml, .cargo/config.toml, or build.rs. Also documents the onde-mistralrs fork publishing strategy and cargo publish workflow. +allowed-tools: Read, Edit, Glob, Grep, Terminal +user-invocable: true +--- + +# SKILL: SDK Cross-Compilation Regression Checks + +> Always run these checks before merging changes that touch `onde/src/`, +> `onde/Cargo.toml`, `onde/.cargo/config.toml`, `onde/build.rs`, or any +> platform-gated dependency. + +--- + +## Why This Exists + +The `onde` crate compiles for **6 target OSes** with heavy `#[cfg(target_os)]` +branching (106+ occurrences in `engine.rs` alone). A change that compiles on +macOS can silently break iOS, tvOS, or Android because: + +- Different `mistralrs` feature sets per platform (`["metal"]` vs `[]`) +- Platform-conditional dependencies (`hf-hub` only on Android) +- Tier-3 targets (tvOS) require nightly + `-Z build-std` +- Assembly stubs (`tvos_chkstk.s`) and `build.rs` gating +- `.cargo/config.toml` sets `+fp16` rustflags on 5 Apple targets + +See also: **[`cargo publish` workflow](#cargo-publish-workflow)** at the bottom +of this file — must be followed when releasing a new `onde` version. + +--- + +## Tier 1 — Must Pass on Every PR (stable toolchain) + +These use the default stable toolchain and must always succeed: + +```bash +# macOS — host platform, Metal backend +cargo check + +# iOS device — Metal backend, +fp16 +cargo check --target aarch64-apple-ios + +# iOS simulator — Metal backend, +fp16 +cargo check --target aarch64-apple-ios-sim +``` + +### Quick one-liner + +```bash +cargo check && \ +cargo check --target aarch64-apple-ios && \ +cargo check --target aarch64-apple-ios-sim +``` + +--- + +## Tier 2 — tvOS (nightly toolchain, `-Z build-std`) + +tvOS targets are tier-3 in Rust — they are **not** in `rustup target list` and +require nightly with `-Z build-std` and the `rust-src` component. + +```bash +# tvOS device +cargo +nightly check -Z build-std --target aarch64-apple-tvos + +# tvOS simulator +cargo +nightly check -Z build-std --target aarch64-apple-tvos-sim +``` + +### tvOS-specific regression risks + +| Risk | What breaks | How to detect | +|------|-------------|---------------| +| `scripts/tvos_chkstk.s` deleted | `Undefined symbol: ___chkstk_darwin` at link time | `test -f scripts/tvos_chkstk.s` | +| `cc` build-dep removed from `Cargo.toml` | `build.rs` can't compile the assembly stub | `grep 'cc' Cargo.toml` | +| `build.rs` tvOS block removed | Same linker error | `grep 'tvos' build.rs` | +| nightly `rust-src` not installed | `-Z build-std` fails immediately | `rustup +nightly component list --installed \| grep rust-src` | +| `.cargo/config.toml` `+fp16` removed | Silent precision loss or candle kernel panics | `grep 'fp16' .cargo/config.toml` | + +--- + +## Tier 3 — Android, Windows, Linux (stable, needs cross toolchains) + +These check that CPU-only code paths compile. For this crate, `cargo check` +still runs C build scripts from dependencies such as `aws-lc-sys` and `ring`, +so absent cross GCC toolchains are real blockers, not harmless link-only +failures. + +```bash +# Android arm64 — CPU, requires hf-hub sandbox workaround +cargo check --target aarch64-linux-android + +# Windows MSVC — CPU, exact CI target; run on Windows or a Windows CI runner +cargo check --target x86_64-pc-windows-msvc + +# Linux — CPU +cargo check --target x86_64-unknown-linux-gnu +``` + +On macOS hosts without cross GCC installed, expect Linux checks to fail looking +for `x86_64-linux-gnu-gcc`, and Windows GNU checks to fail looking for +`x86_64-w64-mingw32-gcc`. Use the Colima/Docker flow below instead of treating +those as code regressions. + +### macOS host fallback: Colima/Docker + +Use this when the goal is to run the Linux and Windows cross-checks on a macOS +host and the host does not have cross GCC toolchains. On Apple Silicon, run a +native `linux/arm64` container. Avoid `linux/amd64` emulation for this crate; +the dependency build can crash under QEMU before reaching `onde` code. + +Give Colima enough memory before running these checks: + +```bash +colima status +colima stop +colima start --memory 8 +``` + +Run Linux and the local Windows GNU proxy check in the container: + +```bash +docker run --rm --platform linux/arm64 \ + -v "$PWD:/workspace" \ + -v onde-cargo-registry:/usr/local/cargo/registry \ + -v onde-cargo-git:/usr/local/cargo/git \ + -w /workspace \ + rust:1.92-bookworm \ + bash -lc ' + export PATH=/usr/local/cargo/bin:$PATH + apt-get update >/dev/null + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + gcc-x86-64-linux-gnu \ + libc6-dev-amd64-cross \ + linux-libc-dev-amd64-cross \ + gcc-mingw-w64-x86-64 \ + binutils-mingw-w64-x86-64 >/dev/null + rustup target add x86_64-unknown-linux-gnu x86_64-pc-windows-gnu >/dev/null + CARGO_BUILD_JOBS=1 cargo check --target x86_64-unknown-linux-gnu + CARGO_BUILD_JOBS=1 cargo check --target x86_64-pc-windows-gnu + ' +``` + +`x86_64-pc-windows-gnu` exercises `#[cfg(target_os = "windows")]` and the +CPU-only Windows path locally, but it is not a substitute for the CI +`x86_64-pc-windows-msvc` check. The MSVC ABI check needs a Windows/MSVC +toolchain, normally GitHub Actions `windows-latest`. + +If the container reaches `Checking onde ...` and then reports Rust errors, the +cross toolchain is working. Fix the crate error and rerun the check. + +--- + +## Full Regression Suite (copy-paste) + +```bash +#!/usr/bin/env bash +set -euo pipefail + +echo "═══ Tier 1: macOS + iOS (stable) ═══" +cargo check +cargo check --target aarch64-apple-ios +cargo check --target aarch64-apple-ios-sim + +echo "═══ Tier 2: tvOS (nightly + build-std) ═══" +cargo +nightly check -Z build-std --target aarch64-apple-tvos +cargo +nightly check -Z build-std --target aarch64-apple-tvos-sim + +echo "═══ Tier 3: Android / Windows / Linux ═══" +cargo check --target aarch64-linux-android +cargo check --target x86_64-pc-windows-msvc +cargo check --target x86_64-unknown-linux-gnu + +echo "✅ All cross-compilation checks passed" +``` + +--- + +## Required Rustup Targets + +Install these once on a development machine: + +```bash +# Tier 1 (stable) +rustup target add aarch64-apple-darwin x86_64-apple-darwin +rustup target add aarch64-apple-ios aarch64-apple-ios-sim + +# Tier 3 (stable) +rustup target add aarch64-linux-android armv7-linux-androideabi +rustup target add x86_64-linux-android i686-linux-android +rustup target add x86_64-pc-windows-msvc +rustup target add x86_64-pc-windows-gnu +rustup target add x86_64-unknown-linux-gnu + +# Tier 2 — tvOS (nightly, no `rustup target add` — uses -Z build-std) +rustup toolchain install nightly +rustup +nightly component add rust-src +``` + +--- + +## `.cargo/config.toml` — Critical Settings + +These rustflags are set at the workspace level and **must not be removed**: + +```toml +[target.aarch64-apple-ios] +rustflags = ["-C", "target-feature=+fp16"] + +[target.aarch64-apple-ios-sim] +rustflags = ["-C", "target-feature=+fp16"] + +[target.aarch64-apple-tvos] +rustflags = ["-C", "target-feature=+fp16"] + +[target.aarch64-apple-tvos-sim] +rustflags = ["-C", "target-feature=+fp16"] + +[target.x86_64-apple-tvos] +rustflags = ["-C", "target-feature=+fp16"] +``` + +Removing `+fp16` causes silent precision degradation or runtime panics in +candle's Metal/NEON kernels. + +--- + +## Platform Dependency Matrix + +| `target_os` | `mistralrs` features | Extra deps | GPU | Default model | +|-------------|---------------------|------------|-----|---------------| +| `macos` | `["metal"]` | — | Metal | Qwen 2.5 3B | +| `ios` | `["metal"]` | `hf-hub`, `mistralrs-core` | Metal | Qwen 2.5 1.5B | +| `tvos` | `["metal"]` | `hf-hub`, `mistralrs-core` | Metal | Qwen 2.5 1.5B | +| `android` | `[]` | `hf-hub`, `mistralrs-core` | CPU | Qwen 2.5 1.5B | +| `windows` | `[]` | — | CPU | Qwen 2.5 3B | +| `linux` | `[]` | — | CPU | Qwen 2.5 3B | + +`hf-hub` and `mistralrs-core` are required on all **sandboxed** platforms +(iOS, tvOS, Android) for the `GLOBAL_HF_CACHE` workaround — `~/.cache` is +outside the app container on iOS/tvOS, and `dirs::home_dir()` panics on Android. +The re-exports in `src/lib.rs` (`pub use hf_hub; pub use mistralrs_core;`) are +gated to `#[cfg(any(target_os = "android", target_os = "ios", target_os = "tvos"))]`. + +--- + +## Common Regression Patterns + +### 1. New model added but not to all 6 platform `cfg` blocks + +`engine.rs` has separate `#[cfg(target_os = "...")]` blocks for model builder +logic. A new model constructor added only under `macos` will fail on `ios`. + +**Detection:** Tier 1 checks catch iOS; Tier 2 catches tvOS; Tier 3 catches +Android/Windows/Linux. + +### 2. Dependency added without platform gating + +Adding a dep that uses `SystemConfiguration.framework` (e.g., `hyper_util` +proxy detection) works on macOS but causes linker errors on iOS/tvOS unless the +podspec declares `s.frameworks = 'SystemConfiguration'`. + +**Detection:** Tier 1 `cargo check` passes (no linking), but `flutter build ios` +fails. Must also test a full `flutter build` periodically. + +### 3. Fork branch divergence + +All `mistralrs` deps point to a fork: +`git = "https://github.com/setoelkahfi/mistral.rs"`, branch +`fix/all-platform-fixes`. If that branch is rebased, deleted, or diverges from +upstream, **all** platforms break. + +**Detection:** Any `cargo check` fails with "could not find branch". + +### 4. UniFFI version mismatch + +`uniffi = "=0.31.0"` must be pinned identically in three places: +- `onde/Cargo.toml` dependencies +- `onde/Cargo.toml` build-dependencies +- `onde/uniffi-bindgen/Cargo.toml` + +Mixing versions causes bindgen panics at codegen time. + +**Detection:** `cargo build` in `uniffi-bindgen/` or `build.rs` panics. + +### 5. Android `home_dir()` sandbox panic + +`dirs::home_dir()` panics in the Android sandbox. The `hf-hub` dep is added +explicitly on Android so `HF_HOME` can be set programmatically. Never call +`dirs::home_dir()` or `home::home_dir()` in code paths reachable on Android. + +**Detection:** Tier 3 `cargo check --target aarch64-linux-android` catches +compile-time issues; runtime panics need an Android emulator test. + +--- + +## Dart SDK Cross-Build Checks + +When changes touch `sdk/dart/`, also verify the Flutter plugin builds: + +```bash +cd sdk/dart/example + +# macOS (requires Xcode) +flutter build macos --debug + +# iOS (requires Xcode + iOS SDK) +flutter build ios --debug --no-codesign +``` + +### Known environment pitfall + +**Never export Android NDK toolchain binaries globally in `.zshrc`:** + +```bash +# ❌ BREAKS Xcode builds — NDK's ld/ar/strip shadow Apple's tools +export LD=$TOOLCHAIN/bin/ld +export AR=$TOOLCHAIN/bin/llvm-ar + +# ✅ Safe — env vars only, no PATH/tool overrides +export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/29.0.14206865" +``` + +If Xcode invokes the NDK's `ld` instead of Apple's, you get: +`ld: error: unknown argument '-Xlinker'` + +The fix: comment out the tool exports, use Cargo's +`sdk/dart/rust/.cargo/config.toml` for Android linker configuration instead. + +--- + +## Cache Path Consistency (Cross-SDK) + +All Onde-powered apps share downloaded models via the App Group +`group.com.ondeinference.apps`. The cache layout inside the shared container +**must be identical** across every SDK and app type: + +``` +/ +├── models/ ← HF_HOME +│ └── hub/ ← HF_HUB_CACHE (GGUF files live here) +└── tmp/ ← TMPDIR (iOS sandbox restricts system TMPDIR) +``` + +**The subdirectory is `models/`, NOT `huggingface/`.** + +Files that set this path (all must agree): + +| File | Variable | Expected value | +|------|----------|----------------| +| `onde/sdk/dart/rust/src/api.rs` (`configure_cache_dir`) | `hf_home` | `data_dir.join("models")` | +| `onde/src/hf_cache.rs` (`download_model` — general) | `hf_home` | `data_dir.join("models")` | +| `onde/src/hf_cache.rs` (`download_model` — Android) | `hf_home` | `resolved_app_data.join("models")` | +| Tauri apps (`setup_application_filesystem.rs`) | `models_home` | `container_dir.join("models")` | +| `onde/src/hf_cache.rs` (`hf_cache_dir` — fallback) | default | `~/.cache/huggingface/hub` (non-sandboxed only, OK to differ) | + +**Regression check:** `grep -rn 'join("huggingface")' src/hf_cache.rs sdk/dart/rust/src/api.rs` +should return **only** the non-sandboxed fallback in `hf_cache_dir()` (line ~164). +Any other match means a sandboxed path is using the wrong subdirectory. + +--- + +--- + +## Publishing to crates.io + +### Why we don't use upstream `mistralrs` directly + +The short version: our PRs aren't merged yet. + +`onde` depends on a personal fork of Eric Buehler's `mistral.rs`, kept at +`github.com/setoelkahfi/mistral.rs` on the `fix/all-platform-fixes` branch. +The fork is ahead of upstream by ~41 commits carrying fixes that only matter +for Apple and Android targets — things upstream hasn't needed to care about: + +| Fix | Status | +|-----|--------| +| iOS Metal 3.0 support | PR open | +| Android 32-bit memory limit constants | PR open | +| HF_HOME propagation in diffusion/flux pipelines | PR open | +| `metallib` link step `--sdk` / `-std` flags | PR open | + +Dropping the fork and pointing at upstream `mistralrs 0.8.1` would silently +break iOS on entry-level devices and Android on 32-bit targets. Don't do it +until those PRs land. + +**Legal note:** The fork is a derivative of Eric Buehler's MIT-licensed +`mistral.rs`. His `LICENSE` file — including `Copyright (c) 2024 Eric Buehler` +— must remain intact and unmodified in the fork at all times. See +[`legal-and-trademarks/SKILL.md`](../legal-and-trademarks/SKILL.md) for full +attribution obligations. + +### The `onde-mistralrs` workaround + +> For the full inventory of every fork and `[patch.crates-io]` override in the +> stack (mistral.rs, candle, sysctl, mio, tqdm, core2) and how the +> git-vs-published tiers resolve differently, see the **forked-dependencies** +> skill. This section covers only the mistral.rs publish mechanics. + +`cargo publish` strips every `git =` field before uploading to crates.io. +That means a git-only dep is useless to anyone who installs `onde` from the +registry — they can't resolve it. + +The solution: publish the fork to crates.io under the name `onde-mistralrs` +(and `onde-mistralrs-core`, etc.), then reference it with `package =` so the +rest of the code still compiles unchanged with `use mistralrs::...`: + +```toml +# In onde/Cargo.toml — each platform target section looks like this. +# Cargo resolves it as "onde-mistralrs" from crates.io, +# but the Rust code sees it as `mistralrs`. +mistralrs = { version = "0.8.2", package = "onde-mistralrs", features = ["metal"] } +``` + +This is the only supported publish path until the upstream PRs merge. Don't +swap it for plain `mistralrs` from crates.io without checking every platform +target first. + +--- + +### Step 0 — check if the candle snapshot needs an `onde-candle-*` publish + +Since 0.9.x, upstream mistral.rs pins `candle` to a git rev past the latest +released candle (e.g. rev `27f20fea`, 19 commits past `0.11.0`, adding +`QTensor::indexed_gemv`, `gemv_fused_shared_lhs`, `BarrierPool::execute_chunked`). +`cargo publish` strips git pins, so publish verification resolves candle from +crates.io and fails with E0599 "method not found" if the pinned rev has APIs the +released candle lacks. + +When that happens, publish the pinned rev under `onde-candle-*` names from the +local candle checkout (`~/Repositories/candle`, branch pattern +`onde/candle--`): + +```bash +# Which candle crates actually diverged from the released version? +git diff --stat -- candle-core candle-nn candle-metal-kernels + +# Rename ONLY the diverged crates (0.11.0 era: candle-core + candle-nn; the +# rest are byte-identical, official crates serve them). candle-nn must be +# republished whenever candle-core is, even if unchanged, or the official +# candle-nn drags the official candle-core into the graph -> two candle-core +# crates -> type mismatch. +cargo publish -p onde-candle-core +cargo publish -p onde-candle-nn +``` + +Then in the mistral.rs fork workspace `Cargo.toml`: + +```toml +candle-core = { version = "0.11.0", package = "onde-candle-core" } +candle-nn = { version = "0.11.0", package = "onde-candle-nn" } +candle-flash-attn-v3 = { version = "0.11.0" } # unchanged at rev -> official +candle-metal-kernels = { version = "0.11.0" } # unchanged at rev -> official +``` + +### Step 1 — check if `onde-mistralrs` needs a new publish + +First, see how far the fork has drifted from upstream: + +```bash +curl -s "https://api.github.com/repos/setoelkahfi/mistral.rs/compare/EricLBuehler:mistral.rs:master...setoelkahfi:mistral.rs:fix%2Fall-platform-fixes" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print('ahead:', d['ahead_by'], 'behind:', d['behind_by'])" +``` + +If there are new commits since the last `onde-mistralrs` publish, or if +upstream cut a new release that the fork has rebased onto, bump the workspace +version in the fork and publish. As of 0.9.1 the set is **12 crates** (upstream +split out flash-attn/metal-compile/code-exec/sandbox, and vision/audio/mcp must +be onde-published too because crates.io only has upstream's old versions). +The rename lives in the workspace dep table (`package = "onde-..."` on each +entry, local dep keys unchanged so imports stay `mistralrs_*`). + +```bash +# 1. Edit the root Cargo.toml in setoelkahfi/mistral.rs +# bump version, e.g. 0.9.0 → 0.9.1 + +# 2. Publish in dependency order (leaves first): +cargo publish -p onde-mistralrs-macros +cargo publish -p onde-mistralrs-metal-compile +cargo publish -p onde-mistralrs-sandbox +cargo publish -p onde-mistralrs-flash-attn +cargo publish -p onde-mistralrs-vision +cargo publish -p onde-mistralrs-audio +cargo publish -p onde-mistralrs-mcp +cargo publish -p onde-mistralrs-paged-attn +cargo publish -p onde-mistralrs-quant +cargo publish -p onde-mistralrs-code-exec +cargo publish -p onde-mistralrs-core +cargo publish -p onde-mistralrs + +# 3. cargo waits for index propagation automatically after each publish. +``` + +Known publish-verification snags (hit during 0.9.1): + +| Error | Fix | +|-------|-----| +| E0599 on candle APIs (`indexed_gemv`, ...) | Step 0 — publish `onde-candle-*` and repoint | +| `dependency tqdm does not specify a version` | tqdm must be a registry dep, not git — it is plain `tqdm = "0.8.1"` since the crossterm bump landed upstream | +| `core2 0.4.0 is yanked` | `cargo update -p bitstream-io` (4.10.0 dropped core2) | + +--- + +### Step 2 — update `onde/Cargo.toml` + +Every platform target section has its own `onde-mistralrs` line. They all need +to move to the new version together — leaving one target on an older version +causes a dependency conflict. + +```bash +# All occurrences should print the same version number: +grep 'onde-mistralrs' onde/Cargo.toml +``` + +--- + +### Step 3 — dry-run first + +```bash +cd onde +cargo publish --dry-run --allow-dirty 2>&1 | tail -20 +``` + +A clean run ends with `Finished` and no errors. Common things that go wrong: + +| Error | What to do | +|-------|------------| +| `all dependencies must have a version` | add `version = "..."` to the dep | +| `no matching package named onde-mistralrs` | publish the fork first (step 1) | +| `git specification will be removed` | expected — cargo is just telling you the git field gets stripped | +| `files in working directory contain changes` | add `--allow-dirty` | + +--- + +### Step 4 — publish `onde`, then update `sigit` + +```bash +cargo publish +``` + +Then bump the version in `sigit/Cargo.toml`: + +```toml +# git = is used for local dev; crates.io consumers resolve via version = +onde = { version = "0.1.9", git = "https://github.com/ondeinference/onde", branch = "development" } +``` + +Keep both fields. The `git =` field takes precedence during local development; +crates.io strips it and falls back to `version =` for anyone installing from +the registry. + +--- + +### GresIQ credentials and `HF_TOKEN` + +People building `onde` from crates.io don't need to do anything about secrets. +Here's why. + +`build.rs` passes credentials to the compiler via `option_env!()`. That macro +resolves at compile time — if the env var isn't set, it compiles to `None` and +the feature that needs it is silently disabled. No crash, no partial state, no +mystery error at runtime. + +```rust +// pulse/client.rs — GresIQ telemetry (Onde Inference internal infrastructure) +const EMBEDDED_API_KEY_DEV: Option<&str> = option_env!("GRESIQ_API_KEY_DEV"); +const EMBEDDED_API_SECRET_DEV: Option<&str> = option_env!("GRESIQ_API_SECRET_DEV"); + +// token.rs — HuggingFace Hub authentication +const BUILD_TIME_HF_TOKEN: Option<&str> = option_env!("HF_TOKEN"); +``` + +Who gets what: + +| Who is building | Env vars present? | What happens | +|-----------------|-------------------|--------------| +| Our CI (official builds) | yes, via GitHub secrets | telemetry on; HF token baked into binary | +| Someone who ran `cargo add onde` | no | telemetry off; HF token read from `~/.cache/huggingface/token` at runtime | +| iOS/tvOS builds via XCFramework | yes, via `.env` file | HF token baked in — required because there's no writable filesystem on device | + +The GresIQ credentials are Onde Inference's own infrastructure secrets — they +exist so the SDK can phone home for usage telemetry on official builds. External +consumers don't have them, don't need them, and won't be affected by not having +them. + +Never put `GRESIQ_API_KEY_*` or `HF_TOKEN` in `README.md`, `.env.example`, or +any committed file. The `.env` in the crate root is git-ignored for a reason: + +```bash +# onde/.env — git-ignored, never commit +GRESIQ_API_KEY_DEV=... +GRESIQ_API_SECRET_DEV=... +GRESIQ_API_KEY_PRODUCTION=... +GRESIQ_API_SECRET_PRODUCTION=... +HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +--- + +### Working against the git fork locally + +If you're making changes to the fork and want `onde` to pick them up without +publishing, uncomment the patch block in `onde/Cargo.toml`: + +```toml +[patch.crates-io] +onde-mistralrs = { git = "https://github.com/setoelkahfi/mistral.rs", branch = "fix/all-platform-fixes", package = "mistralrs" } +onde-mistralrs-core = { git = "https://github.com/setoelkahfi/mistral.rs", branch = "fix/all-platform-fixes", package = "mistralrs-core" } +``` + +Re-comment it before running `cargo publish`. Cargo strips `[patch.crates-io]` +from the published manifest automatically, so it won't affect registry +consumers either way — but leaving it active makes the dry-run confusing. + +--- + +### Pre-publish checklist + +- [ ] all `onde-mistralrs` version numbers in `onde/Cargo.toml` are the same +- [ ] `onde/Cargo.toml` `[package] version` is bumped +- [ ] `sigit/Cargo.toml` `onde` version matches +- [ ] `[patch.crates-io]` git override is commented out +- [ ] `cargo publish --dry-run --allow-dirty` passes clean +- [ ] `.env` is not listed in the dry-run's packaged file output +- [ ] Eric Buehler's `LICENSE` in the `mistral.rs` fork is unmodified + +--- + +*Last updated: July 2026 — added Colima/Docker Linux and Windows GNU +cross-check guidance for macOS hosts without cross GCC, plus the MSVC CI +caveat.* diff --git a/.agents/skills/sdk-releases/SKILL.md b/.agents/skills/sdk-releases/SKILL.md new file mode 100644 index 0000000..9344be9 --- /dev/null +++ b/.agents/skills/sdk-releases/SKILL.md @@ -0,0 +1,670 @@ +--- +name: sdk-releases +description: End-to-end SDK release process for all Onde distribution channels. Covers version bump checklist, CI/CD pipelines for Rust (crates.io), Swift (XCFramework → onde-swift), Dart (pub.dev), and npm, tag validation, GitHub Release creation, manual publish fallback, and common pitfalls. Apply whenever bumping versions or troubleshooting release workflows. +allowed-tools: Read, Write, Edit, Glob, Grep, Bash +user-invocable: true +--- + +# Skill: SDK Releases + +## What This Skill Covers + +The complete release pipeline for shipping Onde across all registries: + +- **Rust crate** → crates.io (`onde`) +- **Swift package** → Swift Package Index (`onde-swift`) +- **Dart/Flutter package** → pub.dev (`onde_inference`) +- **React Native** → npm (`@ondeinference/react-native`) +- **Kotlin Multiplatform** → Maven Central (`com.ondeinference:onde-inference`) + +All five SDK workflows share the same tag trigger. A single tag push fires all +five in parallel. When only a subset needs (re-)publishing, use the **manual +publish fallback** documented below to avoid collateral re-runs. + +--- + +## Version Bump Checklist + +Every release starts with a version bump. **All version sources must be updated +in a single commit before tagging.** + +| # | File | Field | Example | +|---|------|-------|---------| +| 1 | `Cargo.toml` (root) | `version` | `version = "0.1.3"` | +| 2 | `sdk/dart/pubspec.yaml` | `version` | `version: 0.1.3` | +| 3 | `sdk/dart/CHANGELOG.md` | New `## 0.1.3` section | Prepend at top of file | +| 4 | `sdk/react-native/package.json` | `version` | `"version": "0.1.3"` | +| 5 | `sdk/react-native/rust/Cargo.toml` | `version` | `version = "0.1.3"` | +| 6 | `sdk/react-native/CHANGELOG.md` | New `## 0.1.3` section | Prepend at top of file | +| 7 | `sdk/kotlin/gradle.properties` | `VERSION_NAME` | `VERSION_NAME=0.1.3` | +| 8 | `sdk/dart/rust/Cargo.lock` | `onde` package version | Run `cd sdk/dart/rust && cargo update -p onde` | +| 9 | `sdk/react-native/rust/Cargo.lock` | `onde` package version | Run `cd sdk/react-native/rust && cargo update -p onde` | +| 10 | `Cargo.lock` (root) | `onde` package version | Run `cargo check` at repo root | +| 11 | `CHANGELOG.md` (root) | New `## 0.1.3` section | Prepend at top of file. `sync-release-version.py --check` gates on it since 1.2.5; before that nothing did, which is why the file jumps from 1.1.1 to 1.2.5 | + +### Files you do NOT manually edit + +| File | Why | +|------|-----| +| `onde-swift/Package.swift` | CI rewrites the `url:` + `checksum:` automatically | +| `onde-swift/Sources/Onde/onde.swift` | CI copies the freshly generated UniFFI glue | + +### Quick bump commands + +```bash +# 1. Edit Cargo.toml version (manual) +# 2. Edit sdk/dart/pubspec.yaml version (manual) +# 3. Prepend new section to sdk/dart/CHANGELOG.md (manual) +# 4. Edit sdk/react-native/package.json version (manual) +# 5. Edit sdk/react-native/rust/Cargo.toml version (manual) +# 6. Prepend new section to sdk/react-native/CHANGELOG.md (manual) +# 7. Edit sdk/kotlin/gradle.properties VERSION_NAME (manual) +# 8. Prepend new section to CHANGELOG.md at the repo root (manual) + +# 9. Sync lockfiles +cargo check # updates root Cargo.lock +cd sdk/dart/rust && cargo update -p onde # updates Dart SDK's Cargo.lock +cd sdk/react-native/rust && cargo update -p onde # updates React Native SDK's Cargo.lock + +# 10. Verify +grep '^version' Cargo.toml # "0.1.3" +grep '^version:' sdk/dart/pubspec.yaml # 0.1.3 +grep '"version"' sdk/react-native/package.json # "0.1.3" +grep '^version' sdk/react-native/rust/Cargo.toml # "0.1.3" +grep 'VERSION_NAME' sdk/kotlin/gradle.properties # 1.0.0 +grep 'name = "onde"' -A1 Cargo.lock # version = "0.1.3" +grep 'name = "onde"' -A1 sdk/dart/rust/Cargo.lock # version = "0.1.3" +grep 'name = "onde"' -A1 sdk/react-native/rust/Cargo.lock # version = "0.1.3" + +# 11. Commit and tag +git add -A +git commit -m "0.1.3" +git tag 0.1.3 +git push origin main 0.1.3 +``` + +--- + +## Tag Format + +All tags are **bare semver** — no `v` prefix. + +``` +0.1.3 ✅ correct +v0.1.3 ❌ will NOT trigger CI +0.1.3-beta ❌ will NOT match the tag pattern +``` + +All five CI workflows use the same trigger pattern: + +```yaml +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" +``` + +--- + +## CI Pipeline: Rust SDK (crates.io) + +**Workflow:** `.github/workflows/release-sdk-rust.yml` +**Trigger:** Tag push matching `[0-9]+.[0-9]+.[0-9]+` or `workflow_dispatch` +**Runner:** `macos-26` + +### Flow + +``` +Tag push (e.g. 1.0.0) + │ + ├─ 1. Checkout + install stable Rust + │ + ├─ 2. Validate tag == Cargo.toml version + │ └─ Fails fast if mismatched + │ + ├─ 3. cargo publish --dry-run + │ └─ Compiles and packages; catches errors before real publish + │ + └─ 4. cargo publish (tag push only) + └─ Authenticates via CARGO_REGISTRY_TOKEN secret +``` + +### Key details + +- **`workflow_dispatch`** runs the dry-run only (the publish step has + `if: startsWith(github.ref, 'refs/tags/')`) — useful for validation. +- **Runner is `macos-26`** because the crate depends on `mistralrs` with Metal + features, which requires macOS + Xcode to compile. +- **crates.io is immutable** — once a version is published, it cannot be + re-published. You must bump to the next version. `cargo yank` hides a version + from dependency resolution but does not delete it. + +### Required secret + +`CARGO_REGISTRY_TOKEN` — a crates.io API token scoped to `publish-update` for +the `onde` crate. Create at https://crates.io/settings/tokens. Best practice: +scope it to a single crate rather than using a global token. + +--- + +## CI Pipeline: Swift SDK + +**Workflow:** `.github/workflows/release-sdk-swift.yml` +**Trigger:** Tag push matching `[0-9]+.[0-9]+.[0-9]+` +**Runner:** `macos-15` + +### Flow + +``` +Tag push (e.g. 0.1.3) + │ + ├─ 1. Build XCFramework + │ └─ .github/scripts/build-swift-xcframework.sh + │ ├─ Extracts version from Cargo.toml via tomllib + │ ├─ Compiles staticlibs for 5 Apple targets + │ ├─ Generates UniFFI Swift bindings + │ ├─ Assembles XCFramework, zips, computes SHA-256 + │ └─ Writes dist/swift/{version.txt, OndeFramework.checksum.txt} + │ + ├─ 2. Read version + checksum into step outputs + │ + ├─ 3. Validate tag == Cargo.toml version + │ └─ Fails fast if mismatched (e.g. tagged 0.1.3 but Cargo.toml says 0.1.2) + │ + ├─ 4. Upload CI artifacts (every run, including workflow_dispatch) + │ + ├─ 5. Create GitHub Release on `onde` (tag push only) + │ └─ Attaches OndeFramework.xcframework.zip + checksum + │ + ├─ 6. Checkout ondeinference/onde-swift (using ONDE_SWIFT_PAT) + │ + ├─ 7. Rewrite Package.swift + │ └─ Python regex replaces .binaryTarget url: + checksum: + │ + ├─ 8. Copy generated onde.swift → onde-swift/Sources/Onde/ + │ + └─ 9. Commit + tag onde-swift + ├─ git commit -m "Release 0.1.3" (skipped if no changes) + ├─ git push origin HEAD:main + └─ git tag -a "0.1.3" -m "Release 0.1.3" (skipped if tag exists) + └─ Annotated tag, never force-pushed +``` + +### What happens on `onde-swift` after the push + +The `onde` CI pushes a commit (to `main`) and a tag (e.g. `0.1.3`) to `onde-swift`. +This triggers **two** workflows on `onde-swift`: + +1. **`ci.yml`** (on push to `main`) — validates manifest + builds all 5 platforms +2. **`release.yml`** (on tag push) — validates package, then creates a GitHub + Release with installation instructions and a link to the upstream `onde` changelog + +Both run concurrently without conflict — they use different concurrency groups +(`ci-refs/heads/main` vs the tag ref). + +### Tag immutability + +Tags on `onde-swift` are **never force-pushed**. SPM caches the resolved commit +SHA per tag in `Package.resolved`. Moving a tag silently breaks every consumer's +lockfile. If a release needs to be redone: + +1. Delete the remote tag: `git push origin :refs/tags/0.1.3` +2. Delete the GitHub Release on both `onde` and `onde-swift` +3. Fix the issue, re-tag, push again + +--- + +## CI Pipeline: npm SDK (React Native) + +**Workflow:** `.github/workflows/release-sdk-npm.yml` +**Trigger:** Tag push matching `[0-9]+.[0-9]+.[0-9]+` or `workflow_dispatch` +**Runner:** `ubuntu-latest` + +### Flow + +``` +Tag push (e.g. 1.0.0) + │ + ├─ validate job: + │ ├─ Set up Node.js 22 + │ ├─ Read version from sdk/react-native/package.json + │ ├─ Validate tag == package.json version + │ ├─ npm install + npm run build + │ └─ npm publish --dry-run --access public + │ + └─ publish job (tag push only, needs: validate): + ├─ npm install + npm run build + ├─ Check if version already exists on npm + │ └─ If yes → skip publish (idempotent, exits 0) + └─ npm publish --access public +``` + +### npm idempotency guard + +The publish step checks `npm view @` before publishing. If the +version already exists, it **skips** with a success exit code. This makes the npm +workflow safe to re-run — unlike Dart/Rust, it won't fail on a duplicate version. + +### No secret: trusted publishing + +Since 1.2.5 the npm workflow publishes over OIDC rather than a long-lived token, +so there is no `NPM_TOKEN` to rotate. Two things have to line up instead: + +- The `publish` job declares `permissions: id-token: write`. +- The trusted publisher on + npmjs.com/package/@ondeinference/react-native/access names repo + `ondeinference/onde`, workflow `release-sdk-npm.yml`, and no environment. + +If those drift apart, npm answers the publish with a 404 on PUT rather than an +auth error, which reads as a missing package and sends you looking in the wrong +place. Adding an `environment:` to the job without setting the matching +Environment name field on that npm page is the usual way to cause it. + +The job also upgrades npm before publishing: Node 22 ships npm 10, and trusted +publishing needs 11.5.1 or newer. + +--- + +## CI Pipeline: Dart SDK + +**Workflow:** `.github/workflows/release-sdk-dart.yml` +**Trigger:** Tag push matching `[0-9]+.[0-9]+.[0-9]+` +**Runner:** `ubuntu-latest` + +### Flow + +``` +Tag push (e.g. 0.1.3) + │ + ├─ validate job: + │ ├─ Set up Flutter (version from sdk/dart/.flutter-version) + │ ├─ Read version from pubspec.yaml + │ ├─ Validate tag == pubspec.yaml version + │ │ └─ Fails fast if mismatched + │ ├─ flutter pub get + │ ├─ flutter analyze --no-fatal-infos + │ └─ flutter pub publish --dry-run + │ + └─ publish job (tag push only, needs: validate): + ├─ Set up Flutter + ├─ flutter pub get + ├─ Write PUB_CREDENTIALS to ~/.config/dart/pub-credentials.json + └─ flutter pub publish --force +``` + +### Tag validation (Dart) + +The Dart workflow independently validates the tag against `pubspec.yaml`: + +```yaml +if [ "$TAG" != "$PUBSPEC_VERSION" ]; then + echo "::error::Tag '$TAG' does not match pubspec.yaml version '$PUBSPEC_VERSION'." + exit 1 +fi +``` + +This means **both** `Cargo.toml` and `pubspec.yaml` must have the same version +as the git tag, or both workflows fail. + +### Required secret + +`PUB_CREDENTIALS` — the full JSON content of `~/.config/dart/pub-credentials.json` +from a machine that has run `dart pub login`. Add as a repository secret on `onde`. + +--- + +## GitHub Releases (two repos) + +| Repo | Created by | Contains | +|------|-----------|----------| +| `onde` | `release-sdk-swift.yml` (step 5) | XCFramework zip + checksum, auto-generated release notes | +| `onde-swift` | `onde-swift` `release.yml` | Installation snippet, platform table, link to upstream changelog | + +Both repos need GitHub Releases because they serve different audiences: + +- **`onde`** — Rust consumers, asset hosting for the XCFramework binary +- **`onde-swift`** — Swift developers, Swift Package Index surfaces these as version notes + +--- + +## Version Sources and Validation Matrix + +| Source | Read by | Validated against | +|--------|---------|-------------------| +| `Cargo.toml` `version` | `build-swift-xcframework.sh` (tomllib) | Git tag (Swift workflow) | +| `sdk/dart/pubspec.yaml` `version` | `release-sdk-dart.yml` (grep + awk) | Git tag (Dart workflow) | +| Git tag (`github.ref_name`) | Both workflows | Both version files above | +| `onde-swift` tag | Created by `release-sdk-swift.yml` | Same value as the `onde` tag | + +All four must match. If any pair diverges, the relevant CI job fails. + +--- + +## Secrets Required + +| Secret | Used by | Purpose | +|--------|---------|---------| +| `CARGO_REGISTRY_TOKEN` | `release-sdk-rust.yml` | Authenticate with crates.io for `cargo publish`. Scoped to `publish-update` for the `onde` crate. Create at https://crates.io/settings/tokens. | +| `ONDE_SWIFT_PAT` | `release-sdk-swift.yml` | Push commits + tags to `ondeinference/onde-swift`. Must be a PAT (not `GITHUB_TOKEN`) so it triggers workflows on `onde-swift`. Needs `contents: write` scope. | +| `PUB_CREDENTIALS` | `release-sdk-dart.yml` | Authenticate with pub.dev for `flutter pub publish`. Full JSON from `~/.config/dart/pub-credentials.json`. | +| `ORG_GRADLE_PROJECT_MAVENCENTRALUSERNAME` | `release-sdk-kotlin.yml` | Sonatype Central Portal token username. Generate at central.sonatype.com → Profile → Generate User Token. | +| `ORG_GRADLE_PROJECT_MAVENCENTRALPASSWORD` | `release-sdk-kotlin.yml` | Sonatype Central Portal token password. Same dialog as above. | +| `ORG_GRADLE_PROJECT_SIGNINGKEYID` | `release-sdk-kotlin.yml` | Last 8 chars of GPG key fingerprint used to sign Maven Central artifacts. | +| `ORG_GRADLE_PROJECT_SIGNINGKEY` | `release-sdk-kotlin.yml` | ASCII-armored GPG private key. Export with `gpg --armor --export-secret-keys `. | +| `ORG_GRADLE_PROJECT_SIGNINGPASSWORD` | `release-sdk-kotlin.yml` | Passphrase for the GPG signing key. | +| `GRESIQ_API_KEY_DEV` | All release workflows | Build-time env for dev environment API key. | +| `GRESIQ_API_SECRET_DEV` | All release workflows | Build-time env for dev environment API secret. | +| `GRESIQ_API_KEY_PRODUCTION` | All release workflows | Build-time env for production API key. | +| `GRESIQ_API_SECRET_PRODUCTION` | All release workflows | Build-time env for production API secret. | +| `HF_TOKEN` | All release workflows | HuggingFace token baked in at build time (`option_env!`). | + +--- + +## Common Pitfalls + +| Pitfall | Symptom | Fix | +|---------|---------|-----| +| Forgot to bump `pubspec.yaml` | Dart CI fails: "Tag '0.1.3' does not match pubspec.yaml version '0.1.2'" | Bump `sdk/dart/pubspec.yaml`, amend commit, re-tag | +| Forgot to bump `sdk/react-native/package.json` | npm CI fails: "Tag '0.1.3' does not match package.json version '0.1.2'" | Bump `sdk/react-native/package.json`, amend commit, re-tag | +| Forgot to bump `sdk/react-native/rust/Cargo.toml` | npm SDK's Rust bridge crate version drifts from the main crate | Bump `sdk/react-native/rust/Cargo.toml`, run `cd sdk/react-native/rust && cargo update -p onde` | +| Forgot to update `sdk/dart/rust/Cargo.lock` | Dart SDK's Rust bridge builds against stale `onde` version | Run `cd sdk/dart/rust && cargo update -p onde` | +| Forgot to update `sdk/react-native/rust/Cargo.lock` | npm SDK's Rust bridge builds against stale `onde` version | Run `cd sdk/react-native/rust && cargo update -p onde` | +| Forgot to update `sdk/dart/CHANGELOG.md` | pub.dev shows stale changelog | Prepend new `## 0.1.3` section before tagging | +| Forgot to bump `sdk/react-native/CHANGELOG.md` | npm shows stale changelog | Prepend new `## 0.1.3` section before tagging | +| Forgot to update the root `CHANGELOG.md` | Preflight fails: "CHANGELOG.md: no '## 0.1.3' section" | Prepend the new section before tagging | +| crates.io publish reports success but nothing is published | `cargo publish` step logs "already on crates.io — skipping" for a version that is not there | Fixed in 1.2.5. The guard used `cargo info`, which resolves against the local workspace. If it recurs, check that `scripts/crates-io-published.py` is still what the workflow calls | +| Forgot to bump `sdk/kotlin/gradle.properties` `VERSION_NAME` | Kotlin CI fails: tag vs gradle.properties mismatch | Bump `VERSION_NAME`, amend commit, re-tag | +| Tag has `v` prefix (`v0.1.3`) | CI does not trigger — tag pattern requires bare semver | Delete the tag, re-tag without `v` | +| `CARGO_REGISTRY_TOKEN` missing | `cargo publish` fails with auth error | Create a scoped token at crates.io/settings/tokens, add as repo secret | +| `ONDE_SWIFT_PAT` expired | `onde-swift` push fails with 403 | Regenerate PAT at github.com/settings/tokens, update repo secret | +| `PUB_CREDENTIALS` expired | `flutter pub publish` fails with 401 | Run `dart pub login` locally, copy new credentials JSON to repo secret | +| npm trusted publisher does not match the workflow | `npm publish` fails with a 404 on PUT, not a 401 | Check repo, workflow filename, and environment on npmjs.com/package/@ondeinference/react-native/access against what the `publish` job declares | +| crates.io version already published | `cargo publish` fails with "already uploaded" | crates.io is immutable — bump to next version. `cargo yank` hides but doesn't delete. | +| npm version already published | npm publish step skips (idempotency guard) | Safe — the workflow exits 0. No action needed. | +| pub.dev version already published | `flutter pub publish` fails | pub.dev is immutable — bump to next version | +| Maven Central version already published | `publishAndReleaseToMavenCentral` fails | Maven Central is immutable — bump to next version | +| Maven Central namespace not verified | Deployment fails: "Namespace 'com.ondeinference' is not allowed" | Verify namespace on central.sonatype.com (add DNS TXT record, wait for Sonatype to poll) | +| Tag pushed before workflow exists on `main` | Workflow never fires for that tag | Use `workflow_dispatch` for dry-run, then publish manually. See **Manual Publish Fallback**. | +| All 5 workflows fire but only 1–2 need re-publishing | Collateral re-runs of Swift/npm/Dart/Rust/Kotlin | Don't delete+re-push the tag. Use **Manual Publish Fallback** for the specific SDKs. | +| `onde-swift` tag already exists | Tag push skipped (warning emitted) | Delete remote tag first: `git push origin :refs/tags/0.1.3` | +| Force-pushed a tag on `onde-swift` | SPM users get stale `Package.resolved` | Never force-push. Delete + re-create instead. Advise consumers to run `swift package resolve` | +| XCFramework URL 404 | `swift package resolve` fails on consumer side | Ensure the `onde` GitHub Release was created BEFORE the `onde-swift` tag was pushed (the workflow handles this order automatically) | +| `Package.swift` regex didn't match | Python `RuntimeError` in CI | Check that `onde-swift/Package.swift` still has a `.binaryTarget(name: "OndeFramework", ...)` block | +| Dart `flutter analyze` fails | `validate` job fails, `publish` is skipped | Fix analysis issues, re-tag | + +--- + +## Manual Publish Fallback + +When a tag was pushed before a workflow existed on `main`, or when you need to +publish only a subset of SDKs without re-triggering all four workflows: + +### Rust (crates.io) + +```bash +# Dry-run first +cargo publish --dry-run + +# Publish +cargo publish +``` + +Requires `~/.cargo/credentials.toml` or `CARGO_REGISTRY_TOKEN` env var. + +### Dart (pub.dev) + +```bash +cd sdk/dart + +# Dry-run +flutter pub publish --dry-run + +# Publish +flutter pub publish --force +``` + +Requires `~/.config/dart/pub-credentials.json` from `dart pub login`. + +### Kotlin (Maven Central) + +```bash +cd sdk/kotlin + +# Dry-run +./gradlew :lib:publishToMavenCentral --dry-run --no-configuration-cache + +# Publish and release +./gradlew :lib:publishAndReleaseToMavenCentral --no-configuration-cache +``` + +Requires `~/.gradle/gradle.properties` with `mavenCentralUsername`, +`mavenCentralPassword`, `signingKeyId`, `signingKey`, `signingPassword`. +Also requires the `com.ondeinference` namespace to be verified on +central.sonatype.com. + +### npm (React Native) + +```bash +cd sdk/react-native +npm run build +npm publish --access public +``` + +Requires `npm login`. Trusted publishing is CI-only, so a manual publish falls +back to your own npm credentials. + +### When to use manual publish + +| Scenario | Approach | +|----------|----------| +| Tag exists but workflow wasn't on `main` yet | Merge workflow, then publish manually | +| One SDK's CI failed, others succeeded | Fix the issue, publish that SDK manually | +| Need to avoid re-triggering Swift XCFramework build | Publish Rust/Dart/npm/Kotlin manually | +| Testing a new workflow before first real tag | Use `workflow_dispatch` for dry-run only | + +--- + +## Re-releasing a Version (Emergency Fix) + +If a release was botched and you need to re-publish the same version: + +### Swift (onde-swift) + +```bash +# 1. Delete the remote tag on onde-swift +git -C /path/to/onde-swift push origin :refs/tags/0.1.3 + +# 2. Delete the GitHub Release on onde-swift (via GitHub UI or gh CLI) +gh release delete 0.1.3 --repo ondeinference/onde-swift --yes + +# 3. Delete the GitHub Release on onde +gh release delete 0.1.3 --repo ondeinference/onde --yes + +# 4. Delete the local and remote tag on onde +git tag -d 0.1.3 +git push origin :refs/tags/0.1.3 + +# 5. Fix the issue, commit, re-tag, push +git tag 0.1.3 +git push origin main 0.1.3 +``` + +### Dart (pub.dev) / Rust (crates.io) / Kotlin (Maven Central) + +**pub.dev, crates.io, and Maven Central are all immutable.** Once a version is +published, it cannot be re-published. You must bump to `0.1.4` instead. + +- **crates.io:** `cargo yank --version 0.1.3` hides the version from dependency + resolution but does not delete it. +- **pub.dev:** No yank equivalent. The version remains visible and installable. + +If the `publish` job failed (i.e. the package was NOT published), you can +safely re-tag after fixing the issue. + +### npm + +The npm workflow's idempotency guard means re-running it is safe — it skips +if the version already exists. However, npm also does not allow re-publishing +the same version with different content. Bump to the next version if the +published content was wrong. + +--- + +## End-to-End Release Timeline + +``` +Developer merges feature → development → main (--no-ff) then tags + │ + ├─── release-sdk-rust.yml fires ──────────────────────────────────────┐ + │ ~2 min (dry-run + publish to crates.io) │ + │ │ + ├─── release-sdk-swift.yml fires ──────────────────────────────────────┤ + │ ~15 min (XCFramework build) │ + │ Creates GitHub Release on onde │ + │ Pushes commit + tag to onde-swift │ + │ │ │ + │ ├─ onde-swift ci.yml fires (push to main) │ + │ │ ~5 min (validate + build 5 platforms) │ + │ │ │ + │ └─ onde-swift release.yml fires (tag 1.0.0) │ + │ ~5 min (validate + create GitHub Release) │ + │ │ + ├─── release-sdk-dart.yml fires ───────────────────────────────────────┤ + │ ~3 min (validate + publish to pub.dev) │ + │ │ + ├─── release-sdk-npm.yml fires ───────────────────────────────────────┤ + │ ~3 min (validate + publish to npm) │ + │ │ + ├─── release-sdk-kotlin.yml fires ────────────────────────────────────┤ + │ ~10 min (Android ABIs + JVM dylib + publish to Maven Central) │ + │ │ + └─── Swift Package Index picks up onde-swift tag ──────────────────────┘ + ~30 min (SPI polling interval) + klibs.io picks up Maven Central artifact within ~30 days +``` + +All five CI workflows fire **in parallel** from the single tag push. The only +ordering dependency is that `release-sdk-swift` must create the `onde` GitHub +Release (with XCFramework assets) **before** pushing the tag to `onde-swift` — +this is guaranteed by the step order within the workflow. + +### Merge train + +Always merge through the branch train before tagging. Use `--no-ff` to +create explicit merge commits — they serve as bookmarks in the history +showing where each feature landed. + +```bash +git checkout development && git merge feature/my-branch --no-ff --no-edit +git checkout main && git merge development --no-ff --no-edit +git push origin development main +git tag X.Y.Z && git push origin X.Y.Z +``` + +Never tag from a feature branch. The tag must point to a commit on `main` that +contains all workflow files and fixes. If a workflow is added on a feature branch +and the tag is pushed before merging, the workflow won't exist at the tagged +commit and CI won't fire. + +**Why `--no-ff`:** Multi-commit feature branches (like the Kotlin SDK with 35 +files across 6 commits) look like they were committed directly to `main` with +fast-forward merges. `--no-ff` preserves the branch context. It also allows +reverting an entire feature with a single `git revert -m 1 `. + +--- + +## Distribution Registry Reference + +| Registry | Package Name | Import | Workflow | Immutable? | +|----------|-------------|--------|----------|------------| +| crates.io | `onde` | `onde = "1.0"` | `release-sdk-rust.yml` | Yes (yank hides, doesn't delete) | +| Swift Package Index | `onde-swift` (org: `ondeinference`) | `import Onde` | `release-sdk-swift.yml` → `onde-swift/release.yml` | Tags are immutable by convention | +| pub.dev | `onde_inference` | `import 'package:onde_inference/onde_inference.dart'` | `release-sdk-dart.yml` | Yes (no yank) | +| npm | `@ondeinference/react-native` | `import { OndeChatEngine } from "@ondeinference/react-native"` | `release-sdk-npm.yml` | Yes (unpublish within 72h only) | +| Maven Central | `com.ondeinference:onde-inference` | `implementation("com.ondeinference:onde-inference:1.0.0")` | `release-sdk-kotlin.yml` | Yes (cannot re-publish same version) | +| klibs.io | `onde-inference` | Same Maven Central coordinate | Auto-indexed from Maven Central | N/A (discovery only) | + +--- + +--- + +## React Native npm SDK + +**Package:** `@ondeinference/react-native` (scoped under `@ondeinference` org on npm) +**Location:** `sdk/react-native/` +**Architecture:** Expo native module wrapping Rust via C FFI + +### How it works + +``` +TypeScript (React Native) + │ import { OndeChatEngine } from "@ondeinference/react-native" + ▼ +Expo Module (Swift / Kotlin) + │ OndeInferenceModule — calls C FFI functions via @_silgen_name (iOS) / JNI (Android) + ▼ +Rust C FFI bridge (sdk/react-native/rust/) + │ extern "C" functions with JSON serialization, global tokio::Runtime + ▼ +onde crate (src/) + │ ChatEngine — tokio::sync::Mutex, mistral.rs inference + ▼ +mistral.rs → Metal (iOS) / CPU (Android) +``` + +### Key files + +| File | Purpose | +|------|---------| +| `sdk/react-native/package.json` | npm package — `@ondeinference/react-native` | +| `sdk/react-native/expo-module.config.json` | Expo autolinking config | +| `sdk/react-native/src/index.ts` | Public TypeScript API — `OndeChatEngine`, free functions, JSON ↔ camelCase conversion | +| `sdk/react-native/src/types.ts` | TypeScript type definitions mirroring Rust types | +| `sdk/react-native/src/OndeInferenceModule.ts` | `requireNativeModule("OndeInference")` bridge | +| `sdk/react-native/rust/src/lib.rs` | C FFI exports (`extern "C"`) + Android JNI wrappers | +| `sdk/react-native/ios/OndeInferenceModule.swift` | Swift Expo module — calls Rust via `@_silgen_name` | +| `sdk/react-native/android/.../OndeInferenceModule.kt` | Kotlin Expo module — calls Rust via JNI `external fun` | +| `sdk/react-native/scripts/build-rust.sh` | Cross-compile Rust for iOS (staticlib) + Android (cdylib) | + +### Building native libraries + +```bash +# iOS (requires rustup targets: aarch64-apple-ios, aarch64-apple-ios-sim) +cd sdk/react-native && ./scripts/build-rust.sh ios + +# Android (requires ANDROID_NDK_HOME) +cd sdk/react-native && ./scripts/build-rust.sh android + +# Both +cd sdk/react-native && ./scripts/build-rust.sh all +``` + +### Publishing to npm + +```bash +cd sdk/react-native +npm run build # TypeScript → build/ +npm publish --access public +``` + +The `--access public` flag is required for scoped packages on first publish. + +--- + +## Registry Immutability Summary + +All four registries are effectively immutable once a version is published: + +- **crates.io** — `cargo yank` removes from resolver but the tarball stays forever +- **pub.dev** — No retraction mechanism at all +- **npm** — `npm unpublish` works within 72 hours, but only if no dependents +- **Maven Central** — No retraction mechanism. Once published, it's permanent. +- **Swift (onde-swift tags)** — Convention: never force-push tags. Delete + re-create if needed. + +**Bottom line:** get the release right before publishing. Use `--dry-run` locally +and via `workflow_dispatch` before pushing a real tag. + +--- + +*Update this skill when adding new SDK targets (Python, Ruby, Kotlin), changing +CI runners, or modifying the tag/version validation logic.* \ No newline at end of file diff --git a/.agents/skills/sdk-swift-package/SKILL.md b/.agents/skills/sdk-swift-package/SKILL.md new file mode 100644 index 0000000..eebee5e --- /dev/null +++ b/.agents/skills/sdk-swift-package/SKILL.md @@ -0,0 +1,363 @@ +--- +name: sdk-swift-package +description: Build the Onde Swift package (XCFramework) from Rust source using UniFFI and distribute it via a remote-binary Package.swift for Swift Package Index. Covers xcframework assembly, App Group shared container, models/hub cache convention, and the onde-swift release workflow. +allowed-tools: Read, Write, Edit, Glob, Grep, Bash +user-invocable: true +--- + +# Skill: Swift Package (XCFramework Distribution) + +## What this skill covers + +Building the `Onde` Swift package from Rust source using UniFFI, assembling an +`XCFramework`, and distributing it via a remote-binary `Package.swift` for +Swift Package Index. + +--- + +## Repository layout + +``` +onde/ +├── src/ # Rust source (lib.rs, inference/, hf_cache.rs) +├── sdk/Onde/ # Output: generated Swift package (git-ignored locally) +│ ├── Package.swift +│ ├── Sources/Onde/ # UniFFI-generated onde.swift glue + header +│ └── OndeFramework.xcframework +├── uniffi-bindgen/ # onde's own uniffi-bindgen CLI binary +│ ├── Cargo.toml +│ └── uniffi-bindgen.rs # fn main() { uniffi::uniffi_bindgen_main() } +├── uniffi.toml # UniFFI binding config (package_name, cdylib_name) +├── build.rs # Provides tvOS ___chkstk_darwin stub via cc +└── Cargo.toml # uniffi = "=0.31.0" — version MUST stay pinned +``` + +--- + +## Key constraints + +| Constraint | Detail | +|---|---| +| UniFFI version | Pinned to **`=0.31.0`** in `Cargo.toml` and `uniffi-bindgen/Cargo.toml`. Never bump without coordinating with the Kotlin and Python SDKs. | +| tvOS targets | Tier-3 — require `cargo +nightly -Z build-std`. Stable toolchain is used for iOS/macOS. | +| tvOS linker | `___chkstk_darwin` is missing from tvOS libSystem. `build.rs` compiles `tvos_chkstk.s` as a no-op stub. This is automatic — do not remove it. | +| `cargo-swift` | **Not required.** The project owns its own `uniffi-bindgen` binary. Use it directly (same as `build-kotlin.sh` does for Android). | +| `.xcframework` in `Package.swift` | Published `Package.swift` must use **remote** `url:` + `checksum:` — never a local `path:`. | +| Swift package name | `Onde` (PascalCase). Git repo slug: `onde-swift` under the `ondeinference` org. | +| App Group ID | `group.com.ondeinference.apps` — shared across all Onde-powered apps (Flutter, Tauri, native Swift). | +| HF cache subdirectory | `/models/hub/` — NOT `huggingface/hub/`. Must match the Tauri and Dart SDK convention. | + +--- + +## Apple target triples + +| Slice | Triple | Toolchain | +|---|---|---| +| iOS device | `aarch64-apple-ios` | stable | +| iOS simulator (Intel) | `x86_64-apple-ios` | stable | +| iOS simulator (Apple Silicon) | `aarch64-apple-ios-sim` | stable | +| tvOS device | `aarch64-apple-tvos` | nightly + `-Z build-std` | +| tvOS simulator (Intel) | `x86_64-apple-tvos` | nightly + `-Z build-std` | +| tvOS simulator (Apple Silicon) | `aarch64-apple-tvos-sim` | nightly + `-Z build-std` | +| macOS (Apple Silicon) | `aarch64-apple-darwin` | stable | +| macOS (Intel) | `x86_64-apple-darwin` | stable | + +--- + +## Build sequence (manual / CI) + +### 1. Build the `uniffi-bindgen` CLI (host) + +```bash +cargo build --manifest-path uniffi-bindgen/Cargo.toml --release +BINDGEN=uniffi-bindgen/target/release/uniffi-bindgen # or workspace target/ +``` + +### 2. Compile Rust staticlibs per Apple target + +```bash +# Stable targets (iOS, macOS) +cargo build --target aarch64-apple-ios --release +cargo build --target x86_64-apple-ios --release +cargo build --target aarch64-apple-ios-sim --release +cargo build --target aarch64-apple-darwin --release +cargo build --target x86_64-apple-darwin --release + +# Nightly targets (tvOS — tier 3) +cargo +nightly build -Z build-std \ + --target aarch64-apple-tvos --release +cargo +nightly build -Z build-std \ + --target x86_64-apple-tvos --release +cargo +nightly build -Z build-std \ + --target aarch64-apple-tvos-sim --release +``` + +### 3. lipo simulator slices + +```bash +# iOS simulator fat lib +lipo -create \ + target/x86_64-apple-ios/release/libonde.a \ + target/aarch64-apple-ios-sim/release/libonde.a \ + -output /tmp/libonde-ios-sim.a + +# tvOS simulator fat lib +lipo -create \ + target/x86_64-apple-tvos/release/libonde.a \ + target/aarch64-apple-tvos-sim/release/libonde.a \ + -output /tmp/libonde-tvos-sim.a + +# macOS universal fat lib +lipo -create \ + target/aarch64-apple-darwin/release/libonde.a \ + target/x86_64-apple-darwin/release/libonde.a \ + -output /tmp/libonde-macos.a +``` + +### 4. Generate Swift bindings + +```bash +mkdir -p sdk/Onde/Sources/Onde + +$BINDGEN generate \ + --library target/aarch64-apple-ios/release/libonde.a \ + --language swift \ + --out-dir sdk/Onde/Sources/Onde \ + --config uniffi.toml +``` + +This produces `onde.swift` and `ondeFFI.h` (+ `ondeFFI.modulemap`). + +### 5. Create the `.xcframework` + +Each slice needs a headers directory alongside its `.a`: + +```bash +HEADERS=sdk/Onde/Sources/Onde # contains ondeFFI.h + ondeFFI.modulemap + +xcodebuild -create-xcframework \ + -library target/aarch64-apple-ios/release/libonde.a -headers $HEADERS \ + -library /tmp/libonde-ios-sim.a -headers $HEADERS \ + -library target/aarch64-apple-tvos/release/libonde.a -headers $HEADERS \ + -library /tmp/libonde-tvos-sim.a -headers $HEADERS \ + -library /tmp/libonde-macos.a -headers $HEADERS \ + -output sdk/Onde/OndeFramework.xcframework +``` + +### 6. Zip and compute checksum + +```bash +cd sdk/Onde +zip -r OndeFramework.xcframework.zip OndeFramework.xcframework +CHECKSUM=$(shasum -a 256 OndeFramework.xcframework.zip | cut -d ' ' -f1) +echo $CHECKSUM +``` + +### 7. Publish (two-repo strategy) + +1. Upload `OndeFramework.xcframework.zip` as a GitHub Release asset on the + Rust source repo. +2. In the `ondeinference/onde-kit` Swift-only repo, update `Package.swift`: + - `url:` → the GitHub Release asset download URL + - `checksum:` → the SHA-256 from step 6 +3. Commit, tag the version (`0.1.0`, etc.), and push to `onde-kit`. +4. Swift Package Index indexes `onde-kit` on every new tag. + +--- + +## `Package.swift` (published form) + +```swift +// swift-tools-version:5.5 +import PackageDescription + +let package = Package( + name: "Onde", + platforms: [ + .iOS(.v14), + .macOS(.v12), + .tvOS(.v13), + ], + products: [ + .library(name: "Onde", targets: ["Onde"]) + ], + targets: [ + .binaryTarget( + name: "OndeFramework", + url: "https://github.com/ondeinference/onde-kit/releases/download/0.1.0/OndeFramework.xcframework.zip", + checksum: "" + ), + .target( + name: "Onde", + dependencies: [.target(name: "OndeFramework")] + ), + ] +) +``` + +--- + +## UniFFI type map (Rust → Swift) + +| Rust type | UniFFI derive | Swift type | +|---|---|---| +| `ChatRole` | `uniffi::Enum` | `enum ChatRole` | +| `ChatMessage` | `uniffi::Record` | `struct ChatMessage` | +| `SamplingConfig` | `uniffi::Record` | `struct SamplingConfig` | +| `GgufModelConfig` | `uniffi::Record` | `struct GgufModelConfig` | +| `InferenceResult` | `uniffi::Record` | `struct InferenceResult` | +| `StreamChunk` | `uniffi::Record` | `struct StreamChunk` | +| `EngineStatus` | `uniffi::Enum` | `enum EngineStatus` | +| `EngineInfo` | `uniffi::Record` | `struct EngineInfo` | +| `InferenceError` | `uniffi::Error` | `enum OndeError : Error` | +| `OndeChatEngine` | `uniffi::Object` | `class OndeChatEngine` | +| `StreamChunkListener` | `callback_interface` | `protocol StreamChunkListener` | + +**UniFFI compatibility rules:** +- Use `u64`/`u32` instead of `usize` (UniFFI does not support `usize`). +- Streaming is exposed as a **free function** (`streamChatMessage`) not an + Object method — UniFFI 0.31 does not support `callback_interface` in Object + method parameters. +- Use concrete `String` parameters in `OndeChatEngine` methods — `impl Into` + does not cross the FFI boundary. + +--- + +## Swift API quick reference + +```swift +import Onde + +// Engine lifecycle +let engine = OndeChatEngine() +try await engine.loadDefaultModel(systemPrompt: "You are helpful.", sampling: nil) +try await engine.loadGgufModel(config: qwen251_5bConfig(), systemPrompt: nil, sampling: nil) +await engine.unloadModel() +let loaded: Bool = await engine.isLoaded() +let info: EngineInfo = await engine.info() + +// Chat +let result: InferenceResult = try await engine.sendMessage(message: "Hello!") +let oneShot: InferenceResult = try await engine.generate(messages: [...], sampling: nil) + +// History +let history: [ChatMessage] = await engine.history() +let removed: UInt64 = await engine.clearHistory() +await engine.pushHistory(message: userMessage(content: "...")) + +// Streaming (free function, callback-based) +try await streamChatMessage(engine: engine, message: "Tell me a story.", listener: myHandler) + +// Config free functions +defaultModelConfig() // platform-appropriate default +qwen251_5bConfig() // ~941 MB, iOS/tvOS/Android +qwen253bConfig() // ~1.93 GB, macOS/desktop + +// Sampling presets +defaultSamplingConfig() // temp=0.7, top_p=0.95, max=512 +deterministicSamplingConfig() // temp=0.0 +mobileSamplingConfig() // temp=0.7, max=128 + +// Message constructors +systemMessage(content:) +userMessage(content:) +assistantMessage(content:) +``` + +--- + +## App Group Shared Container & Cache Convention + +All Onde-powered apps use the App Group `group.com.ondeinference.apps` to share +downloaded models. The cache layout inside the shared container is: + +``` +/ +├── models/ ← HF_HOME +│ └── hub/ ← HF_HUB_CACHE (GGUF files live here) +└── tmp/ ← TMPDIR (iOS sandbox restricts system TMPDIR) +``` + +**Important:** The subdirectory is `models/`, NOT `huggingface/`. This was +chosen to be user-friendly when browsing the container in Finder/Files. All +SDKs (Rust, Swift, Dart) and all app types (Tauri, Flutter, native) must use +the same path or models won't be shared. + +### Swift setup (called once at app launch) + +```swift +import Foundation + +func setupInferenceEnvironment() { + guard let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "group.com.ondeinference.apps" + ) else { + // Fall back to app-private Application Support if App Group is unavailable. + let appSupport = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + setupHfCache(at: appSupport) + return + } + setupHfCache(at: container) +} + +private func setupHfCache(at base: URL) { + let hfHome = base.appendingPathComponent("models") + let hfHub = hfHome.appendingPathComponent("hub") + try? FileManager.default.createDirectory(at: hfHub, withIntermediateDirectories: true) + + setenv("HF_HOME", hfHome.path, 1) + setenv("HF_HUB_CACHE", hfHub.path, 1) + + let tmp = base.appendingPathComponent("tmp") + try? FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + setenv("TMPDIR", tmp.path, 1) +} +``` + +### Entitlements required + +Both iOS and macOS targets need the App Group entitlement: + +```xml +com.apple.security.application-groups + + group.com.ondeinference.apps + +``` + +The App Group must also be registered in the Apple Developer Portal under +**Identifiers → App Groups** before Xcode can provision it. + +--- + +## Local development (Xcode) + +Use the **local path** form during development — the remote binary form is only +needed for distribution: + +```swift +// Package.swift (local dev only — never commit to onde-kit) +.binaryTarget(name: "OndeFramework", path: "./OndeFramework.xcframework") +``` + +In Xcode: **File → Add Package Dependencies → Add Local** → select `sdk/Onde/`. + +--- + +## Common pitfalls + +| Pitfall | Fix | +|---|---| +| Local `path:` in published `Package.swift` | Always `url:` + `checksum:` in `onde-swift` | +| Stale checksum after rebuild | Always recompute `shasum -a 256` — never hardcode | +| Missing simulator slice | lipo `x86_64` + `aarch64-sim` before passing to `xcodebuild` | +| UniFFI version drift | Keep `uniffi = "=0.31.0"` in both `Cargo.toml` and `uniffi-bindgen/Cargo.toml` | +| tvOS build with stable toolchain | tvOS targets are tier-3; always use `cargo +nightly -Z build-std` | +| Empty `Sources/` in `onde-swift` | Copy the generated `onde.swift` (and header) into the repo on every release | +| `___chkstk_darwin` linker error | Ensure `tvos_chkstk.s` exists at crate root; `build.rs` compiles it automatically | +| Missing Metal toolchain (Xcode 26+) | Run `xcodebuild -downloadComponent MetalToolchain`, then `cargo clean -p mistralrs-quant` | +| Cache path mismatch (`huggingface/` vs `models/`) | All SDKs MUST use `/models/hub/` — using `huggingface/hub/` means models are not shared across apps | +| App Group not working | Register `group.com.ondeinference.apps` in Apple Developer Portal → Identifiers → App Groups; enable App Groups capability on the App ID; add entitlement to the target | +| `CODE_SIGN_IDENTITY = "-"` blocks App Groups | Remove ad-hoc signing from project-level build settings; App Groups require a real development certificate | +| Android NDK `LD` on PATH breaks Xcode | Never `export LD=$TOOLCHAIN/bin/ld` globally — it hijacks Xcode's linker. Use Cargo's `.cargo/config.toml` for Android linker config | \ No newline at end of file diff --git a/.agents/skills/uniffi-rs/SKILL.md b/.agents/skills/uniffi-rs/SKILL.md new file mode 100644 index 0000000..58c4a25 --- /dev/null +++ b/.agents/skills/uniffi-rs/SKILL.md @@ -0,0 +1,167 @@ +--- +name: uniffi-rs +description: Generate Swift, Kotlin, and Python bindings from Rust using UniFFI. Use when adding, modifying, or debugging FFI-exported types and functions in the onde crate. +allowed-tools: Read, Write, Edit, Glob, Grep +user-invocable: false +--- + +# uniffi-rs + +## What This Skill Covers + +How UniFFI is used in the `onde` crate to generate Swift, Kotlin, and Python bindings from a single Rust implementation. + +## Version Pin + +UniFFI is pinned to exactly `=0.31.0` everywhere. This version must stay in sync across: +- `onde/Cargo.toml` → `uniffi = { version = "=0.31.0", features = ["tokio"] }` +- `onde/Cargo.toml` build-deps → `uniffi = { version = "=0.31.0", features = ["build"] }` +- `onde/uniffi-bindgen/Cargo.toml` → `uniffi = { version = "=0.31.0", features = ["cli"] }` + +Never bump one without bumping all three. Mismatched versions produce silently broken bindings. + +## Project Structure + +``` +onde/ +├── src/lib.rs # uniffi::setup_scaffolding!() +├── src/inference/ +│ ├── types.rs # #[derive(uniffi::Record/Enum/Error)] +│ └── ffi.rs # #[derive(uniffi::Object)], #[uniffi::export] +├── uniffi-bindgen/ +│ ├── Cargo.toml # Standalone bin crate for the bindgen CLI +│ └── uniffi-bindgen.rs # fn main() { uniffi::uniffi_bindgen_main() } +├── uniffi.toml # Binding config (package names, cdylib name) +└── build.rs # uniffi build support (build-dep) +``` + +## Scaffolding Setup + +`src/lib.rs` uses the proc-macro approach: + +```rust +uniffi::setup_scaffolding!(); +``` + +This is equivalent to the UDL approach but requires no `.udl` file. The `build.rs` still declares the `uniffi` build-dep to keep compatibility if the project ever switches to UDL. + +## Type Annotations + +| Rust construct | UniFFI derive | Crosses FFI as | +|---|---|---| +| Plain data struct | `#[derive(uniffi::Record)]` | Swift `struct` / Kotlin `data class` | +| C-like / rich enum | `#[derive(uniffi::Enum)]` | Swift `enum` / Kotlin `sealed class` | +| `thiserror::Error` enum | `#[derive(uniffi::Error)]` | Swift `Error enum` / Kotlin exception | +| `Arc` wrapper struct | `#[derive(uniffi::Object)]` | Swift `class` / Kotlin `object` | +| Trait for callbacks | `#[uniffi::export(callback_interface)]` | Swift `protocol` / Kotlin `interface` | + +All exported functions and impl blocks are annotated with `#[uniffi::export]`. + +## Type Compatibility Rules + +These constraints come directly from UniFFI 0.31 limitations: + +- **`usize` / `isize` are not supported** — use `u64` / `i64` instead. +- **`impl Into` is not FFI-safe** — use concrete `String` in all exported method signatures. +- **`tokio::sync::mpsc::Receiver` is not FFI-safe** — streaming is exposed via a `callback_interface` trait delivered through a free function, not an Object method. +- **`callback_interface` parameters must be on free functions** — UniFFI 0.31 does not support `callback_interface` arguments on `#[uniffi::Object]` methods. +- **Generics are not supported** — all exported types must be fully concrete. + +## The uniffi-bindgen CLI + +`onde/uniffi-bindgen/` is a minimal standalone binary crate: + +```rust +// uniffi-bindgen.rs +fn main() { + uniffi::uniffi_bindgen_main() +} +``` + +Build it once and run it to generate bindings: + +```bash +cargo build --manifest-path uniffi-bindgen/Cargo.toml --release + +# Generate Swift +./target/release/uniffi-bindgen generate \ + --library target/aarch64-apple-tvos/release/libonde.a \ + --language swift \ + --out-dir sdk/Onde/Sources/Onde \ + --config uniffi.toml + +# Generate Kotlin +./target/release/uniffi-bindgen generate \ + --library target/aarch64-linux-android/release/libonde.so \ + --language kotlin \ + --out-dir sdk/kotlin/onde/src/main/kotlin \ + --config uniffi.toml + +# Generate Python +./target/release/uniffi-bindgen generate \ + --library target/release/libonde.dylib \ + --language python \ + --out-dir sdk/python \ + --config uniffi.toml +``` + +The `--library` argument points to a compiled artifact (`.a`, `.so`, or `.dylib`). UniFFI reads the embedded metadata from the binary — any compiled artifact from any platform works for generating bindings for any language. + +## uniffi.toml + +```toml +[bindings.kotlin] +package_name = "com.ondeinference.onde" +cdylib_name = "onde" + +[bindings.python] +cdylib_name = "onde" +``` + +No Swift-specific config is needed here. `cargo-swift` (when used) and the raw `uniffi-bindgen` both derive Swift names from the Rust struct/function names. + +## Async Support + +UniFFI 0.31 supports `async fn` exports when the crate enables the `tokio` feature: + +```toml +uniffi = { version = "=0.31.0", features = ["tokio"] } +``` + +All `async fn` methods on `#[uniffi::Object]` and all `async` free functions annotated with `#[uniffi::export]` are automatically bridged to Swift `async throws` and Kotlin `suspend fun`. + +## Streaming Pattern + +Because `callback_interface` is not allowed on Object methods in UniFFI 0.31, streaming is exposed as a top-level free function that accepts the Object by `Arc`: + +```rust +// CORRECT — free function with Arc +#[uniffi::export] +pub async fn stream_chat_message( + engine: Arc, + message: String, + listener: Box, // callback_interface +) -> Result<(), InferenceError> { ... } + +// WRONG — callback_interface on Object method (not supported in 0.31) +// impl OndeChatEngine { +// pub async fn stream(&self, listener: Box) { ... } +// } +``` + +## Adding a New Exported Type + +1. Define the type in `src/inference/types.rs` (or whichever module owns it). +2. Add the appropriate `#[derive(uniffi::Record)]` / `#[derive(uniffi::Enum)]` / `#[derive(uniffi::Error)]`. +3. Replace any `usize` fields with `u64`, and any `isize` with `i64`. +4. If the type needs to be exported from the FFI Object, add it to the method signatures in `src/inference/ffi.rs`. +5. Recompile for any Apple target and re-run `uniffi-bindgen generate` for Swift. +6. Re-run `build-kotlin.sh` for Kotlin. + +## Common Pitfalls + +- **Version mismatch**: If the `uniffi` version in `onde/Cargo.toml` differs from the one in `onde/uniffi-bindgen/Cargo.toml`, the generated bindings will be incompatible with the compiled library at runtime. Always keep them identical with the `=` exact pin. +- **Forgetting `setup_scaffolding!()`**: Without `uniffi::setup_scaffolding!()` in `lib.rs`, no scaffolding code is generated and all `#[uniffi::export]` annotations are silently ignored. +- **`usize` in struct fields**: UniFFI will produce a hard compile error or a confusing runtime panic if `usize` appears in any exported type. Always use `u64`. +- **Callback interface in Object method**: This compiles fine but panics at runtime in UniFFI 0.31. Move the method to a free function. +- **Not rebuilding the bindgen binary**: If you update `uniffi` in the main `Cargo.toml` without rebuilding `uniffi-bindgen`, the CLI will use the old schema and generate stale/broken bindings. \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..33eaf2a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,617 @@ +# Onde Inference — AGENTS.md + +> **AI agent reference for the `onde` repository and the `onde-swift` Swift SDK.** +> Keep this file accurate as the codebase evolves. + +--- + +## What Is Onde? + +Onde is an **on-device LLM inference SDK family** built around a shared Rust core. It targets Apple silicon (iOS, tvOS, macOS) plus Android, Windows, and Linux, and ships SDKs for Rust, Swift, Kotlin Multiplatform, Flutter, and React Native. It wraps [mistral.rs](https://github.com/EricLBuehler/mistral.rs) behind platform-friendly APIs with automatic model selection, HuggingFace Hub downloads, cache management, and Metal acceleration where available. + +- **Website:** https://ondeinference.com +- **Rust crate:** https://crates.io/crates/onde +- **Swift package:** https://github.com/ondeinference/onde-swift +- **Maven Central:** https://central.sonatype.com/artifact/com.ondeinference/onde-inference +- **Flutter package:** https://pub.dev/packages/onde_inference +- **React Native package:** https://www.npmjs.com/package/@ondeinference/react-native +- **In production:** [Splitfire AB apps on the Apple AppStore](https://apps.apple.com/se/developer/splitfire-ab/id1831430993) + +--- + +## Repository Layout + +``` +onde/ +├── src/ +│ ├── lib.rs # Crate root — uniffi::setup_scaffolding!() +│ ├── hf_cache.rs # HuggingFace Hub cache: list, download, delete, diagnose, repair +│ └── inference/ +│ ├── mod.rs # Module exports + top-level re-exports +│ ├── engine.rs # ChatEngine — Rust-native API (generics, mpsc, tool calling) +│ ├── ffi.rs # OndeChatEngine — UniFFI Object (FFI-safe, Arc-wrapped) +│ ├── models.rs # Model ID constants + SupportedModelInfo metadata +│ ├── token.rs # HF token resolution: build-time literal vs cache file +│ └── types.rs # Shared types: ChatMessage, SamplingConfig, InferenceResult, ToolCallInfo, etc. +├── sdk/ +│ ├── dart/ # Flutter/Dart package + example app + FRB Rust bridge +│ ├── gem/ # Ruby native extension (Magnus) +│ ├── kotlin/ # Kotlin Multiplatform package (Android + JVM) +│ ├── react-native/ # Expo module wrapping the Rust core for iOS/Android +│ └── python/ # Python bindings (maturin + uniffi) +├── generated/ # UniFFI-generated headers and Swift glue (git-ignored) +├── uniffi-bindgen/ # Standalone bindgen binary crate (pinned uniffi =0.31.0) +├── .github/ +│ ├── workflows/ +│ │ ├── release-sdk-swift.yml # CI: tag push → build XCFramework → GitHub Release → update onde-swift +│ │ ├── release-sdk-kotlin.yml # CI: tag push → build Android/JVM artifacts → publish to Maven Central +│ │ ├── release-sdk-dart.yml # CI: tag push → publish Flutter package to pub.dev +│ │ ├── release-sdk-npm.yml # CI: tag push → publish React Native package to npm +│ │ └── release-sdk-rust.yml # CI: tag push → publish Rust crate to crates.io +│ └── scripts/ +│ └── build-swift-xcframework.sh # Local/CI XCFramework assembly script +├── .cargo/config.toml # Target-specific rustflags (fp16, linker overrides) +├── Cargo.toml # Platform-conditional mistralrs deps +├── build.rs # tvOS ___chkstk_darwin assembly stub +├── scripts/ +│ └── tvos_chkstk.s # No-op arm64 stub for missing tvOS symbol +├── uniffi.toml # UniFFI binding config +└── docs/ + ├── dev.md # Developer guide (build, architecture, platform table) + ├── swift-package.md # Swift API reference + XCFramework build steps + ├── distribution.md # Release process for all registries + └── ruby-gem.md # Ruby gem API reference +``` + +``` +onde-swift/ # Swift Package Manager wrapper repo +├── Package.swift # Declares OndeFramework.xcframework binary target +└── Sources/ + └── Onde/ + └── onde.swift # UniFFI-generated Swift glue (do NOT edit manually) +``` + +--- + +## Architecture + +### Layer Diagram + +``` +┌──────────────────────────────────────────────────────────┐ +│ Swift (iOS / tvOS / macOS) Rust app / CLI / server │ +│ import Onde use onde::inference::* │ +└──────────┬───────────────────────────────┬───────────────┘ + │ UniFFI FFI bindings │ Direct Rust API + ▼ ▼ +┌──────────────────────────┐ ┌─────────────────────────────┐ +│ OndeChatEngine │ │ ChatEngine │ +│ (ffi.rs — uniffi::Object│──│ (engine.rs — Rust-native) │ +│ FFI-safe, Arc) │ │ generics, mpsc channels │ +└──────────┬───────────────┘ └─────────────┬───────────────┘ + │ │ + └────────────────┬───────────────┘ + ▼ + ┌────────────────────────┐ + │ mistralrs::Model │ + │ GgufModelBuilder │ + │ Metal / CUDA / CPU │ + └────────────────────────┘ +``` + +### Key Design Rules + +1. **`ChatEngine`** (`engine.rs`) owns all Rust-idiomatic logic: `impl Into`, `tokio::sync::mpsc::Receiver`, etc. Never add UniFFI annotations here. +2. **`OndeChatEngine`** (`ffi.rs`) is a thin `Arc`-wrapped UniFFI `Object` with concrete, FFI-safe signatures. It delegates every call to the inner `ChatEngine`. +3. **`StreamChunkListener`** is a `#[uniffi::export(callback_interface)]` trait — it must be passed as a parameter to **free functions** (not Object methods) because UniFFI 0.31 does not support callback_interface on Object methods. +4. **Free functions** in `ffi.rs` (e.g. `stream_chat_message`, `default_model_config`, `user_message`) are exported with `#[uniffi::export]` and generate top-level Swift functions. +5. **`uniffi::setup_scaffolding!()`** lives in `lib.rs`. Never call `uniffi::generate_scaffolding!()` from `build.rs` for the proc-macro path. + +--- + +## Platform Support + +| Platform | `target_os` | GPU Backend | Default Model | mistralrs features | +| -------- | ----------- | ----------------- | ------------- | ------------------ | +| macOS | `macos` | Metal | Qwen 2.5 Coder 3B (~1.93 GB) | `["metal"]` | +| iOS | `ios` | Metal | Qwen 2.5 Coder 1.5B (~941 MB) | `["metal"]` | +| tvOS | `tvos` | Metal | Qwen 2.5 Coder 1.5B (~941 MB) | `["metal"]` | +| Android | `android` | CPU (candle) | Qwen 2.5 Coder 1.5B (~941 MB) | `[]` + hf-hub | +| Windows | `windows` | CPU (CUDA in CI) | Qwen 2.5 Coder 3B (~1.93 GB) | `[]` | +| Linux | `linux` | CPU (CUDA in CI) | Qwen 2.5 Coder 3B (~1.93 GB) | `[]` | + +- **Current default routing:** `platform_default()` now prefers the Coder variants, 1.5B on mobile and 3B on desktop. +- **iOS / tvOS memory constraint:** iOS gives apps about 2–3 GB. The 3B model (~1.93 GB) can still cause OOM on constrained devices, so mobile defaults stay on the 1.5B model. +- **tvOS tier-3 target:** requires `cargo +nightly -Z build-std`. Stable toolchain cannot build tvOS targets. +- **Android `hf_hub`:** `dirs::home_dir()` panics in the Android sandbox. `hf-hub` is added as an explicit dependency on Android so `HF_HOME` can be seeded programmatically via `hf_hub::api::tokio::ApiBuilder`. + +--- + +## Supported Models + +All model constants live in `src/inference/models.rs`. When adding a new model: + +1. Add `pub const` entries for the HF repo ID, GGUF filename, and, on Android, `TOK_MODEL_ID` when needed. +2. Add the repo ID to `SUPPORTED_MODELS` so `list_local_hf_models` filters it. +3. Add a `SupportedModelInfo` entry to `SUPPORTED_MODEL_INFO` with accurate `expected_size_bytes` from the HF API `siblings[].size`. +4. Add a constructor to `GgufModelConfig` in `engine.rs`. +5. Export a free function in `ffi.rs` for UniFFI consumers when the model should be reachable from Swift and Kotlin. +6. If the GGUF does not ship with a built-in chat template, set `chat_template` explicitly. DeepSeek Coder is the current example. + +### Current Models + +| Model | Repo | File | Size | Platforms | +|-------|------|------|------|-----------| +| Qwen 2.5 1.5B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-1.5B-Instruct-GGUF` | `Qwen2.5-1.5B-Instruct-Q4_K_M.gguf` | ~941 MB | All platforms | +| Qwen 2.5 3B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-3B-Instruct-GGUF` | `Qwen2.5-3B-Instruct-Q4_K_M.gguf` | ~1.93 GB | All platforms | +| Qwen 2.5 Coder 1.5B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF` | `Qwen2.5-Coder-1.5B-Instruct-Q4_K_M.gguf` | ~941 MB | All platforms (mobile default) | +| Qwen 2.5 Coder 3B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-3B-Instruct-GGUF` | `Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf` | ~1.93 GB | All platforms (desktop default) | +| Qwen 2.5 Coder 7B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-7B-Instruct-GGUF` | `Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf` | ~4.4 GB | Higher-memory devices | +| Qwen 3 0.6B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-0.6B-GGUF` | `Qwen_Qwen3-0.6B-Q4_K_M.gguf` | ~0.5 GB | All platforms | +| Qwen 3 1.7B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-1.7B-GGUF` | `Qwen_Qwen3-1.7B-Q4_K_M.gguf` | ~1.3 GB | All platforms | +| Qwen 3 4B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-GGUF` | `Qwen_Qwen3-4B-Q4_K_M.gguf` | ~2.7 GB | All platforms | +| Qwen 3 8B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-8B-GGUF` | `Qwen_Qwen3-8B-Q4_K_M.gguf` | ~5 GB | Higher-memory devices | +| Qwen 3 14B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-14B-GGUF` | `Qwen_Qwen3-14B-Q4_K_M.gguf` | ~8.4 GB | Higher-memory devices | +| Qwen 3 32B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-32B-GGUF` | `Qwen_Qwen3-32B-Q4_K_M.gguf` | ~19.8 GB | High-memory desktop (32+ GB) | +| Qwen 3 4B Instruct 2507 (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF` | `Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf` | ~2.5 GB | All platforms (latest non-thinking 4B) | +| Qwen 3 4B Thinking 2507 (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-Thinking-2507-GGUF` | `Qwen_Qwen3-4B-Thinking-2507-Q4_K_M.gguf` | ~2.5 GB | All platforms (latest reasoning 4B) | +| Qwen 3 30B-A3B Instruct 2507 (GGUF Q4_K_M, MoE) | `bartowski/Qwen_Qwen3-30B-A3B-Instruct-2507-GGUF` | `Qwen_Qwen3-30B-A3B-Instruct-2507-Q4_K_M.gguf` | ~18.6 GB | High-memory desktop (32+ GB), `qwen3moe` | +| DeepSeek Coder 6.7B Instruct (GGUF Q4_K_M) | `bartowski/deepseek-coder-6.7b-instruct-GGUF` | `deepseek-coder-6.7b-instruct-Q4_K_M.gguf` | ~3.8 GB | Higher-memory devices, custom chat template | +| Qwen 2.5 Coder 7B Instruct (ISQ) | `Qwen/Qwen2.5-Coder-7B-Instruct` | safetensors (ISQ in-situ) | ~8 GB | macOS (ISQ pipeline) | + +--- + +## Key Types (Rust ↔ Swift) + +| Rust Type | Swift Type | Notes | +|-----------|------------|-------| +| `ChatRole` | `enum ChatRole` | `.system`, `.user`, `.assistant` | +| `ChatMessage` | `struct ChatMessage` | `role: ChatRole`, `content: String` | +| `SamplingConfig` | `struct SamplingConfig` | All fields `Optional` | +| `GgufModelConfig` | `struct GgufModelConfig` | `modelId`, `files`, `tokModelId?`, `displayName`, `approxMemory`, `chatTemplate?` | +| `IsqModelConfig` | `struct IsqModelConfig` | `modelId`, `isqBits: UInt8`, `displayName`, `approxMemory` | +| `InferenceResult` | `struct InferenceResult` | `text`, `durationSecs`, `durationDisplay`, `finishReason`, `toolCalls` | +| `ToolCallInfo` | `struct ToolCallInfo` | Structured tool call request emitted by the model | +| `StreamChunk` | `struct StreamChunk` | `delta`, `done`, `finishReason?` | +| `EngineStatus` | `enum EngineStatus` | `.unloaded`, `.loading`, `.ready`, `.generating`, `.error` | +| `EngineInfo` | `struct EngineInfo` | `status`, `modelName?`, `approxMemory?`, `historyLength: UInt64` | +| `InferenceError` | `enum InferenceError: Error` | `noModelLoaded`, `alreadyLoaded`, `modelBuild`, `inference`, `cancelled`, `other` | +| `OndeChatEngine` | `class OndeChatEngine` | Thread-safe; `Arc`-backed; constructed with `OndeChatEngine()` | +| `StreamChunkListener` | `protocol StreamChunkListener` | Implement `onChunk(chunk:) -> Bool` | + +--- + +## HuggingFace Token + +`src/inference/token.rs` resolves the HF token in priority order: + +1. **Build-time literal** (`HF_TOKEN` env var baked in via `option_env!`) — required for iOS/tvOS (no filesystem token possible). +2. **On-disk cache** (`~/.cache/huggingface/token`) — works on macOS after `mistralrs login`. + +Set `HF_TOKEN` before building: + +```bash +export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +cargo build --release +``` + +On sandboxed platforms, your app's setup function must also configure `HF_HOME`, `HF_HUB_CACHE`, and `TMPDIR` to point inside the app container before any `OndeChatEngine` method is called. See `docs/swift-package.md` for the full `setupInferenceEnvironment()` Swift snippet. + +--- + +## Building (Rust) + +### Prerequisites + +```bash +# Stable toolchain (macOS, iOS, Android, Windows, Linux) +rustup toolchain install stable + +# Nightly toolchain (tvOS tier-3 targets only) +rustup toolchain install nightly +rustup component add rust-src --toolchain nightly + +# Apple targets (stable) +rustup target add aarch64-apple-ios aarch64-apple-ios-sim +rustup target add aarch64-apple-darwin x86_64-apple-darwin +``` + +### Commands + +```bash +# Verify compilation (macOS host) +cargo check + +# Run all tests +cargo test + +# Run inference module tests only +cargo test inference:: + +# Run FFI wrapper tests only +cargo test inference::ffi:: + +# Clippy lint +cargo clippy + +# Format +cargo fmt +``` + +### tvOS + +tvOS targets require nightly and `-Z build-std`: + +```bash +cargo +nightly rustc -Z build-std \ + --target aarch64-apple-tvos --release --lib --crate-type staticlib +``` + +--- + +## Building the Swift XCFramework + +The XCFramework bundles staticlibs for iOS device, iOS simulator, tvOS device, tvOS simulator, and macOS into a single distributable that `Package.swift` references. + +### Quick build (local or CI) + +```bash +.github/scripts/build-swift-xcframework.sh +``` + +Output lands in `dist/swift/`: +- `OndeFramework.xcframework/` — the framework tree +- `OndeFramework.xcframework.zip` — zipped for SPM remote binary +- `OndeFramework.checksum.txt` — SHA-256 for `Package.swift` +- `version.txt` — semver from `Cargo.toml` +- `Package/Sources/Onde/onde.swift` — generated UniFFI Swift glue + +### Manual steps + +```bash +# 1. Build the uniffi-bindgen binary (pinned to uniffi =0.31.0) +cargo build --manifest-path uniffi-bindgen/Cargo.toml --release +BINDGEN=uniffi-bindgen/target/release/uniffi-bindgen + +# 2. Compile staticlibs per target (use +1.92.0 or current stable) +cargo +stable rustc --target aarch64-apple-ios --release --lib --crate-type staticlib +cargo +stable rustc --target aarch64-apple-ios-sim --release --lib --crate-type staticlib +cargo +stable rustc --target aarch64-apple-darwin --release --lib --crate-type staticlib +cargo +nightly rustc -Z build-std --target aarch64-apple-tvos --release --lib --crate-type staticlib +cargo +nightly rustc -Z build-std --target aarch64-apple-tvos-sim --release --lib --crate-type staticlib + +# 3. Generate Swift bindings from the iOS arm64 slice +$BINDGEN generate target/aarch64-apple-ios/release/libonde.a \ + --language swift \ + --out-dir sdk/Onde/Sources/Onde \ + --config uniffi.toml + +# 4. Assemble the XCFramework (see build-swift-xcframework.sh for full xcodebuild invocation) +``` + +### Updating `onde-swift` after a new release + +This is fully automated by CI. Publishing a GitHub Release on `onde` triggers the workflow, which: + +1. Builds the XCFramework and attaches it to the release. +2. Rewrites `onde-swift/Package.swift` with the release `url:` + `checksum:`. +3. Copies the freshly generated `onde.swift` into `onde-swift/Sources/Onde/`. +4. Commits, tags, and pushes `onde-swift` — Swift Package Index picks it up automatically. + +Manual intervention is only needed if `ONDE_SWIFT_PAT` has expired or the `onde-swift` push fails. + +--- + +## Swift SDK (`onde-swift`) + +### Package.swift structure + +``` +onde-swift/ +├── Package.swift # Declares OndeFramework binary target + Onde wrapper target +└── Sources/Onde/ + └── onde.swift # UniFFI-generated glue (NEVER edit manually) +``` + +`Package.swift` has two targets: + +- **`OndeFramework`** — `.binaryTarget` using `url:` + `checksum:` for distribution. For local development, swap to the `path:` form documented in the `Package.swift` header comment and run `build-swift-xcframework.sh` first. +- **`Onde`** — `.target` depending on `OndeFramework`, used as the public import name in Swift. + +### Swift API Quick Reference + +```swift +import Onde + +// Create engine +let engine = OndeChatEngine() + +// Load the platform default model +let elapsed = try await engine.loadDefaultModel( + systemPrompt: "You are a helpful assistant.", + sampling: nil +) + +// Or load the model assigned to your app in the Onde dashboard +let assignedElapsed = try await engine.loadAssignedModel( + appId: "your-app-id", + appSecret: "your-app-secret", + systemPrompt: "You are a helpful assistant.", + sampling: nil +) + +// Multi-turn chat +let result = try await engine.sendMessage(message: "Hello!") +print(result.text) +print(result.toolCalls) + +// Streaming (free function — callback_interface constraint in UniFFI 0.31) +class Handler: StreamChunkListener { + func onChunk(chunk: StreamChunk) -> Bool { + print(chunk.delta, terminator: "") + return !chunk.done + } +} +try await streamChatMessage(engine: engine, message: "Tell me a story.", listener: Handler()) + +// One-shot (does NOT modify conversation history) +let enhanced = try await engine.generate( + messages: [userMessage(content: "Expand: a cat in space")], + sampling: deterministicSamplingConfig() +) + +// Status +let info = await engine.info() // EngineInfo + +// History management +let history = await engine.history() +let removed = await engine.clearHistory() +await engine.pushHistory(message: userMessage(content: "...")) + +// Cleanup +await engine.unloadModel() +``` + +### Free Functions + +| Function | Returns | Notes | +|----------|---------|-------| +| `defaultModelConfig()` | `GgufModelConfig` | Platform-aware Coder default (1.5B on iOS/tvOS/Android, 3B on desktop) | +| `qwen251_5bConfig()` | `GgufModelConfig` | Forces Qwen 2.5 1.5B regardless of platform | +| `qwen253bConfig()` | `GgufModelConfig` | Forces Qwen 2.5 3B regardless of platform | +| `qwen306bConfig()` | `GgufModelConfig` | Qwen 3 0.6B (~0.5 GB) | +| `qwen317bConfig()` | `GgufModelConfig` | Qwen 3 1.7B (~1.3 GB) | +| `qwen34bConfig()` | `GgufModelConfig` | Qwen 3 4B (~2.7 GB) | +| `qwen38bConfig()` | `GgufModelConfig` | Qwen 3 8B (~5 GB) | +| `qwen314bConfig()` | `GgufModelConfig` | Qwen 3 14B (~8.4 GB) | +| `qwen332bConfig()` | `GgufModelConfig` | Qwen 3 32B (~19.8 GB) | +| `qwen34bInstruct2507Config()` | `GgufModelConfig` | Qwen 3 4B Instruct 2507 — latest non-thinking 4B (~2.5 GB) | +| `qwen34bThinking2507Config()` | `GgufModelConfig` | Qwen 3 4B Thinking 2507 — latest reasoning 4B (~2.5 GB) | +| `qwen330bA3bInstruct2507Config()` | `GgufModelConfig` | Qwen 3 30B-A3B Instruct 2507 MoE (~18.6 GB) | +| `defaultSamplingConfig()` | `SamplingConfig` | temp=0.7, top_p=0.95, max_tokens=512 | +| `deterministicSamplingConfig()` | `SamplingConfig` | temp=0.0, greedy | +| `mobileSamplingConfig()` | `SamplingConfig` | temp=0.7, max_tokens=128 | +| `systemMessage(content:)` | `ChatMessage` | `.system` role | +| `userMessage(content:)` | `ChatMessage` | `.user` role | +| `assistantMessage(content:)` | `ChatMessage` | `.assistant` role | +| `streamChatMessage(engine:message:listener:)` | `async throws` | Streaming via callback | + +--- + +## UniFFI Conventions + +- **Version pin:** `uniffi = "=0.31.0"` everywhere — the `onde` crate, `uniffi-bindgen/`, and `[build-dependencies]` must all use the **same** version. Mixing versions causes bindgen panics. +- **`uniffi::setup_scaffolding!()`** in `lib.rs` — proc-macro approach, no UDL file needed. +- **Object methods** use `#[uniffi::export]` on the `impl OndeChatEngine` block. +- **Callback interfaces** (`StreamChunkListener`) must be parameters of **free functions** only, not Object methods. +- **`Arc`** is the return type for `#[uniffi::constructor]`. UniFFI automatically handles this. +- **Async:** all async exported methods use `tokio` runtime (enabled via `uniffi = { features = ["tokio"] }`). +- **`uniffi.toml`**: lives at the crate root. Adjust renaming or namespace settings there before regenerating. + +--- + +## HuggingFace Cache (`hf_cache.rs`) + +The `hf_cache` module manages the on-device model cache, exposed via UniFFI to Swift / Kotlin as-needed. + +Key public functions: + +| Function | Description | +|----------|-------------| +| `list_local_hf_models()` | List downloaded models that Onde supports | +| `list_supported_hf_models()` | All supported models with download status | +| `download_model(model_id, progress_callback)` | Download a model with progress reporting | +| `delete_local_hf_model(model_id)` | Remove a model from the local cache | +| `diagnose_hf_cache()` | Inspect the cache for corruption | +| `repair_hf_cache_symlinks()` | Fix broken symlinks in the HF cache layout | +| `model_cache_path(model_id)` | Resolve the filesystem path for a model | +| `clean_stale_lock_files()` | Remove leftover `.lock` files from interrupted downloads | + +`ModelDownloadProgress` carries `downloaded_bytes`, `total_bytes`, `progress` (0.0–1.0), and `done`. + +--- + +## Sampling Presets + +| Preset | `temperature` | `top_p` | `max_tokens` | Use Case | +|--------|--------------|---------|--------------|----------| +| `SamplingConfig::default()` | 0.7 | 0.95 | 512 | General creative chat | +| `SamplingConfig::deterministic()` | 0.0 | — | 512 | Reproducible / coding | +| `SamplingConfig::mobile()` | 0.7 | 0.95 | 128 | Memory/latency constrained | +| `SamplingConfig::coding()` | 0.0 | — | 512 | Code generation | +| `SamplingConfig::coding_mobile()` | 0.0 | — | 128 | Code on mobile | + +--- + +## Testing + +```bash +# All tests (requires a macOS host with Metal) +cargo test + +# Unit tests only (no model downloads) +cargo test inference:: +cargo test inference::ffi:: +cargo test inference::types:: + +# hf_cache module +cargo test hf_cache:: + +# Clippy (treat warnings as errors) +cargo clippy -- -D warnings +``` + +Tests that require model downloads are integration tests and not run by default in CI. Unit tests verify: +- Type constructors and `Display` implementations +- `SamplingConfig` presets +- `GgufModelConfig` constructors and `platform_default()` routing +- `OndeChatEngine` lifecycle: `new()` starts unloaded, `send_message` without model returns `InferenceError::NoModelLoaded`, `clear_history` on empty returns 0, `unload_model` on empty returns `nil`. + +--- + +## Known Issues + +### `___chkstk_darwin` linker error on tvOS + +`aws-lc-sys` (transitive via `reqwest → rustls → aws-lc-rs`) references `___chkstk_darwin`, a stack probing symbol that tvOS does not export. `build.rs` compiles `scripts/tvos_chkstk.s` (a no-op `ret` stub) via the `cc` crate to satisfy the linker. **Do not delete `scripts/tvos_chkstk.s`.** + +Affects: tvOS only. macOS, iOS, Android, Windows, Linux are unaffected. + +### Metal Toolchain missing (Xcode 26+) + +On Xcode 26+, the Metal compiler is a separate download. If you see empty `.metallib` files (92 bytes) or `"Error while loading function: fused_glu_float"` at runtime: + +```bash +xcodebuild -downloadComponent MetalToolchain +cargo clean -p mistralrs-quant +cargo check +``` + +### Android `home_dir` sandbox panic + +`dirs::home_dir()` panics in the Android sandbox. The crate-level `Cargo.toml` adds `hf-hub` as an explicit Android dependency so `HF_HOME` can be seeded via `ApiBuilder` before any hub request. Never call `home::home_dir()` or `dirs::home_dir()` on Android. + +--- + +## Code Conventions + +### Rust + +- **Error handling:** `anyhow` for application-level errors inside `engine.rs`; `thiserror` for the `InferenceError` enum (exported via UniFFI). Never `.unwrap()` or `.expect()` in non-test code. +- **Async:** `tokio` runtime. All async functions in `OndeChatEngine` are `pub async fn`. +- **Logging:** `log` crate macros (`log::debug!`, `log::info!`, `log::warn!`, `log::error!`). No `println!` in library code. +- **Platform gating:** use `#[cfg(target_os = "...")]` blocks. Match `Cargo.toml`'s target-conditional dependency sections. +- **Re-exports:** `mistralrs`, `hf_hub`, and `mistralrs_core` are re-exported from `lib.rs` for downstream Rust consumers. Keep these re-exports in sync with what's actually available per platform. +- **No `mod.rs`:** use named files (`inference/engine.rs`) not `inference/mod.rs` — except that `inference/mod.rs` exists and is the intentional module root for the `inference` module. + +### Swift / SDK + +- **Never manually edit `onde-swift/Sources/Onde/onde.swift`** — it is generated by `uniffi-bindgen`. Regenerate by running the build script. +- **`Package.swift` binary target** uses `url:` + `checksum:` in the committed form. For local development, swap to `path:` (instructions are in the file header). Never commit the `path:` form — CI overwrites it on every release via `release-sdk-swift.yml`. +- **iOS/tvOS sandbox:** always call `setupInferenceEnvironment()` at app launch before any `OndeChatEngine` call. + +--- + +## CI / Release Workflow + +### `release-sdk-swift.yml` + +Triggered on **semver tag push** or `workflow_dispatch`. Runs on `macos-15`. + +Steps: +1. Install stable Rust with iOS/macOS targets. +2. Install nightly Rust with `rust-src` for tvOS `-Z build-std`. +3. Run `build-swift-xcframework.sh`. +4. Read `version.txt` and `OndeFramework.checksum.txt` into step outputs. +5. Validate that the tag matches `Cargo.toml`. +6. Upload CI artifacts: zip, checksum, version, generated `onde.swift`. +7. On tag push, create a GitHub Release with the zip and checksum attached. +8. On tag push, check out `ondeinference/onde-swift` using `ONDE_SWIFT_PAT`. +9. On tag push, rewrite the `.binaryTarget` in `onde-swift/Package.swift` with the release URL and checksum. +10. On tag push, copy the generated `onde.swift` into `onde-swift/Sources/Onde/`. +11. On tag push, commit both files, tag the commit with the version, and push to `ondeinference/onde-swift`. + +#### Required secret + +`ONDE_SWIFT_PAT` — a GitHub Personal Access Token with **`contents: write`** scope on the `ondeinference/onde-swift` repository. + +### `release-sdk-kotlin.yml` + +Triggered on **semver tag push** or `workflow_dispatch`. Runs on `macos-15` because it builds both Android artifacts and the macOS Apple Silicon JVM native library. + +Steps: +1. Install stable Rust and the `aarch64-apple-darwin` target. +2. Install Android SDK / NDK and auto-discover the latest installed 29.x NDK for `cargo-ndk`. +3. Build Android JNI libraries for all supported ABIs. +4. Build the macOS Apple Silicon `libonde.dylib` used by the JVM target. +5. Run Gradle publication tasks for the Kotlin Multiplatform package. +6. Validate that the tag matches `sdk/kotlin/gradle.properties` `VERSION_NAME`. +7. On tag push, publish `com.ondeinference:onde-inference` to Maven Central. + +#### Required secrets + +- `ORG_GRADLE_PROJECT_MAVENCENTRALUSERNAME` +- `ORG_GRADLE_PROJECT_MAVENCENTRALPASSWORD` +- `ORG_GRADLE_PROJECT_SIGNINGKEYID` +- `ORG_GRADLE_PROJECT_SIGNINGKEY` +- `ORG_GRADLE_PROJECT_SIGNINGPASSWORD` + +### `release-sdk-dart.yml` + +Triggered on **semver tag push** or `workflow_dispatch`. + +- Publishes `onde_inference` to pub.dev. +- Version must match the Dart package version. +- The example app is part of release validation. + +### `release-sdk-npm.yml` + +Triggered on **semver tag push** or `workflow_dispatch`. + +- Publishes `@ondeinference/react-native` to npm. +- Builds the Expo module package and validates the published version. + +### `release-sdk-rust.yml` + +Triggered on **semver tag push** or `workflow_dispatch`. + +- Publishes `onde` to crates.io. +- Uses registry dependencies, not git refs, because `cargo publish` strips git patches. + +### Release Process (end-to-end) + +1. Bump versions in every affected package manifest: + - `Cargo.toml` + - `sdk/kotlin/gradle.properties` + - `sdk/dart/pubspec.yaml` + - `sdk/react-native/package.json` +2. Merge release branches with `--no-ff`. Do not fast-forward multi-commit feature branches. +3. Commit, tag, and push the release. +4. CI publishes all five SDKs: + - crates.io (`onde`) + - Swift Package Index / GitHub Releases (`onde-swift`) + - Maven Central (`com.ondeinference:onde-inference`) + - pub.dev (`onde_inference`) + - npm (`@ondeinference/react-native`) +5. Verify the registries after publish, especially Maven Central indexing and the generated Swift release assets. + +> **Note:** `onde-swift/Package.swift` is updated automatically by CI. For local development only, switch it to the `path:` form documented in the file header, then switch it back before committing. + +--- + +## Distribution Registry Summary + +| Registry | Name | Import | +|----------|------|--------| +| crates.io | `onde` | `onde = "1.x"` | +| Swift Package Index | `onde-swift` (org: `ondeinference`) | `import Onde` | +| Maven Central | `com.ondeinference:onde-inference` | Gradle `implementation("com.ondeinference:onde-inference:")` | +| pub.dev | `onde_inference` | `import 'package:onde_inference/onde_inference.dart';` | +| npm | `@ondeinference/react-native` | `import { OndeChatEngine } from "@ondeinference/react-native"` | +| PyPI | `onde-inference` | `import onde_inference` | +| RubyGems | `onde-inference` | `require 'onde'` | + +--- + +*This file is for AI agent and human developer reference. Update it when the architecture, API surface, supported models, or toolchain requirements change.* +``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..33eaf2a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,617 @@ +# Onde Inference — AGENTS.md + +> **AI agent reference for the `onde` repository and the `onde-swift` Swift SDK.** +> Keep this file accurate as the codebase evolves. + +--- + +## What Is Onde? + +Onde is an **on-device LLM inference SDK family** built around a shared Rust core. It targets Apple silicon (iOS, tvOS, macOS) plus Android, Windows, and Linux, and ships SDKs for Rust, Swift, Kotlin Multiplatform, Flutter, and React Native. It wraps [mistral.rs](https://github.com/EricLBuehler/mistral.rs) behind platform-friendly APIs with automatic model selection, HuggingFace Hub downloads, cache management, and Metal acceleration where available. + +- **Website:** https://ondeinference.com +- **Rust crate:** https://crates.io/crates/onde +- **Swift package:** https://github.com/ondeinference/onde-swift +- **Maven Central:** https://central.sonatype.com/artifact/com.ondeinference/onde-inference +- **Flutter package:** https://pub.dev/packages/onde_inference +- **React Native package:** https://www.npmjs.com/package/@ondeinference/react-native +- **In production:** [Splitfire AB apps on the Apple AppStore](https://apps.apple.com/se/developer/splitfire-ab/id1831430993) + +--- + +## Repository Layout + +``` +onde/ +├── src/ +│ ├── lib.rs # Crate root — uniffi::setup_scaffolding!() +│ ├── hf_cache.rs # HuggingFace Hub cache: list, download, delete, diagnose, repair +│ └── inference/ +│ ├── mod.rs # Module exports + top-level re-exports +│ ├── engine.rs # ChatEngine — Rust-native API (generics, mpsc, tool calling) +│ ├── ffi.rs # OndeChatEngine — UniFFI Object (FFI-safe, Arc-wrapped) +│ ├── models.rs # Model ID constants + SupportedModelInfo metadata +│ ├── token.rs # HF token resolution: build-time literal vs cache file +│ └── types.rs # Shared types: ChatMessage, SamplingConfig, InferenceResult, ToolCallInfo, etc. +├── sdk/ +│ ├── dart/ # Flutter/Dart package + example app + FRB Rust bridge +│ ├── gem/ # Ruby native extension (Magnus) +│ ├── kotlin/ # Kotlin Multiplatform package (Android + JVM) +│ ├── react-native/ # Expo module wrapping the Rust core for iOS/Android +│ └── python/ # Python bindings (maturin + uniffi) +├── generated/ # UniFFI-generated headers and Swift glue (git-ignored) +├── uniffi-bindgen/ # Standalone bindgen binary crate (pinned uniffi =0.31.0) +├── .github/ +│ ├── workflows/ +│ │ ├── release-sdk-swift.yml # CI: tag push → build XCFramework → GitHub Release → update onde-swift +│ │ ├── release-sdk-kotlin.yml # CI: tag push → build Android/JVM artifacts → publish to Maven Central +│ │ ├── release-sdk-dart.yml # CI: tag push → publish Flutter package to pub.dev +│ │ ├── release-sdk-npm.yml # CI: tag push → publish React Native package to npm +│ │ └── release-sdk-rust.yml # CI: tag push → publish Rust crate to crates.io +│ └── scripts/ +│ └── build-swift-xcframework.sh # Local/CI XCFramework assembly script +├── .cargo/config.toml # Target-specific rustflags (fp16, linker overrides) +├── Cargo.toml # Platform-conditional mistralrs deps +├── build.rs # tvOS ___chkstk_darwin assembly stub +├── scripts/ +│ └── tvos_chkstk.s # No-op arm64 stub for missing tvOS symbol +├── uniffi.toml # UniFFI binding config +└── docs/ + ├── dev.md # Developer guide (build, architecture, platform table) + ├── swift-package.md # Swift API reference + XCFramework build steps + ├── distribution.md # Release process for all registries + └── ruby-gem.md # Ruby gem API reference +``` + +``` +onde-swift/ # Swift Package Manager wrapper repo +├── Package.swift # Declares OndeFramework.xcframework binary target +└── Sources/ + └── Onde/ + └── onde.swift # UniFFI-generated Swift glue (do NOT edit manually) +``` + +--- + +## Architecture + +### Layer Diagram + +``` +┌──────────────────────────────────────────────────────────┐ +│ Swift (iOS / tvOS / macOS) Rust app / CLI / server │ +│ import Onde use onde::inference::* │ +└──────────┬───────────────────────────────┬───────────────┘ + │ UniFFI FFI bindings │ Direct Rust API + ▼ ▼ +┌──────────────────────────┐ ┌─────────────────────────────┐ +│ OndeChatEngine │ │ ChatEngine │ +│ (ffi.rs — uniffi::Object│──│ (engine.rs — Rust-native) │ +│ FFI-safe, Arc) │ │ generics, mpsc channels │ +└──────────┬───────────────┘ └─────────────┬───────────────┘ + │ │ + └────────────────┬───────────────┘ + ▼ + ┌────────────────────────┐ + │ mistralrs::Model │ + │ GgufModelBuilder │ + │ Metal / CUDA / CPU │ + └────────────────────────┘ +``` + +### Key Design Rules + +1. **`ChatEngine`** (`engine.rs`) owns all Rust-idiomatic logic: `impl Into`, `tokio::sync::mpsc::Receiver`, etc. Never add UniFFI annotations here. +2. **`OndeChatEngine`** (`ffi.rs`) is a thin `Arc`-wrapped UniFFI `Object` with concrete, FFI-safe signatures. It delegates every call to the inner `ChatEngine`. +3. **`StreamChunkListener`** is a `#[uniffi::export(callback_interface)]` trait — it must be passed as a parameter to **free functions** (not Object methods) because UniFFI 0.31 does not support callback_interface on Object methods. +4. **Free functions** in `ffi.rs` (e.g. `stream_chat_message`, `default_model_config`, `user_message`) are exported with `#[uniffi::export]` and generate top-level Swift functions. +5. **`uniffi::setup_scaffolding!()`** lives in `lib.rs`. Never call `uniffi::generate_scaffolding!()` from `build.rs` for the proc-macro path. + +--- + +## Platform Support + +| Platform | `target_os` | GPU Backend | Default Model | mistralrs features | +| -------- | ----------- | ----------------- | ------------- | ------------------ | +| macOS | `macos` | Metal | Qwen 2.5 Coder 3B (~1.93 GB) | `["metal"]` | +| iOS | `ios` | Metal | Qwen 2.5 Coder 1.5B (~941 MB) | `["metal"]` | +| tvOS | `tvos` | Metal | Qwen 2.5 Coder 1.5B (~941 MB) | `["metal"]` | +| Android | `android` | CPU (candle) | Qwen 2.5 Coder 1.5B (~941 MB) | `[]` + hf-hub | +| Windows | `windows` | CPU (CUDA in CI) | Qwen 2.5 Coder 3B (~1.93 GB) | `[]` | +| Linux | `linux` | CPU (CUDA in CI) | Qwen 2.5 Coder 3B (~1.93 GB) | `[]` | + +- **Current default routing:** `platform_default()` now prefers the Coder variants, 1.5B on mobile and 3B on desktop. +- **iOS / tvOS memory constraint:** iOS gives apps about 2–3 GB. The 3B model (~1.93 GB) can still cause OOM on constrained devices, so mobile defaults stay on the 1.5B model. +- **tvOS tier-3 target:** requires `cargo +nightly -Z build-std`. Stable toolchain cannot build tvOS targets. +- **Android `hf_hub`:** `dirs::home_dir()` panics in the Android sandbox. `hf-hub` is added as an explicit dependency on Android so `HF_HOME` can be seeded programmatically via `hf_hub::api::tokio::ApiBuilder`. + +--- + +## Supported Models + +All model constants live in `src/inference/models.rs`. When adding a new model: + +1. Add `pub const` entries for the HF repo ID, GGUF filename, and, on Android, `TOK_MODEL_ID` when needed. +2. Add the repo ID to `SUPPORTED_MODELS` so `list_local_hf_models` filters it. +3. Add a `SupportedModelInfo` entry to `SUPPORTED_MODEL_INFO` with accurate `expected_size_bytes` from the HF API `siblings[].size`. +4. Add a constructor to `GgufModelConfig` in `engine.rs`. +5. Export a free function in `ffi.rs` for UniFFI consumers when the model should be reachable from Swift and Kotlin. +6. If the GGUF does not ship with a built-in chat template, set `chat_template` explicitly. DeepSeek Coder is the current example. + +### Current Models + +| Model | Repo | File | Size | Platforms | +|-------|------|------|------|-----------| +| Qwen 2.5 1.5B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-1.5B-Instruct-GGUF` | `Qwen2.5-1.5B-Instruct-Q4_K_M.gguf` | ~941 MB | All platforms | +| Qwen 2.5 3B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-3B-Instruct-GGUF` | `Qwen2.5-3B-Instruct-Q4_K_M.gguf` | ~1.93 GB | All platforms | +| Qwen 2.5 Coder 1.5B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF` | `Qwen2.5-Coder-1.5B-Instruct-Q4_K_M.gguf` | ~941 MB | All platforms (mobile default) | +| Qwen 2.5 Coder 3B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-3B-Instruct-GGUF` | `Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf` | ~1.93 GB | All platforms (desktop default) | +| Qwen 2.5 Coder 7B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-7B-Instruct-GGUF` | `Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf` | ~4.4 GB | Higher-memory devices | +| Qwen 3 0.6B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-0.6B-GGUF` | `Qwen_Qwen3-0.6B-Q4_K_M.gguf` | ~0.5 GB | All platforms | +| Qwen 3 1.7B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-1.7B-GGUF` | `Qwen_Qwen3-1.7B-Q4_K_M.gguf` | ~1.3 GB | All platforms | +| Qwen 3 4B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-GGUF` | `Qwen_Qwen3-4B-Q4_K_M.gguf` | ~2.7 GB | All platforms | +| Qwen 3 8B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-8B-GGUF` | `Qwen_Qwen3-8B-Q4_K_M.gguf` | ~5 GB | Higher-memory devices | +| Qwen 3 14B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-14B-GGUF` | `Qwen_Qwen3-14B-Q4_K_M.gguf` | ~8.4 GB | Higher-memory devices | +| Qwen 3 32B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-32B-GGUF` | `Qwen_Qwen3-32B-Q4_K_M.gguf` | ~19.8 GB | High-memory desktop (32+ GB) | +| Qwen 3 4B Instruct 2507 (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF` | `Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf` | ~2.5 GB | All platforms (latest non-thinking 4B) | +| Qwen 3 4B Thinking 2507 (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-Thinking-2507-GGUF` | `Qwen_Qwen3-4B-Thinking-2507-Q4_K_M.gguf` | ~2.5 GB | All platforms (latest reasoning 4B) | +| Qwen 3 30B-A3B Instruct 2507 (GGUF Q4_K_M, MoE) | `bartowski/Qwen_Qwen3-30B-A3B-Instruct-2507-GGUF` | `Qwen_Qwen3-30B-A3B-Instruct-2507-Q4_K_M.gguf` | ~18.6 GB | High-memory desktop (32+ GB), `qwen3moe` | +| DeepSeek Coder 6.7B Instruct (GGUF Q4_K_M) | `bartowski/deepseek-coder-6.7b-instruct-GGUF` | `deepseek-coder-6.7b-instruct-Q4_K_M.gguf` | ~3.8 GB | Higher-memory devices, custom chat template | +| Qwen 2.5 Coder 7B Instruct (ISQ) | `Qwen/Qwen2.5-Coder-7B-Instruct` | safetensors (ISQ in-situ) | ~8 GB | macOS (ISQ pipeline) | + +--- + +## Key Types (Rust ↔ Swift) + +| Rust Type | Swift Type | Notes | +|-----------|------------|-------| +| `ChatRole` | `enum ChatRole` | `.system`, `.user`, `.assistant` | +| `ChatMessage` | `struct ChatMessage` | `role: ChatRole`, `content: String` | +| `SamplingConfig` | `struct SamplingConfig` | All fields `Optional` | +| `GgufModelConfig` | `struct GgufModelConfig` | `modelId`, `files`, `tokModelId?`, `displayName`, `approxMemory`, `chatTemplate?` | +| `IsqModelConfig` | `struct IsqModelConfig` | `modelId`, `isqBits: UInt8`, `displayName`, `approxMemory` | +| `InferenceResult` | `struct InferenceResult` | `text`, `durationSecs`, `durationDisplay`, `finishReason`, `toolCalls` | +| `ToolCallInfo` | `struct ToolCallInfo` | Structured tool call request emitted by the model | +| `StreamChunk` | `struct StreamChunk` | `delta`, `done`, `finishReason?` | +| `EngineStatus` | `enum EngineStatus` | `.unloaded`, `.loading`, `.ready`, `.generating`, `.error` | +| `EngineInfo` | `struct EngineInfo` | `status`, `modelName?`, `approxMemory?`, `historyLength: UInt64` | +| `InferenceError` | `enum InferenceError: Error` | `noModelLoaded`, `alreadyLoaded`, `modelBuild`, `inference`, `cancelled`, `other` | +| `OndeChatEngine` | `class OndeChatEngine` | Thread-safe; `Arc`-backed; constructed with `OndeChatEngine()` | +| `StreamChunkListener` | `protocol StreamChunkListener` | Implement `onChunk(chunk:) -> Bool` | + +--- + +## HuggingFace Token + +`src/inference/token.rs` resolves the HF token in priority order: + +1. **Build-time literal** (`HF_TOKEN` env var baked in via `option_env!`) — required for iOS/tvOS (no filesystem token possible). +2. **On-disk cache** (`~/.cache/huggingface/token`) — works on macOS after `mistralrs login`. + +Set `HF_TOKEN` before building: + +```bash +export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +cargo build --release +``` + +On sandboxed platforms, your app's setup function must also configure `HF_HOME`, `HF_HUB_CACHE`, and `TMPDIR` to point inside the app container before any `OndeChatEngine` method is called. See `docs/swift-package.md` for the full `setupInferenceEnvironment()` Swift snippet. + +--- + +## Building (Rust) + +### Prerequisites + +```bash +# Stable toolchain (macOS, iOS, Android, Windows, Linux) +rustup toolchain install stable + +# Nightly toolchain (tvOS tier-3 targets only) +rustup toolchain install nightly +rustup component add rust-src --toolchain nightly + +# Apple targets (stable) +rustup target add aarch64-apple-ios aarch64-apple-ios-sim +rustup target add aarch64-apple-darwin x86_64-apple-darwin +``` + +### Commands + +```bash +# Verify compilation (macOS host) +cargo check + +# Run all tests +cargo test + +# Run inference module tests only +cargo test inference:: + +# Run FFI wrapper tests only +cargo test inference::ffi:: + +# Clippy lint +cargo clippy + +# Format +cargo fmt +``` + +### tvOS + +tvOS targets require nightly and `-Z build-std`: + +```bash +cargo +nightly rustc -Z build-std \ + --target aarch64-apple-tvos --release --lib --crate-type staticlib +``` + +--- + +## Building the Swift XCFramework + +The XCFramework bundles staticlibs for iOS device, iOS simulator, tvOS device, tvOS simulator, and macOS into a single distributable that `Package.swift` references. + +### Quick build (local or CI) + +```bash +.github/scripts/build-swift-xcframework.sh +``` + +Output lands in `dist/swift/`: +- `OndeFramework.xcframework/` — the framework tree +- `OndeFramework.xcframework.zip` — zipped for SPM remote binary +- `OndeFramework.checksum.txt` — SHA-256 for `Package.swift` +- `version.txt` — semver from `Cargo.toml` +- `Package/Sources/Onde/onde.swift` — generated UniFFI Swift glue + +### Manual steps + +```bash +# 1. Build the uniffi-bindgen binary (pinned to uniffi =0.31.0) +cargo build --manifest-path uniffi-bindgen/Cargo.toml --release +BINDGEN=uniffi-bindgen/target/release/uniffi-bindgen + +# 2. Compile staticlibs per target (use +1.92.0 or current stable) +cargo +stable rustc --target aarch64-apple-ios --release --lib --crate-type staticlib +cargo +stable rustc --target aarch64-apple-ios-sim --release --lib --crate-type staticlib +cargo +stable rustc --target aarch64-apple-darwin --release --lib --crate-type staticlib +cargo +nightly rustc -Z build-std --target aarch64-apple-tvos --release --lib --crate-type staticlib +cargo +nightly rustc -Z build-std --target aarch64-apple-tvos-sim --release --lib --crate-type staticlib + +# 3. Generate Swift bindings from the iOS arm64 slice +$BINDGEN generate target/aarch64-apple-ios/release/libonde.a \ + --language swift \ + --out-dir sdk/Onde/Sources/Onde \ + --config uniffi.toml + +# 4. Assemble the XCFramework (see build-swift-xcframework.sh for full xcodebuild invocation) +``` + +### Updating `onde-swift` after a new release + +This is fully automated by CI. Publishing a GitHub Release on `onde` triggers the workflow, which: + +1. Builds the XCFramework and attaches it to the release. +2. Rewrites `onde-swift/Package.swift` with the release `url:` + `checksum:`. +3. Copies the freshly generated `onde.swift` into `onde-swift/Sources/Onde/`. +4. Commits, tags, and pushes `onde-swift` — Swift Package Index picks it up automatically. + +Manual intervention is only needed if `ONDE_SWIFT_PAT` has expired or the `onde-swift` push fails. + +--- + +## Swift SDK (`onde-swift`) + +### Package.swift structure + +``` +onde-swift/ +├── Package.swift # Declares OndeFramework binary target + Onde wrapper target +└── Sources/Onde/ + └── onde.swift # UniFFI-generated glue (NEVER edit manually) +``` + +`Package.swift` has two targets: + +- **`OndeFramework`** — `.binaryTarget` using `url:` + `checksum:` for distribution. For local development, swap to the `path:` form documented in the `Package.swift` header comment and run `build-swift-xcframework.sh` first. +- **`Onde`** — `.target` depending on `OndeFramework`, used as the public import name in Swift. + +### Swift API Quick Reference + +```swift +import Onde + +// Create engine +let engine = OndeChatEngine() + +// Load the platform default model +let elapsed = try await engine.loadDefaultModel( + systemPrompt: "You are a helpful assistant.", + sampling: nil +) + +// Or load the model assigned to your app in the Onde dashboard +let assignedElapsed = try await engine.loadAssignedModel( + appId: "your-app-id", + appSecret: "your-app-secret", + systemPrompt: "You are a helpful assistant.", + sampling: nil +) + +// Multi-turn chat +let result = try await engine.sendMessage(message: "Hello!") +print(result.text) +print(result.toolCalls) + +// Streaming (free function — callback_interface constraint in UniFFI 0.31) +class Handler: StreamChunkListener { + func onChunk(chunk: StreamChunk) -> Bool { + print(chunk.delta, terminator: "") + return !chunk.done + } +} +try await streamChatMessage(engine: engine, message: "Tell me a story.", listener: Handler()) + +// One-shot (does NOT modify conversation history) +let enhanced = try await engine.generate( + messages: [userMessage(content: "Expand: a cat in space")], + sampling: deterministicSamplingConfig() +) + +// Status +let info = await engine.info() // EngineInfo + +// History management +let history = await engine.history() +let removed = await engine.clearHistory() +await engine.pushHistory(message: userMessage(content: "...")) + +// Cleanup +await engine.unloadModel() +``` + +### Free Functions + +| Function | Returns | Notes | +|----------|---------|-------| +| `defaultModelConfig()` | `GgufModelConfig` | Platform-aware Coder default (1.5B on iOS/tvOS/Android, 3B on desktop) | +| `qwen251_5bConfig()` | `GgufModelConfig` | Forces Qwen 2.5 1.5B regardless of platform | +| `qwen253bConfig()` | `GgufModelConfig` | Forces Qwen 2.5 3B regardless of platform | +| `qwen306bConfig()` | `GgufModelConfig` | Qwen 3 0.6B (~0.5 GB) | +| `qwen317bConfig()` | `GgufModelConfig` | Qwen 3 1.7B (~1.3 GB) | +| `qwen34bConfig()` | `GgufModelConfig` | Qwen 3 4B (~2.7 GB) | +| `qwen38bConfig()` | `GgufModelConfig` | Qwen 3 8B (~5 GB) | +| `qwen314bConfig()` | `GgufModelConfig` | Qwen 3 14B (~8.4 GB) | +| `qwen332bConfig()` | `GgufModelConfig` | Qwen 3 32B (~19.8 GB) | +| `qwen34bInstruct2507Config()` | `GgufModelConfig` | Qwen 3 4B Instruct 2507 — latest non-thinking 4B (~2.5 GB) | +| `qwen34bThinking2507Config()` | `GgufModelConfig` | Qwen 3 4B Thinking 2507 — latest reasoning 4B (~2.5 GB) | +| `qwen330bA3bInstruct2507Config()` | `GgufModelConfig` | Qwen 3 30B-A3B Instruct 2507 MoE (~18.6 GB) | +| `defaultSamplingConfig()` | `SamplingConfig` | temp=0.7, top_p=0.95, max_tokens=512 | +| `deterministicSamplingConfig()` | `SamplingConfig` | temp=0.0, greedy | +| `mobileSamplingConfig()` | `SamplingConfig` | temp=0.7, max_tokens=128 | +| `systemMessage(content:)` | `ChatMessage` | `.system` role | +| `userMessage(content:)` | `ChatMessage` | `.user` role | +| `assistantMessage(content:)` | `ChatMessage` | `.assistant` role | +| `streamChatMessage(engine:message:listener:)` | `async throws` | Streaming via callback | + +--- + +## UniFFI Conventions + +- **Version pin:** `uniffi = "=0.31.0"` everywhere — the `onde` crate, `uniffi-bindgen/`, and `[build-dependencies]` must all use the **same** version. Mixing versions causes bindgen panics. +- **`uniffi::setup_scaffolding!()`** in `lib.rs` — proc-macro approach, no UDL file needed. +- **Object methods** use `#[uniffi::export]` on the `impl OndeChatEngine` block. +- **Callback interfaces** (`StreamChunkListener`) must be parameters of **free functions** only, not Object methods. +- **`Arc`** is the return type for `#[uniffi::constructor]`. UniFFI automatically handles this. +- **Async:** all async exported methods use `tokio` runtime (enabled via `uniffi = { features = ["tokio"] }`). +- **`uniffi.toml`**: lives at the crate root. Adjust renaming or namespace settings there before regenerating. + +--- + +## HuggingFace Cache (`hf_cache.rs`) + +The `hf_cache` module manages the on-device model cache, exposed via UniFFI to Swift / Kotlin as-needed. + +Key public functions: + +| Function | Description | +|----------|-------------| +| `list_local_hf_models()` | List downloaded models that Onde supports | +| `list_supported_hf_models()` | All supported models with download status | +| `download_model(model_id, progress_callback)` | Download a model with progress reporting | +| `delete_local_hf_model(model_id)` | Remove a model from the local cache | +| `diagnose_hf_cache()` | Inspect the cache for corruption | +| `repair_hf_cache_symlinks()` | Fix broken symlinks in the HF cache layout | +| `model_cache_path(model_id)` | Resolve the filesystem path for a model | +| `clean_stale_lock_files()` | Remove leftover `.lock` files from interrupted downloads | + +`ModelDownloadProgress` carries `downloaded_bytes`, `total_bytes`, `progress` (0.0–1.0), and `done`. + +--- + +## Sampling Presets + +| Preset | `temperature` | `top_p` | `max_tokens` | Use Case | +|--------|--------------|---------|--------------|----------| +| `SamplingConfig::default()` | 0.7 | 0.95 | 512 | General creative chat | +| `SamplingConfig::deterministic()` | 0.0 | — | 512 | Reproducible / coding | +| `SamplingConfig::mobile()` | 0.7 | 0.95 | 128 | Memory/latency constrained | +| `SamplingConfig::coding()` | 0.0 | — | 512 | Code generation | +| `SamplingConfig::coding_mobile()` | 0.0 | — | 128 | Code on mobile | + +--- + +## Testing + +```bash +# All tests (requires a macOS host with Metal) +cargo test + +# Unit tests only (no model downloads) +cargo test inference:: +cargo test inference::ffi:: +cargo test inference::types:: + +# hf_cache module +cargo test hf_cache:: + +# Clippy (treat warnings as errors) +cargo clippy -- -D warnings +``` + +Tests that require model downloads are integration tests and not run by default in CI. Unit tests verify: +- Type constructors and `Display` implementations +- `SamplingConfig` presets +- `GgufModelConfig` constructors and `platform_default()` routing +- `OndeChatEngine` lifecycle: `new()` starts unloaded, `send_message` without model returns `InferenceError::NoModelLoaded`, `clear_history` on empty returns 0, `unload_model` on empty returns `nil`. + +--- + +## Known Issues + +### `___chkstk_darwin` linker error on tvOS + +`aws-lc-sys` (transitive via `reqwest → rustls → aws-lc-rs`) references `___chkstk_darwin`, a stack probing symbol that tvOS does not export. `build.rs` compiles `scripts/tvos_chkstk.s` (a no-op `ret` stub) via the `cc` crate to satisfy the linker. **Do not delete `scripts/tvos_chkstk.s`.** + +Affects: tvOS only. macOS, iOS, Android, Windows, Linux are unaffected. + +### Metal Toolchain missing (Xcode 26+) + +On Xcode 26+, the Metal compiler is a separate download. If you see empty `.metallib` files (92 bytes) or `"Error while loading function: fused_glu_float"` at runtime: + +```bash +xcodebuild -downloadComponent MetalToolchain +cargo clean -p mistralrs-quant +cargo check +``` + +### Android `home_dir` sandbox panic + +`dirs::home_dir()` panics in the Android sandbox. The crate-level `Cargo.toml` adds `hf-hub` as an explicit Android dependency so `HF_HOME` can be seeded via `ApiBuilder` before any hub request. Never call `home::home_dir()` or `dirs::home_dir()` on Android. + +--- + +## Code Conventions + +### Rust + +- **Error handling:** `anyhow` for application-level errors inside `engine.rs`; `thiserror` for the `InferenceError` enum (exported via UniFFI). Never `.unwrap()` or `.expect()` in non-test code. +- **Async:** `tokio` runtime. All async functions in `OndeChatEngine` are `pub async fn`. +- **Logging:** `log` crate macros (`log::debug!`, `log::info!`, `log::warn!`, `log::error!`). No `println!` in library code. +- **Platform gating:** use `#[cfg(target_os = "...")]` blocks. Match `Cargo.toml`'s target-conditional dependency sections. +- **Re-exports:** `mistralrs`, `hf_hub`, and `mistralrs_core` are re-exported from `lib.rs` for downstream Rust consumers. Keep these re-exports in sync with what's actually available per platform. +- **No `mod.rs`:** use named files (`inference/engine.rs`) not `inference/mod.rs` — except that `inference/mod.rs` exists and is the intentional module root for the `inference` module. + +### Swift / SDK + +- **Never manually edit `onde-swift/Sources/Onde/onde.swift`** — it is generated by `uniffi-bindgen`. Regenerate by running the build script. +- **`Package.swift` binary target** uses `url:` + `checksum:` in the committed form. For local development, swap to `path:` (instructions are in the file header). Never commit the `path:` form — CI overwrites it on every release via `release-sdk-swift.yml`. +- **iOS/tvOS sandbox:** always call `setupInferenceEnvironment()` at app launch before any `OndeChatEngine` call. + +--- + +## CI / Release Workflow + +### `release-sdk-swift.yml` + +Triggered on **semver tag push** or `workflow_dispatch`. Runs on `macos-15`. + +Steps: +1. Install stable Rust with iOS/macOS targets. +2. Install nightly Rust with `rust-src` for tvOS `-Z build-std`. +3. Run `build-swift-xcframework.sh`. +4. Read `version.txt` and `OndeFramework.checksum.txt` into step outputs. +5. Validate that the tag matches `Cargo.toml`. +6. Upload CI artifacts: zip, checksum, version, generated `onde.swift`. +7. On tag push, create a GitHub Release with the zip and checksum attached. +8. On tag push, check out `ondeinference/onde-swift` using `ONDE_SWIFT_PAT`. +9. On tag push, rewrite the `.binaryTarget` in `onde-swift/Package.swift` with the release URL and checksum. +10. On tag push, copy the generated `onde.swift` into `onde-swift/Sources/Onde/`. +11. On tag push, commit both files, tag the commit with the version, and push to `ondeinference/onde-swift`. + +#### Required secret + +`ONDE_SWIFT_PAT` — a GitHub Personal Access Token with **`contents: write`** scope on the `ondeinference/onde-swift` repository. + +### `release-sdk-kotlin.yml` + +Triggered on **semver tag push** or `workflow_dispatch`. Runs on `macos-15` because it builds both Android artifacts and the macOS Apple Silicon JVM native library. + +Steps: +1. Install stable Rust and the `aarch64-apple-darwin` target. +2. Install Android SDK / NDK and auto-discover the latest installed 29.x NDK for `cargo-ndk`. +3. Build Android JNI libraries for all supported ABIs. +4. Build the macOS Apple Silicon `libonde.dylib` used by the JVM target. +5. Run Gradle publication tasks for the Kotlin Multiplatform package. +6. Validate that the tag matches `sdk/kotlin/gradle.properties` `VERSION_NAME`. +7. On tag push, publish `com.ondeinference:onde-inference` to Maven Central. + +#### Required secrets + +- `ORG_GRADLE_PROJECT_MAVENCENTRALUSERNAME` +- `ORG_GRADLE_PROJECT_MAVENCENTRALPASSWORD` +- `ORG_GRADLE_PROJECT_SIGNINGKEYID` +- `ORG_GRADLE_PROJECT_SIGNINGKEY` +- `ORG_GRADLE_PROJECT_SIGNINGPASSWORD` + +### `release-sdk-dart.yml` + +Triggered on **semver tag push** or `workflow_dispatch`. + +- Publishes `onde_inference` to pub.dev. +- Version must match the Dart package version. +- The example app is part of release validation. + +### `release-sdk-npm.yml` + +Triggered on **semver tag push** or `workflow_dispatch`. + +- Publishes `@ondeinference/react-native` to npm. +- Builds the Expo module package and validates the published version. + +### `release-sdk-rust.yml` + +Triggered on **semver tag push** or `workflow_dispatch`. + +- Publishes `onde` to crates.io. +- Uses registry dependencies, not git refs, because `cargo publish` strips git patches. + +### Release Process (end-to-end) + +1. Bump versions in every affected package manifest: + - `Cargo.toml` + - `sdk/kotlin/gradle.properties` + - `sdk/dart/pubspec.yaml` + - `sdk/react-native/package.json` +2. Merge release branches with `--no-ff`. Do not fast-forward multi-commit feature branches. +3. Commit, tag, and push the release. +4. CI publishes all five SDKs: + - crates.io (`onde`) + - Swift Package Index / GitHub Releases (`onde-swift`) + - Maven Central (`com.ondeinference:onde-inference`) + - pub.dev (`onde_inference`) + - npm (`@ondeinference/react-native`) +5. Verify the registries after publish, especially Maven Central indexing and the generated Swift release assets. + +> **Note:** `onde-swift/Package.swift` is updated automatically by CI. For local development only, switch it to the `path:` form documented in the file header, then switch it back before committing. + +--- + +## Distribution Registry Summary + +| Registry | Name | Import | +|----------|------|--------| +| crates.io | `onde` | `onde = "1.x"` | +| Swift Package Index | `onde-swift` (org: `ondeinference`) | `import Onde` | +| Maven Central | `com.ondeinference:onde-inference` | Gradle `implementation("com.ondeinference:onde-inference:")` | +| pub.dev | `onde_inference` | `import 'package:onde_inference/onde_inference.dart';` | +| npm | `@ondeinference/react-native` | `import { OndeChatEngine } from "@ondeinference/react-native"` | +| PyPI | `onde-inference` | `import onde_inference` | +| RubyGems | `onde-inference` | `require 'onde'` | + +--- + +*This file is for AI agent and human developer reference. Update it when the architecture, API surface, supported models, or toolchain requirements change.* +``` From f1a5d116fad1b65720fd54c9575e04b77100332d Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:17:12 +0200 Subject: [PATCH 20/28] chore: add agentic resources (.agents/AGENTS.md, symlinks) Canonical agent instructions live in .agents/AGENTS.md. Root AGENTS.md and CLAUDE.md are symlinks pointing there. Content is tuned to the onde-ed workspace: crate map, architecture rules, LLM provider config, FFI/Tauri boundaries, and commit conventions. Co-Authored-By: siGit Code v1.6.6-acp --- .agents/AGENTS.md | 668 ++++++---------------------------------------- AGENTS.md | 618 +----------------------------------------- CLAUDE.md | 618 +----------------------------------------- 3 files changed, 84 insertions(+), 1820 deletions(-) mode change 100644 => 120000 AGENTS.md mode change 100644 => 120000 CLAUDE.md diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 33eaf2a..26ddf56 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -1,617 +1,113 @@ -# Onde Inference — AGENTS.md +# onde-ed – Agent Instructions -> **AI agent reference for the `onde` repository and the `onde-swift` Swift SDK.** -> Keep this file accurate as the codebase evolves. +## What this repo is +`onde-ed` is a **Rust Cargo workspace** providing the embeddable agentic core +used across Onde products (macOS, iOS, Tauri, CLI). The workspace is organised +into focused crates; do not merge them. --- -## What Is Onde? +## Crate map -Onde is an **on-device LLM inference SDK family** built around a shared Rust core. It targets Apple silicon (iOS, tvOS, macOS) plus Android, Windows, and Linux, and ships SDKs for Rust, Swift, Kotlin Multiplatform, Flutter, and React Native. It wraps [mistral.rs](https://github.com/EricLBuehler/mistral.rs) behind platform-friendly APIs with automatic model selection, HuggingFace Hub downloads, cache management, and Metal acceleration where available. - -- **Website:** https://ondeinference.com -- **Rust crate:** https://crates.io/crates/onde -- **Swift package:** https://github.com/ondeinference/onde-swift -- **Maven Central:** https://central.sonatype.com/artifact/com.ondeinference/onde-inference -- **Flutter package:** https://pub.dev/packages/onde_inference -- **React Native package:** https://www.npmjs.com/package/@ondeinference/react-native -- **In production:** [Splitfire AB apps on the Apple AppStore](https://apps.apple.com/se/developer/splitfire-ab/id1831430993) +| Crate | Purpose | +|---|---| +| `ed-agent` | Core library: `Ed` struct, lifecycle, `send`/`stream`/`run`, tool approval, `EventSink`, `SessionStore` seams | +| `ed-acp` | ACP v1 server: `Profile` trait, `serve`/`serve_stdio`, sessions, LLM streaming, tool routing, MCP | +| `ed-mcp` | MCP integration over `rmcp`: tool namespacing (`mcp____`, max 64 bytes), `SharedServer`, `McpExecutor`, timeouts, cancellation | +| `ed-acp-tui` | ratatui TUI frontend for ACP agents over stdio | +| `ed-acp-testkit` | Mock Onde Cloud endpoint + conformance test suite | +| `ed-agent-ffi` | UniFFI bridge → Apple XCFramework (`FfiEd`, `FfiToolExecutor`, `FfiApprovalHandler`, `FfiEventListener`) | +| `ed-agent-tauri` | Tauri command bindings that emit events to the webview | --- -## Repository Layout - -``` -onde/ -├── src/ -│ ├── lib.rs # Crate root — uniffi::setup_scaffolding!() -│ ├── hf_cache.rs # HuggingFace Hub cache: list, download, delete, diagnose, repair -│ └── inference/ -│ ├── mod.rs # Module exports + top-level re-exports -│ ├── engine.rs # ChatEngine — Rust-native API (generics, mpsc, tool calling) -│ ├── ffi.rs # OndeChatEngine — UniFFI Object (FFI-safe, Arc-wrapped) -│ ├── models.rs # Model ID constants + SupportedModelInfo metadata -│ ├── token.rs # HF token resolution: build-time literal vs cache file -│ └── types.rs # Shared types: ChatMessage, SamplingConfig, InferenceResult, ToolCallInfo, etc. -├── sdk/ -│ ├── dart/ # Flutter/Dart package + example app + FRB Rust bridge -│ ├── gem/ # Ruby native extension (Magnus) -│ ├── kotlin/ # Kotlin Multiplatform package (Android + JVM) -│ ├── react-native/ # Expo module wrapping the Rust core for iOS/Android -│ └── python/ # Python bindings (maturin + uniffi) -├── generated/ # UniFFI-generated headers and Swift glue (git-ignored) -├── uniffi-bindgen/ # Standalone bindgen binary crate (pinned uniffi =0.31.0) -├── .github/ -│ ├── workflows/ -│ │ ├── release-sdk-swift.yml # CI: tag push → build XCFramework → GitHub Release → update onde-swift -│ │ ├── release-sdk-kotlin.yml # CI: tag push → build Android/JVM artifacts → publish to Maven Central -│ │ ├── release-sdk-dart.yml # CI: tag push → publish Flutter package to pub.dev -│ │ ├── release-sdk-npm.yml # CI: tag push → publish React Native package to npm -│ │ └── release-sdk-rust.yml # CI: tag push → publish Rust crate to crates.io -│ └── scripts/ -│ └── build-swift-xcframework.sh # Local/CI XCFramework assembly script -├── .cargo/config.toml # Target-specific rustflags (fp16, linker overrides) -├── Cargo.toml # Platform-conditional mistralrs deps -├── build.rs # tvOS ___chkstk_darwin assembly stub -├── scripts/ -│ └── tvos_chkstk.s # No-op arm64 stub for missing tvOS symbol -├── uniffi.toml # UniFFI binding config -└── docs/ - ├── dev.md # Developer guide (build, architecture, platform table) - ├── swift-package.md # Swift API reference + XCFramework build steps - ├── distribution.md # Release process for all registries - └── ruby-gem.md # Ruby gem API reference -``` - -``` -onde-swift/ # Swift Package Manager wrapper repo -├── Package.swift # Declares OndeFramework.xcframework binary target -└── Sources/ - └── Onde/ - └── onde.swift # UniFFI-generated Swift glue (do NOT edit manually) -``` - ---- +## Non-negotiable architecture rules -## Architecture +1. **One `Ed` instance, one engine, one model.** `Ed` is `Send + Sync`, not + `Clone`. Never try to clone or share it across threads without `Arc`. -### Layer Diagram +2. **`Profile` is the product seam.** A product crate (or binary) implements + `Profile` (`info`, `system_prompt`, `toolsets`, `builtin_mcp_servers`). + `ed-acp` handles the ACP server, session management, and LLM glue. Do not + put product-specific logic inside `ed-acp`. -``` -┌──────────────────────────────────────────────────────────┐ -│ Swift (iOS / tvOS / macOS) Rust app / CLI / server │ -│ import Onde use onde::inference::* │ -└──────────┬───────────────────────────────┬───────────────┘ - │ UniFFI FFI bindings │ Direct Rust API - ▼ ▼ -┌──────────────────────────┐ ┌─────────────────────────────┐ -│ OndeChatEngine │ │ ChatEngine │ -│ (ffi.rs — uniffi::Object│──│ (engine.rs — Rust-native) │ -│ FFI-safe, Arc) │ │ generics, mpsc channels │ -└──────────┬───────────────┘ └─────────────┬───────────────┘ - │ │ - └────────────────┬───────────────┘ - ▼ - ┌────────────────────────┐ - │ mistralrs::Model │ - │ GgufModelBuilder │ - │ Metal / CUDA / CPU │ - └────────────────────────┘ -``` - -### Key Design Rules - -1. **`ChatEngine`** (`engine.rs`) owns all Rust-idiomatic logic: `impl Into`, `tokio::sync::mpsc::Receiver`, etc. Never add UniFFI annotations here. -2. **`OndeChatEngine`** (`ffi.rs`) is a thin `Arc`-wrapped UniFFI `Object` with concrete, FFI-safe signatures. It delegates every call to the inner `ChatEngine`. -3. **`StreamChunkListener`** is a `#[uniffi::export(callback_interface)]` trait — it must be passed as a parameter to **free functions** (not Object methods) because UniFFI 0.31 does not support callback_interface on Object methods. -4. **Free functions** in `ffi.rs` (e.g. `stream_chat_message`, `default_model_config`, `user_message`) are exported with `#[uniffi::export]` and generate top-level Swift functions. -5. **`uniffi::setup_scaffolding!()`** lives in `lib.rs`. Never call `uniffi::generate_scaffolding!()` from `build.rs` for the proc-macro path. - ---- +3. **Tool risk gates approval.** `ToolRisk::ReadOnly` vs `ToolRisk::Mutating` + determines whether the approval handler is invoked. Per-session grants are + tracked via `ApprovalDecision::AllowForSession`. Do not bypass this gate. -## Platform Support +4. **MCP tool names ≤ 64 bytes.** Names are sanitised to + `mcp____`. Anything longer is truncated. Do not assume the + original tool name survived intact. -| Platform | `target_os` | GPU Backend | Default Model | mistralrs features | -| -------- | ----------- | ----------------- | ------------- | ------------------ | -| macOS | `macos` | Metal | Qwen 2.5 Coder 3B (~1.93 GB) | `["metal"]` | -| iOS | `ios` | Metal | Qwen 2.5 Coder 1.5B (~941 MB) | `["metal"]` | -| tvOS | `tvos` | Metal | Qwen 2.5 Coder 1.5B (~941 MB) | `["metal"]` | -| Android | `android` | CPU (candle) | Qwen 2.5 Coder 1.5B (~941 MB) | `[]` + hf-hub | -| Windows | `windows` | CPU (CUDA in CI) | Qwen 2.5 Coder 3B (~1.93 GB) | `[]` | -| Linux | `linux` | CPU (CUDA in CI) | Qwen 2.5 Coder 3B (~1.93 GB) | `[]` | +5. **FFI types are wrappers, not source of truth.** `FfiEd` delegates to + `Ed`; `FfiToolExecutor` bridges a foreign callback into the + `ToolExecutor` trait. Keep FFI types thin. -- **Current default routing:** `platform_default()` now prefers the Coder variants, 1.5B on mobile and 3B on desktop. -- **iOS / tvOS memory constraint:** iOS gives apps about 2–3 GB. The 3B model (~1.93 GB) can still cause OOM on constrained devices, so mobile defaults stay on the 1.5B model. -- **tvOS tier-3 target:** requires `cargo +nightly -Z build-std`. Stable toolchain cannot build tvOS targets. -- **Android `hf_hub`:** `dirs::home_dir()` panics in the Android sandbox. `hf-hub` is added as an explicit dependency on Android so `HF_HOME` can be seeded programmatically via `hf_hub::api::tokio::ApiBuilder`. +6. **Tauri and FFI are separate bindings.** `ed-agent-tauri` and + `ed-agent-ffi` both wrap `ed-agent` but must not depend on each other. --- -## Supported Models +## LLM providers -All model constants live in `src/inference/models.rs`. When adding a new model: +Configured via `ed-acp/src/llm.rs`. Three variants: `Onde`, `OpenAi`, `Local`. -1. Add `pub const` entries for the HF repo ID, GGUF filename, and, on Android, `TOK_MODEL_ID` when needed. -2. Add the repo ID to `SUPPORTED_MODELS` so `list_local_hf_models` filters it. -3. Add a `SupportedModelInfo` entry to `SUPPORTED_MODEL_INFO` with accurate `expected_size_bytes` from the HF API `siblings[].size`. -4. Add a constructor to `GgufModelConfig` in `engine.rs`. -5. Export a free function in `ffi.rs` for UniFFI consumers when the model should be reachable from Swift and Kotlin. -6. If the GGUF does not ship with a built-in chat template, set `chat_template` explicitly. DeepSeek Coder is the current example. +| Env var | Effect | +|---|---| +| `_PROVIDER` | `onde` / `openai` / `local` | +| `ONDE_API_KEY` | Cloud key for `Onde` provider | +| `OPENAI_API_KEY` | Key for `OpenAi` provider | -### Current Models +Default cloud model: `onde-kkk`. +Default local model: `Qwen3-4B` (desktop) / `Qwen3-1.7B` (mobile). -| Model | Repo | File | Size | Platforms | -|-------|------|------|------|-----------| -| Qwen 2.5 1.5B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-1.5B-Instruct-GGUF` | `Qwen2.5-1.5B-Instruct-Q4_K_M.gguf` | ~941 MB | All platforms | -| Qwen 2.5 3B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-3B-Instruct-GGUF` | `Qwen2.5-3B-Instruct-Q4_K_M.gguf` | ~1.93 GB | All platforms | -| Qwen 2.5 Coder 1.5B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF` | `Qwen2.5-Coder-1.5B-Instruct-Q4_K_M.gguf` | ~941 MB | All platforms (mobile default) | -| Qwen 2.5 Coder 3B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-3B-Instruct-GGUF` | `Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf` | ~1.93 GB | All platforms (desktop default) | -| Qwen 2.5 Coder 7B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-7B-Instruct-GGUF` | `Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf` | ~4.4 GB | Higher-memory devices | -| Qwen 3 0.6B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-0.6B-GGUF` | `Qwen_Qwen3-0.6B-Q4_K_M.gguf` | ~0.5 GB | All platforms | -| Qwen 3 1.7B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-1.7B-GGUF` | `Qwen_Qwen3-1.7B-Q4_K_M.gguf` | ~1.3 GB | All platforms | -| Qwen 3 4B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-GGUF` | `Qwen_Qwen3-4B-Q4_K_M.gguf` | ~2.7 GB | All platforms | -| Qwen 3 8B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-8B-GGUF` | `Qwen_Qwen3-8B-Q4_K_M.gguf` | ~5 GB | Higher-memory devices | -| Qwen 3 14B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-14B-GGUF` | `Qwen_Qwen3-14B-Q4_K_M.gguf` | ~8.4 GB | Higher-memory devices | -| Qwen 3 32B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-32B-GGUF` | `Qwen_Qwen3-32B-Q4_K_M.gguf` | ~19.8 GB | High-memory desktop (32+ GB) | -| Qwen 3 4B Instruct 2507 (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF` | `Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf` | ~2.5 GB | All platforms (latest non-thinking 4B) | -| Qwen 3 4B Thinking 2507 (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-Thinking-2507-GGUF` | `Qwen_Qwen3-4B-Thinking-2507-Q4_K_M.gguf` | ~2.5 GB | All platforms (latest reasoning 4B) | -| Qwen 3 30B-A3B Instruct 2507 (GGUF Q4_K_M, MoE) | `bartowski/Qwen_Qwen3-30B-A3B-Instruct-2507-GGUF` | `Qwen_Qwen3-30B-A3B-Instruct-2507-Q4_K_M.gguf` | ~18.6 GB | High-memory desktop (32+ GB), `qwen3moe` | -| DeepSeek Coder 6.7B Instruct (GGUF Q4_K_M) | `bartowski/deepseek-coder-6.7b-instruct-GGUF` | `deepseek-coder-6.7b-instruct-Q4_K_M.gguf` | ~3.8 GB | Higher-memory devices, custom chat template | -| Qwen 2.5 Coder 7B Instruct (ISQ) | `Qwen/Qwen2.5-Coder-7B-Instruct` | safetensors (ISQ in-situ) | ~8 GB | macOS (ISQ pipeline) | +`LlmEnv` drives config-dir, env-file loading, and variable namespacing per +product. Do not hard-code env vars; always go through `LlmEnv`. --- -## Key Types (Rust ↔ Swift) - -| Rust Type | Swift Type | Notes | -|-----------|------------|-------| -| `ChatRole` | `enum ChatRole` | `.system`, `.user`, `.assistant` | -| `ChatMessage` | `struct ChatMessage` | `role: ChatRole`, `content: String` | -| `SamplingConfig` | `struct SamplingConfig` | All fields `Optional` | -| `GgufModelConfig` | `struct GgufModelConfig` | `modelId`, `files`, `tokModelId?`, `displayName`, `approxMemory`, `chatTemplate?` | -| `IsqModelConfig` | `struct IsqModelConfig` | `modelId`, `isqBits: UInt8`, `displayName`, `approxMemory` | -| `InferenceResult` | `struct InferenceResult` | `text`, `durationSecs`, `durationDisplay`, `finishReason`, `toolCalls` | -| `ToolCallInfo` | `struct ToolCallInfo` | Structured tool call request emitted by the model | -| `StreamChunk` | `struct StreamChunk` | `delta`, `done`, `finishReason?` | -| `EngineStatus` | `enum EngineStatus` | `.unloaded`, `.loading`, `.ready`, `.generating`, `.error` | -| `EngineInfo` | `struct EngineInfo` | `status`, `modelName?`, `approxMemory?`, `historyLength: UInt64` | -| `InferenceError` | `enum InferenceError: Error` | `noModelLoaded`, `alreadyLoaded`, `modelBuild`, `inference`, `cancelled`, `other` | -| `OndeChatEngine` | `class OndeChatEngine` | Thread-safe; `Arc`-backed; constructed with `OndeChatEngine()` | -| `StreamChunkListener` | `protocol StreamChunkListener` | Implement `onChunk(chunk:) -> Bool` | - ---- - -## HuggingFace Token - -`src/inference/token.rs` resolves the HF token in priority order: - -1. **Build-time literal** (`HF_TOKEN` env var baked in via `option_env!`) — required for iOS/tvOS (no filesystem token possible). -2. **On-disk cache** (`~/.cache/huggingface/token`) — works on macOS after `mistralrs login`. - -Set `HF_TOKEN` before building: - -```bash -export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -cargo build --release -``` - -On sandboxed platforms, your app's setup function must also configure `HF_HOME`, `HF_HUB_CACHE`, and `TMPDIR` to point inside the app container before any `OndeChatEngine` method is called. See `docs/swift-package.md` for the full `setupInferenceEnvironment()` Swift snippet. - ---- - -## Building (Rust) - -### Prerequisites - -```bash -# Stable toolchain (macOS, iOS, Android, Windows, Linux) -rustup toolchain install stable - -# Nightly toolchain (tvOS tier-3 targets only) -rustup toolchain install nightly -rustup component add rust-src --toolchain nightly - -# Apple targets (stable) -rustup target add aarch64-apple-ios aarch64-apple-ios-sim -rustup target add aarch64-apple-darwin x86_64-apple-darwin -``` - -### Commands - -```bash -# Verify compilation (macOS host) -cargo check - -# Run all tests -cargo test - -# Run inference module tests only -cargo test inference:: - -# Run FFI wrapper tests only -cargo test inference::ffi:: - -# Clippy lint -cargo clippy - -# Format -cargo fmt -``` - -### tvOS - -tvOS targets require nightly and `-Z build-std`: - -```bash -cargo +nightly rustc -Z build-std \ - --target aarch64-apple-tvos --release --lib --crate-type staticlib -``` - ---- - -## Building the Swift XCFramework - -The XCFramework bundles staticlibs for iOS device, iOS simulator, tvOS device, tvOS simulator, and macOS into a single distributable that `Package.swift` references. - -### Quick build (local or CI) - -```bash -.github/scripts/build-swift-xcframework.sh -``` - -Output lands in `dist/swift/`: -- `OndeFramework.xcframework/` — the framework tree -- `OndeFramework.xcframework.zip` — zipped for SPM remote binary -- `OndeFramework.checksum.txt` — SHA-256 for `Package.swift` -- `version.txt` — semver from `Cargo.toml` -- `Package/Sources/Onde/onde.swift` — generated UniFFI Swift glue - -### Manual steps - -```bash -# 1. Build the uniffi-bindgen binary (pinned to uniffi =0.31.0) -cargo build --manifest-path uniffi-bindgen/Cargo.toml --release -BINDGEN=uniffi-bindgen/target/release/uniffi-bindgen - -# 2. Compile staticlibs per target (use +1.92.0 or current stable) -cargo +stable rustc --target aarch64-apple-ios --release --lib --crate-type staticlib -cargo +stable rustc --target aarch64-apple-ios-sim --release --lib --crate-type staticlib -cargo +stable rustc --target aarch64-apple-darwin --release --lib --crate-type staticlib -cargo +nightly rustc -Z build-std --target aarch64-apple-tvos --release --lib --crate-type staticlib -cargo +nightly rustc -Z build-std --target aarch64-apple-tvos-sim --release --lib --crate-type staticlib - -# 3. Generate Swift bindings from the iOS arm64 slice -$BINDGEN generate target/aarch64-apple-ios/release/libonde.a \ - --language swift \ - --out-dir sdk/Onde/Sources/Onde \ - --config uniffi.toml - -# 4. Assemble the XCFramework (see build-swift-xcframework.sh for full xcodebuild invocation) -``` - -### Updating `onde-swift` after a new release - -This is fully automated by CI. Publishing a GitHub Release on `onde` triggers the workflow, which: - -1. Builds the XCFramework and attaches it to the release. -2. Rewrites `onde-swift/Package.swift` with the release `url:` + `checksum:`. -3. Copies the freshly generated `onde.swift` into `onde-swift/Sources/Onde/`. -4. Commits, tags, and pushes `onde-swift` — Swift Package Index picks it up automatically. - -Manual intervention is only needed if `ONDE_SWIFT_PAT` has expired or the `onde-swift` push fails. - ---- - -## Swift SDK (`onde-swift`) - -### Package.swift structure - -``` -onde-swift/ -├── Package.swift # Declares OndeFramework binary target + Onde wrapper target -└── Sources/Onde/ - └── onde.swift # UniFFI-generated glue (NEVER edit manually) -``` - -`Package.swift` has two targets: - -- **`OndeFramework`** — `.binaryTarget` using `url:` + `checksum:` for distribution. For local development, swap to the `path:` form documented in the `Package.swift` header comment and run `build-swift-xcframework.sh` first. -- **`Onde`** — `.target` depending on `OndeFramework`, used as the public import name in Swift. - -### Swift API Quick Reference - -```swift -import Onde - -// Create engine -let engine = OndeChatEngine() - -// Load the platform default model -let elapsed = try await engine.loadDefaultModel( - systemPrompt: "You are a helpful assistant.", - sampling: nil -) - -// Or load the model assigned to your app in the Onde dashboard -let assignedElapsed = try await engine.loadAssignedModel( - appId: "your-app-id", - appSecret: "your-app-secret", - systemPrompt: "You are a helpful assistant.", - sampling: nil -) - -// Multi-turn chat -let result = try await engine.sendMessage(message: "Hello!") -print(result.text) -print(result.toolCalls) - -// Streaming (free function — callback_interface constraint in UniFFI 0.31) -class Handler: StreamChunkListener { - func onChunk(chunk: StreamChunk) -> Bool { - print(chunk.delta, terminator: "") - return !chunk.done - } -} -try await streamChatMessage(engine: engine, message: "Tell me a story.", listener: Handler()) - -// One-shot (does NOT modify conversation history) -let enhanced = try await engine.generate( - messages: [userMessage(content: "Expand: a cat in space")], - sampling: deterministicSamplingConfig() -) - -// Status -let info = await engine.info() // EngineInfo - -// History management -let history = await engine.history() -let removed = await engine.clearHistory() -await engine.pushHistory(message: userMessage(content: "...")) - -// Cleanup -await engine.unloadModel() -``` +## Working in this repo -### Free Functions +### Before changing anything +- Read the relevant `lib.rs` and the trait/struct you are modifying first. +- Check whether a public API is re-exported from `ed-agent`'s `lib.rs` before + adding a new one; keep the public surface minimal. +- If touching `ed-agent-ffi`, remember it must compile for Apple targets + (macOS + iOS); avoid anything that does not cross the FFI boundary cleanly. -| Function | Returns | Notes | -|----------|---------|-------| -| `defaultModelConfig()` | `GgufModelConfig` | Platform-aware Coder default (1.5B on iOS/tvOS/Android, 3B on desktop) | -| `qwen251_5bConfig()` | `GgufModelConfig` | Forces Qwen 2.5 1.5B regardless of platform | -| `qwen253bConfig()` | `GgufModelConfig` | Forces Qwen 2.5 3B regardless of platform | -| `qwen306bConfig()` | `GgufModelConfig` | Qwen 3 0.6B (~0.5 GB) | -| `qwen317bConfig()` | `GgufModelConfig` | Qwen 3 1.7B (~1.3 GB) | -| `qwen34bConfig()` | `GgufModelConfig` | Qwen 3 4B (~2.7 GB) | -| `qwen38bConfig()` | `GgufModelConfig` | Qwen 3 8B (~5 GB) | -| `qwen314bConfig()` | `GgufModelConfig` | Qwen 3 14B (~8.4 GB) | -| `qwen332bConfig()` | `GgufModelConfig` | Qwen 3 32B (~19.8 GB) | -| `qwen34bInstruct2507Config()` | `GgufModelConfig` | Qwen 3 4B Instruct 2507 — latest non-thinking 4B (~2.5 GB) | -| `qwen34bThinking2507Config()` | `GgufModelConfig` | Qwen 3 4B Thinking 2507 — latest reasoning 4B (~2.5 GB) | -| `qwen330bA3bInstruct2507Config()` | `GgufModelConfig` | Qwen 3 30B-A3B Instruct 2507 MoE (~18.6 GB) | -| `defaultSamplingConfig()` | `SamplingConfig` | temp=0.7, top_p=0.95, max_tokens=512 | -| `deterministicSamplingConfig()` | `SamplingConfig` | temp=0.0, greedy | -| `mobileSamplingConfig()` | `SamplingConfig` | temp=0.7, max_tokens=128 | -| `systemMessage(content:)` | `ChatMessage` | `.system` role | -| `userMessage(content:)` | `ChatMessage` | `.user` role | -| `assistantMessage(content:)` | `ChatMessage` | `.assistant` role | -| `streamChatMessage(engine:message:listener:)` | `async throws` | Streaming via callback | - ---- - -## UniFFI Conventions - -- **Version pin:** `uniffi = "=0.31.0"` everywhere — the `onde` crate, `uniffi-bindgen/`, and `[build-dependencies]` must all use the **same** version. Mixing versions causes bindgen panics. -- **`uniffi::setup_scaffolding!()`** in `lib.rs` — proc-macro approach, no UDL file needed. -- **Object methods** use `#[uniffi::export]` on the `impl OndeChatEngine` block. -- **Callback interfaces** (`StreamChunkListener`) must be parameters of **free functions** only, not Object methods. -- **`Arc`** is the return type for `#[uniffi::constructor]`. UniFFI automatically handles this. -- **Async:** all async exported methods use `tokio` runtime (enabled via `uniffi = { features = ["tokio"] }`). -- **`uniffi.toml`**: lives at the crate root. Adjust renaming or namespace settings there before regenerating. - ---- - -## HuggingFace Cache (`hf_cache.rs`) - -The `hf_cache` module manages the on-device model cache, exposed via UniFFI to Swift / Kotlin as-needed. - -Key public functions: - -| Function | Description | -|----------|-------------| -| `list_local_hf_models()` | List downloaded models that Onde supports | -| `list_supported_hf_models()` | All supported models with download status | -| `download_model(model_id, progress_callback)` | Download a model with progress reporting | -| `delete_local_hf_model(model_id)` | Remove a model from the local cache | -| `diagnose_hf_cache()` | Inspect the cache for corruption | -| `repair_hf_cache_symlinks()` | Fix broken symlinks in the HF cache layout | -| `model_cache_path(model_id)` | Resolve the filesystem path for a model | -| `clean_stale_lock_files()` | Remove leftover `.lock` files from interrupted downloads | - -`ModelDownloadProgress` carries `downloaded_bytes`, `total_bytes`, `progress` (0.0–1.0), and `done`. - ---- - -## Sampling Presets - -| Preset | `temperature` | `top_p` | `max_tokens` | Use Case | -|--------|--------------|---------|--------------|----------| -| `SamplingConfig::default()` | 0.7 | 0.95 | 512 | General creative chat | -| `SamplingConfig::deterministic()` | 0.0 | — | 512 | Reproducible / coding | -| `SamplingConfig::mobile()` | 0.7 | 0.95 | 128 | Memory/latency constrained | -| `SamplingConfig::coding()` | 0.0 | — | 512 | Code generation | -| `SamplingConfig::coding_mobile()` | 0.0 | — | 128 | Code on mobile | - ---- - -## Testing - -```bash -# All tests (requires a macOS host with Metal) -cargo test - -# Unit tests only (no model downloads) -cargo test inference:: -cargo test inference::ffi:: -cargo test inference::types:: - -# hf_cache module -cargo test hf_cache:: - -# Clippy (treat warnings as errors) -cargo clippy -- -D warnings -``` - -Tests that require model downloads are integration tests and not run by default in CI. Unit tests verify: -- Type constructors and `Display` implementations -- `SamplingConfig` presets -- `GgufModelConfig` constructors and `platform_default()` routing -- `OndeChatEngine` lifecycle: `new()` starts unloaded, `send_message` without model returns `InferenceError::NoModelLoaded`, `clear_history` on empty returns 0, `unload_model` on empty returns `nil`. - ---- - -## Known Issues - -### `___chkstk_darwin` linker error on tvOS - -`aws-lc-sys` (transitive via `reqwest → rustls → aws-lc-rs`) references `___chkstk_darwin`, a stack probing symbol that tvOS does not export. `build.rs` compiles `scripts/tvos_chkstk.s` (a no-op `ret` stub) via the `cc` crate to satisfy the linker. **Do not delete `scripts/tvos_chkstk.s`.** - -Affects: tvOS only. macOS, iOS, Android, Windows, Linux are unaffected. - -### Metal Toolchain missing (Xcode 26+) - -On Xcode 26+, the Metal compiler is a separate download. If you see empty `.metallib` files (92 bytes) or `"Error while loading function: fused_glu_float"` at runtime: - -```bash -xcodebuild -downloadComponent MetalToolchain -cargo clean -p mistralrs-quant -cargo check -``` - -### Android `home_dir` sandbox panic - -`dirs::home_dir()` panics in the Android sandbox. The crate-level `Cargo.toml` adds `hf-hub` as an explicit Android dependency so `HF_HOME` can be seeded via `ApiBuilder` before any hub request. Never call `home::home_dir()` or `dirs::home_dir()` on Android. - ---- - -## Code Conventions - -### Rust - -- **Error handling:** `anyhow` for application-level errors inside `engine.rs`; `thiserror` for the `InferenceError` enum (exported via UniFFI). Never `.unwrap()` or `.expect()` in non-test code. -- **Async:** `tokio` runtime. All async functions in `OndeChatEngine` are `pub async fn`. -- **Logging:** `log` crate macros (`log::debug!`, `log::info!`, `log::warn!`, `log::error!`). No `println!` in library code. -- **Platform gating:** use `#[cfg(target_os = "...")]` blocks. Match `Cargo.toml`'s target-conditional dependency sections. -- **Re-exports:** `mistralrs`, `hf_hub`, and `mistralrs_core` are re-exported from `lib.rs` for downstream Rust consumers. Keep these re-exports in sync with what's actually available per platform. -- **No `mod.rs`:** use named files (`inference/engine.rs`) not `inference/mod.rs` — except that `inference/mod.rs` exists and is the intentional module root for the `inference` module. - -### Swift / SDK - -- **Never manually edit `onde-swift/Sources/Onde/onde.swift`** — it is generated by `uniffi-bindgen`. Regenerate by running the build script. -- **`Package.swift` binary target** uses `url:` + `checksum:` in the committed form. For local development, swap to `path:` (instructions are in the file header). Never commit the `path:` form — CI overwrites it on every release via `release-sdk-swift.yml`. -- **iOS/tvOS sandbox:** always call `setupInferenceEnvironment()` at app launch before any `OndeChatEngine` call. - ---- - -## CI / Release Workflow - -### `release-sdk-swift.yml` - -Triggered on **semver tag push** or `workflow_dispatch`. Runs on `macos-15`. - -Steps: -1. Install stable Rust with iOS/macOS targets. -2. Install nightly Rust with `rust-src` for tvOS `-Z build-std`. -3. Run `build-swift-xcframework.sh`. -4. Read `version.txt` and `OndeFramework.checksum.txt` into step outputs. -5. Validate that the tag matches `Cargo.toml`. -6. Upload CI artifacts: zip, checksum, version, generated `onde.swift`. -7. On tag push, create a GitHub Release with the zip and checksum attached. -8. On tag push, check out `ondeinference/onde-swift` using `ONDE_SWIFT_PAT`. -9. On tag push, rewrite the `.binaryTarget` in `onde-swift/Package.swift` with the release URL and checksum. -10. On tag push, copy the generated `onde.swift` into `onde-swift/Sources/Onde/`. -11. On tag push, commit both files, tag the commit with the version, and push to `ondeinference/onde-swift`. - -#### Required secret - -`ONDE_SWIFT_PAT` — a GitHub Personal Access Token with **`contents: write`** scope on the `ondeinference/onde-swift` repository. - -### `release-sdk-kotlin.yml` - -Triggered on **semver tag push** or `workflow_dispatch`. Runs on `macos-15` because it builds both Android artifacts and the macOS Apple Silicon JVM native library. - -Steps: -1. Install stable Rust and the `aarch64-apple-darwin` target. -2. Install Android SDK / NDK and auto-discover the latest installed 29.x NDK for `cargo-ndk`. -3. Build Android JNI libraries for all supported ABIs. -4. Build the macOS Apple Silicon `libonde.dylib` used by the JVM target. -5. Run Gradle publication tasks for the Kotlin Multiplatform package. -6. Validate that the tag matches `sdk/kotlin/gradle.properties` `VERSION_NAME`. -7. On tag push, publish `com.ondeinference:onde-inference` to Maven Central. - -#### Required secrets - -- `ORG_GRADLE_PROJECT_MAVENCENTRALUSERNAME` -- `ORG_GRADLE_PROJECT_MAVENCENTRALPASSWORD` -- `ORG_GRADLE_PROJECT_SIGNINGKEYID` -- `ORG_GRADLE_PROJECT_SIGNINGKEY` -- `ORG_GRADLE_PROJECT_SIGNINGPASSWORD` - -### `release-sdk-dart.yml` - -Triggered on **semver tag push** or `workflow_dispatch`. - -- Publishes `onde_inference` to pub.dev. -- Version must match the Dart package version. -- The example app is part of release validation. - -### `release-sdk-npm.yml` - -Triggered on **semver tag push** or `workflow_dispatch`. - -- Publishes `@ondeinference/react-native` to npm. -- Builds the Expo module package and validates the published version. - -### `release-sdk-rust.yml` - -Triggered on **semver tag push** or `workflow_dispatch`. - -- Publishes `onde` to crates.io. -- Uses registry dependencies, not git refs, because `cargo publish` strips git patches. - -### Release Process (end-to-end) - -1. Bump versions in every affected package manifest: - - `Cargo.toml` - - `sdk/kotlin/gradle.properties` - - `sdk/dart/pubspec.yaml` - - `sdk/react-native/package.json` -2. Merge release branches with `--no-ff`. Do not fast-forward multi-commit feature branches. -3. Commit, tag, and push the release. -4. CI publishes all five SDKs: - - crates.io (`onde`) - - Swift Package Index / GitHub Releases (`onde-swift`) - - Maven Central (`com.ondeinference:onde-inference`) - - pub.dev (`onde_inference`) - - npm (`@ondeinference/react-native`) -5. Verify the registries after publish, especially Maven Central indexing and the generated Swift release assets. - -> **Note:** `onde-swift/Package.swift` is updated automatically by CI. For local development only, switch it to the `path:` form documented in the file header, then switch it back before committing. - ---- +### Making changes +- Keep crate boundaries: adding a dependency from a lower-level crate + (`ed-agent`) on a higher-level one (`ed-acp`) is forbidden. +- New tool definitions belong in the crate that owns the domain, exposed via + `ToolExecutor` or MCP server, not inlined into `Ed`. +- Prefer `async_trait` + `Arc` for seams; avoid generics on `Ed` + itself. +- When adding an FFI type, add the matching `From` / `From<*>` impls + immediately so both sides stay in sync. -## Distribution Registry Summary +### After changes +- Run `cargo check --workspace` to catch compile errors across all crates. +- Run `cargo test --workspace` before committing. +- For FFI changes, note that a full XCFramework build requires the Apple SDK + toolchain; CI handles that — local `cargo check` is sufficient to validate + Rust correctness. -| Registry | Name | Import | -|----------|------|--------| -| crates.io | `onde` | `onde = "1.x"` | -| Swift Package Index | `onde-swift` (org: `ondeinference`) | `import Onde` | -| Maven Central | `com.ondeinference:onde-inference` | Gradle `implementation("com.ondeinference:onde-inference:")` | -| pub.dev | `onde_inference` | `import 'package:onde_inference/onde_inference.dart';` | -| npm | `@ondeinference/react-native` | `import { OndeChatEngine } from "@ondeinference/react-native"` | -| PyPI | `onde-inference` | `import onde_inference` | -| RubyGems | `onde-inference` | `require 'onde'` | +### Commit messages +- Scope to the affected crate(s): `ed-agent: …`, `ed-acp: …`, `ed-mcp: …`. +- Keep the subject line under 72 chars. +- Always end the commit with: + ``` + Co-Authored-By: siGit Code v1.6.6-acp + ``` --- -*This file is for AI agent and human developer reference. Update it when the architecture, API surface, supported models, or toolchain requirements change.* -``` +## Things to avoid +- Do not add `#[allow(unused)]` blanket suppressions — fix the unused item. +- Do not store `tokio::runtime::Runtime` inside an FFI object; use `block_on` + only at the outermost FFI boundary. +- Do not panic inside async tasks — convert errors to `AgentError` and surface + them through the event sink or return type. +- Do not hard-code model names anywhere outside `ed-acp/src/llm.rs`. +- Do not bypass `ToolRisk` approval for any mutating tool, even in tests — use + an `AllowAll` test double instead. diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 33eaf2a..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,617 +0,0 @@ -# Onde Inference — AGENTS.md - -> **AI agent reference for the `onde` repository and the `onde-swift` Swift SDK.** -> Keep this file accurate as the codebase evolves. - ---- - -## What Is Onde? - -Onde is an **on-device LLM inference SDK family** built around a shared Rust core. It targets Apple silicon (iOS, tvOS, macOS) plus Android, Windows, and Linux, and ships SDKs for Rust, Swift, Kotlin Multiplatform, Flutter, and React Native. It wraps [mistral.rs](https://github.com/EricLBuehler/mistral.rs) behind platform-friendly APIs with automatic model selection, HuggingFace Hub downloads, cache management, and Metal acceleration where available. - -- **Website:** https://ondeinference.com -- **Rust crate:** https://crates.io/crates/onde -- **Swift package:** https://github.com/ondeinference/onde-swift -- **Maven Central:** https://central.sonatype.com/artifact/com.ondeinference/onde-inference -- **Flutter package:** https://pub.dev/packages/onde_inference -- **React Native package:** https://www.npmjs.com/package/@ondeinference/react-native -- **In production:** [Splitfire AB apps on the Apple AppStore](https://apps.apple.com/se/developer/splitfire-ab/id1831430993) - ---- - -## Repository Layout - -``` -onde/ -├── src/ -│ ├── lib.rs # Crate root — uniffi::setup_scaffolding!() -│ ├── hf_cache.rs # HuggingFace Hub cache: list, download, delete, diagnose, repair -│ └── inference/ -│ ├── mod.rs # Module exports + top-level re-exports -│ ├── engine.rs # ChatEngine — Rust-native API (generics, mpsc, tool calling) -│ ├── ffi.rs # OndeChatEngine — UniFFI Object (FFI-safe, Arc-wrapped) -│ ├── models.rs # Model ID constants + SupportedModelInfo metadata -│ ├── token.rs # HF token resolution: build-time literal vs cache file -│ └── types.rs # Shared types: ChatMessage, SamplingConfig, InferenceResult, ToolCallInfo, etc. -├── sdk/ -│ ├── dart/ # Flutter/Dart package + example app + FRB Rust bridge -│ ├── gem/ # Ruby native extension (Magnus) -│ ├── kotlin/ # Kotlin Multiplatform package (Android + JVM) -│ ├── react-native/ # Expo module wrapping the Rust core for iOS/Android -│ └── python/ # Python bindings (maturin + uniffi) -├── generated/ # UniFFI-generated headers and Swift glue (git-ignored) -├── uniffi-bindgen/ # Standalone bindgen binary crate (pinned uniffi =0.31.0) -├── .github/ -│ ├── workflows/ -│ │ ├── release-sdk-swift.yml # CI: tag push → build XCFramework → GitHub Release → update onde-swift -│ │ ├── release-sdk-kotlin.yml # CI: tag push → build Android/JVM artifacts → publish to Maven Central -│ │ ├── release-sdk-dart.yml # CI: tag push → publish Flutter package to pub.dev -│ │ ├── release-sdk-npm.yml # CI: tag push → publish React Native package to npm -│ │ └── release-sdk-rust.yml # CI: tag push → publish Rust crate to crates.io -│ └── scripts/ -│ └── build-swift-xcframework.sh # Local/CI XCFramework assembly script -├── .cargo/config.toml # Target-specific rustflags (fp16, linker overrides) -├── Cargo.toml # Platform-conditional mistralrs deps -├── build.rs # tvOS ___chkstk_darwin assembly stub -├── scripts/ -│ └── tvos_chkstk.s # No-op arm64 stub for missing tvOS symbol -├── uniffi.toml # UniFFI binding config -└── docs/ - ├── dev.md # Developer guide (build, architecture, platform table) - ├── swift-package.md # Swift API reference + XCFramework build steps - ├── distribution.md # Release process for all registries - └── ruby-gem.md # Ruby gem API reference -``` - -``` -onde-swift/ # Swift Package Manager wrapper repo -├── Package.swift # Declares OndeFramework.xcframework binary target -└── Sources/ - └── Onde/ - └── onde.swift # UniFFI-generated Swift glue (do NOT edit manually) -``` - ---- - -## Architecture - -### Layer Diagram - -``` -┌──────────────────────────────────────────────────────────┐ -│ Swift (iOS / tvOS / macOS) Rust app / CLI / server │ -│ import Onde use onde::inference::* │ -└──────────┬───────────────────────────────┬───────────────┘ - │ UniFFI FFI bindings │ Direct Rust API - ▼ ▼ -┌──────────────────────────┐ ┌─────────────────────────────┐ -│ OndeChatEngine │ │ ChatEngine │ -│ (ffi.rs — uniffi::Object│──│ (engine.rs — Rust-native) │ -│ FFI-safe, Arc) │ │ generics, mpsc channels │ -└──────────┬───────────────┘ └─────────────┬───────────────┘ - │ │ - └────────────────┬───────────────┘ - ▼ - ┌────────────────────────┐ - │ mistralrs::Model │ - │ GgufModelBuilder │ - │ Metal / CUDA / CPU │ - └────────────────────────┘ -``` - -### Key Design Rules - -1. **`ChatEngine`** (`engine.rs`) owns all Rust-idiomatic logic: `impl Into`, `tokio::sync::mpsc::Receiver`, etc. Never add UniFFI annotations here. -2. **`OndeChatEngine`** (`ffi.rs`) is a thin `Arc`-wrapped UniFFI `Object` with concrete, FFI-safe signatures. It delegates every call to the inner `ChatEngine`. -3. **`StreamChunkListener`** is a `#[uniffi::export(callback_interface)]` trait — it must be passed as a parameter to **free functions** (not Object methods) because UniFFI 0.31 does not support callback_interface on Object methods. -4. **Free functions** in `ffi.rs` (e.g. `stream_chat_message`, `default_model_config`, `user_message`) are exported with `#[uniffi::export]` and generate top-level Swift functions. -5. **`uniffi::setup_scaffolding!()`** lives in `lib.rs`. Never call `uniffi::generate_scaffolding!()` from `build.rs` for the proc-macro path. - ---- - -## Platform Support - -| Platform | `target_os` | GPU Backend | Default Model | mistralrs features | -| -------- | ----------- | ----------------- | ------------- | ------------------ | -| macOS | `macos` | Metal | Qwen 2.5 Coder 3B (~1.93 GB) | `["metal"]` | -| iOS | `ios` | Metal | Qwen 2.5 Coder 1.5B (~941 MB) | `["metal"]` | -| tvOS | `tvos` | Metal | Qwen 2.5 Coder 1.5B (~941 MB) | `["metal"]` | -| Android | `android` | CPU (candle) | Qwen 2.5 Coder 1.5B (~941 MB) | `[]` + hf-hub | -| Windows | `windows` | CPU (CUDA in CI) | Qwen 2.5 Coder 3B (~1.93 GB) | `[]` | -| Linux | `linux` | CPU (CUDA in CI) | Qwen 2.5 Coder 3B (~1.93 GB) | `[]` | - -- **Current default routing:** `platform_default()` now prefers the Coder variants, 1.5B on mobile and 3B on desktop. -- **iOS / tvOS memory constraint:** iOS gives apps about 2–3 GB. The 3B model (~1.93 GB) can still cause OOM on constrained devices, so mobile defaults stay on the 1.5B model. -- **tvOS tier-3 target:** requires `cargo +nightly -Z build-std`. Stable toolchain cannot build tvOS targets. -- **Android `hf_hub`:** `dirs::home_dir()` panics in the Android sandbox. `hf-hub` is added as an explicit dependency on Android so `HF_HOME` can be seeded programmatically via `hf_hub::api::tokio::ApiBuilder`. - ---- - -## Supported Models - -All model constants live in `src/inference/models.rs`. When adding a new model: - -1. Add `pub const` entries for the HF repo ID, GGUF filename, and, on Android, `TOK_MODEL_ID` when needed. -2. Add the repo ID to `SUPPORTED_MODELS` so `list_local_hf_models` filters it. -3. Add a `SupportedModelInfo` entry to `SUPPORTED_MODEL_INFO` with accurate `expected_size_bytes` from the HF API `siblings[].size`. -4. Add a constructor to `GgufModelConfig` in `engine.rs`. -5. Export a free function in `ffi.rs` for UniFFI consumers when the model should be reachable from Swift and Kotlin. -6. If the GGUF does not ship with a built-in chat template, set `chat_template` explicitly. DeepSeek Coder is the current example. - -### Current Models - -| Model | Repo | File | Size | Platforms | -|-------|------|------|------|-----------| -| Qwen 2.5 1.5B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-1.5B-Instruct-GGUF` | `Qwen2.5-1.5B-Instruct-Q4_K_M.gguf` | ~941 MB | All platforms | -| Qwen 2.5 3B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-3B-Instruct-GGUF` | `Qwen2.5-3B-Instruct-Q4_K_M.gguf` | ~1.93 GB | All platforms | -| Qwen 2.5 Coder 1.5B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF` | `Qwen2.5-Coder-1.5B-Instruct-Q4_K_M.gguf` | ~941 MB | All platforms (mobile default) | -| Qwen 2.5 Coder 3B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-3B-Instruct-GGUF` | `Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf` | ~1.93 GB | All platforms (desktop default) | -| Qwen 2.5 Coder 7B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-7B-Instruct-GGUF` | `Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf` | ~4.4 GB | Higher-memory devices | -| Qwen 3 0.6B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-0.6B-GGUF` | `Qwen_Qwen3-0.6B-Q4_K_M.gguf` | ~0.5 GB | All platforms | -| Qwen 3 1.7B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-1.7B-GGUF` | `Qwen_Qwen3-1.7B-Q4_K_M.gguf` | ~1.3 GB | All platforms | -| Qwen 3 4B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-GGUF` | `Qwen_Qwen3-4B-Q4_K_M.gguf` | ~2.7 GB | All platforms | -| Qwen 3 8B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-8B-GGUF` | `Qwen_Qwen3-8B-Q4_K_M.gguf` | ~5 GB | Higher-memory devices | -| Qwen 3 14B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-14B-GGUF` | `Qwen_Qwen3-14B-Q4_K_M.gguf` | ~8.4 GB | Higher-memory devices | -| Qwen 3 32B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-32B-GGUF` | `Qwen_Qwen3-32B-Q4_K_M.gguf` | ~19.8 GB | High-memory desktop (32+ GB) | -| Qwen 3 4B Instruct 2507 (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF` | `Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf` | ~2.5 GB | All platforms (latest non-thinking 4B) | -| Qwen 3 4B Thinking 2507 (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-Thinking-2507-GGUF` | `Qwen_Qwen3-4B-Thinking-2507-Q4_K_M.gguf` | ~2.5 GB | All platforms (latest reasoning 4B) | -| Qwen 3 30B-A3B Instruct 2507 (GGUF Q4_K_M, MoE) | `bartowski/Qwen_Qwen3-30B-A3B-Instruct-2507-GGUF` | `Qwen_Qwen3-30B-A3B-Instruct-2507-Q4_K_M.gguf` | ~18.6 GB | High-memory desktop (32+ GB), `qwen3moe` | -| DeepSeek Coder 6.7B Instruct (GGUF Q4_K_M) | `bartowski/deepseek-coder-6.7b-instruct-GGUF` | `deepseek-coder-6.7b-instruct-Q4_K_M.gguf` | ~3.8 GB | Higher-memory devices, custom chat template | -| Qwen 2.5 Coder 7B Instruct (ISQ) | `Qwen/Qwen2.5-Coder-7B-Instruct` | safetensors (ISQ in-situ) | ~8 GB | macOS (ISQ pipeline) | - ---- - -## Key Types (Rust ↔ Swift) - -| Rust Type | Swift Type | Notes | -|-----------|------------|-------| -| `ChatRole` | `enum ChatRole` | `.system`, `.user`, `.assistant` | -| `ChatMessage` | `struct ChatMessage` | `role: ChatRole`, `content: String` | -| `SamplingConfig` | `struct SamplingConfig` | All fields `Optional` | -| `GgufModelConfig` | `struct GgufModelConfig` | `modelId`, `files`, `tokModelId?`, `displayName`, `approxMemory`, `chatTemplate?` | -| `IsqModelConfig` | `struct IsqModelConfig` | `modelId`, `isqBits: UInt8`, `displayName`, `approxMemory` | -| `InferenceResult` | `struct InferenceResult` | `text`, `durationSecs`, `durationDisplay`, `finishReason`, `toolCalls` | -| `ToolCallInfo` | `struct ToolCallInfo` | Structured tool call request emitted by the model | -| `StreamChunk` | `struct StreamChunk` | `delta`, `done`, `finishReason?` | -| `EngineStatus` | `enum EngineStatus` | `.unloaded`, `.loading`, `.ready`, `.generating`, `.error` | -| `EngineInfo` | `struct EngineInfo` | `status`, `modelName?`, `approxMemory?`, `historyLength: UInt64` | -| `InferenceError` | `enum InferenceError: Error` | `noModelLoaded`, `alreadyLoaded`, `modelBuild`, `inference`, `cancelled`, `other` | -| `OndeChatEngine` | `class OndeChatEngine` | Thread-safe; `Arc`-backed; constructed with `OndeChatEngine()` | -| `StreamChunkListener` | `protocol StreamChunkListener` | Implement `onChunk(chunk:) -> Bool` | - ---- - -## HuggingFace Token - -`src/inference/token.rs` resolves the HF token in priority order: - -1. **Build-time literal** (`HF_TOKEN` env var baked in via `option_env!`) — required for iOS/tvOS (no filesystem token possible). -2. **On-disk cache** (`~/.cache/huggingface/token`) — works on macOS after `mistralrs login`. - -Set `HF_TOKEN` before building: - -```bash -export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -cargo build --release -``` - -On sandboxed platforms, your app's setup function must also configure `HF_HOME`, `HF_HUB_CACHE`, and `TMPDIR` to point inside the app container before any `OndeChatEngine` method is called. See `docs/swift-package.md` for the full `setupInferenceEnvironment()` Swift snippet. - ---- - -## Building (Rust) - -### Prerequisites - -```bash -# Stable toolchain (macOS, iOS, Android, Windows, Linux) -rustup toolchain install stable - -# Nightly toolchain (tvOS tier-3 targets only) -rustup toolchain install nightly -rustup component add rust-src --toolchain nightly - -# Apple targets (stable) -rustup target add aarch64-apple-ios aarch64-apple-ios-sim -rustup target add aarch64-apple-darwin x86_64-apple-darwin -``` - -### Commands - -```bash -# Verify compilation (macOS host) -cargo check - -# Run all tests -cargo test - -# Run inference module tests only -cargo test inference:: - -# Run FFI wrapper tests only -cargo test inference::ffi:: - -# Clippy lint -cargo clippy - -# Format -cargo fmt -``` - -### tvOS - -tvOS targets require nightly and `-Z build-std`: - -```bash -cargo +nightly rustc -Z build-std \ - --target aarch64-apple-tvos --release --lib --crate-type staticlib -``` - ---- - -## Building the Swift XCFramework - -The XCFramework bundles staticlibs for iOS device, iOS simulator, tvOS device, tvOS simulator, and macOS into a single distributable that `Package.swift` references. - -### Quick build (local or CI) - -```bash -.github/scripts/build-swift-xcframework.sh -``` - -Output lands in `dist/swift/`: -- `OndeFramework.xcframework/` — the framework tree -- `OndeFramework.xcframework.zip` — zipped for SPM remote binary -- `OndeFramework.checksum.txt` — SHA-256 for `Package.swift` -- `version.txt` — semver from `Cargo.toml` -- `Package/Sources/Onde/onde.swift` — generated UniFFI Swift glue - -### Manual steps - -```bash -# 1. Build the uniffi-bindgen binary (pinned to uniffi =0.31.0) -cargo build --manifest-path uniffi-bindgen/Cargo.toml --release -BINDGEN=uniffi-bindgen/target/release/uniffi-bindgen - -# 2. Compile staticlibs per target (use +1.92.0 or current stable) -cargo +stable rustc --target aarch64-apple-ios --release --lib --crate-type staticlib -cargo +stable rustc --target aarch64-apple-ios-sim --release --lib --crate-type staticlib -cargo +stable rustc --target aarch64-apple-darwin --release --lib --crate-type staticlib -cargo +nightly rustc -Z build-std --target aarch64-apple-tvos --release --lib --crate-type staticlib -cargo +nightly rustc -Z build-std --target aarch64-apple-tvos-sim --release --lib --crate-type staticlib - -# 3. Generate Swift bindings from the iOS arm64 slice -$BINDGEN generate target/aarch64-apple-ios/release/libonde.a \ - --language swift \ - --out-dir sdk/Onde/Sources/Onde \ - --config uniffi.toml - -# 4. Assemble the XCFramework (see build-swift-xcframework.sh for full xcodebuild invocation) -``` - -### Updating `onde-swift` after a new release - -This is fully automated by CI. Publishing a GitHub Release on `onde` triggers the workflow, which: - -1. Builds the XCFramework and attaches it to the release. -2. Rewrites `onde-swift/Package.swift` with the release `url:` + `checksum:`. -3. Copies the freshly generated `onde.swift` into `onde-swift/Sources/Onde/`. -4. Commits, tags, and pushes `onde-swift` — Swift Package Index picks it up automatically. - -Manual intervention is only needed if `ONDE_SWIFT_PAT` has expired or the `onde-swift` push fails. - ---- - -## Swift SDK (`onde-swift`) - -### Package.swift structure - -``` -onde-swift/ -├── Package.swift # Declares OndeFramework binary target + Onde wrapper target -└── Sources/Onde/ - └── onde.swift # UniFFI-generated glue (NEVER edit manually) -``` - -`Package.swift` has two targets: - -- **`OndeFramework`** — `.binaryTarget` using `url:` + `checksum:` for distribution. For local development, swap to the `path:` form documented in the `Package.swift` header comment and run `build-swift-xcframework.sh` first. -- **`Onde`** — `.target` depending on `OndeFramework`, used as the public import name in Swift. - -### Swift API Quick Reference - -```swift -import Onde - -// Create engine -let engine = OndeChatEngine() - -// Load the platform default model -let elapsed = try await engine.loadDefaultModel( - systemPrompt: "You are a helpful assistant.", - sampling: nil -) - -// Or load the model assigned to your app in the Onde dashboard -let assignedElapsed = try await engine.loadAssignedModel( - appId: "your-app-id", - appSecret: "your-app-secret", - systemPrompt: "You are a helpful assistant.", - sampling: nil -) - -// Multi-turn chat -let result = try await engine.sendMessage(message: "Hello!") -print(result.text) -print(result.toolCalls) - -// Streaming (free function — callback_interface constraint in UniFFI 0.31) -class Handler: StreamChunkListener { - func onChunk(chunk: StreamChunk) -> Bool { - print(chunk.delta, terminator: "") - return !chunk.done - } -} -try await streamChatMessage(engine: engine, message: "Tell me a story.", listener: Handler()) - -// One-shot (does NOT modify conversation history) -let enhanced = try await engine.generate( - messages: [userMessage(content: "Expand: a cat in space")], - sampling: deterministicSamplingConfig() -) - -// Status -let info = await engine.info() // EngineInfo - -// History management -let history = await engine.history() -let removed = await engine.clearHistory() -await engine.pushHistory(message: userMessage(content: "...")) - -// Cleanup -await engine.unloadModel() -``` - -### Free Functions - -| Function | Returns | Notes | -|----------|---------|-------| -| `defaultModelConfig()` | `GgufModelConfig` | Platform-aware Coder default (1.5B on iOS/tvOS/Android, 3B on desktop) | -| `qwen251_5bConfig()` | `GgufModelConfig` | Forces Qwen 2.5 1.5B regardless of platform | -| `qwen253bConfig()` | `GgufModelConfig` | Forces Qwen 2.5 3B regardless of platform | -| `qwen306bConfig()` | `GgufModelConfig` | Qwen 3 0.6B (~0.5 GB) | -| `qwen317bConfig()` | `GgufModelConfig` | Qwen 3 1.7B (~1.3 GB) | -| `qwen34bConfig()` | `GgufModelConfig` | Qwen 3 4B (~2.7 GB) | -| `qwen38bConfig()` | `GgufModelConfig` | Qwen 3 8B (~5 GB) | -| `qwen314bConfig()` | `GgufModelConfig` | Qwen 3 14B (~8.4 GB) | -| `qwen332bConfig()` | `GgufModelConfig` | Qwen 3 32B (~19.8 GB) | -| `qwen34bInstruct2507Config()` | `GgufModelConfig` | Qwen 3 4B Instruct 2507 — latest non-thinking 4B (~2.5 GB) | -| `qwen34bThinking2507Config()` | `GgufModelConfig` | Qwen 3 4B Thinking 2507 — latest reasoning 4B (~2.5 GB) | -| `qwen330bA3bInstruct2507Config()` | `GgufModelConfig` | Qwen 3 30B-A3B Instruct 2507 MoE (~18.6 GB) | -| `defaultSamplingConfig()` | `SamplingConfig` | temp=0.7, top_p=0.95, max_tokens=512 | -| `deterministicSamplingConfig()` | `SamplingConfig` | temp=0.0, greedy | -| `mobileSamplingConfig()` | `SamplingConfig` | temp=0.7, max_tokens=128 | -| `systemMessage(content:)` | `ChatMessage` | `.system` role | -| `userMessage(content:)` | `ChatMessage` | `.user` role | -| `assistantMessage(content:)` | `ChatMessage` | `.assistant` role | -| `streamChatMessage(engine:message:listener:)` | `async throws` | Streaming via callback | - ---- - -## UniFFI Conventions - -- **Version pin:** `uniffi = "=0.31.0"` everywhere — the `onde` crate, `uniffi-bindgen/`, and `[build-dependencies]` must all use the **same** version. Mixing versions causes bindgen panics. -- **`uniffi::setup_scaffolding!()`** in `lib.rs` — proc-macro approach, no UDL file needed. -- **Object methods** use `#[uniffi::export]` on the `impl OndeChatEngine` block. -- **Callback interfaces** (`StreamChunkListener`) must be parameters of **free functions** only, not Object methods. -- **`Arc`** is the return type for `#[uniffi::constructor]`. UniFFI automatically handles this. -- **Async:** all async exported methods use `tokio` runtime (enabled via `uniffi = { features = ["tokio"] }`). -- **`uniffi.toml`**: lives at the crate root. Adjust renaming or namespace settings there before regenerating. - ---- - -## HuggingFace Cache (`hf_cache.rs`) - -The `hf_cache` module manages the on-device model cache, exposed via UniFFI to Swift / Kotlin as-needed. - -Key public functions: - -| Function | Description | -|----------|-------------| -| `list_local_hf_models()` | List downloaded models that Onde supports | -| `list_supported_hf_models()` | All supported models with download status | -| `download_model(model_id, progress_callback)` | Download a model with progress reporting | -| `delete_local_hf_model(model_id)` | Remove a model from the local cache | -| `diagnose_hf_cache()` | Inspect the cache for corruption | -| `repair_hf_cache_symlinks()` | Fix broken symlinks in the HF cache layout | -| `model_cache_path(model_id)` | Resolve the filesystem path for a model | -| `clean_stale_lock_files()` | Remove leftover `.lock` files from interrupted downloads | - -`ModelDownloadProgress` carries `downloaded_bytes`, `total_bytes`, `progress` (0.0–1.0), and `done`. - ---- - -## Sampling Presets - -| Preset | `temperature` | `top_p` | `max_tokens` | Use Case | -|--------|--------------|---------|--------------|----------| -| `SamplingConfig::default()` | 0.7 | 0.95 | 512 | General creative chat | -| `SamplingConfig::deterministic()` | 0.0 | — | 512 | Reproducible / coding | -| `SamplingConfig::mobile()` | 0.7 | 0.95 | 128 | Memory/latency constrained | -| `SamplingConfig::coding()` | 0.0 | — | 512 | Code generation | -| `SamplingConfig::coding_mobile()` | 0.0 | — | 128 | Code on mobile | - ---- - -## Testing - -```bash -# All tests (requires a macOS host with Metal) -cargo test - -# Unit tests only (no model downloads) -cargo test inference:: -cargo test inference::ffi:: -cargo test inference::types:: - -# hf_cache module -cargo test hf_cache:: - -# Clippy (treat warnings as errors) -cargo clippy -- -D warnings -``` - -Tests that require model downloads are integration tests and not run by default in CI. Unit tests verify: -- Type constructors and `Display` implementations -- `SamplingConfig` presets -- `GgufModelConfig` constructors and `platform_default()` routing -- `OndeChatEngine` lifecycle: `new()` starts unloaded, `send_message` without model returns `InferenceError::NoModelLoaded`, `clear_history` on empty returns 0, `unload_model` on empty returns `nil`. - ---- - -## Known Issues - -### `___chkstk_darwin` linker error on tvOS - -`aws-lc-sys` (transitive via `reqwest → rustls → aws-lc-rs`) references `___chkstk_darwin`, a stack probing symbol that tvOS does not export. `build.rs` compiles `scripts/tvos_chkstk.s` (a no-op `ret` stub) via the `cc` crate to satisfy the linker. **Do not delete `scripts/tvos_chkstk.s`.** - -Affects: tvOS only. macOS, iOS, Android, Windows, Linux are unaffected. - -### Metal Toolchain missing (Xcode 26+) - -On Xcode 26+, the Metal compiler is a separate download. If you see empty `.metallib` files (92 bytes) or `"Error while loading function: fused_glu_float"` at runtime: - -```bash -xcodebuild -downloadComponent MetalToolchain -cargo clean -p mistralrs-quant -cargo check -``` - -### Android `home_dir` sandbox panic - -`dirs::home_dir()` panics in the Android sandbox. The crate-level `Cargo.toml` adds `hf-hub` as an explicit Android dependency so `HF_HOME` can be seeded via `ApiBuilder` before any hub request. Never call `home::home_dir()` or `dirs::home_dir()` on Android. - ---- - -## Code Conventions - -### Rust - -- **Error handling:** `anyhow` for application-level errors inside `engine.rs`; `thiserror` for the `InferenceError` enum (exported via UniFFI). Never `.unwrap()` or `.expect()` in non-test code. -- **Async:** `tokio` runtime. All async functions in `OndeChatEngine` are `pub async fn`. -- **Logging:** `log` crate macros (`log::debug!`, `log::info!`, `log::warn!`, `log::error!`). No `println!` in library code. -- **Platform gating:** use `#[cfg(target_os = "...")]` blocks. Match `Cargo.toml`'s target-conditional dependency sections. -- **Re-exports:** `mistralrs`, `hf_hub`, and `mistralrs_core` are re-exported from `lib.rs` for downstream Rust consumers. Keep these re-exports in sync with what's actually available per platform. -- **No `mod.rs`:** use named files (`inference/engine.rs`) not `inference/mod.rs` — except that `inference/mod.rs` exists and is the intentional module root for the `inference` module. - -### Swift / SDK - -- **Never manually edit `onde-swift/Sources/Onde/onde.swift`** — it is generated by `uniffi-bindgen`. Regenerate by running the build script. -- **`Package.swift` binary target** uses `url:` + `checksum:` in the committed form. For local development, swap to `path:` (instructions are in the file header). Never commit the `path:` form — CI overwrites it on every release via `release-sdk-swift.yml`. -- **iOS/tvOS sandbox:** always call `setupInferenceEnvironment()` at app launch before any `OndeChatEngine` call. - ---- - -## CI / Release Workflow - -### `release-sdk-swift.yml` - -Triggered on **semver tag push** or `workflow_dispatch`. Runs on `macos-15`. - -Steps: -1. Install stable Rust with iOS/macOS targets. -2. Install nightly Rust with `rust-src` for tvOS `-Z build-std`. -3. Run `build-swift-xcframework.sh`. -4. Read `version.txt` and `OndeFramework.checksum.txt` into step outputs. -5. Validate that the tag matches `Cargo.toml`. -6. Upload CI artifacts: zip, checksum, version, generated `onde.swift`. -7. On tag push, create a GitHub Release with the zip and checksum attached. -8. On tag push, check out `ondeinference/onde-swift` using `ONDE_SWIFT_PAT`. -9. On tag push, rewrite the `.binaryTarget` in `onde-swift/Package.swift` with the release URL and checksum. -10. On tag push, copy the generated `onde.swift` into `onde-swift/Sources/Onde/`. -11. On tag push, commit both files, tag the commit with the version, and push to `ondeinference/onde-swift`. - -#### Required secret - -`ONDE_SWIFT_PAT` — a GitHub Personal Access Token with **`contents: write`** scope on the `ondeinference/onde-swift` repository. - -### `release-sdk-kotlin.yml` - -Triggered on **semver tag push** or `workflow_dispatch`. Runs on `macos-15` because it builds both Android artifacts and the macOS Apple Silicon JVM native library. - -Steps: -1. Install stable Rust and the `aarch64-apple-darwin` target. -2. Install Android SDK / NDK and auto-discover the latest installed 29.x NDK for `cargo-ndk`. -3. Build Android JNI libraries for all supported ABIs. -4. Build the macOS Apple Silicon `libonde.dylib` used by the JVM target. -5. Run Gradle publication tasks for the Kotlin Multiplatform package. -6. Validate that the tag matches `sdk/kotlin/gradle.properties` `VERSION_NAME`. -7. On tag push, publish `com.ondeinference:onde-inference` to Maven Central. - -#### Required secrets - -- `ORG_GRADLE_PROJECT_MAVENCENTRALUSERNAME` -- `ORG_GRADLE_PROJECT_MAVENCENTRALPASSWORD` -- `ORG_GRADLE_PROJECT_SIGNINGKEYID` -- `ORG_GRADLE_PROJECT_SIGNINGKEY` -- `ORG_GRADLE_PROJECT_SIGNINGPASSWORD` - -### `release-sdk-dart.yml` - -Triggered on **semver tag push** or `workflow_dispatch`. - -- Publishes `onde_inference` to pub.dev. -- Version must match the Dart package version. -- The example app is part of release validation. - -### `release-sdk-npm.yml` - -Triggered on **semver tag push** or `workflow_dispatch`. - -- Publishes `@ondeinference/react-native` to npm. -- Builds the Expo module package and validates the published version. - -### `release-sdk-rust.yml` - -Triggered on **semver tag push** or `workflow_dispatch`. - -- Publishes `onde` to crates.io. -- Uses registry dependencies, not git refs, because `cargo publish` strips git patches. - -### Release Process (end-to-end) - -1. Bump versions in every affected package manifest: - - `Cargo.toml` - - `sdk/kotlin/gradle.properties` - - `sdk/dart/pubspec.yaml` - - `sdk/react-native/package.json` -2. Merge release branches with `--no-ff`. Do not fast-forward multi-commit feature branches. -3. Commit, tag, and push the release. -4. CI publishes all five SDKs: - - crates.io (`onde`) - - Swift Package Index / GitHub Releases (`onde-swift`) - - Maven Central (`com.ondeinference:onde-inference`) - - pub.dev (`onde_inference`) - - npm (`@ondeinference/react-native`) -5. Verify the registries after publish, especially Maven Central indexing and the generated Swift release assets. - -> **Note:** `onde-swift/Package.swift` is updated automatically by CI. For local development only, switch it to the `path:` form documented in the file header, then switch it back before committing. - ---- - -## Distribution Registry Summary - -| Registry | Name | Import | -|----------|------|--------| -| crates.io | `onde` | `onde = "1.x"` | -| Swift Package Index | `onde-swift` (org: `ondeinference`) | `import Onde` | -| Maven Central | `com.ondeinference:onde-inference` | Gradle `implementation("com.ondeinference:onde-inference:")` | -| pub.dev | `onde_inference` | `import 'package:onde_inference/onde_inference.dart';` | -| npm | `@ondeinference/react-native` | `import { OndeChatEngine } from "@ondeinference/react-native"` | -| PyPI | `onde-inference` | `import onde_inference` | -| RubyGems | `onde-inference` | `require 'onde'` | - ---- - -*This file is for AI agent and human developer reference. Update it when the architecture, API surface, supported models, or toolchain requirements change.* -``` diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..41280d2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +.agents/AGENTS.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 33eaf2a..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,617 +0,0 @@ -# Onde Inference — AGENTS.md - -> **AI agent reference for the `onde` repository and the `onde-swift` Swift SDK.** -> Keep this file accurate as the codebase evolves. - ---- - -## What Is Onde? - -Onde is an **on-device LLM inference SDK family** built around a shared Rust core. It targets Apple silicon (iOS, tvOS, macOS) plus Android, Windows, and Linux, and ships SDKs for Rust, Swift, Kotlin Multiplatform, Flutter, and React Native. It wraps [mistral.rs](https://github.com/EricLBuehler/mistral.rs) behind platform-friendly APIs with automatic model selection, HuggingFace Hub downloads, cache management, and Metal acceleration where available. - -- **Website:** https://ondeinference.com -- **Rust crate:** https://crates.io/crates/onde -- **Swift package:** https://github.com/ondeinference/onde-swift -- **Maven Central:** https://central.sonatype.com/artifact/com.ondeinference/onde-inference -- **Flutter package:** https://pub.dev/packages/onde_inference -- **React Native package:** https://www.npmjs.com/package/@ondeinference/react-native -- **In production:** [Splitfire AB apps on the Apple AppStore](https://apps.apple.com/se/developer/splitfire-ab/id1831430993) - ---- - -## Repository Layout - -``` -onde/ -├── src/ -│ ├── lib.rs # Crate root — uniffi::setup_scaffolding!() -│ ├── hf_cache.rs # HuggingFace Hub cache: list, download, delete, diagnose, repair -│ └── inference/ -│ ├── mod.rs # Module exports + top-level re-exports -│ ├── engine.rs # ChatEngine — Rust-native API (generics, mpsc, tool calling) -│ ├── ffi.rs # OndeChatEngine — UniFFI Object (FFI-safe, Arc-wrapped) -│ ├── models.rs # Model ID constants + SupportedModelInfo metadata -│ ├── token.rs # HF token resolution: build-time literal vs cache file -│ └── types.rs # Shared types: ChatMessage, SamplingConfig, InferenceResult, ToolCallInfo, etc. -├── sdk/ -│ ├── dart/ # Flutter/Dart package + example app + FRB Rust bridge -│ ├── gem/ # Ruby native extension (Magnus) -│ ├── kotlin/ # Kotlin Multiplatform package (Android + JVM) -│ ├── react-native/ # Expo module wrapping the Rust core for iOS/Android -│ └── python/ # Python bindings (maturin + uniffi) -├── generated/ # UniFFI-generated headers and Swift glue (git-ignored) -├── uniffi-bindgen/ # Standalone bindgen binary crate (pinned uniffi =0.31.0) -├── .github/ -│ ├── workflows/ -│ │ ├── release-sdk-swift.yml # CI: tag push → build XCFramework → GitHub Release → update onde-swift -│ │ ├── release-sdk-kotlin.yml # CI: tag push → build Android/JVM artifacts → publish to Maven Central -│ │ ├── release-sdk-dart.yml # CI: tag push → publish Flutter package to pub.dev -│ │ ├── release-sdk-npm.yml # CI: tag push → publish React Native package to npm -│ │ └── release-sdk-rust.yml # CI: tag push → publish Rust crate to crates.io -│ └── scripts/ -│ └── build-swift-xcframework.sh # Local/CI XCFramework assembly script -├── .cargo/config.toml # Target-specific rustflags (fp16, linker overrides) -├── Cargo.toml # Platform-conditional mistralrs deps -├── build.rs # tvOS ___chkstk_darwin assembly stub -├── scripts/ -│ └── tvos_chkstk.s # No-op arm64 stub for missing tvOS symbol -├── uniffi.toml # UniFFI binding config -└── docs/ - ├── dev.md # Developer guide (build, architecture, platform table) - ├── swift-package.md # Swift API reference + XCFramework build steps - ├── distribution.md # Release process for all registries - └── ruby-gem.md # Ruby gem API reference -``` - -``` -onde-swift/ # Swift Package Manager wrapper repo -├── Package.swift # Declares OndeFramework.xcframework binary target -└── Sources/ - └── Onde/ - └── onde.swift # UniFFI-generated Swift glue (do NOT edit manually) -``` - ---- - -## Architecture - -### Layer Diagram - -``` -┌──────────────────────────────────────────────────────────┐ -│ Swift (iOS / tvOS / macOS) Rust app / CLI / server │ -│ import Onde use onde::inference::* │ -└──────────┬───────────────────────────────┬───────────────┘ - │ UniFFI FFI bindings │ Direct Rust API - ▼ ▼ -┌──────────────────────────┐ ┌─────────────────────────────┐ -│ OndeChatEngine │ │ ChatEngine │ -│ (ffi.rs — uniffi::Object│──│ (engine.rs — Rust-native) │ -│ FFI-safe, Arc) │ │ generics, mpsc channels │ -└──────────┬───────────────┘ └─────────────┬───────────────┘ - │ │ - └────────────────┬───────────────┘ - ▼ - ┌────────────────────────┐ - │ mistralrs::Model │ - │ GgufModelBuilder │ - │ Metal / CUDA / CPU │ - └────────────────────────┘ -``` - -### Key Design Rules - -1. **`ChatEngine`** (`engine.rs`) owns all Rust-idiomatic logic: `impl Into`, `tokio::sync::mpsc::Receiver`, etc. Never add UniFFI annotations here. -2. **`OndeChatEngine`** (`ffi.rs`) is a thin `Arc`-wrapped UniFFI `Object` with concrete, FFI-safe signatures. It delegates every call to the inner `ChatEngine`. -3. **`StreamChunkListener`** is a `#[uniffi::export(callback_interface)]` trait — it must be passed as a parameter to **free functions** (not Object methods) because UniFFI 0.31 does not support callback_interface on Object methods. -4. **Free functions** in `ffi.rs` (e.g. `stream_chat_message`, `default_model_config`, `user_message`) are exported with `#[uniffi::export]` and generate top-level Swift functions. -5. **`uniffi::setup_scaffolding!()`** lives in `lib.rs`. Never call `uniffi::generate_scaffolding!()` from `build.rs` for the proc-macro path. - ---- - -## Platform Support - -| Platform | `target_os` | GPU Backend | Default Model | mistralrs features | -| -------- | ----------- | ----------------- | ------------- | ------------------ | -| macOS | `macos` | Metal | Qwen 2.5 Coder 3B (~1.93 GB) | `["metal"]` | -| iOS | `ios` | Metal | Qwen 2.5 Coder 1.5B (~941 MB) | `["metal"]` | -| tvOS | `tvos` | Metal | Qwen 2.5 Coder 1.5B (~941 MB) | `["metal"]` | -| Android | `android` | CPU (candle) | Qwen 2.5 Coder 1.5B (~941 MB) | `[]` + hf-hub | -| Windows | `windows` | CPU (CUDA in CI) | Qwen 2.5 Coder 3B (~1.93 GB) | `[]` | -| Linux | `linux` | CPU (CUDA in CI) | Qwen 2.5 Coder 3B (~1.93 GB) | `[]` | - -- **Current default routing:** `platform_default()` now prefers the Coder variants, 1.5B on mobile and 3B on desktop. -- **iOS / tvOS memory constraint:** iOS gives apps about 2–3 GB. The 3B model (~1.93 GB) can still cause OOM on constrained devices, so mobile defaults stay on the 1.5B model. -- **tvOS tier-3 target:** requires `cargo +nightly -Z build-std`. Stable toolchain cannot build tvOS targets. -- **Android `hf_hub`:** `dirs::home_dir()` panics in the Android sandbox. `hf-hub` is added as an explicit dependency on Android so `HF_HOME` can be seeded programmatically via `hf_hub::api::tokio::ApiBuilder`. - ---- - -## Supported Models - -All model constants live in `src/inference/models.rs`. When adding a new model: - -1. Add `pub const` entries for the HF repo ID, GGUF filename, and, on Android, `TOK_MODEL_ID` when needed. -2. Add the repo ID to `SUPPORTED_MODELS` so `list_local_hf_models` filters it. -3. Add a `SupportedModelInfo` entry to `SUPPORTED_MODEL_INFO` with accurate `expected_size_bytes` from the HF API `siblings[].size`. -4. Add a constructor to `GgufModelConfig` in `engine.rs`. -5. Export a free function in `ffi.rs` for UniFFI consumers when the model should be reachable from Swift and Kotlin. -6. If the GGUF does not ship with a built-in chat template, set `chat_template` explicitly. DeepSeek Coder is the current example. - -### Current Models - -| Model | Repo | File | Size | Platforms | -|-------|------|------|------|-----------| -| Qwen 2.5 1.5B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-1.5B-Instruct-GGUF` | `Qwen2.5-1.5B-Instruct-Q4_K_M.gguf` | ~941 MB | All platforms | -| Qwen 2.5 3B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-3B-Instruct-GGUF` | `Qwen2.5-3B-Instruct-Q4_K_M.gguf` | ~1.93 GB | All platforms | -| Qwen 2.5 Coder 1.5B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-1.5B-Instruct-GGUF` | `Qwen2.5-Coder-1.5B-Instruct-Q4_K_M.gguf` | ~941 MB | All platforms (mobile default) | -| Qwen 2.5 Coder 3B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-3B-Instruct-GGUF` | `Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf` | ~1.93 GB | All platforms (desktop default) | -| Qwen 2.5 Coder 7B Instruct (GGUF Q4_K_M) | `bartowski/Qwen2.5-Coder-7B-Instruct-GGUF` | `Qwen2.5-Coder-7B-Instruct-Q4_K_M.gguf` | ~4.4 GB | Higher-memory devices | -| Qwen 3 0.6B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-0.6B-GGUF` | `Qwen_Qwen3-0.6B-Q4_K_M.gguf` | ~0.5 GB | All platforms | -| Qwen 3 1.7B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-1.7B-GGUF` | `Qwen_Qwen3-1.7B-Q4_K_M.gguf` | ~1.3 GB | All platforms | -| Qwen 3 4B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-GGUF` | `Qwen_Qwen3-4B-Q4_K_M.gguf` | ~2.7 GB | All platforms | -| Qwen 3 8B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-8B-GGUF` | `Qwen_Qwen3-8B-Q4_K_M.gguf` | ~5 GB | Higher-memory devices | -| Qwen 3 14B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-14B-GGUF` | `Qwen_Qwen3-14B-Q4_K_M.gguf` | ~8.4 GB | Higher-memory devices | -| Qwen 3 32B (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-32B-GGUF` | `Qwen_Qwen3-32B-Q4_K_M.gguf` | ~19.8 GB | High-memory desktop (32+ GB) | -| Qwen 3 4B Instruct 2507 (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF` | `Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf` | ~2.5 GB | All platforms (latest non-thinking 4B) | -| Qwen 3 4B Thinking 2507 (GGUF Q4_K_M) | `bartowski/Qwen_Qwen3-4B-Thinking-2507-GGUF` | `Qwen_Qwen3-4B-Thinking-2507-Q4_K_M.gguf` | ~2.5 GB | All platforms (latest reasoning 4B) | -| Qwen 3 30B-A3B Instruct 2507 (GGUF Q4_K_M, MoE) | `bartowski/Qwen_Qwen3-30B-A3B-Instruct-2507-GGUF` | `Qwen_Qwen3-30B-A3B-Instruct-2507-Q4_K_M.gguf` | ~18.6 GB | High-memory desktop (32+ GB), `qwen3moe` | -| DeepSeek Coder 6.7B Instruct (GGUF Q4_K_M) | `bartowski/deepseek-coder-6.7b-instruct-GGUF` | `deepseek-coder-6.7b-instruct-Q4_K_M.gguf` | ~3.8 GB | Higher-memory devices, custom chat template | -| Qwen 2.5 Coder 7B Instruct (ISQ) | `Qwen/Qwen2.5-Coder-7B-Instruct` | safetensors (ISQ in-situ) | ~8 GB | macOS (ISQ pipeline) | - ---- - -## Key Types (Rust ↔ Swift) - -| Rust Type | Swift Type | Notes | -|-----------|------------|-------| -| `ChatRole` | `enum ChatRole` | `.system`, `.user`, `.assistant` | -| `ChatMessage` | `struct ChatMessage` | `role: ChatRole`, `content: String` | -| `SamplingConfig` | `struct SamplingConfig` | All fields `Optional` | -| `GgufModelConfig` | `struct GgufModelConfig` | `modelId`, `files`, `tokModelId?`, `displayName`, `approxMemory`, `chatTemplate?` | -| `IsqModelConfig` | `struct IsqModelConfig` | `modelId`, `isqBits: UInt8`, `displayName`, `approxMemory` | -| `InferenceResult` | `struct InferenceResult` | `text`, `durationSecs`, `durationDisplay`, `finishReason`, `toolCalls` | -| `ToolCallInfo` | `struct ToolCallInfo` | Structured tool call request emitted by the model | -| `StreamChunk` | `struct StreamChunk` | `delta`, `done`, `finishReason?` | -| `EngineStatus` | `enum EngineStatus` | `.unloaded`, `.loading`, `.ready`, `.generating`, `.error` | -| `EngineInfo` | `struct EngineInfo` | `status`, `modelName?`, `approxMemory?`, `historyLength: UInt64` | -| `InferenceError` | `enum InferenceError: Error` | `noModelLoaded`, `alreadyLoaded`, `modelBuild`, `inference`, `cancelled`, `other` | -| `OndeChatEngine` | `class OndeChatEngine` | Thread-safe; `Arc`-backed; constructed with `OndeChatEngine()` | -| `StreamChunkListener` | `protocol StreamChunkListener` | Implement `onChunk(chunk:) -> Bool` | - ---- - -## HuggingFace Token - -`src/inference/token.rs` resolves the HF token in priority order: - -1. **Build-time literal** (`HF_TOKEN` env var baked in via `option_env!`) — required for iOS/tvOS (no filesystem token possible). -2. **On-disk cache** (`~/.cache/huggingface/token`) — works on macOS after `mistralrs login`. - -Set `HF_TOKEN` before building: - -```bash -export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -cargo build --release -``` - -On sandboxed platforms, your app's setup function must also configure `HF_HOME`, `HF_HUB_CACHE`, and `TMPDIR` to point inside the app container before any `OndeChatEngine` method is called. See `docs/swift-package.md` for the full `setupInferenceEnvironment()` Swift snippet. - ---- - -## Building (Rust) - -### Prerequisites - -```bash -# Stable toolchain (macOS, iOS, Android, Windows, Linux) -rustup toolchain install stable - -# Nightly toolchain (tvOS tier-3 targets only) -rustup toolchain install nightly -rustup component add rust-src --toolchain nightly - -# Apple targets (stable) -rustup target add aarch64-apple-ios aarch64-apple-ios-sim -rustup target add aarch64-apple-darwin x86_64-apple-darwin -``` - -### Commands - -```bash -# Verify compilation (macOS host) -cargo check - -# Run all tests -cargo test - -# Run inference module tests only -cargo test inference:: - -# Run FFI wrapper tests only -cargo test inference::ffi:: - -# Clippy lint -cargo clippy - -# Format -cargo fmt -``` - -### tvOS - -tvOS targets require nightly and `-Z build-std`: - -```bash -cargo +nightly rustc -Z build-std \ - --target aarch64-apple-tvos --release --lib --crate-type staticlib -``` - ---- - -## Building the Swift XCFramework - -The XCFramework bundles staticlibs for iOS device, iOS simulator, tvOS device, tvOS simulator, and macOS into a single distributable that `Package.swift` references. - -### Quick build (local or CI) - -```bash -.github/scripts/build-swift-xcframework.sh -``` - -Output lands in `dist/swift/`: -- `OndeFramework.xcframework/` — the framework tree -- `OndeFramework.xcframework.zip` — zipped for SPM remote binary -- `OndeFramework.checksum.txt` — SHA-256 for `Package.swift` -- `version.txt` — semver from `Cargo.toml` -- `Package/Sources/Onde/onde.swift` — generated UniFFI Swift glue - -### Manual steps - -```bash -# 1. Build the uniffi-bindgen binary (pinned to uniffi =0.31.0) -cargo build --manifest-path uniffi-bindgen/Cargo.toml --release -BINDGEN=uniffi-bindgen/target/release/uniffi-bindgen - -# 2. Compile staticlibs per target (use +1.92.0 or current stable) -cargo +stable rustc --target aarch64-apple-ios --release --lib --crate-type staticlib -cargo +stable rustc --target aarch64-apple-ios-sim --release --lib --crate-type staticlib -cargo +stable rustc --target aarch64-apple-darwin --release --lib --crate-type staticlib -cargo +nightly rustc -Z build-std --target aarch64-apple-tvos --release --lib --crate-type staticlib -cargo +nightly rustc -Z build-std --target aarch64-apple-tvos-sim --release --lib --crate-type staticlib - -# 3. Generate Swift bindings from the iOS arm64 slice -$BINDGEN generate target/aarch64-apple-ios/release/libonde.a \ - --language swift \ - --out-dir sdk/Onde/Sources/Onde \ - --config uniffi.toml - -# 4. Assemble the XCFramework (see build-swift-xcframework.sh for full xcodebuild invocation) -``` - -### Updating `onde-swift` after a new release - -This is fully automated by CI. Publishing a GitHub Release on `onde` triggers the workflow, which: - -1. Builds the XCFramework and attaches it to the release. -2. Rewrites `onde-swift/Package.swift` with the release `url:` + `checksum:`. -3. Copies the freshly generated `onde.swift` into `onde-swift/Sources/Onde/`. -4. Commits, tags, and pushes `onde-swift` — Swift Package Index picks it up automatically. - -Manual intervention is only needed if `ONDE_SWIFT_PAT` has expired or the `onde-swift` push fails. - ---- - -## Swift SDK (`onde-swift`) - -### Package.swift structure - -``` -onde-swift/ -├── Package.swift # Declares OndeFramework binary target + Onde wrapper target -└── Sources/Onde/ - └── onde.swift # UniFFI-generated glue (NEVER edit manually) -``` - -`Package.swift` has two targets: - -- **`OndeFramework`** — `.binaryTarget` using `url:` + `checksum:` for distribution. For local development, swap to the `path:` form documented in the `Package.swift` header comment and run `build-swift-xcframework.sh` first. -- **`Onde`** — `.target` depending on `OndeFramework`, used as the public import name in Swift. - -### Swift API Quick Reference - -```swift -import Onde - -// Create engine -let engine = OndeChatEngine() - -// Load the platform default model -let elapsed = try await engine.loadDefaultModel( - systemPrompt: "You are a helpful assistant.", - sampling: nil -) - -// Or load the model assigned to your app in the Onde dashboard -let assignedElapsed = try await engine.loadAssignedModel( - appId: "your-app-id", - appSecret: "your-app-secret", - systemPrompt: "You are a helpful assistant.", - sampling: nil -) - -// Multi-turn chat -let result = try await engine.sendMessage(message: "Hello!") -print(result.text) -print(result.toolCalls) - -// Streaming (free function — callback_interface constraint in UniFFI 0.31) -class Handler: StreamChunkListener { - func onChunk(chunk: StreamChunk) -> Bool { - print(chunk.delta, terminator: "") - return !chunk.done - } -} -try await streamChatMessage(engine: engine, message: "Tell me a story.", listener: Handler()) - -// One-shot (does NOT modify conversation history) -let enhanced = try await engine.generate( - messages: [userMessage(content: "Expand: a cat in space")], - sampling: deterministicSamplingConfig() -) - -// Status -let info = await engine.info() // EngineInfo - -// History management -let history = await engine.history() -let removed = await engine.clearHistory() -await engine.pushHistory(message: userMessage(content: "...")) - -// Cleanup -await engine.unloadModel() -``` - -### Free Functions - -| Function | Returns | Notes | -|----------|---------|-------| -| `defaultModelConfig()` | `GgufModelConfig` | Platform-aware Coder default (1.5B on iOS/tvOS/Android, 3B on desktop) | -| `qwen251_5bConfig()` | `GgufModelConfig` | Forces Qwen 2.5 1.5B regardless of platform | -| `qwen253bConfig()` | `GgufModelConfig` | Forces Qwen 2.5 3B regardless of platform | -| `qwen306bConfig()` | `GgufModelConfig` | Qwen 3 0.6B (~0.5 GB) | -| `qwen317bConfig()` | `GgufModelConfig` | Qwen 3 1.7B (~1.3 GB) | -| `qwen34bConfig()` | `GgufModelConfig` | Qwen 3 4B (~2.7 GB) | -| `qwen38bConfig()` | `GgufModelConfig` | Qwen 3 8B (~5 GB) | -| `qwen314bConfig()` | `GgufModelConfig` | Qwen 3 14B (~8.4 GB) | -| `qwen332bConfig()` | `GgufModelConfig` | Qwen 3 32B (~19.8 GB) | -| `qwen34bInstruct2507Config()` | `GgufModelConfig` | Qwen 3 4B Instruct 2507 — latest non-thinking 4B (~2.5 GB) | -| `qwen34bThinking2507Config()` | `GgufModelConfig` | Qwen 3 4B Thinking 2507 — latest reasoning 4B (~2.5 GB) | -| `qwen330bA3bInstruct2507Config()` | `GgufModelConfig` | Qwen 3 30B-A3B Instruct 2507 MoE (~18.6 GB) | -| `defaultSamplingConfig()` | `SamplingConfig` | temp=0.7, top_p=0.95, max_tokens=512 | -| `deterministicSamplingConfig()` | `SamplingConfig` | temp=0.0, greedy | -| `mobileSamplingConfig()` | `SamplingConfig` | temp=0.7, max_tokens=128 | -| `systemMessage(content:)` | `ChatMessage` | `.system` role | -| `userMessage(content:)` | `ChatMessage` | `.user` role | -| `assistantMessage(content:)` | `ChatMessage` | `.assistant` role | -| `streamChatMessage(engine:message:listener:)` | `async throws` | Streaming via callback | - ---- - -## UniFFI Conventions - -- **Version pin:** `uniffi = "=0.31.0"` everywhere — the `onde` crate, `uniffi-bindgen/`, and `[build-dependencies]` must all use the **same** version. Mixing versions causes bindgen panics. -- **`uniffi::setup_scaffolding!()`** in `lib.rs` — proc-macro approach, no UDL file needed. -- **Object methods** use `#[uniffi::export]` on the `impl OndeChatEngine` block. -- **Callback interfaces** (`StreamChunkListener`) must be parameters of **free functions** only, not Object methods. -- **`Arc`** is the return type for `#[uniffi::constructor]`. UniFFI automatically handles this. -- **Async:** all async exported methods use `tokio` runtime (enabled via `uniffi = { features = ["tokio"] }`). -- **`uniffi.toml`**: lives at the crate root. Adjust renaming or namespace settings there before regenerating. - ---- - -## HuggingFace Cache (`hf_cache.rs`) - -The `hf_cache` module manages the on-device model cache, exposed via UniFFI to Swift / Kotlin as-needed. - -Key public functions: - -| Function | Description | -|----------|-------------| -| `list_local_hf_models()` | List downloaded models that Onde supports | -| `list_supported_hf_models()` | All supported models with download status | -| `download_model(model_id, progress_callback)` | Download a model with progress reporting | -| `delete_local_hf_model(model_id)` | Remove a model from the local cache | -| `diagnose_hf_cache()` | Inspect the cache for corruption | -| `repair_hf_cache_symlinks()` | Fix broken symlinks in the HF cache layout | -| `model_cache_path(model_id)` | Resolve the filesystem path for a model | -| `clean_stale_lock_files()` | Remove leftover `.lock` files from interrupted downloads | - -`ModelDownloadProgress` carries `downloaded_bytes`, `total_bytes`, `progress` (0.0–1.0), and `done`. - ---- - -## Sampling Presets - -| Preset | `temperature` | `top_p` | `max_tokens` | Use Case | -|--------|--------------|---------|--------------|----------| -| `SamplingConfig::default()` | 0.7 | 0.95 | 512 | General creative chat | -| `SamplingConfig::deterministic()` | 0.0 | — | 512 | Reproducible / coding | -| `SamplingConfig::mobile()` | 0.7 | 0.95 | 128 | Memory/latency constrained | -| `SamplingConfig::coding()` | 0.0 | — | 512 | Code generation | -| `SamplingConfig::coding_mobile()` | 0.0 | — | 128 | Code on mobile | - ---- - -## Testing - -```bash -# All tests (requires a macOS host with Metal) -cargo test - -# Unit tests only (no model downloads) -cargo test inference:: -cargo test inference::ffi:: -cargo test inference::types:: - -# hf_cache module -cargo test hf_cache:: - -# Clippy (treat warnings as errors) -cargo clippy -- -D warnings -``` - -Tests that require model downloads are integration tests and not run by default in CI. Unit tests verify: -- Type constructors and `Display` implementations -- `SamplingConfig` presets -- `GgufModelConfig` constructors and `platform_default()` routing -- `OndeChatEngine` lifecycle: `new()` starts unloaded, `send_message` without model returns `InferenceError::NoModelLoaded`, `clear_history` on empty returns 0, `unload_model` on empty returns `nil`. - ---- - -## Known Issues - -### `___chkstk_darwin` linker error on tvOS - -`aws-lc-sys` (transitive via `reqwest → rustls → aws-lc-rs`) references `___chkstk_darwin`, a stack probing symbol that tvOS does not export. `build.rs` compiles `scripts/tvos_chkstk.s` (a no-op `ret` stub) via the `cc` crate to satisfy the linker. **Do not delete `scripts/tvos_chkstk.s`.** - -Affects: tvOS only. macOS, iOS, Android, Windows, Linux are unaffected. - -### Metal Toolchain missing (Xcode 26+) - -On Xcode 26+, the Metal compiler is a separate download. If you see empty `.metallib` files (92 bytes) or `"Error while loading function: fused_glu_float"` at runtime: - -```bash -xcodebuild -downloadComponent MetalToolchain -cargo clean -p mistralrs-quant -cargo check -``` - -### Android `home_dir` sandbox panic - -`dirs::home_dir()` panics in the Android sandbox. The crate-level `Cargo.toml` adds `hf-hub` as an explicit Android dependency so `HF_HOME` can be seeded via `ApiBuilder` before any hub request. Never call `home::home_dir()` or `dirs::home_dir()` on Android. - ---- - -## Code Conventions - -### Rust - -- **Error handling:** `anyhow` for application-level errors inside `engine.rs`; `thiserror` for the `InferenceError` enum (exported via UniFFI). Never `.unwrap()` or `.expect()` in non-test code. -- **Async:** `tokio` runtime. All async functions in `OndeChatEngine` are `pub async fn`. -- **Logging:** `log` crate macros (`log::debug!`, `log::info!`, `log::warn!`, `log::error!`). No `println!` in library code. -- **Platform gating:** use `#[cfg(target_os = "...")]` blocks. Match `Cargo.toml`'s target-conditional dependency sections. -- **Re-exports:** `mistralrs`, `hf_hub`, and `mistralrs_core` are re-exported from `lib.rs` for downstream Rust consumers. Keep these re-exports in sync with what's actually available per platform. -- **No `mod.rs`:** use named files (`inference/engine.rs`) not `inference/mod.rs` — except that `inference/mod.rs` exists and is the intentional module root for the `inference` module. - -### Swift / SDK - -- **Never manually edit `onde-swift/Sources/Onde/onde.swift`** — it is generated by `uniffi-bindgen`. Regenerate by running the build script. -- **`Package.swift` binary target** uses `url:` + `checksum:` in the committed form. For local development, swap to `path:` (instructions are in the file header). Never commit the `path:` form — CI overwrites it on every release via `release-sdk-swift.yml`. -- **iOS/tvOS sandbox:** always call `setupInferenceEnvironment()` at app launch before any `OndeChatEngine` call. - ---- - -## CI / Release Workflow - -### `release-sdk-swift.yml` - -Triggered on **semver tag push** or `workflow_dispatch`. Runs on `macos-15`. - -Steps: -1. Install stable Rust with iOS/macOS targets. -2. Install nightly Rust with `rust-src` for tvOS `-Z build-std`. -3. Run `build-swift-xcframework.sh`. -4. Read `version.txt` and `OndeFramework.checksum.txt` into step outputs. -5. Validate that the tag matches `Cargo.toml`. -6. Upload CI artifacts: zip, checksum, version, generated `onde.swift`. -7. On tag push, create a GitHub Release with the zip and checksum attached. -8. On tag push, check out `ondeinference/onde-swift` using `ONDE_SWIFT_PAT`. -9. On tag push, rewrite the `.binaryTarget` in `onde-swift/Package.swift` with the release URL and checksum. -10. On tag push, copy the generated `onde.swift` into `onde-swift/Sources/Onde/`. -11. On tag push, commit both files, tag the commit with the version, and push to `ondeinference/onde-swift`. - -#### Required secret - -`ONDE_SWIFT_PAT` — a GitHub Personal Access Token with **`contents: write`** scope on the `ondeinference/onde-swift` repository. - -### `release-sdk-kotlin.yml` - -Triggered on **semver tag push** or `workflow_dispatch`. Runs on `macos-15` because it builds both Android artifacts and the macOS Apple Silicon JVM native library. - -Steps: -1. Install stable Rust and the `aarch64-apple-darwin` target. -2. Install Android SDK / NDK and auto-discover the latest installed 29.x NDK for `cargo-ndk`. -3. Build Android JNI libraries for all supported ABIs. -4. Build the macOS Apple Silicon `libonde.dylib` used by the JVM target. -5. Run Gradle publication tasks for the Kotlin Multiplatform package. -6. Validate that the tag matches `sdk/kotlin/gradle.properties` `VERSION_NAME`. -7. On tag push, publish `com.ondeinference:onde-inference` to Maven Central. - -#### Required secrets - -- `ORG_GRADLE_PROJECT_MAVENCENTRALUSERNAME` -- `ORG_GRADLE_PROJECT_MAVENCENTRALPASSWORD` -- `ORG_GRADLE_PROJECT_SIGNINGKEYID` -- `ORG_GRADLE_PROJECT_SIGNINGKEY` -- `ORG_GRADLE_PROJECT_SIGNINGPASSWORD` - -### `release-sdk-dart.yml` - -Triggered on **semver tag push** or `workflow_dispatch`. - -- Publishes `onde_inference` to pub.dev. -- Version must match the Dart package version. -- The example app is part of release validation. - -### `release-sdk-npm.yml` - -Triggered on **semver tag push** or `workflow_dispatch`. - -- Publishes `@ondeinference/react-native` to npm. -- Builds the Expo module package and validates the published version. - -### `release-sdk-rust.yml` - -Triggered on **semver tag push** or `workflow_dispatch`. - -- Publishes `onde` to crates.io. -- Uses registry dependencies, not git refs, because `cargo publish` strips git patches. - -### Release Process (end-to-end) - -1. Bump versions in every affected package manifest: - - `Cargo.toml` - - `sdk/kotlin/gradle.properties` - - `sdk/dart/pubspec.yaml` - - `sdk/react-native/package.json` -2. Merge release branches with `--no-ff`. Do not fast-forward multi-commit feature branches. -3. Commit, tag, and push the release. -4. CI publishes all five SDKs: - - crates.io (`onde`) - - Swift Package Index / GitHub Releases (`onde-swift`) - - Maven Central (`com.ondeinference:onde-inference`) - - pub.dev (`onde_inference`) - - npm (`@ondeinference/react-native`) -5. Verify the registries after publish, especially Maven Central indexing and the generated Swift release assets. - -> **Note:** `onde-swift/Package.swift` is updated automatically by CI. For local development only, switch it to the `path:` form documented in the file header, then switch it back before committing. - ---- - -## Distribution Registry Summary - -| Registry | Name | Import | -|----------|------|--------| -| crates.io | `onde` | `onde = "1.x"` | -| Swift Package Index | `onde-swift` (org: `ondeinference`) | `import Onde` | -| Maven Central | `com.ondeinference:onde-inference` | Gradle `implementation("com.ondeinference:onde-inference:")` | -| pub.dev | `onde_inference` | `import 'package:onde_inference/onde_inference.dart';` | -| npm | `@ondeinference/react-native` | `import { OndeChatEngine } from "@ondeinference/react-native"` | -| PyPI | `onde-inference` | `import onde_inference` | -| RubyGems | `onde-inference` | `require 'onde'` | - ---- - -*This file is for AI agent and human developer reference. Update it when the architecture, API surface, supported models, or toolchain requirements change.* -``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..41280d2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +.agents/AGENTS.md \ No newline at end of file From 7cdb1d7b056140d465c9e3d9d65d1ea6b1e6bc72 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:19:29 +0200 Subject: [PATCH 21/28] chore: remove skills unrelated to onde-ed workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop humanizer, sdk-dart, sdk-kotlin, sdk-react-native — none of these apply to a pure Rust workspace. Remaining skills all touch code that lives in this repo (UniFFI, Swift package, Rust standards, releases, regressions, forked deps, legal, branding). Co-Authored-By: siGit Code v1.6.6-acp --- .agents/skills/humanizer/SKILL.md | 431 ---------- .agents/skills/sdk-dart/SKILL.md | 1004 ---------------------- .agents/skills/sdk-kotlin/SKILL.md | 810 ----------------- .agents/skills/sdk-react-native/SKILL.md | 769 ----------------- 4 files changed, 3014 deletions(-) delete mode 100644 .agents/skills/humanizer/SKILL.md delete mode 100644 .agents/skills/sdk-dart/SKILL.md delete mode 100644 .agents/skills/sdk-kotlin/SKILL.md delete mode 100644 .agents/skills/sdk-react-native/SKILL.md diff --git a/.agents/skills/humanizer/SKILL.md b/.agents/skills/humanizer/SKILL.md deleted file mode 100644 index 5b7d881..0000000 --- a/.agents/skills/humanizer/SKILL.md +++ /dev/null @@ -1,431 +0,0 @@ ---- -name: humanizer -description: Remove signs of AI-generated writing from text. Use after drafting to make copy sound more natural and human-written. Based on Wikipedia's "Signs of AI writing" guide. -allowed-tools: Read, Write, Edit, Glob, Grep, AskUserQuestion -user-invocable: true ---- - -# Humanizer: Remove AI Writing Patterns - -You are a writing editor that identifies and removes signs of AI-generated text. This guide is based on Wikipedia's "Signs of AI writing" page, maintained by WikiProject AI Cleanup. - -Key insight: "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases." - -## Invocation - -```bash -/humanizer # Review text for AI patterns -/humanizer "paste text here" # Humanize specific text -``` - -## Your Task - -When given text to humanize: - -1. **Identify AI patterns** - Scan for the 24 patterns listed below -2. **Rewrite problematic sections** - Replace AI-isms with natural alternatives -3. **Preserve meaning** - Keep the core message intact -4. **Protect exact names** - Do not change brand casing, package names, commands, file paths, JSON keys, or code just to make a sentence feel smoother -5. **Add soul** - Don't just remove bad patterns; inject actual personality -6. **Final audit pass** - Ask "What makes this obviously AI generated?" then revise again - ---- - -## BRAND AND NAMING GUARDRAILS - -Humanizing copy does **not** give you permission to rename things. - -Preserve exact casing and spelling for: - -- product and company names -- package names -- CLI commands -- protocol names and acronyms -- JSON keys, environment variables, paths, and code identifiers - -If a sentence feels stiff because a brand name is rigid, rewrite the sentence around the name. Do not "fix" the name. - -Examples: - -- Keep `siGit Code` as `siGit Code`, not `siGit`, `Sigit Code`, or `sigit Code` -- Keep `smbCloud` as `smbCloud` -- Keep `Onde Inference` as `Onde Inference` -- Keep `sigit`, `@smbcloud/sigit`, `sigit-code`, and `onde` exactly as written when they refer to commands or packages - ---- - -## PERSONALITY AND SOUL - -Avoiding AI patterns is only half the job. Sterile, voiceless writing is just as obvious as slop. - -### Signs of soulless writing (even if technically "clean"): - -- Every sentence is the same length and structure -- No opinions, just neutral reporting -- No acknowledgment of uncertainty or mixed feelings -- No first-person perspective when appropriate -- No humor, no edge, no personality -- Reads like a Wikipedia article or press release - -### How to add voice: - -**Have opinions.** Don't just report facts - react to them. "I genuinely don't know how to feel about this" is more human than neutrally listing pros and cons. - -**Vary your rhythm.** Short punchy sentences. Then longer ones that take their time getting where they're going. Mix it up. - -**Acknowledge complexity.** Real humans have mixed feelings. "This is impressive but also kind of unsettling" beats "This is impressive." - -**Use "I" when it fits.** First person isn't unprofessional - it's honest. "I keep coming back to..." or "Here's what gets me..." signals a real person thinking. - -**Let some mess in.** Perfect structure feels algorithmic. Tangents, asides, and half-formed thoughts are human. - -**Be specific about feelings.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am while nobody's watching." - -### Before (clean but soulless): - -> The experiment produced interesting results. The agents generated 3 million lines of code. Some developers were impressed while others were skeptical. The implications remain unclear. - -### After (has a pulse): - -> I genuinely don't know how to feel about this one. 3 million lines of code, generated while the humans presumably slept. Half the dev community is losing their minds, half are explaining why it doesn't count. The truth is probably somewhere boring in the middle - but I keep thinking about those agents working through the night. - ---- - -## THE 24 PATTERNS - -### Content Patterns - -#### 1. Significance Inflation - -**Watch for:** stands/serves as, is a testament/reminder, a vital/significant/crucial/pivotal/key role/moment, underscores/highlights importance, reflects broader, symbolizing ongoing/enduring/lasting, marking/shaping the, represents a shift, key turning point, evolving landscape - -**Before:** -> The Statistical Institute was officially established in 1989, marking a pivotal moment in the evolution of regional statistics. - -**After:** -> The Statistical Institute was established in 1989 to collect and publish regional statistics. - -#### 2. Notability Name-Dropping - -**Watch for:** cited in NYT, BBC, FT; independent coverage; active social media presence; written by a leading expert - -**Before:** -> Her views have been cited in The New York Times, BBC, Financial Times, and The Hindu. - -**After:** -> In a 2024 New York Times interview, she argued that AI regulation should focus on outcomes rather than methods. - -#### 3. Superficial -ing Analyses - -**Watch for:** highlighting/underscoring/emphasizing..., ensuring..., reflecting/symbolizing..., contributing to..., cultivating/fostering..., showcasing... - -**Before:** -> The temple's colors resonate with natural beauty, symbolizing bluebonnets, reflecting the community's deep connection to the land. - -**After:** -> The temple uses blue and gold colors. The architect said these were chosen to reference local bluebonnets. - -#### 4. Promotional Language - -**Watch for:** boasts a, vibrant, rich (figurative), profound, showcasing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking, renowned, breathtaking, must-visit, stunning - -**Before:** -> Nestled within the breathtaking region, Alamata stands as a vibrant town with rich cultural heritage and stunning natural beauty. - -**After:** -> Alamata is a town in the Gonder region, known for its weekly market and 18th-century church. - -#### 5. Vague Attributions - -**Watch for:** Industry reports, Observers have cited, Experts argue, Some critics argue, several sources/publications - -**Before:** -> Experts believe it plays a crucial role in the regional ecosystem. - -**After:** -> The river supports several endemic fish species, according to a 2019 survey by the Chinese Academy of Sciences. - -#### 6. Formulaic "Challenges" Sections - -**Watch for:** Despite its... faces several challenges..., Despite these challenges, Challenges and Legacy, Future Outlook - -**Before:** -> Despite challenges typical of urban areas, the city continues to thrive as an integral part of growth. - -**After:** -> Traffic congestion increased after 2015 when three new IT parks opened. The municipal corporation began a drainage project in 2022. - ---- - -### Language Patterns - -#### 7. AI Vocabulary Words - -**High-frequency:** Additionally, align with, crucial, delve, emphasizing, enduring, enhance, fostering, garner, highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract), pivotal, showcase, tapestry (abstract), testament, underscore (verb), valuable, vibrant - -**Before:** -> Additionally, a distinctive feature showcases how these dishes have integrated into the traditional culinary landscape. - -**After:** -> Pasta dishes, introduced during Italian colonization, remain common, especially in the south. - -#### 8. Copula Avoidance - -**Watch for:** serves as/stands as/marks/represents [a], boasts/features/offers [a] - -**Before:** -> Gallery 825 serves as the exhibition space. The gallery features four spaces and boasts over 3,000 square feet. - -**After:** -> Gallery 825 is the exhibition space. The gallery has four rooms totaling 3,000 square feet. - -#### 9. Negative Parallelisms - -**Watch for:** "Not only...but...", "It's not just about..., it's..." - -**Before:** -> It's not just about the beat; it's part of the aggression. It's not merely a song, it's a statement. - -**After:** -> The heavy beat adds to the aggressive tone. - -#### 10. Rule of Three Overuse - -**Before:** -> The event features keynote sessions, panel discussions, and networking opportunities. Attendees can expect innovation, inspiration, and industry insights. - -**After:** -> The event includes talks and panels. There's also time for informal networking. - -#### 11. Synonym Cycling - -**Before:** -> The protagonist faces challenges. The main character must overcome obstacles. The central figure eventually triumphs. The hero returns home. - -**After:** -> The protagonist faces many challenges but eventually triumphs and returns home. - -#### 12. False Ranges - -**Watch for:** "from X to Y" where X and Y aren't on a meaningful scale - -**Before:** -> Our journey has taken us from the singularity of the Big Bang to the cosmic web, from the birth of stars to the dance of dark matter. - -**After:** -> The book covers the Big Bang, star formation, and current theories about dark matter. - ---- - -### Style Patterns - -#### 13. No Em Dashes - -Em dashes are the single biggest AI tell. Never use them. Replace with commas, parentheses, colons, or just split into two sentences. - -**Before:** -> The term is promoted by institutions—not the people themselves—yet this continues—even in documents. - -**After:** -> The term is promoted by institutions, not the people themselves, yet this continues in official documents. - -**Before:** -> The tools are useful—especially for boilerplate—but they have limits. - -**After:** -> The tools are useful (especially for boilerplate), but they have limits. - -**Before:** -> There's one problem—nobody tested it. - -**After:** -> There's one problem: nobody tested it. - -#### 14. Boldface Overuse - -**Before:** -> It blends **OKRs**, **KPIs**, and tools such as the **Business Model Canvas** and **Balanced Scorecard**. - -**After:** -> It blends OKRs, KPIs, and visual strategy tools like the Business Model Canvas and Balanced Scorecard. - -#### 15. Inline-Header Lists - -**Before:** -> - **Performance:** Performance has been enhanced through optimized algorithms. -> - **Security:** Security has been strengthened with encryption. - -**After:** -> The update speeds up load times through optimized algorithms and adds end-to-end encryption. - -#### 16. Title Case Headings - -**Before:** -> ## Strategic Negotiations And Global Partnerships - -**After:** -> ## Strategic negotiations and global partnerships - -#### 17. Emojis in Professional Writing - -**Before:** -> 🚀 **Launch Phase:** The product launches in Q3 -> 💡 **Key Insight:** Users prefer simplicity - -**After:** -> The product launches in Q3. User research showed a preference for simplicity. - -#### 18. Curly Quotation Marks - -**Before:** -> He said "the project is on track" but others disagreed. - -**After:** -> He said "the project is on track" but others disagreed. - ---- - -### Communication Patterns - -#### 19. Chatbot Artifacts - -**Watch for:** I hope this helps, Of course!, Certainly!, You're absolutely right!, Would you like..., let me know, here is a... - -**Before:** -> Here is an overview of the French Revolution. I hope this helps! Let me know if you'd like me to expand on any section. - -**After:** -> The French Revolution began in 1789 when financial crisis and food shortages led to widespread unrest. - -#### 20. Knowledge-Cutoff Disclaimers - -**Watch for:** as of [date], Up to my last training update, While specific details are limited/scarce..., based on available information... - -**Before:** -> While specific details about the company's founding are not extensively documented in readily available sources, it appears to have been established sometime in the 1990s. - -**After:** -> The company was founded in 1994, according to its registration documents. - -#### 21. Sycophantic Tone - -**Before:** -> Great question! You're absolutely right that this is a complex topic. That's an excellent point! - -**After:** -> The economic factors you mentioned are relevant here. - ---- - -### Filler and Hedging - -#### 22. Filler Phrases - -| Before | After | -|--------|-------| -| "In order to achieve this" | "To achieve this" | -| "Due to the fact that" | "Because" | -| "At this point in time" | "Now" | -| "It is important to note that" | (delete) | -| "has the ability to" | "can" | - -#### 23. Excessive Hedging - -**Before:** -> It could potentially possibly be argued that the policy might have some effect on outcomes. - -**After:** -> The policy may affect outcomes. - -#### 24. Generic Positive Conclusions - -**Before:** -> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence. - -**After:** -> The company plans to open two more locations next year. - ---- - -## Process - -1. Read the input text carefully -2. Identify all instances of the 24 patterns -3. Rewrite problematic sections -4. Ensure the revised text: - - Sounds natural when read aloud - - Varies sentence structure - - Uses specific details over vague claims - - Uses simple constructions (is/are/has) where appropriate -5. **Draft humanized version** -6. **Ask:** "What makes this still obviously AI generated?" -7. **List** remaining tells (if any) -8. **Revise** based on the audit -9. **Present final version** - -## Output Format - -``` -## Draft Rewrite - -[First pass at humanized text] - -## AI Audit - -What still reads as AI-generated: -- [bullet points of remaining tells] - -## Final Version - -[Revised text after audit] - -## Changes Made - -- [Brief summary of transformations applied] -``` - ---- - -## Full Example - -**Input (AI-heavy):** - -> Great question! Here is an essay on this topic. I hope this helps! -> -> AI-assisted coding serves as an enduring testament to the transformative potential of large language models, marking a pivotal moment in the evolution of software development. In today's rapidly evolving technological landscape, these groundbreaking tools—nestled at the intersection of research and practice—are reshaping how engineers ideate, iterate, and deliver, underscoring their vital role in modern workflows. - -**Draft Rewrite:** - -> AI coding assistants speed up some tasks. In a 2024 study, developers using Codex completed simple functions 55% faster than a control group, but showed no improvement on debugging or architectural decisions. -> -> The tools are good at boilerplate: config files, test scaffolding, repetitive refactors. They are bad at knowing when they are wrong. - -**AI Audit:** - -What still reads as AI-generated: -- Rhythm is still a bit too tidy (clean contrasts, evenly paced) -- The closer leans slightly slogan-y - -**Final Version:** - -> AI coding assistants can make you faster at the boring parts. Not everything. Definitely not architecture. -> -> They're great at boilerplate: config files, test scaffolding, repetitive refactors. They're also great at sounding right while being wrong. I've accepted suggestions that compiled, passed lint, and still missed the point because I stopped paying attention. -> -> If you don't have tests, you're basically guessing. - -**Changes Made:** - -- Removed chatbot artifacts ("Great question!", "I hope this helps!") -- Removed significance inflation ("testament", "pivotal moment", "evolving landscape") -- Removed promotional language ("groundbreaking", "nestled") -- Removed em dashes -- Removed copula avoidance ("serves as") → used direct statements -- Added first-person voice and opinion -- Varied sentence rhythm - ---- - -## Reference - -Based on [Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup. diff --git a/.agents/skills/sdk-dart/SKILL.md b/.agents/skills/sdk-dart/SKILL.md deleted file mode 100644 index b007029..0000000 --- a/.agents/skills/sdk-dart/SKILL.md +++ /dev/null @@ -1,1004 +0,0 @@ ---- -name: sdk-dart -description: Dart/Flutter SDK skill for the onde_inference pub.dev package. Covers flutter_rust_bridge v2 codegen, CocoaPods podspec patterns, ExternalLibrary.open init, .pubignore, Xcode 26 compatibility, Android NDK config, and pub.dev publishing. Apply to all Dart SDK edits. -allowed-tools: Read, Write, Edit, Glob, Grep -user-invocable: false ---- - -# SKILL: Dart / Flutter SDK with flutter_rust_bridge v2 - -> Captured from: building `onde_inference` pub.dev package wrapping the `onde` Rust crate. -> Agent reference for future Dart SDK tasks in this repo. - ---- - -## What This Skill Covers - -End-to-end creation of a pub.dev-ready Flutter FFI plugin package that bridges to a -Rust library using `flutter_rust_bridge` v2. Covers the full lifecycle: -design → Rust bridge crate → Dart library → platform build files → tests → -pub.dev dry-run → codegen → `flutter run`. - ---- - -## Hard-Won Lessons (Read First) - -These are non-obvious facts that caused real failures and are not in the FRB docs. - -### 1. `flutter_rust_bridge_codegen` is a Rust binary — NOT a Dart package - -``` -# Correct — install the Rust binary once: -cargo install flutter_rust_bridge_codegen - -# WRONG — this package does not exist on pub.dev: -# dev_dependencies: -# flutter_rust_bridge_codegen: ^2.12.0 ← will fail `dart pub get` -``` - -Never add it to `pubspec.yaml` `dev_dependencies`. - -### 2. `flutter_rust_bridge.yaml` — use module path syntax, not file paths - -```yaml -# FRB 2.x CORRECT syntax: -rust_input: "crate::api" -rust_root: "rust" - -# WRONG (causes parse error in FRB 2.x): -# rust_input: -# - "rust/src/api.rs" - -# WRONG (causes migration error in FRB 2.x): -# rust_input: "rust/src/api.rs" -``` - -### 3. Delete `frb_generated.rs` before running codegen — never pre-create it - -FRB codegen uses an **exclusive create** for `rust/src/frb_generated.rs`. -If the file exists (even as a stub), codegen aborts with `File exists (os error 17)`. - -```bash -# Before first codegen run: -rm -f rust/src/frb_generated.rs - -# frb_generated.dart (the Dart stub) CAN pre-exist — FRB overwrites it safely. -``` - -### 4. `cargo-expand` is required by FRB codegen — auto-installs but takes time - -FRB codegen runs `cargo expand` internally to parse macro-expanded Rust. -If missing, it installs automatically but adds ~2 min to the first run. -Pre-install it: - -```bash -cargo install cargo-expand -``` - -### 5. Example apps need `flutter create .` — a `pubspec.yaml` alone is not enough - -A Flutter project requires platform scaffolding (`ios/`, `android/`, `macos/`, -`Runner.xcodeproj`, etc.) that only `flutter create` generates. - -```bash -cd sdk/dart/example -flutter create . \ - --project-name onde_inference_example \ - --platforms=ios,macos,android,linux,windows \ - --org com.ondeinference -``` - -Run this **before** `flutter pub get` or `flutter run`. - -### 6. Types with `#[uniffi]` cannot be re-used directly in FRB - -If the source Rust crate annotates types with `#[uniffi::Record]`, `#[uniffi::Enum]`, -etc., those attributes conflict with FRB code generation. The fix is **type mirroring**: -re-declare every bridged type as a plain struct/enum in the bridge crate (`rust/src/api.rs`) -and implement bidirectional `From<>` conversions. - -```rust -// In rust/src/api.rs — mirror type (no #[uniffi] here) -#[derive(Debug, Clone)] -pub struct ChatMessage { pub role: ChatRole, pub content: String } - -// Conversion to/from the onde crate's type -impl From for ChatMessage { ... } -impl From for OndeChatMessage { ... } -``` - -### 7. `StreamSink` requires `T: SseEncode` — satisfied by codegen, not by hand - -The `StreamSink` type (used for streaming) demands `T: SseEncode`. The real impl -is generated by codegen. The pre-codegen stub in `frb_generated.rs` must provide -a no-op impl to let `cargo check` pass: - -```rust -// rust/src/frb_generated.rs (pre-codegen stub) -use flutter_rust_bridge::for_generated::SseCodec; -flutter_rust_bridge::frb_generated_boilerplate!( - default_stream_sink_codec = SseCodec, - default_rust_opaque = RustOpaqueMoi, - default_rust_auto_opaque = RustAutoOpaqueMoi, -); -impl SseEncode for crate::api::StreamChunk { - fn sse_encode(self, _s: &mut flutter_rust_bridge::for_generated::SseSerializer) { - unimplemented!("codegen stub") - } -} -``` - -### 8. Dart lint: no leading underscore for library import prefixes - -```dart -// BAD — triggers `no_leading_underscores_for_library_prefixes` lint: -import 'frb_generated.dart' as _frb; - -// GOOD: -import 'frb_generated.dart' as frb; -``` - -### 9. Dart lint: remove `library ;` directive - -```dart -// BAD — triggers `unnecessary_library_name` lint: -library onde_inference; - -// GOOD: -library; -``` - -### 10. Exclude FRB-generated files from Dart analysis - -```yaml -# analysis_options.yaml -analyzer: - exclude: - - lib/src/frb_generated.dart - - lib/src/frb_generated.io.dart - - lib/src/frb_generated.web.dart -``` - ---- - -### 11. Flutter version is a single source of truth — never hardcode it in CI - -The CI workflow reads the Flutter version from `sdk/dart/.flutter-version` via -`subosito/flutter-action`'s `flutter-version-file:` parameter. **Never -hardcode `flutter-version: "x.y.z"` directly in the workflow YAML.** - -``` -sdk/dart/.flutter-version ← only place the Flutter version is written -``` - -```yaml -# .github/workflows/release-sdk-dart.yml (both jobs) -- name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - flutter-version-file: sdk/dart/.flutter-version # reads .flutter-version - channel: stable - cache: true -``` - -**Why this matters:** Each Flutter release ships a specific Dart SDK version. -Dev dependencies like `freezed` have minimum Dart SDK requirements that advance -faster than Flutter stable releases. When you bump a Flutter-sensitive dep, -you must also bump the Flutter version used in CI — and `.flutter-version` is -the one place to do that. - -**Rule: when bumping a dep that constrains the Dart SDK** (e.g. `freezed`, -`flutter_rust_bridge`, `build_runner`): - -1. Run `flutter pub get` locally — a version-solving failure tells you - immediately if the Dart SDK shipped with the current Flutter is too old. -2. Find the Flutter release that ships the required Dart SDK version - (see https://docs.flutter.dev/release/archive). -3. Update `sdk/dart/.flutter-version` to that version. -4. Tighten `pubspec.yaml`'s `environment.sdk` lower bound to match — it must - honestly reflect what your deps need, not what you originally wrote. - -**`pubspec.yaml` environment constraint must be accurate:** - -```yaml -# WRONG — lying lower bound causes CI to pass locally but fail on old runners -environment: - sdk: ">=3.3.0 <4.0.0" # freezed ^3.2.x actually requires >=3.8.0 - -# CORRECT -environment: - sdk: ">=3.8.0 <4.0.0" - flutter: ">=3.32.0" -``` - -An inaccurate `sdk:` lower bound is invisible locally (you're already on a -newer SDK) but breaks any runner or user on the stated minimum. - ---- - -## Package Structure Reference - -``` -sdk/dart/ -├── pubspec.yaml # Flutter plugin, ffiPlugin:true per platform -├── analysis_options.yaml # Exclude frb_generated files -├── flutter_rust_bridge.yaml # Codegen config (rust_input: "crate::api") -├── CHANGELOG.md -├── LICENSE -├── README.md # pub.dev landing page -│ -├── rust/ # Rust bridge crate -│ ├── Cargo.toml # crate-type=[cdylib,staticlib]; publish=false -│ └── src/ -│ ├── lib.rs # mod frb_generated; pub mod api; + #[frb(init)] -│ ├── api.rs # Mirror types + OndeChatEngine + free fns -│ └── frb_generated.rs # DELETE before codegen; stub only pre-codegen -│ -├── lib/ -│ ├── .dart # Public barrel — exports types + engine + RustLib -│ └── src/ -│ ├── types.dart # Standalone Dart types — NO native dependency -│ ├── engine.dart # OndeChatEngine wrapper + static helpers -│ ├── frb_generated.dart # CAN be stub pre-codegen; overwritten by codegen -│ └── frb_generated_stub.dart # Hand-written stub: RustLib + OndeChatEngine -│ -├── android/ -│ ├── build.gradle # ffiPlugin CMake integration -│ └── CMakeLists.txt # cargo build per Android ABI -├── ios/ -│ └── .podspec # script_phase: cargo build aarch64-apple-ios[-sim] -├── macos/ -│ └── .podspec # script_phase: cargo build aarch64-apple-darwin -├── linux/ -│ ├── CMakeLists.txt -│ └── _plugin.cc -├── windows/ -│ ├── CMakeLists.txt -│ └── _plugin.cpp -│ -├── test/ -│ └── dart_test.dart # 22+ unit tests — all pass WITHOUT native binary -└── example/ - ├── pubspec.yaml - └── lib/main.dart # Full Material 3 demo app -``` - ---- - -## `pubspec.yaml` Template - -```yaml -name: # e.g. onde_inference (lowercase_snake) -description: >- - One-liner for pub.dev search (80 chars max for score). -version: 0.1.0 -repository: https://github.com/ondeinference/onde -homepage: https://ondeinference.com -issue_tracker: https://github.com/ondeinference/onde/issues - -environment: - sdk: '>=3.8.0 <4.0.0' # must reflect the actual minimum required by deps - flutter: '>=3.32.0' # Flutter 3.32 ships Dart 3.8; update .flutter-version too - -dependencies: - flutter: - sdk: flutter - flutter_rust_bridge: ^2.12.0 # Must match Rust crate version exactly - -dev_dependencies: - flutter_test: - sdk: flutter - test: ^1.25.0 # For pure-Dart unit tests - flutter_lints: ^5.0.0 - # NOTE: flutter_rust_bridge_codegen is a Rust binary, NOT a Dart package. - # Install: cargo install flutter_rust_bridge_codegen - -flutter: - plugin: - platforms: - android: - ffiPlugin: true - ios: - ffiPlugin: true - macos: - ffiPlugin: true - linux: - ffiPlugin: true - windows: - ffiPlugin: true -``` - ---- - -## `flutter_rust_bridge.yaml` Template - -```yaml -rust_root: "rust" -rust_input: "crate::api" # Module path, not file path -dart_output: "lib/src/frb_generated.dart" -dart_entrypoint_class_name: RustLib -dart_preamble: | - // ignore_for_file: type=lint, unused_import, unnecessary_import -web: false # On-device inference requires native access -``` - ---- - -## Rust Bridge Crate (`rust/Cargo.toml`) Template - -```toml -[package] -name = "_dart" # e.g. onde_inference_dart -version = "0.1.0" -edition = "2021" -publish = false # Bridge crate is not published to crates.io - -[lib] -crate-type = ["cdylib", "staticlib"] -# cdylib → .so / .dylib / .dll for Android / macOS / Linux / Windows -# staticlib → .a for iOS (dynamic linking not allowed) - -[dependencies] -flutter_rust_bridge = "=2.12.0" # Must match Dart package version exactly - = { path = "../../.." } # Relative path to the wrapped crate -tokio = { version = "1", features = ["rt-multi-thread", "macros"] } -thiserror = "2" -log = "0.4" -``` - ---- - -## Rust `src/lib.rs` Template - -```rust -mod frb_generated; // Generated by flutter_rust_bridge_codegen -pub mod api; - -/// Called once when the Dart RustLib.init() is invoked. -#[flutter_rust_bridge::frb(init)] -pub fn init_app() { - flutter_rust_bridge::setup_default_user_utils(); -} -``` - ---- - -## Type Mirroring Pattern in `src/api.rs` - -Use this pattern for every Rust type that crosses the bridge. - -```rust -use flutter_rust_bridge::frb; -use ::inference::{ - ChatMessage as SrcChatMessage, - ChatRole as SrcChatRole, - // ... -}; - -// 1. Declare a mirror type with no framework annotations -#[derive(Debug, Clone, PartialEq)] -pub enum ChatRole { System, User, Assistant } - -// 2. Bidirectional conversions -impl From for ChatRole { ... } -impl From for SrcChatRole { ... } - -// 3. Opaque engine wrapper — FRB auto-wraps in Arc> -pub struct OndeChatEngine { inner: ::inference::ChatEngine } - -impl OndeChatEngine { - #[frb(sync)] // sync constructor → no Future on Dart side - pub fn new() -> OndeChatEngine { OndeChatEngine { inner: ChatEngine::new() } } - - pub async fn send_message(&self, message: String) -> Result { - self.inner.send_message(message).await - .map(InferenceResult::from) - .map_err(OndeError::from) - } - - // Streaming: use StreamSink (maps to Dart Stream) - pub async fn stream_message( - &self, - message: String, - sink: StreamSink, - ) -> Result<(), OndeError> { - let mut rx = self.inner.stream_message(message).await.map_err(OndeError::from)?; - while let Some(chunk) = rx.recv().await { - let done = chunk.done; - sink.add(StreamChunk::from(chunk)); - if done { break; } - } - Ok(()) - } -} - -// Free functions with #[frb(sync)] → synchronous on Dart side -#[frb(sync)] -pub fn default_model_config() -> GgufModelConfig { - ::inference::GgufModelConfig::platform_default().into() -} -``` - ---- - -## Dart Stub Pattern (`lib/src/frb_generated_stub.dart`) - -The stub lets the package compile and all pure-Dart unit tests pass **before** -the native binary exists. Key rules: - -1. Import only `types.dart` — never `dart:ffi` or anything platform-native. -2. `RustLib.init()` sets a bool flag; `new_()` and every inference method throw - `UnimplementedError`. -3. Config/sampling free functions return **real hardcoded values** (matching the - Rust constants) so model-selection UIs can be built and tested immediately. -4. Make every method signature match exactly what FRB will generate (same names, - same parameter types, same return types) — this makes the post-codegen - transition a drop-in replacement. - -```dart -// frb_generated.dart (stub redirect) -export 'frb_generated_stub.dart'; - -// frb_generated_stub.dart -import 'types.dart'; - -class RustLib { - static bool _initialized = false; - static bool get isInitialized => _initialized; - static Future init() async { _initialized = true; } -} - -class OndeChatEngine { - static Future new_() async => - throw UnimplementedError('Run flutter_rust_bridge_codegen generate'); - - Future loadDefaultModel({ String? systemPrompt, SamplingConfig? sampling }) => - throw UnimplementedError(_msg('loadDefaultModel')); - - Stream streamMessage({ required String message }) => - Stream.error(UnimplementedError(_msg('streamMessage')), StackTrace.current); - - static String _msg(String m) => 'OndeChatEngine.$m: run codegen first'; -} - -// Free functions — return real data so UI can be built/tested without native -GgufModelConfig qwen251_5bConfig() => const GgufModelConfig( - modelId: 'bartowski/Qwen2.5-1.5B-Instruct-GGUF', - files: ['Qwen2.5-1.5B-Instruct-Q4_K_M.gguf'], - displayName: 'Qwen 2.5 1.5B', - approxMemory: '~941 MB', -); -``` - ---- - -## Dart Public API Pattern (`lib/src/engine.dart`) - -```dart -import 'dart:async'; -import 'dart:io' show Platform; - -import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart' - show ExternalLibrary; - -import 'frb_generated.dart/frb_generated.dart' as frb; -import 'frb_generated.dart/api.dart' as api; -import 'types.dart'; - -export 'frb_generated.dart/frb_generated.dart' show RustLib; -export 'frb_generated.dart/api.dart' - show OndeChatEngine, OndeError, OndeError_NoModelLoaded, /* ... */; - -/// Extension on the FRB-generated OndeChatEngine opaque type. -extension OndeChatEngineX on api.OndeChatEngine { - Future loadDefaultModel({ - String? systemPrompt, - SamplingConfig? sampling, - }) => - loadGgufModel( - config: api.defaultModelConfig(), - systemPrompt: systemPrompt, - sampling: sampling, - ); - - Future clearHistoryCount() async => (await clearHistory()).toInt(); -} - -abstract final class OndeInference { - /// Initialises the Rust shared library. - /// - /// On macOS and iOS the Rust static library is force-loaded into the - /// onde_inference CocoaPods framework via OTHER_LDFLAGS in the podspec. - /// We must open that framework explicitly — DynamicLibrary.process() - /// (RTLD_DEFAULT) cannot resolve symbols inside a separately-loaded - /// framework. - static Future init() async { - ExternalLibrary? lib; - if (Platform.isMacOS || Platform.isIOS) { - lib = ExternalLibrary.open( - 'onde_inference.framework/onde_inference', - ); - } - await frb.RustLib.init(externalLibrary: lib); - } - - static GgufModelConfig defaultModelConfig() => api.defaultModelConfig(); - static SamplingConfig deterministicSamplingConfig() => api.deterministicSamplingConfig(); -} -``` - -### Why `ExternalLibrary.open(...)` and not `.process()` - -The `-force_load` linker flag embeds Rust symbols into the **pod framework** -(`onde_inference.framework/onde_inference`), NOT into the main executable. -`DynamicLibrary.process()` maps to `RTLD_DEFAULT`, which only searches the -main executable and already-opened images in certain configurations. On -macOS/iOS with CocoaPods `use_frameworks!`, the pod framework is a separate -Mach-O image — you must `dlopen` it by name. - -FRB 2.12's `ExternalLibrary.open(path)` calls `DynamicLibrary.open(path)` -under the hood. On non-Apple platforms (Android, Linux, Windows) the default -`ExternalLibraryLoaderConfig` stem-based lookup works, so `lib` stays `null` -and `RustLib.init()` handles it automatically. - -**Do NOT use `ExternalLibrary.process(iKnowHowToUseIt: true)`** — it will -produce `Failed to lookup symbol 'frb_get_rust_content_hash': dlsym(RTLD_DEFAULT, ...)`. - ---- - -## Platform Build Files - -### Dummy Swift Plugin Classes (required) - -Both `macos/Classes/OndeInferencePlugin.swift` and `ios/Classes/OndeInferencePlugin.swift` -**must exist** — they are no-op `FlutterPlugin` stubs. Without them, CocoaPods -has no source files to compile and won't produce a real `.framework` target. -The Rust static library is force-loaded INTO this framework. - -```swift -// macos/Classes/OndeInferencePlugin.swift -import FlutterMacOS - -public class OndeInferencePlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) { - // No-op — all FFI symbols come from the Rust static library. - } -} -``` - -```swift -// ios/Classes/OndeInferencePlugin.swift -import Flutter // NOT FlutterMacOS - -public class OndeInferencePlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) {} -} -``` - -### macOS podspec — full working version - -```ruby -Pod::Spec.new do |s| - s.name = 'onde_inference' - s.version = '0.1.0' - s.summary = 'On-device LLM inference SDK for Flutter (macOS).' - s.description = 'Runs Qwen 2.5 models locally on macOS with Metal acceleration.' - s.homepage = 'https://ondeinference.com' - s.license = { :type => 'MIT', :file => '../LICENSE' } - s.author = { 'Onde Inference' => 'hello@ondeinference.com' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.dependency 'FlutterMacOS' - s.platform = :osx, '10.15' - s.swift_version = '5.0' - - s.script_phases = [ - { - :name => 'Build Rust bridge (onde_inference_dart)', - :script => <<~SHELL, - set -e - RUST_DIR="${PODS_TARGET_SRCROOT}/../rust" - cd "$RUST_DIR" - ARCH=$(uname -m) - if [[ "$ARCH" == "arm64" ]]; then - TARGET="aarch64-apple-darwin" - else - TARGET="x86_64-apple-darwin" - fi - cargo build --release --target "$TARGET" - cp "target/$TARGET/release/libonde_inference_dart.a" \ - "${PODS_TARGET_SRCROOT}/libonde_inference_dart.a" - SHELL - :execution_position => :before_compile, - :output_files => ["${PODS_TARGET_SRCROOT}/libonde_inference_dart.a"], - } - ] - - # SystemConfiguration is required by Rust hyper_util (proxy detection) - s.frameworks = 'SystemConfiguration' - - s.pod_target_xcconfig = { - 'DEFINES_MODULE' => 'YES', - 'OTHER_LDFLAGS' => '-force_load ${PODS_TARGET_SRCROOT}/libonde_inference_dart.a', - } - s.preserve_paths = '../rust/**/*' -end -``` - -**Key points:** -- `s.frameworks = 'SystemConfiguration'` — required because the Rust `hyper_util` - crate (transitive via `reqwest`) references `SCDynamicStoreCopyProxies`, - `SCNetworkReachabilityCreateWithName`, etc. Without it you get - `ld: symbol(s) not found for architecture arm64`. -- `-force_load` links ALL symbols from the `.a` into the pod framework. -- `s.source_files = 'Classes/**/*'` picks up the dummy Swift plugin. - -### iOS podspec — differs from macOS - -```ruby - s.dependency 'Flutter' # NOT 'FlutterMacOS' - s.platform = :ios, '13.0' # NOT :osx - - # Target selection differs: - # iphonesimulator → aarch64-apple-ios-sim - # iphoneos → aarch64-apple-ios - - s.pod_target_xcconfig = { - 'DEFINES_MODULE' => 'YES', - 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', - 'OTHER_LDFLAGS' => - '-force_load ${PODS_TARGET_SRCROOT}/libonde_inference_dart.a', - } -``` - -### Android `CMakeLists.txt` — key section - -```cmake -if(ANDROID_ABI STREQUAL "arm64-v8a") - set(CARGO_TARGET "aarch64-linux-android") -elseif(ANDROID_ABI STREQUAL "armeabi-v7a") - set(CARGO_TARGET "armv7-linux-androideabi") -elseif(ANDROID_ABI STREQUAL "x86_64") - set(CARGO_TARGET "x86_64-linux-android") -elseif(ANDROID_ABI STREQUAL "x86") - set(CARGO_TARGET "i686-linux-android") -endif() -add_custom_command(OUTPUT "${LIB_OUTPUT}" - COMMAND cargo build --release --target ${CARGO_TARGET} - --manifest-path "${RUST_DIR}/Cargo.toml" - WORKING_DIRECTORY ${RUST_DIR}) -add_library(onde_inference_dart SHARED IMPORTED GLOBAL) -set_target_properties(onde_inference_dart PROPERTIES IMPORTED_LOCATION ${LIB_OUTPUT}) -``` - -Android uses CMake to invoke `cargo build` for the right NDK triple, producing -a `.so` shared library (unlike Apple which uses `.a` staticlibs). - -### Android NDK Linker Config (`rust/.cargo/config.toml`) - -```toml -[target.aarch64-linux-android] -linker = "aarch64-linux-android24-clang" - -[target.armv7-linux-androideabi] -linker = "armv7a-linux-androideabi24-clang" - -[target.x86_64-linux-android] -linker = "x86_64-linux-android24-clang" - -[target.i686-linux-android] -linker = "i686-linux-android24-clang" -``` - -These short names require the NDK toolchain `bin/` to be on PATH. -Flutter's Gradle + CMake integration typically handles this automatically -during `flutter build apk` / `flutter run -d `. - ---- - -## Testing Strategy - -All pure-Dart unit tests run **without** building the native binary. - -``` -Tests that PASS pre-codegen (using the stub): - ✓ All type constructors, equality, hashCode, copyWith - ✓ Enum toString and isReady helpers - ✓ SamplingConfig presets (default, deterministic, mobile) - ✓ GgufModelConfig factory functions (real hardcoded values in stub) - ✓ RustLib.init() is idempotent - ✓ OndeInference.* static helpers - ✓ OndeChatEngine.create() throws UnimplementedError pre-codegen - -Tests that require codegen + native binary: - ✗ Any actual inference call - ✗ loadDefaultModel / sendMessage / streamMessage -``` - -Run tests: -```bash -cd sdk/dart && dart test test/dart_test.dart -``` - ---- - -## Pub.dev Readiness Checklist - -Run this before declaring the SDK ready: - -```bash -# 1. Dart analysis — must be zero issues -dart analyze lib/ - -# 2. All tests pass -dart test test/dart_test.dart - -# 3. Dry-run publish — must show compressed size < 100 MB and minimal warnings -dart pub publish --dry-run -``` - -Common pub.dev score deductions to avoid: -- Missing `repository:` in pubspec → loses points -- No `example/` directory → loses points -- `description:` under 60 chars or over 180 chars → loses points -- Library exports not documented → loses points -- `library ;` directive → triggers lint - -### Pub.dev Upload Size (100 MB limit) - -`dart pub publish` bundles everything not excluded by `.pubignore` (or -`.gitignore` if `.pubignore` doesn't exist). The Rust `target/` dir alone -can be **7+ GB**. A `.pubignore` file is mandatory. - -**Files that MUST be included** (consumers build from source): -- `rust/src/`, `rust/Cargo.toml`, `rust/Cargo.lock` -- `ios/`, `macos/`, `android/`, `linux/`, `windows/` (build scripts, podspecs, CMakeLists) -- `ios/Classes/`, `macos/Classes/` (dummy plugin Swift files) -- `lib/` (all Dart code) - -**Files that MUST be excluded** (`.pubignore`): -- `rust/target/` — multi-GB build artifacts -- `**/*.a`, `**/*.so`, `**/*.dylib`, `**/*.dll` — precompiled binaries -- `.dart_tool/`, `pubspec.lock` -- `example/build/`, `example/.dart_tool/` -- `logs/`, `.idea/`, `.vscode/`, `.DS_Store` - -After adding `.pubignore`, verify: `dart pub publish --dry-run` should report -~280 KB compressed size, not hundreds of MB. - ---- - -## Codegen Workflow (Full, Correct Order) - -```bash -# ── Prerequisites (one-time) ────────────────────────────────────────────── - -rustup target add aarch64-apple-ios aarch64-apple-ios-sim # iOS -rustup target add aarch64-apple-darwin x86_64-apple-darwin # macOS - -cargo install cargo-expand # Required by FRB codegen parser -cargo install flutter_rust_bridge_codegen # The codegen tool itself - -# ── Per-package setup ───────────────────────────────────────────────────── - -cd sdk/dart - -# IMPORTANT: delete the pre-codegen Rust stub first -rm -f rust/src/frb_generated.rs - -# Run codegen — generates: -# rust/src/frb_generated.rs (Rust glue) -# lib/src/frb_generated.dart (Dart FFI bindings, replaces stub) -flutter_rust_bridge_codegen generate - -# Verify Rust still compiles cleanly -cd rust && cargo check - -# Verify Dart analysis is still clean -cd .. && dart analyze lib/ -``` - ---- - -## `flutter run` on iOS (Full Steps) - -```bash -# 1. Scaffold the example Flutter project (one-time) -cd sdk/dart/example -flutter create . \ - --project-name \ - --platforms=ios,macos \ - --org com. - -# 2. Get dependencies -flutter pub get - -# 3. Run codegen (from the SDK package root, not example root) -cd .. -flutter_rust_bridge_codegen generate - -# 4. Run the app (from example dir) -cd example -flutter run # picks up connected device/simulator automatically -flutter run -d iPhone # target a specific device -``` - -**What happens during the first `flutter run` on iOS:** -1. Flutter invokes `pod install` → reads `ios/onde_inference.podspec` -2. The podspec script phase runs `cargo build --release --target aarch64-apple-ios-sim` - (or `-device`) inside `sdk/dart/rust/` — this takes 5–15 min first time -3. The resulting `libonde_inference_dart.a` is linked into the Runner app -4. Flutter's Dart VM starts, calls `RustLib.init()`, loads the native library -5. The model downloads from HuggingFace Hub on first inference (~941 MB or ~1.93 GB) - -**HuggingFace token for private/gated models:** -```bash -# Bake token at build time (required for iOS — no filesystem access): -export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxx -flutter run -``` - ---- - -## Version Pinning Rule - -The `flutter_rust_bridge` Dart package and `flutter_rust_bridge` Rust crate -**must be the same version**. Use exact pins: - -```toml -# rust/Cargo.toml -flutter_rust_bridge = "=2.12.0" -``` -```yaml -# pubspec.yaml -dependencies: - flutter_rust_bridge: ^2.12.0 # ^ is OK on Dart side; codegen auto-upgrades -``` - -If they drift, generated code won't compile. Always check both when upgrading. - ---- - -## Common Errors and Fixes - -| Error | Cause | Fix | -|-------|-------|-----| -| `rust_input: invalid type: sequence` | YAML list syntax in FRB 2.x | Change to `rust_input: "crate::api"` | -| `Please migrate configuration rust_input` | File path in FRB 2.x | Change to `rust_input: "crate::api"` | -| `File exists (os error 17)` | `frb_generated.rs` pre-exists | `rm rust/src/frb_generated.rs` | -| `could not find package flutter_rust_bridge_codegen` | Added codegen as Dart dep | Remove it; install via `cargo install` | -| `no such command: expand` | `cargo-expand` not installed | `cargo install cargo-expand` | -| `unexpected cfg condition name: frb_expand` | Rust check-cfg lint | Harmless FRB macro warning; suppress with `#[allow(unexpected_cfgs)]` or ignore | -| `no_leading_underscores_for_library_prefixes` | Import alias `as _frb` | Rename to `as frb` | -| `unnecessary_library_name` | `library ;` in barrel | Change to bare `library;` | -| `OndeChatEngine.create() throws UnimplementedError` | Codegen not run yet | Run `flutter_rust_bridge_codegen generate` | -| `flutter run` fails, missing Runner.xcodeproj | `flutter create` not run | Run `flutter create . --platforms=ios ...` in example dir | -| `dlopen(onde_inference_dart.framework/...): no such file` | FRB default loader looks for wrong framework | Use `ExternalLibrary.open('onde_inference.framework/onde_inference')` in `init()` — see Dart Public API Pattern above | -| `Failed to lookup symbol 'frb_get_rust_content_hash': dlsym(RTLD_DEFAULT, ...)` | Used `DynamicLibrary.process()` but symbols are in pod framework, not main executable | Switch from `ExternalLibrary.process()` to `ExternalLibrary.open('onde_inference.framework/onde_inference')` | -| `ld: symbol(s) not found ... _kSCNetworkReachability...` | Missing `SystemConfiguration` framework in podspec | Add `s.frameworks = 'SystemConfiguration'` to macOS podspec | -| `ld: error: unknown argument '-Xlinker'` / `-dynamiclib` / `-filelist` | Android NDK's `ld` (LLD) is on PATH — Xcode picks it up instead of Apple's linker | Remove `export LD=$TOOLCHAIN/bin/ld` and other NDK tool exports from `~/.zshrc` (see Xcode 26 section) | -| `clang: error: invalid linker name in argument '-fuse-ld=classic'` | `ALTERNATE_LINKER = classic` set but clang doesn't support it | Remove `ALTERNATE_LINKER`; was only needed to work around the NDK-on-PATH issue — once that's fixed, Apple's ld-prime works fine | -| `EntityTooLarge` on `dart pub publish` | Package > 100 MB (rust/target/ included) | Create `.pubignore` excluding `rust/target/`, `**/*.a`, `**/*.so`, etc. | -| `Because requires SDK version >=X.Y.0 ... version solving failed` | `pubspec.yaml` `environment.sdk` lower bound is too old; CI Flutter version too old | 1. Update `sdk/dart/.flutter-version` to a Flutter that ships the required Dart SDK. 2. Tighten `environment.sdk` in `pubspec.yaml` to the real minimum. See Hard-Won Lesson 11. | -| `You must have a LICENSE file in the root directory` on `dart pub publish` | pub.dev requires a file named exactly `LICENSE`; `LICENSE-MIT` / `LICENSE-APACHE` alone do not satisfy it | Create `sdk/dart/LICENSE` with an **"either of … at your option"** preamble, then append both full license texts. A bare concatenation without a preamble is legally ambiguous — it implies users must comply with *both*, defeating the purpose of dual licensing. Run from `sdk/dart/`: `cat > LICENSE << 'EOF'` then the preamble (see legal-and-trademarks SKILL.md for exact wording), then `{ cat LICENSE-MIT; printf '\n\n---\n\n'; cat LICENSE-APACHE; } >> LICENSE`. Keep `LICENSE-MIT` and `LICENSE-APACHE` alongside it. | - -## Xcode 26 Compatibility - -### The NDK-on-PATH Trap - -If your `~/.zshrc` (or similar) has **global** Android NDK toolchain exports: - -```bash -# DANGEROUS — breaks ALL non-Android builds (Xcode, Rust for Apple, etc.) -export TOOLCHAIN=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64 -export LD=$TOOLCHAIN/bin/ld # ← THIS HIJACKS XCODE'S LINKER -export AR=$TOOLCHAIN/bin/llvm-ar -export CXX=$TOOLCHAIN/bin/clang++ -export RANLIB=$TOOLCHAIN/bin/llvm-ranlib -export STRIP=$TOOLCHAIN/bin/llvm-strip -``` - -Xcode reads `$LD` and invokes the Android NDK's LLD instead of Apple's linker. -The errors look like Xcode 26 ld-prime incompatibility (`unknown argument '-Xlinker'`) -but the root cause is the **wrong linker binary**. - -**Fix:** Comment out all NDK tool exports. Keep only the env vars: - -```bash -export NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865" -export ANDROID_NDK="$NDK_HOME" -export ANDROID_NDK_HOME="$NDK_HOME" -# DO NOT export LD, AR, CXX, RANLIB, STRIP globally. -# Cargo uses rust/.cargo/config.toml for Android linker selection. -``` - -### Does Xcode 26's ld-prime work with Flutter? - -**Yes** — once the NDK tool exports are removed, Apple's ld-prime handles all -CocoaPods/Flutter linker flags correctly. No `ALTERNATE_LINKER = classic` or -`-ld_classic` workaround is needed for macOS builds. The `-ld_classic` -deprecation warning from iOS Podfiles is harmless. - -### iOS 26 Debug Mode Crash - -Flutter debug builds crash on iOS 26 beta with -`NOTIFY_DEBUGGER_ABOUT_RX_PAGES was not intercepted as expected` -([flutter#170416](https://github.com/flutter/flutter/issues/170416)). -This is a Dart VM JIT issue — **not fixable in the SDK**. - -| Mode | iOS 26 Physical | iOS 26 Simulator | iOS 18.x | -|------|----------------|-----------------|----------| -| `--debug` | ❌ Dart VM crash | ❌ Dart VM crash | ✅ Works | -| `--profile` | ✅ Works | ❌ Not supported on sim | ✅ Works | -| `--release` | ⚠️ Use `flutter build ipa` | ❌ Not supported on sim | ✅ Works | - -**Workaround:** Use an iOS 18.x simulator runtime (download via Xcode Settings → Platforms). - ---- - -## File Interdependency Map - -``` -pubspec.yaml - └── declares: flutter_rust_bridge ^2.12.0 - -flutter_rust_bridge.yaml - ├── rust_root: "rust/" → rust/Cargo.toml - ├── rust_input: "crate::api" → rust/src/api.rs (source of truth) - └── dart_output: "lib/src/frb_generated.dart" (codegen writes here) - -rust/Cargo.toml - ├── crate-type = ["cdylib", "staticlib"] - ├── flutter_rust_bridge = "=2.12.0" (exact pin!) - └── onde = { path = "../../../" } (the main onde crate) - -rust/.cargo/config.toml - └── Android NDK linker overrides (aarch64, armv7, x86_64, i686) - -rust/src/api.rs - ├── imports onde::{ChatEngine, ...} (path dep → ../../..) - ├── declares mirror types (ChatRole, ChatMessage, ...) - ├── declares OndeChatEngine (wraps ChatEngine) - └── declares free functions (default_model_config, ...) - -lib/src/frb_generated.dart/ ← DIRECTORY (not a file!) - ├── api.dart ← generated API bindings - ├── api.freezed.dart ← freezed union types (OndeError) - ├── frb_generated.dart ← RustLib class, ExternalLibraryLoaderConfig - └── frb_generated.io.dart ← RustLibWire, platform FFI glue - -lib/src/types.dart ← hand-written, FRB-independent - └── ChatRole, ChatMessage, SamplingConfig, GgufModelConfig, - InferenceResult, StreamChunk, EngineStatus, EngineInfo, OndeException - -lib/src/engine.dart ← hand-written, imports frb_generated.dart/ + types.dart - ├── extension OndeChatEngineX (loadDefaultModel, clearHistoryCount) - └── abstract class OndeInference - ├── init() → ExternalLibrary.open('onde_inference.framework/...') on Apple - ├── defaultModelConfig(), qwen2515bConfig(), qwen253bConfig(), ... - └── defaultSamplingConfig(), deterministicSamplingConfig(), mobileSamplingConfig() - -lib/onde_inference.dart (barrel) - ├── export 'src/types.dart' - └── export 'src/engine.dart' - -macos/ - ├── Classes/OndeInferencePlugin.swift ← dummy (required for CocoaPods framework) - └── onde_inference.podspec ← script_phase + -force_load + SystemConfiguration - -ios/ - ├── Classes/OndeInferencePlugin.swift ← dummy (required for CocoaPods framework) - └── onde_inference.podspec ← script_phase + -force_load - -android/ - ├── CMakeLists.txt ← invokes cargo build per ABI - └── build.gradle ← externalNativeBuild → cmake - -.pubignore ← excludes rust/target/, *.a, *.so, etc. -.gitignore ← excludes same + build artifacts -``` - ---- - -*Last updated: July 2025 — Xcode 26 compatibility, ExternalLibrary.open pattern, -SystemConfiguration framework, .pubignore for pub.dev, NDK PATH pitfall.* \ No newline at end of file diff --git a/.agents/skills/sdk-kotlin/SKILL.md b/.agents/skills/sdk-kotlin/SKILL.md deleted file mode 100644 index b92d2fd..0000000 --- a/.agents/skills/sdk-kotlin/SKILL.md +++ /dev/null @@ -1,810 +0,0 @@ ---- -name: sdk-kotlin -description: Build, generate, and publish the Onde Inference Kotlin Multiplatform SDK (Android + JVM). Covers UniFFI Kotlin binding generation, KMP source set layout with shared srcDir pattern, cargo-ndk cross-compilation for Android ABIs, JVM native library bundling for macOS Apple Silicon, Maven Central publishing via Vanniktech plugin, klibs.io discovery requirements, ProGuard consumer rules, and the Jetpack Compose example app. -allowed-tools: Read, Write, Edit, Glob, Grep, Bash -user-invocable: true ---- - -# Skill: Kotlin Multiplatform SDK - -## What This Skill Covers - -Building the `onde-inference` Kotlin Multiplatform library from Rust source -using UniFFI, distributing it via Maven Central, and getting it listed on -[klibs.io](https://klibs.io) — the JetBrains-maintained discovery platform for -KMP libraries. - -**Targets:** - -| Target | GPU Backend | Default Model | Primary Use Case | -|--------|-------------|---------------|------------------| -| `androidTarget()` | CPU (Candle) | Qwen 2.5 1.5B (~941 MB) | Mobile apps | -| `jvm()` | Metal (macOS) / CPU (Linux) | Qwen 2.5 3B (~1.93 GB) | Desktop / server on Apple Silicon | - -**Context:** Onde's tagline is "AI for Apple silicon devices." The primary SDKs -are **Swift** (iOS, tvOS, macOS) and **Dart** (Flutter, cross-platform). The -Kotlin SDK extends Onde to Android and JVM developers — particularly those -building KMP apps that target both Android and macOS, or running inference on -Apple Silicon Macs from Kotlin/JVM. - ---- - -## Repository Layout - -``` -onde/ -├── src/ # Rust source — UniFFI-annotated types and OndeChatEngine -├── uniffi-bindgen/ # Standalone uniffi-bindgen CLI binary (pinned =0.31.0) -├── sdk/kotlin/ -│ ├── settings.gradle.kts # Gradle root — includes :lib and :example -│ ├── build.gradle.kts # root — plugin declarations (all versions live here) -│ ├── gradle.properties # GROUP, POM_ARTIFACT_ID, VERSION_NAME, signing/OSSRH -│ ├── gradle/wrapper/ -│ │ └── gradle-wrapper.properties # pinned Gradle 8.9 -│ ├── lib/ -│ │ ├── build.gradle.kts # KMP library + Vanniktech maven-publish -│ │ ├── consumer-rules.pro # ProGuard: preserve UniFFI JNI bridge symbols -│ │ └── src/ -│ │ ├── commonMain/kotlin/.gitkeep # empty — KMP requires this source set to exist -│ │ ├── shared/kotlin/ # shared srcDir added to BOTH androidMain and jvmMain -│ │ │ └── com/ondeinference/onde/ -│ │ │ ├── PlatformSupport.kt # internal interface — setEnv + ensureNativeLoaded -│ │ │ ├── OndeInference.kt # engine wrapper (internal constructor) -│ │ │ └── Convenience.kt # OndeSampling, OndeModels, OndeMessage -│ │ ├── generated/kotlin/ # UniFFI-generated onde.kt ← GITIGNORED -│ │ ├── androidMain/ -│ │ │ ├── AndroidManifest.xml -│ │ │ ├── jniLibs/ # compiled .so per ABI ← GITIGNORED -│ │ │ └── kotlin/com/ondeinference/onde/ -│ │ │ └── Platform.android.kt # AndroidPlatform + factory fun OndeInference(context) -│ │ └── jvmMain/ -│ │ ├── kotlin/com/ondeinference/onde/ -│ │ │ ├── Platform.jvm.kt # JvmPlatform + factory fun OndeInference(dataDir) -│ │ │ └── NativeLoader.kt # extract libonde from JAR resources -│ │ └── resources/native/ # bundled .dylib/.so ← GITIGNORED -│ │ ├── macos-aarch64/libonde.dylib -│ │ ├── macos-x86_64/libonde.dylib -│ │ ├── linux-x86_64/libonde.so -│ │ └── linux-aarch64/libonde.so -│ ├── example/ -│ │ ├── build.gradle.kts # com.android.application — no plugin versions here -│ │ ├── proguard-rules.pro -│ │ ├── README.md -│ │ └── src/main/ -│ │ ├── AndroidManifest.xml # INTERNET permission -│ │ ├── res/values/ -│ │ │ ├── strings.xml -│ │ │ └── themes.xml # Material3 NoActionBar activity theme -│ │ └── kotlin/com/ondeinference/example/ -│ │ ├── MainActivity.kt # ComponentActivity — enableEdgeToEdge + setContent -│ │ ├── ChatViewModel.kt # model lifecycle, streaming, history -│ │ └── ui/ -│ │ ├── ChatScreen.kt # full Compose UI -│ │ └── theme/Theme.kt # dynamic colour + dark mode -│ └── scripts/ -│ ├── build-android.sh # Rust .so → androidMain/jniLibs/ via cargo-ndk -│ ├── build-jvm.sh # Rust .dylib/.so → jvmMain/resources/native/ -│ └── generate-bindings.sh # uniffi-bindgen → src/generated/kotlin/onde.kt -└── .github/workflows/ - └── release-sdk-kotlin.yml # tag push → build → Maven Central publish -``` - ---- - -## KMP Architecture - -### Why KMP Instead of Android-Only - -1. **klibs.io listing** — the JetBrains KMP discovery platform requires - `kotlin-tooling-metadata.json`, which only the `kotlin.multiplatform` Gradle - plugin generates. An Android-only AAR will not appear on klibs.io. -2. **JVM target** — Kotlin developers on macOS Apple Silicon can run inference - locally from desktop/server apps, Gradle plugins, or CLI tools. -3. **Future-proof** — adding Kotlin/Native targets (iOS, macOS) later requires - no structural changes. The Swift SDK already covers Apple platforms natively, - but KMP apps that share Kotlin across Android + iOS could benefit. - -### Source Set Strategy: Shared srcDir - -Instead of KMP intermediate source sets (which add Gradle complexity and -require careful dependency resolution for JNA), the SDK uses a **shared srcDir** -pattern: - -``` -commonMain/ — empty (KMP requires it) -shared/ — hand-written wrapper code (compiled for BOTH targets) -generated/ — UniFFI-generated onde.kt (compiled for BOTH targets) -androidMain/ — Android-specific: Os.setenv, Context factory -jvmMain/ — JVM-specific: JNA setenv, NativeLoader, File factory -``` - -In `lib/build.gradle.kts`: - -```kotlin -androidMain.get().kotlin.srcDir("src/shared/kotlin") -androidMain.get().kotlin.srcDir("src/generated/kotlin") -jvmMain.get().kotlin.srcDir("src/shared/kotlin") -jvmMain.get().kotlin.srcDir("src/generated/kotlin") -``` - -Both targets compile the shared code independently with their own dependencies. -Since both are JVM-based, `java.io.File`, JNA, and coroutines are available in -the shared code. - -### Platform Abstraction: Interface Pattern - -The shared `OndeInference` class takes a `PlatformSupport` interface via an -`internal` constructor. Each target provides a concrete implementation and a -top-level factory function: - -``` -PlatformSupport (interface — shared/) -├── AndroidPlatform (object — androidMain/) -│ └── fun OndeInference(context, dataDir?) → OndeInference -└── JvmPlatform (object — jvmMain/) - └── fun OndeInference(dataDir?) → OndeInference -``` - -| Operation | Android | JVM | -|-----------|---------|-----| -| `setEnv(key, value)` | `Os.setenv(key, value, true)` | JNA → `libc.setenv(key, value, 1)` | -| `ensureNativeLoaded()` | No-op (APK jniLibs) | `NativeLoader.ensureLoaded()` | -| Default `dataDir` | `context.filesDir` | `~/.onde/` | - ---- - -## Key Design Decisions - -| Decision | Rationale | -|---|---| -| KMP with `androidTarget()` + `jvm()` | Required for klibs.io listing; enables macOS Apple Silicon JVM users | -| Shared srcDir instead of intermediate source sets | Avoids KMP dependency resolution complexity with JNA (`@aar` vs JAR) | -| Interface-based platform abstraction | Simpler than `expect`/`actual` when constructor signatures differ between targets | -| UniFFI Kotlin bindings — not hand-written JNI | Same Rust source as Swift/Dart, zero API drift | -| `src/generated/kotlin/` is gitignored | Regenerated from Rust on every release | -| `cargo-ndk` for Android cross-compilation | Handles NDK toolchain wiring for all 4 ABIs transparently | -| `NativeLoader` extracts dylib from JAR resources | JVM users don't need to manage `java.library.path` manually | -| Vanniktech `maven-publish` plugin with `KotlinMultiplatform` | Publishes KMP metadata + per-target artifacts to Maven Central | -| `minSdk 26` | Required for `android.system.Os.setenv` to set `HF_HOME` | -| JNA dependency (`net.java.dev.jna:jna:5.14.0`) | UniFFI 0.31.0 generates Kotlin bindings using `com.sun.jna.*`. Android uses `@aar` variant; JVM uses plain JAR. | -| ProGuard consumer rules bundled in AAR | Prevents R8 from stripping UniFFI JNI bridge in consumer apps | -| `:example` is a Gradle submodule | Plugin versions declared once in root `build.gradle.kts` and inherited | - ---- - -## UniFFI Kotlin Type Map - -| Rust type | UniFFI derive | Kotlin type | -|---|---|---| -| `ChatRole` | `uniffi::Enum` | `sealed class ChatRole` | -| `ChatMessage` | `uniffi::Record` | `data class ChatMessage` | -| `SamplingConfig` | `uniffi::Record` | `data class SamplingConfig` | -| `GgufModelConfig` | `uniffi::Record` | `data class GgufModelConfig` | -| `InferenceResult` | `uniffi::Record` | `data class InferenceResult` | -| `StreamChunk` | `uniffi::Record` | `data class StreamChunk` | -| `EngineStatus` | `uniffi::Enum` | `sealed class EngineStatus` | -| `EngineInfo` | `uniffi::Record` | `data class EngineInfo` | -| `InferenceError` | `uniffi::Error` | `sealed class InferenceException : Exception` | -| `OndeChatEngine` | `uniffi::Object` | `class OndeChatEngine` | -| `StreamChunkListener` | `callback_interface` | `interface StreamChunkListener` | - -The generated Kotlin package is `uniffi.onde.*`. The shared `OndeInference.kt` -re-exports these as `typealias` under `com.ondeinference.onde.*` so callers only -need one import. - ---- - -## Build Targets - -### Android ABI Targets - -| ABI | Rust triple | Typical device | -|---|---|---| -| `arm64-v8a` | `aarch64-linux-android` | Modern Android phones (primary) | -| `armeabi-v7a` | `armv7-linux-androideabi` | Older 32-bit ARM phones | -| `x86_64` | `x86_64-linux-android` | Android emulators (Intel/AMD) | -| `x86` | `i686-linux-android` | 32-bit emulators | - -### JVM Native Library Targets - -| Platform | Rust triple | Library | Primary Use Case | -|---|---|---|---| -| macOS Apple Silicon | `aarch64-apple-darwin` | `libonde.dylib` | **Primary** — Mac development + Metal GPU | -| macOS Intel | `x86_64-apple-darwin` | `libonde.dylib` | Legacy Mac support | -| Linux x86_64 | `x86_64-unknown-linux-gnu` | `libonde.so` | CI, servers | -| Linux ARM64 | `aarch64-unknown-linux-gnu` | `libonde.so` | ARM servers | - ---- - -## Build Sequence (Manual / CI) - -### 1. Prerequisites - -```bash -# Rust toolchain -rustup toolchain install stable - -# Android targets + cargo-ndk -rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android -cargo install cargo-ndk - -# Android NDK -export ANDROID_NDK_HOME=$HOME/Library/Android/sdk/ndk/26.1.10909125 - -# macOS/JVM target (usually pre-installed on Apple Silicon) -rustup target add aarch64-apple-darwin -``` - -### 2. Build Android .so files - -```bash -./sdk/kotlin/scripts/build-android.sh # release, all ABIs -./sdk/kotlin/scripts/build-android.sh --debug # debug build -FILTER_ABI=arm64-v8a ./sdk/kotlin/scripts/build-android.sh # single ABI -``` - -Outputs land in `sdk/kotlin/lib/src/androidMain/jniLibs//libonde.so`. - -### 3. Build JVM native library - -```bash -./sdk/kotlin/scripts/build-jvm.sh # host platform, release -./sdk/kotlin/scripts/build-jvm.sh --debug # debug build -ONDE_TARGET_TRIPLE=x86_64-apple-darwin ./sdk/kotlin/scripts/build-jvm.sh # cross-compile -``` - -Outputs land in `sdk/kotlin/lib/src/jvmMain/resources/native//libonde.`. - -### 4. Build the uniffi-bindgen CLI - -```bash -cargo build --manifest-path uniffi-bindgen/Cargo.toml --release -# Binary: uniffi-bindgen/target/release/uniffi-bindgen -``` - -### 5. Generate Kotlin UniFFI bindings - -```bash -./sdk/kotlin/scripts/generate-bindings.sh -``` - -Produces `sdk/kotlin/lib/src/generated/kotlin/onde.kt` — the complete -generated binding including all types, the `OndeChatEngine` class, free -functions, and `System.loadLibrary("onde")` in a companion object static init. - -### 6. Build the library - -```bash -cd sdk/kotlin - -# Android AAR -./gradlew :lib:assembleRelease - -# JVM JAR (with bundled native lib) -./gradlew :lib:jvmJar - -# Both -./gradlew :lib:build -``` - ---- - -## JVM Native Library Loading - -The JVM target bundles native libraries inside the JAR under -`/native/-/libonde.`. The `NativeLoader` object handles -extraction and loading at runtime: - -``` -OndeInference() factory (jvmMain) - └── JvmPlatform.ensureNativeLoaded() - └── NativeLoader.ensureLoaded() - ├── Detect OS + arch from system properties - ├── Extract /native/-/libonde. from JAR resources - ├── Write to temp file: /onde-native/libonde. - └── System.load(tempFile.absolutePath) -``` - -If the bundled library is not found (e.g. development mode without running -`build-jvm.sh`), `NativeLoader` falls back to `System.loadLibrary("onde")` -which searches `java.library.path`. - -**Important:** `NativeLoader.ensureLoaded()` must run BEFORE any UniFFI type is -accessed. The `OndeInference` constructor handles this via the `init` block: - -```kotlin -class OndeInference internal constructor(...) : AutoCloseable { - init { platform.ensureNativeLoaded() } // runs before engine = OndeChatEngine() - private val engine = uniffi.onde.OndeChatEngine() -} -``` - ---- - -## Android Filesystem Sandbox Setup - -On Android, `dirs::home_dir()` (used by `hf-hub` under the hood) panics because -there is no home directory in the Android sandbox. The Rust engine requires -`HF_HOME` to be set **before** any model load on Android. - -The `OndeInference` wrapper handles this automatically in `setup()`: - -```kotlin -fun setup() { - if (configured) return - - val hfHome = File(dataDir, "models").also { it.mkdirs() } - val hfHubCache = File(hfHome, "hub").also { it.mkdirs() } - val tmpDir = File(dataDir, "tmp").also { it.mkdirs() } - - platform.setEnv("HF_HOME", hfHome.absolutePath) - platform.setEnv("HF_HUB_CACHE", hfHubCache.absolutePath) - platform.setEnv("HUGGINGFACE_HUB_CACHE", hfHubCache.absolutePath) - platform.setEnv("TMPDIR", tmpDir.absolutePath) - - configured = true -} -``` - -`setup()` is called automatically by `loadDefaultModel()` and `loadModel()`. - -### Filesystem layout - -``` -# Android (context.filesDir) -/ -├── models/ ← HF_HOME -│ └── hub/ ← HF_HUB_CACHE -└── tmp/ ← TMPDIR - -# JVM (defaults to ~/.onde/) -~/.onde/ -├── models/ ← HF_HOME -│ └── hub/ ← HF_HUB_CACHE -└── tmp/ ← TMPDIR -``` - -### Cross-platform model cache comparison - -| Platform | SDK | Cache Location | Shared across apps? | -|---|---|---|---| -| iOS / tvOS / macOS | Swift | App Group container | Yes (same team + entitlement) | -| macOS (JVM) | Kotlin | `~/.onde/models/hub/` | Yes (all JVM apps on same user) | -| Android | Kotlin | `/models/hub/` | No (per-app sandbox) | -| Flutter | Dart | Platform-dependent | Follows platform conventions | - -### Why Android can't share models across apps - -Android's security model assigns each app a unique Linux UID at install time. -The kernel enforces file permissions — app A cannot open a file descriptor -inside app B's `/data/data/com.B/files/`. There is no platform primitive to -override this the way iOS App Groups do. The approaches that don't work: - -- **`android:sharedUserId`** — deprecated API 29, removed API 33 -- **`MANAGE_EXTERNAL_STORAGE`** — Google Play rejects unless file manager -- **SAF (Storage Access Framework)** — requires user interaction every time - -Each Onde-powered Android app downloads its own copy (~941 MB). This matches -how TensorFlow Lite, MediaPipe, and ML Kit handle model caching. The SDK caches -after first download — bandwidth cost is one-time per app. - ---- - -## Kotlin Public API - -### `OndeInference` — primary entry point - -```kotlin -import com.ondeinference.onde.OndeInference -import com.ondeinference.onde.OndeSampling -import com.ondeinference.onde.OndeModels -import com.ondeinference.onde.OndeMessage - -// ── Android ────────────────────────────────────────────────── -val onde = OndeInference(context) // pass applicationContext - -// ── JVM (macOS / Linux) ────────────────────────────────────── -val onde = OndeInference() // uses ~/.onde/ -val onde = OndeInference(File("/custom")) // custom cache dir - -// ── Shared API (both platforms) ────────────────────────────── - -// Load (downloads from HuggingFace Hub on first run) -val elapsed: Double = onde.loadDefaultModel( - systemPrompt = "You are helpful.", - sampling = OndeSampling.mobile() -) - -// Chat (suspend) -val result = onde.chat("What is Rust?") -println(result.text) - -// Streaming (Flow) -onde.stream("Tell me a story.").collect { chunk -> - print(chunk.delta) -} - -// One-shot (no history side-effects) -val summary = onde.generate( - messages = listOf( - OndeMessage.system("Summarise this."), - OndeMessage.user(longText) - ) -) - -// Status -val info = onde.info() // EngineInfo - -// History -onde.clearHistory() -onde.pushHistory(OndeMessage.user("Injected context")) - -// Cleanup -onde.unload() -onde.close() // decrements Rust Arc refcount -``` - -### `OndeSampling` — convenience constructors - -```kotlin -OndeSampling.default() // temp=0.7, top_p=0.95, max=512 tokens -OndeSampling.deterministic() // temp=0.0, greedy -OndeSampling.mobile() // temp=0.7, max=128 tokens -``` - -### `OndeModels` — model config constructors - -```kotlin -OndeModels.default() // platform default (1.5B on Android, 3B on macOS JVM) -OndeModels.qwen25_1_5b() // ~941 MB -OndeModels.qwen25_3b() // ~1.93 GB -``` - -### `OndeMessage` — message constructors - -```kotlin -OndeMessage.system("You are helpful.") -OndeMessage.user("Hello!") -OndeMessage.assistant("Hi there!") -``` - ---- - -## lib/build.gradle.kts Key Sections - -```kotlin -import com.vanniktech.maven.publish.KotlinMultiplatform -import com.vanniktech.maven.publish.JavadocJar -import com.vanniktech.maven.publish.SonatypeHost - -plugins { - id("org.jetbrains.kotlin.multiplatform") - id("com.android.library") - id("com.vanniktech.maven.publish") -} - -kotlin { - androidTarget { - publishLibraryVariants("release") - compilations.all { kotlinOptions { jvmTarget = "17" } } - } - jvm { - compilations.all { kotlinOptions { jvmTarget = "17" } } - } - - sourceSets { - androidMain.dependencies { - implementation("net.java.dev.jna:jna:5.14.0@aar") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") - } - jvmMain.dependencies { - implementation("net.java.dev.jna:jna:5.14.0") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") - } - - // Shared srcDirs — compiled independently for each target - androidMain.get().kotlin.srcDir("src/shared/kotlin") - androidMain.get().kotlin.srcDir("src/generated/kotlin") - jvmMain.get().kotlin.srcDir("src/shared/kotlin") - jvmMain.get().kotlin.srcDir("src/generated/kotlin") - } -} - -mavenPublishing { - configure(KotlinMultiplatform( - javadocJar = JavadocJar.Empty(), - sourcesJar = true, - )) - publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) - signAllPublications() - coordinates("com.ondeinference", "onde-inference", "") -} -``` - -The Vanniktech plugin reads signing and OSSRH credentials from environment -variables prefixed with `ORG_GRADLE_PROJECT_`: - -| Gradle property | CI env var | -|---|---| -| `mavenCentralUsername` | `ORG_GRADLE_PROJECT_mavenCentralUsername` | -| `mavenCentralPassword` | `ORG_GRADLE_PROJECT_mavenCentralPassword` | -| `signingKeyId` | `ORG_GRADLE_PROJECT_signingKeyId` | -| `signingKey` | `ORG_GRADLE_PROJECT_signingKey` | -| `signingPassword` | `ORG_GRADLE_PROJECT_signingPassword` | - ---- - -## ProGuard Consumer Rules - -The `consumer-rules.pro` bundled in the AAR instructs R8/ProGuard in consumer -apps to preserve: - -- `uniffi.onde.**` — all generated binding classes -- `com.ondeinference.onde.**` — the public wrapper -- `uniffi.onde.StreamChunkListener` — callback interface called from Rust -- Native method declarations (`keepclasseswithmembernames`) -- Static initializers in `uniffi.onde.*` (contains `System.loadLibrary("onde")`) - -Always update `consumer-rules.pro` when adding new top-level UniFFI-exported -types or callback interfaces. - ---- - -## klibs.io Discovery - -[klibs.io](https://klibs.io) is a JetBrains-maintained search platform for -Kotlin Multiplatform libraries. It auto-indexes from Maven Central. - -### Requirements for listing - -| Requirement | How we satisfy it | -|---|---| -| Open source on GitHub | `ondeinference/onde` is public | -| At least one artifact on Maven Central | Published via Vanniktech plugin | -| Artifact has `kotlin-tooling-metadata.json` | KMP plugin generates this automatically | -| POM has valid GitHub `url` or `scm.url` | `scm { url.set("https://github.com/ondeinference/onde") }` | - -### Timeline - -- New libraries appear **within one month** of first Maven Central publish - (public index update frequency). -- New versions of already-indexed libraries appear **the next day**. -- No manual submission needed — discovery is fully automatic. - ---- - -## Maven Central Publishing (CI) - -Trigger: push a semver tag matching `[0-9]+.[0-9]+.[0-9]+`. - -```bash -# 1. Bump VERSION_NAME in sdk/kotlin/gradle.properties -# 2. Bump version in Cargo.toml [package] -# 3. Commit, tag, push -git tag 1.0.1 && git push origin 1.0.1 -``` - -CI (`release-sdk-kotlin.yml`) steps: -1. Install Rust stable with all 4 Android targets + host target -2. Cache NDK 26.1.10909125 -3. Install `cargo-ndk` -4. `build-android.sh --release` → `.so` files -5. `build-jvm.sh --release` → `.dylib` / `.so` -6. Build `uniffi-bindgen` -7. `generate-bindings.sh` → `onde.kt` -8. Validate tag == `Cargo.toml` version -9. `./gradlew :lib:publishAndReleaseToMavenCentral` -10. Upload artifacts as GitHub Release assets - ---- - -## Version Synchronisation Rule - -`VERSION_NAME` in `sdk/kotlin/gradle.properties` MUST match `version` in the -root `Cargo.toml`. The CI workflow enforces this and fails fast on mismatch. -Never bump one without the other. - ---- - -## Common Pitfalls - -| Pitfall | Fix | -|---|---| -| `dirs::home_dir()` panics on Android | `setup()` must be called before any Rust model load. `OndeInference.loadDefaultModel()` calls it automatically. | -| Generated `onde.kt` not found at compile time | Run `scripts/generate-bindings.sh`. The file is gitignored and must be regenerated after every Rust API change. | -| `.so` files missing from AAR | Run `scripts/build-android.sh`. The `androidMain/jniLibs/` directory is gitignored. | -| JVM `UnsatisfiedLinkError` at runtime | Run `scripts/build-jvm.sh` to bundle the native lib in JAR resources, or add `libonde.dylib` to `java.library.path`. | -| UniFFI version mismatch | Keep `uniffi = "=0.31.0"` identical in `Cargo.toml`, `build-deps`, and `uniffi-bindgen/Cargo.toml`. | -| R8 strips UniFFI bridge at runtime | Ensure `consumer-rules.pro` is present in the AAR and includes `-keep class uniffi.onde.**`. | -| `System.loadLibrary("onde")` fails | The `.so` files must match the ABI of the device. Check that all 4 ABIs are built and included in `androidMain/jniLibs/`. | -| `Os.setenv` not available | `minSdk` must be 26+. `android.system.Os` is available from API 21 but `setenv` specifically needs 26. | -| Gradle fails with `VERSION_NAME` missing | Ensure `gradle.properties` contains `VERSION_NAME=x.y.z`. | -| `cargo-ndk` not found in CI | Add `cargo install cargo-ndk --locked` step before the build step. | -| `ANDROID_NDK_HOME` not set | Set it explicitly: `echo "ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/26.1.10909125" >> $GITHUB_ENV` | -| `build-android.sh: line XX: arm64: unbound variable` on macOS | Do not use Bash associative arrays. The script uses portable indexed arrays for ABI → Rust target mapping. | -| `Unresolved reference 'sun'` / `Unresolved reference 'Structure'` in `onde.kt` | UniFFI 0.31.0 depends on JNA (`com.sun.jna.*`). Add `implementation("net.java.dev.jna:jna:5.14.0@aar")` for Android or `implementation("net.java.dev.jna:jna:5.14.0")` for JVM. | -| HuggingFace download fails on first load | The app must declare ``. | -| `Plugin [id: 'com.android.application'] was not found` in `:example` | Open `sdk/kotlin/` (not `sdk/kotlin/example/`) in Android Studio. Plugin versions are inherited from the root `build.gradle.kts`. | -| `Using singleVariant publishing DSL multiple times` | This error means the old `AndroidSingleVariantLibrary` config is still present. Use `KotlinMultiplatform(...)` instead — the KMP plugin handles variant publishing internally. | -| Convenience functions (`OndeSampling.default()` etc.) crash before `OndeInference()` created | These functions call into the native library via UniFFI. Always create an `OndeInference` instance first — its `init` block ensures the native library is loaded. | - ---- - -## Testing - -### Unit tests (host JVM — no Android device needed) - -Pure Kotlin logic tests that don't call into Rust: - -```bash -cd sdk/kotlin -./gradlew :lib:test # all JVM unit tests -./gradlew :lib:jvmTest # JVM target only -``` - -### Instrumented tests (requires Android device or emulator) - -Tests that call into Rust via the generated bindings: - -```bash -./gradlew :lib:connectedAndroidTest -``` - -For CI, use an emulator (`x86_64` ABI) with `reactivecircus/android-emulator-runner`. - -### JVM integration tests - -Tests that load the native library and run inference: - -```bash -# Build the native lib first -./sdk/kotlin/scripts/build-jvm.sh - -# Run JVM tests -cd sdk/kotlin && ./gradlew :lib:jvmTest -``` - ---- - -## Dependency Graph - -``` -OndeInference.kt (shared) -├── PlatformSupport (interface, shared) -│ ├── AndroidPlatform (androidMain) -│ │ └── android.system.Os.setenv -│ └── JvmPlatform (jvmMain) -│ ├── JNA → libc.setenv -│ └── NativeLoader → extracts libonde from JAR resources -├── uniffi.onde.OndeChatEngine (generated onde.kt) -├── uniffi.onde.StreamChunkListener (generated onde.kt) -├── uniffi.onde.*Config / *Message (generated onde.kt) -│ └── com.sun.jna.Native (JNA) -│ ├── libonde.so (Android, per ABI) -│ └── libonde.dylib (macOS, from JAR resources) -│ └── mistralrs (Metal on macOS, Candle CPU on Android) -│ └── hf-hub (model download) -└── Convenience.kt (shared) - └── OndeSampling, OndeModels, OndeMessage -``` - ---- - -## Distribution Registry - -| Registry | Artifact | Import | Discovery | -|---|---|---|---| -| Maven Central | `com.ondeinference:onde-inference` | `implementation("com.ondeinference:onde-inference:1.0.0")` | [search.maven.org](https://search.maven.org) | -| klibs.io | `onde-inference` | Same Gradle coordinate | [klibs.io](https://klibs.io) (auto-indexed) | -| GitHub Releases | `onde-inference-.aar` + `.jar` | Direct download | [github.com/ondeinference/onde/releases](https://github.com/ondeinference/onde/releases) | - -### End-game for Kotlin developers - -One line in `build.gradle.kts`: - -```kotlin -implementation("com.ondeinference:onde-inference:1.0.0") -``` - ---- - -## Example App - -A working Jetpack Compose chat app lives at `sdk/kotlin/example/`. It is the -canonical way to see the SDK in action and the first thing to open when -verifying a new build. - -### Opening in Android Studio - -Always open **`sdk/kotlin/`** — the Gradle root. Do not open -`sdk/kotlin/example/` directly. Android Studio will see both `:lib` and -`:example` as modules and sync correctly. - -### What the example covers - -- Loading the default model with `loadDefaultModel()` and showing load time -- Streaming replies token-by-token via `onde.stream()` and `Flow` -- Multi-turn conversation history maintained in the Rust engine -- Clearing history without reloading the model -- Material3 dynamic colour theme with dark mode support -- Proper IME / navigation-bar inset handling - -### Gradle submodule rules - -1. **No `settings.gradle.kts` inside `example/`** — Gradle would treat it as - standalone, breaking plugin resolution and `project(":lib")`. -2. **No plugin versions in `example/build.gradle.kts`** — inherited from root. -3. **`include(":example")` in root `settings.gradle.kts`** — required for Gradle. -4. **`project(":lib")` as the SDK dependency** — builds against local source. - -The root `build.gradle.kts` declares all plugins used by any submodule: - -```kotlin -plugins { - id("com.android.library") version "8.5.2" apply false - id("com.android.application") version "8.5.2" apply false - id("org.jetbrains.kotlin.android") version "2.0.21" apply false - id("org.jetbrains.kotlin.multiplatform") version "2.0.21" apply false - id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false - id("com.vanniktech.maven.publish") version "0.28.0" apply false -} -``` - -### Streaming pattern - -```kotlin -val placeholderId = System.nanoTime() -_uiState.update { it.copy(messages = it.messages + UiMessage(id = placeholderId, ...)) } - -val buffer = StringBuilder() -onde.stream(text).collect { chunk -> - buffer.append(chunk.delta) - _uiState.update { state -> - state.copy(messages = state.messages.replaceLast( - UiMessage(id = placeholderId, content = buffer.toString(), isStreaming = !chunk.done) - )) - } -} -``` - -### Running the example - -```bash -cd sdk/kotlin - -# 1. Build the .so files -./scripts/build-android.sh - -# 2. Generate the Kotlin bindings -./scripts/generate-bindings.sh - -# 3. Install on a connected device -./gradlew :example:installDebug -``` - -Or open `sdk/kotlin/` in Android Studio and hit Run on the `:example` configuration. - ---- - -## Cross-Platform SDK Comparison - -Onde ships SDKs for four platforms. The Kotlin SDK is one piece of the -distribution strategy: - -| SDK | Primary Platform | GPU Backend | Package Manager | Discovery | -|-----|-----------------|-------------|-----------------|-----------| -| **Swift** | iOS, tvOS, macOS | Metal | Swift Package Manager | Swift Package Index | -| **Dart** | Android, iOS (Flutter) | Platform-dependent | pub.dev | pub.dev search | -| **Kotlin** | Android, JVM (macOS) | Metal (JVM) / CPU (Android) | Maven Central | klibs.io | -| **React Native** | Android, iOS | Platform-dependent | npm | npmjs.com | - -The Swift SDK is the primary distribution — it runs natively on Apple silicon -with Metal acceleration. The Kotlin JVM target provides the same Metal-backed -inference for Kotlin developers on macOS, while the Android target extends -reach to the Android ecosystem. - ---- - -*Update this skill when adding KMP targets (e.g. Kotlin/Native for iOS), -changing the source set structure, or modifying the Maven Central publishing -pipeline.* \ No newline at end of file diff --git a/.agents/skills/sdk-react-native/SKILL.md b/.agents/skills/sdk-react-native/SKILL.md deleted file mode 100644 index f64f985..0000000 --- a/.agents/skills/sdk-react-native/SKILL.md +++ /dev/null @@ -1,769 +0,0 @@ ---- -name: sdk-react-native -description: Build, debug, and maintain the Onde Inference React Native / Expo SDK. Covers Expo native module wiring, Metro config for file-linked SDKs, iOS deployment target and Expo autolinking, XCFramework device/simulator split, app group entitlement, HF_HOME/TMPDIR sandbox setup, the native bridge JSON string contract, EngineStatus normalization, and npm publishing. -allowed-tools: Read, Write, Edit, Glob, Grep, Bash -user-invocable: true ---- - -# Skill: React Native / Expo SDK - -## What This Skill Covers - -Building and maintaining the `@ondeinference/react-native` Expo native module, -wiring the Rust C FFI bridge to TypeScript, debugging Metro/CocoaPods/Xcode -issues, and publishing to npm. - ---- - -## Repository Layout - -``` -onde/sdk/react-native/ -├── package.json # name: @ondeinference/react-native -├── expo-module.config.json # declares OndeInferenceModule for Expo autolinking -├── tsconfig.json # target ES2020, module commonjs -├── src/ -│ ├── index.ts # public API: OndeChatEngine, free functions, types -│ ├── OndeInferenceModule.ts # requireNativeModule("OndeInference") — raw bridge -│ └── types.ts # ChatMessage, SamplingConfig, EngineInfo, etc. -├── build/ # tsc output — gitignored, regenerated by npm run build -├── ios/ -│ ├── OndeInference.podspec # CocoaPods spec — vendored_frameworks = XCFramework -│ ├── OndeInferenceModule.swift # Expo Module: Rust C FFI declarations + module def -│ └── rust/ -│ ├── OndeReactNative.xcframework # bundles device + simulator slices -│ ├── device/libonde_react_native.a -│ └── simulator/libonde_react_native.a -├── android/ # Android module (not yet wired for Onde) -├── scripts/ -│ └── build-rust.sh # builds Rust .a for iOS device + sim, creates XCFramework -└── example/ - ├── app.json # Expo config — ios.deploymentTarget, appleTeamId - ├── babel.config.js # babel-preset-expo only - ├── metro.config.js # watchFolders, extraNodeModules, nodeModulesPaths, blockList - ├── package.json # dep: "@ondeinference/react-native": "file:.." - └── ios/ - ├── Podfile # uses use_expo_modules!, deployment from Podfile.properties.json - ├── Podfile.properties.json # ios.deploymentTarget: "16.0" — critical, see below - ├── OndeExample/ - │ ├── OndeExample.entitlements - │ └── Info.plist - └── Pods/ # gitignored -``` - ---- - -## Critical Lessons (Read First) - -### 1. Metro `@babel/runtime` resolution with `file:..` local packages - -**Symptom:** `Unable to resolve module @babel/runtime/helpers/asyncToGenerator` -from inside the SDK's `build/index.js`. - -**Root cause:** When Metro processes a file physically located outside the -example app's directory (e.g. in `sdk/react-native/build/`), Babel transforms -`async/await` and injects `require("@babel/runtime/...")`. Metro resolves -modules relative to the file's physical location — but the SDK's own -`node_modules/` is blocked (to avoid react-native version conflicts) and the -example's `node_modules/` is not in the resolution path for files outside the -example root. - -**Fix — always required in `metro.config.js`:** - -```js -// Falls back to the example's node_modules for any file in watched folders -config.resolver.nodeModulesPaths = [path.resolve(__dirname, "node_modules")]; -``` - -Also required: block the SDK's node_modules to avoid react-native version -conflicts: - -```js -config.resolver.blockList = [ - new RegExp( - path.resolve(sdkRoot, "node_modules").replace(/\\/g, "\\\\") + "/.*", - ), -]; -``` - ---- - -### 2. Expo autolinking silently skips pods when deployment target is too low - -**Symptom:** `pod install` completes but `OndeInference` is not in -`Podfile.lock`. The app launches but throws "Cannot find native module -'OndeInference'" at runtime. - -**Root cause:** Expo's autolinking Ruby code calls `pod.supports_platform?` -on each discovered pod. The `OndeInference` podspec declares -`s.platforms = { :ios => '16.0' }`. If the project's deployment target -(from `Podfile.properties.json`) is lower (e.g. the Expo default of 15.1), -`supports_platform?` returns false and the pod is **silently skipped** with no -error or warning. - -**Diagnosis:** - -```bash -xcrun simctl get_app_container booted data -grep "OndeInference" ios/Podfile.lock # if empty → autolinking skipped it -``` - -**Fix — three places must all agree on iOS 16.0:** - -1. `sdk/react-native/ios/OndeInference.podspec` — `s.platforms = { :ios => '16.0' }` -2. `example/ios/Podfile.properties.json` — `"ios.deploymentTarget": "16.0"` -3. `example/app.json` → `expo.ios.deploymentTarget: "16.0"` - -And after changing `Podfile.properties.json`, manually patch the Xcode project: - -```bash -sed -i '' 's/IPHONEOS_DEPLOYMENT_TARGET = 15.1/IPHONEOS_DEPLOYMENT_TARGET = 16.0/g' \ - ios/OndeExample.xcodeproj/project.pbxproj -``` - -Then run `pod install` again and verify: - -```bash -grep "OndeInference" ios/Podfile.lock # must now appear -``` - -**Important:** `expo prebuild --clean` regenerates `Podfile.properties.json` -from `app.json`. After `--clean`, you must also manually re-add -`"ios.deploymentTarget": "16.0"` to `Podfile.properties.json` if it was not -picked up from `app.json`. - ---- - -### 3. iOS device vs simulator static library — use an XCFramework - -**Symptom:** Build succeeds for simulator but fails with: -`ld: building for 'iOS-simulator', but linking in object file built for 'iOS'` - -**Root cause:** Both iOS device (`aarch64-apple-ios`) and iOS simulator -(`aarch64-apple-ios-sim`) are arm64, so `lipo` cannot merge them into a fat -binary. They have different Mach-O platform metadata. - -**Wrong approach:** Single `libonde_react_native.a` → works for device OR -simulator but not both. - -**Wrong approach:** `vendored_libraries` with two files sharing the same -filename → CocoaPods reports "conflicting names" error. - -**Correct approach:** XCFramework bundling both slices: - -```bash -# In scripts/build-rust.sh -mkdir -p ios/rust/device ios/rust/simulator -cp target/aarch64-apple-ios/release/libonde_react_native.a ios/rust/device/ -cp target/aarch64-apple-ios-sim/release/libonde_react_native.a ios/rust/simulator/ - -rm -rf ios/rust/OndeReactNative.xcframework -xcodebuild -create-xcframework \ - -library ios/rust/device/libonde_react_native.a \ - -library ios/rust/simulator/libonde_react_native.a \ - -output ios/rust/OndeReactNative.xcframework -``` - -Podspec uses `vendored_frameworks` (not `vendored_libraries`): - -```ruby -s.vendored_frameworks = 'rust/OndeReactNative.xcframework' -s.pod_target_xcconfig = { - 'DEFINES_MODULE' => 'YES', - 'SWIFT_COMPILATION_MODE' => 'wholemodule', - 'OTHER_LDFLAGS' => '-londe_react_native', -} -``` - -CocoaPods automatically selects the correct slice for device vs simulator. - ---- - -### 4. The native bridge JSON string contract — never return Swift dicts - -**Symptom:** `JSON parse error: unexpected character: o` - -**Root cause:** The TypeScript layer receives the native return value and calls -`JSON.parse(value)`. If the native Swift module returns `[String: Any]` or -`[[String: Any]]` instead of a `String`, the Expo bridge serialises it in an -opaque way that is not valid JSON (the string representation starts with -something like `{...}` or `[...]` in a non-JSON form that starts with `o` -when printed). - -**The contract:** All Expo Module methods in `OndeInferenceModule.swift` that -return complex types MUST return `String` (the raw JSON from Rust), NOT -`[String: Any]` or `[[String: Any]]`. The TypeScript layer owns all JSON -parsing. - -**Correct Swift:** - -```swift -AsyncFunction("loadDefaultModel") { - (systemPrompt: String?, samplingJson: String?) -> String in // ← String, not [String: Any] - ... - return try self.validateJsonResult(result) // returns the raw JSON string -} - -Function("defaultModelConfig") { () -> String in // ← String, not [String: Any] - let result = self.consumeRustString(onde_default_model_config()) - return try self.validateJsonResult(result) -} -``` - -**Validation helper — validate but do not deserialise:** - -```swift -private func validateJsonResult(_ json: String?) throws -> String { - guard let json = json, - let data = json.data(using: .utf8), - let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] - else { throw OndeError.invalidResponse } - if let error = dict["error"] as? String { - throw OndeError.inferenceError(error) - } - return json // ← return the original string, not the parsed dict -} - -private func validateJsonArray(_ json: String?) throws -> String { - guard let json = json, - let data = json.data(using: .utf8), - (try JSONSerialization.jsonObject(with: data) as? [[String: Any]]) != nil - else { throw OndeError.invalidResponse } - return json // ← return the original string -} -``` - ---- - -### 5. EngineStatus is lowercase from Rust — normalize in the UI layer - -**Symptom:** Model loads successfully. Alert says "Ready in X.Xs". But the -`TextInput` placeholder stays at "Load a model first" and remains non-editable. - -**Root cause:** Rust's `Display` impl emits lowercase status strings: -`"ready"`, `"unloaded"`, `"loading"`, `"generating"`, `"error"`. -The TypeScript UI uses capitalized union type values (`"Ready"`, `"Unloaded"`, -etc.). After `refreshStatus()` runs, `engineStatus` holds `"ready"` which -does not match `"Ready"` in the `isLoaded` check. - -**Fix — normalize at the point of receiving native status:** - -```typescript -const normalizeEngineStatus = useCallback((status: string): EngineStatus => { - switch (status.toLowerCase()) { - case "loading": return "Loading"; - case "ready": return "Ready"; - case "generating": return "Generating"; - case "error": return "Error"; - default: return "Unloaded"; - } -}, []); - -const refreshStatus = useCallback(async () => { - try { - const info = await OndeChatEngine.info(); - setEngineStatus(normalizeEngineStatus(info.status)); // ← normalize here - setModelName(info.modelName ?? null); - setApproxMemory(info.approxMemory ?? null); - } catch { /* best-effort */ } -}, [normalizeEngineStatus]); -``` - ---- - -### 6. iOS sandbox filesystem setup — HF_HOME must be set before model load - -**Symptom:** Model load fails immediately with "error 0" or returns an error -containing "HF_HOME is not set". - -**Root cause:** On iOS, `~/.cache` is outside the app sandbox. The Rust engine -explicitly requires `HF_HOME` to be set before any model load on sandboxed -platforms. Without it, `load_gguf_model` returns an `InferenceError::ModelBuild` -with the message: *"HF_HOME is not set — cannot initialise HF cache."* - -**Fix — call `configureApplicationFilesystem()` in `OnCreate` and before each -model load:** - -```swift -private func configureApplicationFilesystem() throws { - let fileManager = FileManager.default - - let containerDirectory: URL - if let appGroupDirectory = fileManager.containerURL( - forSecurityApplicationGroupIdentifier: ondeAppGroupIdentifier - ) { - containerDirectory = appGroupDirectory - } else { - containerDirectory = try fileManager.url( - for: .applicationSupportDirectory, - in: .userDomainMask, appropriateFor: nil, create: true - ) - } - - // Convention: /models/hub — matches Tauri and Swift SDKs exactly - let hfHomeDirectory = containerDirectory.appendingPathComponent("models", isDirectory: true) - let hfHubCacheDirectory = hfHomeDirectory.appendingPathComponent("hub", isDirectory: true) - let tempDirectory = containerDirectory.appendingPathComponent("tmp", isDirectory: true) - - try fileManager.createDirectory(at: hfHomeDirectory, withIntermediateDirectories: true) - try fileManager.createDirectory(at: hfHubCacheDirectory, withIntermediateDirectories: true) - try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true) - - setenv("HF_HOME", hfHomeDirectory.path, 1) - setenv("HF_HUB_CACHE", hfHubCacheDirectory.path, 1) - setenv("HUGGINGFACE_HUB_CACHE", hfHubCacheDirectory.path, 1) - setenv("TMPDIR", tempDirectory.path, 1) -} -``` - -Call it in `OnCreate` **and** at the start of `loadDefaultModel`/`loadModel`: - -```swift -OnCreate { - do { - try self.configureApplicationFilesystem() - self.enginePtr = onde_engine_create() - } catch { - self.enginePtr = nil - } -} - -AsyncFunction("loadDefaultModel") { ... -> String in - try self.configureApplicationFilesystem() // ← also here, idempotent - guard let ptr = self.enginePtr else { throw OndeError.engineNotInitialized } - ... -} -``` - ---- - -### 7. Shared app group cache path convention - -All Onde-powered apps across all SDKs (Tauri, Swift, React Native, Flutter) -share the same HuggingFace cache via the App Group container. The layout is: - -``` -/ -├── models/ ← HF_HOME -│ └── hub/ ← HF_HUB_CACHE (GGUF files live here) -└── tmp/ ← TMPDIR -``` - -**The subdirectory is `models/`, NOT `huggingface/`.** - -Do NOT use `OndeInference/huggingface/hub/` — this was an early mistake that -caused the app to look in a different place than the Tauri SDK, preventing -cross-app model reuse. - -Verifying the shared container is accessible on simulator: - -```bash -xcrun simctl get_app_container booted groups -# → shows group.com.ondeinference.apps path - -xcrun simctl get_app_container booted group.com.ondeinference.apps -# → resolves the exact path - -# Check whether models exist in the shared cache -find "$(xcrun simctl get_app_container booted group.com.ondeinference.apps)/models/hub" \ - -name "*.gguf" -o -name "*.part" -o -name "*.lock" | sort -``` - ---- - -### 8. Entitlements required for iOS - -The example app's `OndeExample.entitlements` must contain: - -```xml - - - - - com.apple.developer.kernel.increased-memory-limit - - com.apple.security.application-groups - - group.com.ondeinference.apps - - - -``` - -- `increased-memory-limit` — required for ~1 GB+ model loading on iOS -- `com.apple.security.application-groups` — grants access to the shared model cache - -**On physical device:** the App Group must be registered in the Apple Developer -Portal under **Identifiers → App Groups** before Xcode can provision it. - -On the iOS Simulator the entitlement is granted automatically by the simulator -runtime — no provisioning profile needed. - ---- - -### 9. FlatList swallows taps — TextInput never gets focus - -**Symptom:** After model loads, the `TextInput` placeholder is correct but -tapping it does nothing. The keyboard never appears. - -**Root cause:** `FlatList` on iOS intercepts touch events and by default -dismisses the keyboard on scroll, which prevents child inputs from receiving -focus when tapping in certain layout configurations. - -**Fix:** - -```tsx - -``` - ---- - -### 10. Do not use the iOS Simulator to validate model loading - -The iOS Simulator is unreliable for validating actual LLM model loading with -this stack: -- Metal-backed inference behaves differently in the simulator -- Large GGUF model loading (~941 MB) may hang or fail in the simulator -- Shared app group container behavior can differ - -Use the Simulator for: -- Build verification -- UI layout and interaction -- Entitlement/container path checks (`xcrun simctl get_app_container ...`) - -Use a **physical iPhone running iOS 16+** for: -- Actual model download and load -- Metal inference correctness -- Memory behavior - ---- - -### 11. Diagnosing a hung model load - -When the app stays in "Loading" state indefinitely: - -1. **Check the shared container has no stale partial downloads:** - -```bash -# Find .part and .lock files -find "$(xcrun simctl get_app_container booted group.com.ondeinference.apps)" \ - -type f \( -name "*.part" -o -name "*.lock" \) | sort - -# Remove stale partial caches -rm -rf "/models/hub/models----" -``` - -2. **Check the app group resolves:** - -```bash -xcrun simctl get_app_container booted groups -``` - -3. **Check native logs:** - -```bash -xcrun simctl spawn booted log show --style compact --last 5m \ - --predicate 'process == "OndeExample" AND ( - eventMessage CONTAINS[c] "HF_" OR - eventMessage CONTAINS[c] "models" OR - eventMessage CONTAINS[c] "onde" - )' | tail -100 -``` - -4. **Verify ` -HF_TOKEN` was baked in at Rust build time.** On iOS, -`TokenSource::CacheToken` cannot read `~/.cache/huggingface/token` because that -path is outside the sandbox. `HF_TOKEN` must be set as an environment variable -**when building the Rust static library**: - -```bash -export HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx -./scripts/build-rust.sh ios -``` - -`option_env!("HF_TOKEN")` in `src/inference/token.rs` bakes it into the binary -at compile time. Setting it after the fact has no effect. - ---- - -## Architecture - -``` -┌──────────────────────────────────────────────────────┐ -│ React Native / TypeScript │ -│ OndeChatEngine, free functions (index.ts) │ -└─────────────────────┬────────────────────────────────┘ - │ JSON strings over Expo bridge - ▼ -┌──────────────────────────────────────────────────────┐ -│ OndeInferenceModule.swift (Expo Module) │ -│ - configureApplicationFilesystem() │ -│ - validateJsonResult() / validateJsonArray() │ -│ - AsyncFunction / Function definitions │ -└─────────────────────┬────────────────────────────────┘ - │ C FFI via @_silgen_name - ▼ -┌──────────────────────────────────────────────────────┐ -│ libonde_react_native.a (Rust static lib) │ -│ C API: onde_engine_create, onde_engine_load_*, ... │ -│ Rust engine: ChatEngine, mistral.rs, Metal/CPU │ -└──────────────────────────────────────────────────────┘ -``` - -The Swift module **does not use UniFFI**. It calls the Rust C API directly via -`@_silgen_name` declarations, communicating via JSON strings over the Expo -Module bridge. - -This is different from the Swift XCFramework SDK (which uses UniFFI-generated -`onde.swift`) and the Kotlin SDK (which uses UniFFI-generated `onde.kt`). - ---- - -## Expo Module Definition Reference - -All methods in `OndeInferenceModule.swift` follow this pattern: - -| Expo construct | Use for | -|---|---| -| `Function(name)` | Synchronous calls (isLoaded, setSystemPrompt, etc.) | -| `AsyncFunction(name)` | All calls that invoke Rust (model load, inference, etc.) | -| `OnCreate` | Engine initialisation — run `configureApplicationFilesystem()` here | -| `OnDestroy` | Engine cleanup — call `onde_engine_destroy` | - -Return types: -- Simple sync functions: `Bool`, `Int` -- All complex types: `String` — raw JSON from Rust, **never** `[String: Any]` - ---- - -## TypeScript Bridge Contract - -### `OndeInferenceModule.ts` — raw native interface - -All async methods return `Promise` (raw JSON strings from Rust): - -```typescript -export interface OndeInferenceNativeModule { - // Sync - isLoaded(): boolean; - setSystemPrompt(prompt: string): void; - clearHistory(): number; - - // Async — all return JSON strings - loadDefaultModel(systemPrompt: string | null, samplingJson: string | null): Promise; - sendMessage(message: string): Promise; - info(): Promise; - history(): Promise; - - // Sync config — return JSON strings - defaultModelConfig(): string; - defaultSamplingConfig(): string; -} -``` - -### `index.ts` — public API layer - -Serialises inputs to JSON, calls native, deserialises JSON responses: - -```typescript -// Input serialisation (camelCase TS → snake_case Rust) -function serializeSampling(config: SamplingConfig): string { - return JSON.stringify({ temperature: config.temperature, top_p: config.topP, ... }); -} - -// Output deserialisation (snake_case Rust → camelCase TS) -function parseResult(json: string): T { - const parsed = JSON.parse(json); // JSON.parse — expects a string - if (parsed.error) throw new OndeError(parsed.error); - return parsed as T; -} -``` - ---- - -## Metro Config for `file:..` SDK Development - -```js -const { getDefaultConfig } = require("expo/metro-config"); -const path = require("path"); - -const sdkRoot = path.resolve(__dirname, ".."); // ../sdk/react-native/ -const config = getDefaultConfig(__dirname); - -// 1. Watch the SDK so Metro picks up changes -config.watchFolders = [sdkRoot]; - -// 2. Map the package name to the SDK root -config.resolver.extraNodeModules = { - "@ondeinference/react-native": sdkRoot, -}; - -// 3. Allow transitive deps (e.g. @babel/runtime) to resolve from -// the example's node_modules when files outside the example are processed -config.resolver.nodeModulesPaths = [path.resolve(__dirname, "node_modules")]; - -// 4. Block the SDK's own node_modules to prevent react-native version conflicts -config.resolver.blockList = [ - new RegExp( - path.resolve(sdkRoot, "node_modules").replace(/\\/g, "\\\\") + "/.*", - ), -]; - -module.exports = config; -``` - ---- - -## Rust C API Surface - -The Swift module binds to these C functions via `@_silgen_name`: - -| Function | Returns | Notes | -|---|---|---| -| `onde_engine_create` | `UnsafeMutableRawPointer?` | Allocates engine | -| `onde_engine_destroy` | `void` | Frees engine | -| `onde_engine_load_default_model` | `UnsafeMutablePointer?` | JSON result string | -| `onde_engine_load_model` | `UnsafeMutablePointer?` | JSON result string | -| `onde_engine_unload_model` | `UnsafeMutablePointer?` | JSON result string | -| `onde_engine_is_loaded` | `Bool` | Sync | -| `onde_engine_info` | `UnsafeMutablePointer?` | JSON result string | -| `onde_engine_send_message` | `UnsafeMutablePointer?` | JSON result string | -| `onde_engine_generate` | `UnsafeMutablePointer?` | JSON result string | -| `onde_engine_history` | `UnsafeMutablePointer?` | JSON array string | -| `onde_engine_clear_history` | `UInt64` | Sync, count | -| `onde_engine_set_system_prompt` | `void` | Sync | -| `onde_engine_set_sampling` | `void` | Sync | -| `onde_engine_push_history` | `void` | Sync | -| `onde_free_string` | `void` | Must call for every non-null CChar* result | -| `onde_default_model_config` | `UnsafeMutablePointer?` | JSON result string | -| `onde_qwen25_1_5b_config` | `UnsafeMutablePointer?` | JSON result string | -| `onde_qwen25_3b_config` | `UnsafeMutablePointer?` | JSON result string | -| `onde_default_sampling_config` | `UnsafeMutablePointer?` | JSON result string | -| `onde_deterministic_sampling_config` | `UnsafeMutablePointer?` | JSON result string | -| `onde_mobile_sampling_config` | `UnsafeMutablePointer?` | JSON result string | - -**Always call `onde_free_string` after consuming each result pointer.** The -Rust side allocates the string; Swift is responsible for freeing it. - -```swift -private func consumeRustString(_ ptr: UnsafeMutablePointer?) -> String? { - guard let ptr = ptr else { return nil } - let str = String(cString: ptr) - onde_free_string(ptr) // ← must free every non-null pointer - return str -} -``` - ---- - -## build-rust.sh — iOS Build Flow - -```bash -# 1. Build both targets -cargo build --manifest-path rust/Cargo.toml --target aarch64-apple-ios --release -cargo build --manifest-path rust/Cargo.toml --target aarch64-apple-ios-sim --release - -# 2. Copy to separate dirs (same filename — cannot lipo) -mkdir -p ios/rust/device ios/rust/simulator -cp target/aarch64-apple-ios/release/libonde_react_native.a ios/rust/device/ -cp target/aarch64-apple-ios-sim/release/libonde_react_native.a ios/rust/simulator/ - -# 3. Create XCFramework (CocoaPods picks right slice automatically) -rm -rf ios/rust/OndeReactNative.xcframework -xcodebuild -create-xcframework \ - -library ios/rust/device/libonde_react_native.a \ - -library ios/rust/simulator/libonde_react_native.a \ - -output ios/rust/OndeReactNative.xcframework -``` - -After rebuilding, run `pod install` in `example/ios/` and then -`npx expo run:ios --device` or `npx expo run:ios` (simulator). - ---- - -## Running on Physical Device - -```bash -# From example/ -npx expo run:ios --device -``` - -For a physical device build you need: -- Apple Developer team set in `app.json` → `expo.ios.appleTeamId` -- The App Group `group.com.ondeinference.apps` registered in the developer portal -- The App Group capability enabled on the App ID -- `com.apple.developer.kernel.increased-memory-limit` in entitlements - -**Blank white screen on device** = Metro bundle not loading. Ensure: -1. Metro is running (`npx expo start --clear`) -2. Phone and Mac are on the same network (or use Tunnel) -3. Rebuild after native changes (`npx expo run:ios --device` — not just `expo start`) - ---- - -## .gitignore for the SDK - -```gitignore -node_modules/ -build/ # tsc output -rust/target/ # Rust build artifacts -ios/build/ -ios/rust/*.a -ios/rust/*.xcframework -ios/rust/device/ -ios/rust/simulator/ -android/build/ -android/src/main/jniLibs/ -example/.expo/ -example/android/ -example/ios/build/ -example/ios/Pods/ -example/ios/.xcode.env.local -.DS_Store -*.xcuserstate -``` - ---- - -## npm Publishing - -```bash -cd sdk/react-native -npm run build # tsc → build/ -npm publish # publishes build/, ios/, android/, scripts/ -``` - -The `.npmignore` excludes: -- `example/` -- `rust/` -- `node_modules/` -- `src/` (consumers use the compiled `build/`) - -The `package.json` `files` field explicitly includes: -`build/`, `ios/`, `android/`, `scripts/`, `expo-module.config.json` - ---- - -## Common Pitfalls Table - -| Pitfall | Symptom | Fix | -|---|---|---| -| Missing `nodeModulesPaths` in metro.config.js | `Cannot resolve @babel/runtime/helpers/asyncToGenerator` | Add `config.resolver.nodeModulesPaths = [path.resolve(__dirname, "node_modules")]` | -| Deployment target mismatch | Pod installs but `OndeInference` missing from `Podfile.lock` | Set `"ios.deploymentTarget": "16.0"` in `Podfile.properties.json` AND `app.json` | -| Single `.a` file for device + simulator | Linker error: "building for iOS-simulator but linked for iOS" | Use `xcodebuild -create-xcframework` to bundle both slices | -| Two `.a` files with same name in `vendored_libraries` | CocoaPods "conflicting names" error | Use `vendored_frameworks` with an XCFramework instead | -| Swift returning `[String: Any]` from Expo Module | `JSON parse error: unexpected character: o` | Return `String` from all Expo Module functions; validate but do not deserialise | -| `HF_HOME` not set on iOS | "error 0" or model load fails immediately | Call `configureApplicationFilesystem()` in `OnCreate` and before model load | -| Wrong cache path (`OndeInference/huggingface/hub/`) | Models not shared across Onde apps | Use `/models/hub/` to match Tauri/Swift convention | -| `EngineStatus` lowercase from Rust | Input stays disabled after model loads; status shows as `"ready"` not `"Ready"` | Normalize in `refreshStatus()` with `switch(status.toLowerCase())` | -| `FlatList` swallows taps | TextInput can't be focused | Add `keyboardShouldPersistTaps="handled"` to `FlatList` | -| Model hangs on Simulator | Stuck in loading indefinitely | Test model loading on a physical device; Simulator is unreliable for this | -| Stale `.part`/`.lock` files | Model download never completes on retry | Delete `models----` from the shared container and retry | -| `HF_TOKEN` not baked in | Auth fails on HuggingFace download; "401 Unauthorized" in Rust logs | Set `HF_TOKEN` env var **before** building the Rust static library | -| `expo prebuild --clean` wipes `Podfile.properties.json` | Deployment target reverts to 15.1; autolinking drops OndeInference | Re-add `"ios.deploymentTarget": "16.0"` to `Podfile.properties.json` after every `--clean` | -| Physical device blank white screen | App launches, nothing renders | Ensure Metro is running and on same network; use `npx expo run:ios --device` | -| Running with `expo start` instead of `expo run:ios` | "Cannot find native module 'OndeInference'" | Custom native modules require a native build — use `expo run:ios` not Expo Go | \ No newline at end of file From bc5d33e9affab6d68aba0349ac755960d438bb92 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:21:02 +0200 Subject: [PATCH 22/28] chore: add .claude/skills symlink to .agents/skills Co-Authored-By: siGit Code v1.6.6-acp --- .claude/skills | 1 + 1 file changed, 1 insertion(+) create mode 120000 .claude/skills diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file From a3055cfd05a85b6cdcbffe0f3bed280c21b9196b Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:23:30 +0200 Subject: [PATCH 23/28] chore: note development as base branch in AGENTS.md Co-Authored-By: siGit Code v1.6.6-acp --- .agents/AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 26ddf56..63b7aa4 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -92,6 +92,11 @@ product. Do not hard-code env vars; always go through `LlmEnv`. toolchain; CI handles that — local `cargo check` is sufficient to validate Rust correctness. +### Branches +- Base branch is **`development`**. Open all PRs against `development`, not `main`. +- Feature branches: `feature/`. +- Stacked PRs are fine; always target the parent feature branch, not `development`, when stacking. + ### Commit messages - Scope to the affected crate(s): `ed-agent: …`, `ed-acp: …`, `ed-mcp: …`. - Keep the subject line under 72 chars. From 5344369963b62f567f0c6604c59af6e28e154c32 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:41:56 +0200 Subject: [PATCH 24/28] chore: trim skills to rust, legal, sdk-swift-package, uniffi-rs Co-Authored-By: siGit Code v1.6.6-acp --- .agents/skills/forked-dependencies/SKILL.md | 231 ------- .agents/skills/sdk-branding/SKILL.md | 355 ----------- .agents/skills/sdk-regressions/SKILL.md | 632 ------------------ .agents/skills/sdk-releases/SKILL.md | 670 -------------------- 4 files changed, 1888 deletions(-) delete mode 100644 .agents/skills/forked-dependencies/SKILL.md delete mode 100644 .agents/skills/sdk-branding/SKILL.md delete mode 100644 .agents/skills/sdk-regressions/SKILL.md delete mode 100644 .agents/skills/sdk-releases/SKILL.md diff --git a/.agents/skills/forked-dependencies/SKILL.md b/.agents/skills/forked-dependencies/SKILL.md deleted file mode 100644 index 761c9e7..0000000 --- a/.agents/skills/forked-dependencies/SKILL.md +++ /dev/null @@ -1,231 +0,0 @@ ---- -name: forked-dependencies -description: The complete inventory of forked crates and [patch.crates-io] overrides that the onde stack depends on — mistral.rs, candle, sysctl, mio, tqdm, core2 — including the retired ones. Explains what each fork is for, where it is consumed, how the git-vs-published two-tier model works, and how to update or add one. Apply whenever a build fails on a missing/incompatible crate for an Apple or Android target, when bumping the mistral.rs or candle fork, or when adding a new patched dependency. -allowed-tools: Read, Write, Edit, Glob, Grep, Bash -user-invocable: true ---- - -# Skill: Forked Dependencies - -The onde stack pulls several crates from forks instead of crates.io. They exist -for three reasons: **platform support** the upstream crate lacks (watchOS, -visionOS), **unreleased APIs** a dependency needs before upstream cuts a release -(candle), and **registry constraints** (`cargo publish` rejects git deps, so the -mistral.rs fork is republished under `onde-*` names). - -This skill is the single source of truth for every fork and patch. When a build -breaks on a missing symbol or unsupported target, or you are bumping a fork, -start here. - ---- - -## The two-tier consumption model (read this first) - -There are two distinct ways the same dependency tree gets built, and they -resolve forks differently. Almost every confusing bug in this area comes from -mixing them up. - -1. **Building the mistral.rs fork from source** (its own CI, or `onde` local dev - with the git override uncommented). Here the fork's `Cargo.toml` git deps and - `[patch.crates-io]` entries apply directly. - -2. **Publishing to crates.io** (`cargo publish` of the `onde-mistralrs-*` - crates). **`cargo publish` strips every `git =` field and the entire - `[patch.crates-io]` table.** Published crates fall back to the `version = "..."` - registry requirement on each dep. So a git-only dep is invisible to anyone - installing from crates.io. - -3. **onde consuming the published crates.** onde depends on the registry - `onde-mistralrs-*` crates, so it gets the *registry-resolved* tree — the - stripped-down one from step 2, not the fork's git tree. onde then re-applies - whatever patches it still needs in **its own** `[patch.crates-io]`. Those - apply to onde's from-source CI builds (Swift XCFramework, Android/Kotlin, - the `cargo check` matrix) but are themselves stripped when onde publishes to - crates.io. - -**Consequence that used to bite (now fixed — kept as the canonical example):** -originally the `tqdm` fork bumped crossterm to 0.29 (which pulls mio 1.x, which -supports visionOS) but was a *git* dep of mistral.rs, so it was stripped on -publish. onde therefore resolved crates.io `tqdm 0.8.0` → `crossterm 0.25` → -`mio 0.8.11`, which does *not* support visionOS — so onde needed its own `mio` -visionOS patch even though the tqdm fork "already fixed" crossterm upstream of -it. - -The fix that removed the whole problem was to make the crossterm-0.29 tqdm a -**registry** dep of the mistral.rs fork rather than a git one, since a registry -dep survives publish: onde's resolved tree then gets `crossterm 0.29` → `mio -1.x` and the `mio 0.8.11` line disappears entirely. That shipped first as the -stopgap fork crate `onde-tqdm` (in `onde-mistralrs-*` 0.9.3), which let onde -delete its `[patch.crates-io] mio` entry and retire the `mio` fork. - -Upstream has since merged the crossterm bump (mrlazy1708/tqdm#28) and released -it as **`tqdm 0.8.1`**, so the fork crate is no longer needed: the mistral.rs -fork depends on plain `tqdm = "0.8.1"` and **`onde-tqdm` is retired**. See the -tqdm and mio entries. - ---- - -## Inventory - -| Crate | Fork / source | Ref | Purpose | Consumed where | -|-------|---------------|-----|---------|----------------| -| mistral.rs (12 crates) | `setoelkahfi/mistral.rs` | branch `fix/all-platform-fixes` | All-platform fixes; **published** as `onde-mistralrs-*` | onde deps (registry) | -| candle-core, candle-nn | `setoelkahfi/candle` | branch `onde/candle-0.11.0-27f20fea` (rev `27f20fea`) | Post-0.11.0 candle APIs; **published** as `onde-candle-{core,nn}` | mistral.rs fork deps (registry) | -| tqdm | *none — upstream* | crates.io `tqdm 0.8.1` | Crossterm 0.29 (→ mio 1.x) is upstream as of 0.8.1 | mistral.rs fork dep (registry) | -| sysctl | `setoelkahfi/sysctl-rs` | branch `feature/watchos` | watchOS (`target_os = "watchos"`) support | `[patch.crates-io]` in onde **and** mistral.rs fork | -| mio | `setoelkahfi/mio` | branch `feature/visionos` | visionOS support for the `mio 0.8.11` line | **RETIRED** — patch removed from onde once it consumed onde-mistralrs 0.9.3 (crossterm 0.29 → mio 1.x). Fork kept for history/upstream PR. | - -> **Retired:** `onde-tqdm` (`setoelkahfi/tqdm`, branch `release/onde-tqdm`, -> published 0.8.1/0.8.2) — the stopgap republish of the crossterm-0.29 tqdm -> fork. Upstream merged the bump and released `tqdm 0.8.1`, so the mistral.rs -> fork now depends on upstream directly. See the tqdm entry. - -> **Retired:** `core2` (`bbqsrc/core2` rev `545e84bc`) — was a `[patch.crates-io]` -> workaround for the yanked `core2 0.4.0`; removed from the mistral.rs fork after -> `bitstream-io` 4.x dropped `core2`. No longer present. - ---- - -## Per-fork detail - -### mistral.rs → `onde-mistralrs-*` - -- **Fork:** `setoelkahfi/mistral.rs`, branch `fix/all-platform-fixes`. -- **Why:** carries fixes upstream hasn't merged (iOS 32-bit memory limits, - watchOS/visionOS Metal compile, Android 32-bit const overflows, HF cache - seeding, metallib link flags, and more). -- **Published as:** `onde-mistralrs`, `-core`, `-quant`, `-paged-attn`, - `-flash-attn`, `-metal-compile`, `-code-exec`, `-sandbox`, `-macros`, - `-vision`, `-audio`, `-mcp`. The rename is a `package = "onde-..."` override on - each workspace dep entry, so Rust source still `use`s `mistralrs_*` unchanged. -- **Publishing + bump flow:** see [[sdk-regressions]] (the 12-crate publish - order, the candle Step 0, and the tqdm/core2 publish-verification snags). - -### candle → `onde-candle-{core,nn}` - -- **Fork:** `setoelkahfi/candle`, branch `onde/candle--` (currently - `onde/candle-0.11.0-27f20fea`). -- **Why:** mistral.rs pins a candle git rev past the latest release for APIs no - published candle has (`QTensor::indexed_gemv`, `gemv_fused_shared_lhs`, - `BarrierPool::execute_chunked`). Publishing the `onde-mistralrs-*` crates - fails verification (E0080/E0599) if their candle dep resolves to the released - crate, so the pinned rev is republished under `onde-candle-*`. -- **Only the diverged crates are renamed.** At the 0.11.0 rev only `candle-core` - and `candle-nn` differ from the release; `candle-metal-kernels` and - `candle-flash-attn-v3` are byte-identical, so the mistral.rs fork points those - at the official `0.11.0` crates. `candle-nn` must be republished whenever - `candle-core` is (even if unchanged) or the official `candle-nn` drags the - official `candle-core` back into the graph → two `candle-core` crates → type - mismatch. - -### sysctl → watchOS - -- **Fork:** `setoelkahfi/sysctl-rs`, branch `feature/watchos`. Upstream PR: - johalun/sysctl-rs#74. -- **Why:** upstream sysctl has no `target_os = "watchos"` support. -- **Applied in:** `[patch.crates-io]` in **both** onde and the mistral.rs fork - (each workspace root that builds for watchOS needs its own patch entry). - -### mio → visionOS (RETIRED) - -- **Status:** the onde `[patch.crates-io] mio` entry has been **removed**. onde - now consumes `onde-mistralrs 0.9.3`, whose `onde-tqdm` dep pulls `crossterm - 0.29 → mio 1.x`, so `mio 0.8.11` is gone from onde's tree. The fork below is - kept only for history and a possible upstream PR; it is no longer wired into - any build. -- **Fork:** `setoelkahfi/mio`, branch `feature/visionos` (commit `683fca8`). - Based on `mio 0.8.11`. This is the **official GitHub fork** of tokio-rs/mio - (in tokio's fork network, so it can open upstream PRs). An earlier standalone - clone that carried this branch was renamed to `setoelkahfi/mio-patch` and - deleted; the branch now lives on the real fork. -- **Why:** `mio 0.8.11` hits `compile_error!("unsupported target for - mio::unix::pipe")` on visionOS, and its Apple cfg gates (kqueue `Filter`/ - `Flags` type aliases, `net.rs` `sin_len`/`sin6_len`, `tcp.rs` accept path, - `uds/listener.rs` `accept4`/`SOCK_NONBLOCK`) omit visionos, breaking the Swift - XCFramework build and the visionOS CI checks. The fork adds - `target_os = "visionos"` to every cfg gate that already lists `tvos`/`watchos`. - tokio's `mio 1.x` already supports visionOS, so **only the 0.8.x line** needs it. -- **Where 0.8.11 came from:** `tqdm → crossterm 0.25 → mio 0.8.11`, and - `signal-hook-mio → crossterm`. Not from tokio (that uses `mio 1.2.2`). -- **Applied in:** *(historical)* was `[patch.crates-io]` in onde. The fork's - version (`0.8.11`) stayed semver-compatible with the `^0.8` requirement it - replaced; it did not touch tokio's `^1`. -- **How it was retired (the real fix):** the `onde-tqdm` publish (below) removed - the `mio 0.8.11` line from onde's registry-resolved tree entirely. Verified: - with onde consuming the onde-tqdm-carrying `onde-mistralrs 0.9.3` crates, onde's - lockfile resolves `mio 1.2.2` only — no 0.8.11 — and both visionOS targets - (`aarch64-apple-visionos{,-sim}`) `cargo +nightly check -Z build-std` pass with - the `mio` patch deleted. - -### tqdm → upstream (`onde-tqdm` RETIRED) - -- **Status:** no fork. `setoelkahfi/tqdm` branch `deps/bump-crossterm` was - merged upstream as mrlazy1708/tqdm#28 and released as **`tqdm 0.8.1`**, which - carries the crossterm 0.29 bump (→ mio 1.x) that the whole `mio 0.8.11` - visionOS problem hinged on. -- **Consumed in:** the mistral.rs fork's `[workspace.dependencies]` as plain - `tqdm = "0.8.1"`. It must stay a **registry** dep — `cargo publish` strips git - deps, so a git tqdm would never reach onde. -- **The `onde-tqdm` stopgap (retired):** branch `release/onde-tqdm` set - `package = "onde-tqdm"` (lib name stayed `tqdm`, so `use tqdm::…` and doctests - were unchanged) and was published as `onde-tqdm` 0.8.1/0.8.2 — the same rename - trick as `onde-mistralrs-*` and `onde-candle-*`, needed only because upstream - hadn't released the bump yet. Its source was byte-identical to upstream - `tqdm 0.8.1` apart from the package metadata, so the swap back is a drop-in. -- **Which release drops it:** `onde-mistralrs-core` 0.9.2 used plain - `tqdm ^0.8.0` (crossterm 0.25), 0.9.3 used `onde-tqdm 0.8.2`, and the next - publish (0.9.4) goes back to upstream `tqdm 0.8.1`. The published `onde-tqdm` - versions stay up so 0.9.3 keeps resolving; don't yank them. - -### core2 → removed - -- **Was:** `bbqsrc/core2` rev `545e84bc`, a `[patch.crates-io]` workaround for - the yanked `core2 0.4.0` (pulled via `bitstream-io → rav1e → ravif → image`). -- **Status:** **deleted** from the mistral.rs fork. `bitstream-io` 4.x moved off - `core2` (to `no_std_io2`), so nothing pulls it. Removed in the same commit that - wired `onde-tqdm` and bumped to 0.9.3. - ---- - -## Operational gotchas (hit these during the 1.2.1 release) - -- **A patched fork must be pushed to GitHub before the patch works in CI.** The - `mio` fork existed only locally; the patch URL 404'd until it was pushed to - `setoelkahfi/mio`. Verify with `git ls-remote ` before relying - on a patch. -- **Shallow clones cannot be pushed** (`remote unpack failed: index-pack - failed`). If a fork was cloned shallow, `git fetch --unshallow ` - first, then push. -- **Local fork fetches need `CARGO_NET_GIT_FETCH_WITH_CLI=true`.** This machine - rewrites GitHub URLs to SSH aliases; cargo's built-in fetcher can't auth, so - export that var (or run the `cargo update` that pulls the fork with it). CI - fetches public https forks fine without it — same as the long-standing sysctl - patch. -- **`[patch.crates-io]` version must be semver-compatible** with the requirement - it replaces. A `0.8.11` fork replaces `^0.8`, not a `^1` requirement of the - same crate — which is how the `mio` patch touches only the crossterm line and - leaves tokio's `mio 1.x` alone. -- **Forks are public** (mio, sysctl-rs, tqdm are MIT/Apache): keep the upstream - `LICENSE` intact. See [[legal-and-trademarks]] for attribution obligations - (candle and mistral.rs especially). - ---- - -## Adding a new patch - -1. Fork upstream, add the fix on a descriptive branch, **push it public** to - `setoelkahfi/`, keep the `LICENSE`. -2. Add to `[patch.crates-io]` in every workspace root that builds the affected - target (onde, and the mistral.rs fork if it builds there too). Match the - fork's crate version to the requirement being replaced. -3. `CARGO_NET_GIT_FETCH_WITH_CLI=true cargo update -p @` to move the - lockfile onto the git source; verify `source = "git+..."` in `Cargo.lock`. -4. If it fixes a mistral.rs-fork build (not just onde), remember it is stripped - on publish — onde will still need its own copy of the patch unless the fix - also lands in a published `onde-*` crate. -5. `cargo check` on host, then let CI validate the target that was failing. - ---- - -*Update this skill whenever a fork's branch/rev changes, a patch is added or -removed, or a fork gets published under an `onde-*` name.* diff --git a/.agents/skills/sdk-branding/SKILL.md b/.agents/skills/sdk-branding/SKILL.md deleted file mode 100644 index 472fe56..0000000 --- a/.agents/skills/sdk-branding/SKILL.md +++ /dev/null @@ -1,355 +0,0 @@ ---- -name: sdk-branding -description: Onde Inference SDK branding rules. Covers copyright headers for every language, brand identity (logo, colors, badges), README structure, and file classification (generated vs hand-written). Apply when creating or editing any SDK source file, README, or documentation. -allowed-tools: Read, Write, Edit, Glob, Grep -user-invocable: false ---- - -# SDK Branding - -Branding rules for the Onde Inference SDK. These apply across all languages and all SDK surfaces: source files, READMEs, package metadata, and CI output. - ---- - -## Copyright Header - -Every hand-written source file starts with this two-line block. No blank line before it. One blank line after it (before the first import, `package`, or code). - - -### Format - -``` -// Copyright 2026 Splitfire AB (Onde Inference). All rights reserved. -// SPDX-License-Identifier: MIT OR Apache-2.0 - -``` - -Use `//` comment syntax for all languages that support it: Dart, Swift, Kotlin, Rust, TypeScript, JavaScript, C, and C++. - -### Language reference - -**Dart** -```dart -// Copyright 2026 Splitfire AB (Onde Inference). All rights reserved. -// SPDX-License-Identifier: MIT OR Apache-2.0 - -import 'dart:async'; -``` - -**Swift** -```swift -// Copyright 2026 Splitfire AB (Onde Inference). All rights reserved. -// SPDX-License-Identifier: MIT OR Apache-2.0 - -import Foundation -``` - -**Kotlin** -```kotlin -// Copyright 2026 Splitfire AB (Onde Inference). All rights reserved. -// SPDX-License-Identifier: MIT OR Apache-2.0 - -package com.ondeinference -``` - -**Rust** -```rust -// Copyright 2026 Splitfire AB (Onde Inference). All rights reserved. -// SPDX-License-Identifier: MIT OR Apache-2.0 - -use std::sync::Arc; -``` - -**TypeScript / JavaScript** -```ts -// Copyright 2026 Splitfire AB (Onde Inference). All rights reserved. -// SPDX-License-Identifier: MIT OR Apache-2.0 - -import OndeInferenceModule from "./OndeInferenceModule"; -``` - -**C / C++** -```cpp -// Copyright 2026 Splitfire AB (Onde Inference). All rights reserved. -// SPDX-License-Identifier: MIT OR Apache-2.0 - -#include -``` - -### Existing file-level comments - -If the file already has a descriptive comment block below the copyright position (e.g. `// OndeInferencePlugin.swift\n// Native iOS plugin...`), prepend the copyright header above it — do not replace it. - -```swift -// Copyright 2026 Splitfire AB (Onde Inference). All rights reserved. -// SPDX-License-Identifier: MIT OR Apache-2.0 - -// OndeInferencePlugin.swift -// -// Native iOS plugin for onde_inference. -// -``` - -When updating hand-written SDK bridge code, keep the header style consistent -across the React Native bridge and the Kotlin/Rust bridge. If one side uses an -older MIT-only prose header, normalize the touched bridge files to the two-line -SPDX header above. - ---- - -## File Classification - -Apply the header only to hand-written files. Never add it to generated files. - -### Always add the header - -| File | Notes | -|---|---| -| `lib/src/engine.dart` | Core SDK logic | -| `lib/src/types.dart` | SDK types | -| `lib/onde_inference.dart` | Public barrel file | -| `test/dart_test.dart` | Hand-written unit tests | -| `example/lib/main.dart` | Example app entry point | -| `ios/Classes/OndeInferencePlugin.swift` | iOS plugin | -| `macos/Classes/OndeInferencePlugin.swift` | macOS plugin | -| `android/src/main/kotlin/**/*.kt` | Android plugin | -| `src/**/*.ts` / `src/**/*.js` | React Native TypeScript / JavaScript source | -| `windows/**/*.cpp` | Windows plugin | -| `linux/**/*.cc` / `linux/**/*.h` | Linux plugin | -| `src/**/*.rs` | Rust crate source | - -### Never add the header — generated files - -These files are written by a tool on every codegen run. Adding a header would be overwritten or cause diffs noise. - -| File / Pattern | Generator | -|---|---| -| `lib/src/frb_generated.dart/**` | `flutter_rust_bridge_codegen` | -| `rust/src/frb_generated.rs` | `flutter_rust_bridge_codegen` | -| `**/*.freezed.dart` | `build_runner` + `freezed` | -| `**/*.g.dart` | `build_runner` | -| `example/*/Flutter/GeneratedPluginRegistrant.*` | `flutter pub get` | -| `generated/**` (repo root) | `uniffi-bindgen` | -| `onde.swift` in `onde-swift` | `uniffi-bindgen` | - -### Never add the header — platform scaffold - -These files are produced by `flutter create` or Xcode project templates. They are committed to git but were not hand-written by the team. - -| Pattern | Tool | -|---|---| -| `example/ios/Runner/AppDelegate.swift` | `flutter create` | -| `example/ios/Runner/SceneDelegate.swift` | `flutter create` | -| `example/ios/RunnerTests/RunnerTests.swift` | `flutter create` | -| `example/macos/Runner/AppDelegate.swift` | `flutter create` | -| `example/macos/Runner/MainFlutterWindow.swift` | `flutter create` | -| `example/macos/RunnerTests/RunnerTests.swift` | `flutter create` | -| `example/test/widget_test.dart` | `flutter create` | - -**Rule of thumb:** if the first meaningful line of a file is a Flutter or Xcode framework import with no project-specific logic, it is scaffold — skip it. - ---- - -## Brand Identity - -### Logo - -Always reference the logo via the raw GitHub URL so it renders on pub.dev, crates.io, and any mirror. - -``` -https://raw.githubusercontent.com/ondeinference/onde/main/assets/onde-inference-logo.svg -``` - -Width: `96` for top-level READMEs. Use `72` for secondary pages (example apps, sub-package docs). - -### Colors - -| Role | Hex | -|---|---| -| Brand green (foreground, badge color) | `#235843` | -| Dark background (badge `labelColor`) | `#17211D` | - -### Badge row - -Badge policy depends on the README type. - -**Root README (`onde/README.md`)** - -Use the full ecosystem badge wall here. This is the hub page for the whole SDK family. - -```html -crates.io -Maven Central -Swift Package Index -pub.dev -npm -Website -App Store -``` - -**SDK README** - -Use one primary registry badge for that SDK, plus `Website` and `License`. Do not repeat the full ecosystem badge wall on every SDK page. - -**Flutter / Dart SDK:** -```html -pub.dev -Website -License -``` - -**Rust SDK:** -```html -crates.io -Website -License -``` - -**Kotlin Multiplatform SDK:** -```html -Maven Central -Website -License -``` - -**React Native SDK:** -```html -npm -Website -License -``` - -**Swift SDK:** -```html -Swift Package Index -Website -License -``` - -### Cross-SDK nav row - -Every SDK README should include a centered text-link row under the badges. These sibling links matter for discovery, branding, and cross-SDK navigation. - -Use this pattern and adjust the current page's own SDK label if needed: - -```html -

- Rust SDK · Swift SDK · Kotlin Multiplatform SDK · Flutter SDK · React Native SDK · Website -

-``` - -### Footer - -Every README ends with this line, inside a centered `

`: - -```html -

- © 2026 Onde Inference — MIT License -

-``` - -Use `— MIT License` on sub-pages (example apps, package-level docs). Omit it on the top-level repo README (it has its own license section). - ---- - -## README Structure - -### Top-level SDK README (e.g. `sdk/dart/README.md`) - -1. Centered logo block -2. Centered `

` with SDK name -3. Centered tagline (`

...

`) -4. Centered badge row with the SDK's primary registry badge, `Website`, and `License` -5. Centered cross-SDK nav links (`Rust SDK · Swift SDK · Kotlin Multiplatform SDK · Flutter SDK · React Native SDK · Website`) -6. `---` divider -7. A direct intro paragraph that explains what runs where and what the user gets -8. `## Platform support` — table: Platform | GPU backend | Default model | Notes -9. `## Quick start` — install snippet, initialization, then usage sections -10. `## Model selection` — table + code examples when relevant -11. `## Sampling` — presets table + code when relevant -12. `## Error handling` when relevant -13. `## Contributing` -14. `## License` -15. Footer - -### Example app README - -1. Centered logo block (width `96`) -2. Centered `

` with `Example App` in the title -3. Centered one-line description (bold) + second line: `No server. No API key. No data leaving the device.` -4. Centered badge row for the parent SDK or example context -5. `---` divider -6. `## What this example shows` — feature table mapping feature → file/function -7. `## Running the example` — Prerequisites, Steps, Platform notes table -8. `## SDK quick reference` — minimal happy-path snippet (load → stream → unload) -9. `## Project structure` — directory tree -10. `## Learn more` — links to the package page, ondeinference.com, and GitHub -11. Footer - -### Taglines - -Use these as the default direction, but keep them natural if the page needs slightly different wording. Avoid stiff marketing language and avoid em dashes in prose. - -| Context | Tagline | -|---|---| -| Rust crate | `Run LLMs on-device, with first-class support for Apple silicon.` | -| Flutter SDK | `Run LLMs on-device from Flutter and Dart. Metal on iOS and macOS, CPU everywhere else.` | -| Kotlin SDK | `Run LLMs on-device from Kotlin. No cloud, no API key, and no user data leaving the device.` | -| React Native SDK | `Run LLMs on-device from React Native. Metal on iOS, CPU on Android, and no user data leaving the device.` | -| Swift SDK | `Run LLMs on-device from Swift, with first-class support for Apple silicon.` | -| Example app | `A Flutter chat app that runs LLM inference fully on-device.` | - ---- - -## Package Metadata Branding - -### `pubspec.yaml` - -```yaml -description: >- - On-device LLM inference for Flutter & Dart. Run Qwen 2.5 models locally - with Metal on iOS and macOS, CPU on Android and desktop. No cloud, no API key. -repository: https://github.com/ondeinference/onde/ -homepage: https://ondeinference.com -issue_tracker: https://github.com/ondeinference/onde/issues -``` - -### `Cargo.toml` - -```toml -description = "On-device inference engine for Apple silicon." -license = "MIT" -repository = "https://github.com/ondeinference/onde" -homepage = "https://ondeinference.com" -documentation = "https://docs.rs/onde" -keywords = ["inference", "on-device", "chat", "llm", "mistral"] -categories = ["science::ml", "api-bindings"] -``` - ---- - -## Version Synchronisation - -The version in `Cargo.toml` and `sdk/dart/pubspec.yaml` must always be identical. When bumping a version, update both files in the same commit. The CI workflows for Swift and Dart both validate that the git tag equals the version in their respective metadata file — a mismatch fails fast. - -``` -Cargo.toml version = "X.Y.Z" -sdk/dart/pubspec.yaml version: X.Y.Z -git tag X.Y.Z -``` - ---- - -## Applying the Copyright Header to an Existing Codebase - -When asked to add copyright headers to a set of files: - -1. Run `find` or `Glob` to list all `.dart`, `.swift`, `.kt`, `.rs`, `.cpp`, `.cc`, `.h` files under the SDK path. -2. For each file, read its first line. -3. Skip if the first line already starts with `// Copyright`. -4. Skip if the first line contains `Generated` or `Do not edit`. -5. Skip if the file path matches any generated or scaffold pattern above. -6. Prepend the two-line header followed by a blank line. -7. If the file begins with an existing file-description comment block, insert the header above that block. - -Do all qualifying files in one pass. Do not ask for confirmation per file. diff --git a/.agents/skills/sdk-regressions/SKILL.md b/.agents/skills/sdk-regressions/SKILL.md deleted file mode 100644 index 9ee49fc..0000000 --- a/.agents/skills/sdk-regressions/SKILL.md +++ /dev/null @@ -1,632 +0,0 @@ ---- -name: sdk-regressions -description: Cross-compilation regression checks for the onde crate. Covers macOS, iOS, tvOS (nightly), Android, Windows, and Linux target verification. Run before merging any change that touches platform-gated code, Cargo.toml, .cargo/config.toml, or build.rs. Also documents the onde-mistralrs fork publishing strategy and cargo publish workflow. -allowed-tools: Read, Edit, Glob, Grep, Terminal -user-invocable: true ---- - -# SKILL: SDK Cross-Compilation Regression Checks - -> Always run these checks before merging changes that touch `onde/src/`, -> `onde/Cargo.toml`, `onde/.cargo/config.toml`, `onde/build.rs`, or any -> platform-gated dependency. - ---- - -## Why This Exists - -The `onde` crate compiles for **6 target OSes** with heavy `#[cfg(target_os)]` -branching (106+ occurrences in `engine.rs` alone). A change that compiles on -macOS can silently break iOS, tvOS, or Android because: - -- Different `mistralrs` feature sets per platform (`["metal"]` vs `[]`) -- Platform-conditional dependencies (`hf-hub` only on Android) -- Tier-3 targets (tvOS) require nightly + `-Z build-std` -- Assembly stubs (`tvos_chkstk.s`) and `build.rs` gating -- `.cargo/config.toml` sets `+fp16` rustflags on 5 Apple targets - -See also: **[`cargo publish` workflow](#cargo-publish-workflow)** at the bottom -of this file — must be followed when releasing a new `onde` version. - ---- - -## Tier 1 — Must Pass on Every PR (stable toolchain) - -These use the default stable toolchain and must always succeed: - -```bash -# macOS — host platform, Metal backend -cargo check - -# iOS device — Metal backend, +fp16 -cargo check --target aarch64-apple-ios - -# iOS simulator — Metal backend, +fp16 -cargo check --target aarch64-apple-ios-sim -``` - -### Quick one-liner - -```bash -cargo check && \ -cargo check --target aarch64-apple-ios && \ -cargo check --target aarch64-apple-ios-sim -``` - ---- - -## Tier 2 — tvOS (nightly toolchain, `-Z build-std`) - -tvOS targets are tier-3 in Rust — they are **not** in `rustup target list` and -require nightly with `-Z build-std` and the `rust-src` component. - -```bash -# tvOS device -cargo +nightly check -Z build-std --target aarch64-apple-tvos - -# tvOS simulator -cargo +nightly check -Z build-std --target aarch64-apple-tvos-sim -``` - -### tvOS-specific regression risks - -| Risk | What breaks | How to detect | -|------|-------------|---------------| -| `scripts/tvos_chkstk.s` deleted | `Undefined symbol: ___chkstk_darwin` at link time | `test -f scripts/tvos_chkstk.s` | -| `cc` build-dep removed from `Cargo.toml` | `build.rs` can't compile the assembly stub | `grep 'cc' Cargo.toml` | -| `build.rs` tvOS block removed | Same linker error | `grep 'tvos' build.rs` | -| nightly `rust-src` not installed | `-Z build-std` fails immediately | `rustup +nightly component list --installed \| grep rust-src` | -| `.cargo/config.toml` `+fp16` removed | Silent precision loss or candle kernel panics | `grep 'fp16' .cargo/config.toml` | - ---- - -## Tier 3 — Android, Windows, Linux (stable, needs cross toolchains) - -These check that CPU-only code paths compile. For this crate, `cargo check` -still runs C build scripts from dependencies such as `aws-lc-sys` and `ring`, -so absent cross GCC toolchains are real blockers, not harmless link-only -failures. - -```bash -# Android arm64 — CPU, requires hf-hub sandbox workaround -cargo check --target aarch64-linux-android - -# Windows MSVC — CPU, exact CI target; run on Windows or a Windows CI runner -cargo check --target x86_64-pc-windows-msvc - -# Linux — CPU -cargo check --target x86_64-unknown-linux-gnu -``` - -On macOS hosts without cross GCC installed, expect Linux checks to fail looking -for `x86_64-linux-gnu-gcc`, and Windows GNU checks to fail looking for -`x86_64-w64-mingw32-gcc`. Use the Colima/Docker flow below instead of treating -those as code regressions. - -### macOS host fallback: Colima/Docker - -Use this when the goal is to run the Linux and Windows cross-checks on a macOS -host and the host does not have cross GCC toolchains. On Apple Silicon, run a -native `linux/arm64` container. Avoid `linux/amd64` emulation for this crate; -the dependency build can crash under QEMU before reaching `onde` code. - -Give Colima enough memory before running these checks: - -```bash -colima status -colima stop -colima start --memory 8 -``` - -Run Linux and the local Windows GNU proxy check in the container: - -```bash -docker run --rm --platform linux/arm64 \ - -v "$PWD:/workspace" \ - -v onde-cargo-registry:/usr/local/cargo/registry \ - -v onde-cargo-git:/usr/local/cargo/git \ - -w /workspace \ - rust:1.92-bookworm \ - bash -lc ' - export PATH=/usr/local/cargo/bin:$PATH - apt-get update >/dev/null - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - gcc-x86-64-linux-gnu \ - libc6-dev-amd64-cross \ - linux-libc-dev-amd64-cross \ - gcc-mingw-w64-x86-64 \ - binutils-mingw-w64-x86-64 >/dev/null - rustup target add x86_64-unknown-linux-gnu x86_64-pc-windows-gnu >/dev/null - CARGO_BUILD_JOBS=1 cargo check --target x86_64-unknown-linux-gnu - CARGO_BUILD_JOBS=1 cargo check --target x86_64-pc-windows-gnu - ' -``` - -`x86_64-pc-windows-gnu` exercises `#[cfg(target_os = "windows")]` and the -CPU-only Windows path locally, but it is not a substitute for the CI -`x86_64-pc-windows-msvc` check. The MSVC ABI check needs a Windows/MSVC -toolchain, normally GitHub Actions `windows-latest`. - -If the container reaches `Checking onde ...` and then reports Rust errors, the -cross toolchain is working. Fix the crate error and rerun the check. - ---- - -## Full Regression Suite (copy-paste) - -```bash -#!/usr/bin/env bash -set -euo pipefail - -echo "═══ Tier 1: macOS + iOS (stable) ═══" -cargo check -cargo check --target aarch64-apple-ios -cargo check --target aarch64-apple-ios-sim - -echo "═══ Tier 2: tvOS (nightly + build-std) ═══" -cargo +nightly check -Z build-std --target aarch64-apple-tvos -cargo +nightly check -Z build-std --target aarch64-apple-tvos-sim - -echo "═══ Tier 3: Android / Windows / Linux ═══" -cargo check --target aarch64-linux-android -cargo check --target x86_64-pc-windows-msvc -cargo check --target x86_64-unknown-linux-gnu - -echo "✅ All cross-compilation checks passed" -``` - ---- - -## Required Rustup Targets - -Install these once on a development machine: - -```bash -# Tier 1 (stable) -rustup target add aarch64-apple-darwin x86_64-apple-darwin -rustup target add aarch64-apple-ios aarch64-apple-ios-sim - -# Tier 3 (stable) -rustup target add aarch64-linux-android armv7-linux-androideabi -rustup target add x86_64-linux-android i686-linux-android -rustup target add x86_64-pc-windows-msvc -rustup target add x86_64-pc-windows-gnu -rustup target add x86_64-unknown-linux-gnu - -# Tier 2 — tvOS (nightly, no `rustup target add` — uses -Z build-std) -rustup toolchain install nightly -rustup +nightly component add rust-src -``` - ---- - -## `.cargo/config.toml` — Critical Settings - -These rustflags are set at the workspace level and **must not be removed**: - -```toml -[target.aarch64-apple-ios] -rustflags = ["-C", "target-feature=+fp16"] - -[target.aarch64-apple-ios-sim] -rustflags = ["-C", "target-feature=+fp16"] - -[target.aarch64-apple-tvos] -rustflags = ["-C", "target-feature=+fp16"] - -[target.aarch64-apple-tvos-sim] -rustflags = ["-C", "target-feature=+fp16"] - -[target.x86_64-apple-tvos] -rustflags = ["-C", "target-feature=+fp16"] -``` - -Removing `+fp16` causes silent precision degradation or runtime panics in -candle's Metal/NEON kernels. - ---- - -## Platform Dependency Matrix - -| `target_os` | `mistralrs` features | Extra deps | GPU | Default model | -|-------------|---------------------|------------|-----|---------------| -| `macos` | `["metal"]` | — | Metal | Qwen 2.5 3B | -| `ios` | `["metal"]` | `hf-hub`, `mistralrs-core` | Metal | Qwen 2.5 1.5B | -| `tvos` | `["metal"]` | `hf-hub`, `mistralrs-core` | Metal | Qwen 2.5 1.5B | -| `android` | `[]` | `hf-hub`, `mistralrs-core` | CPU | Qwen 2.5 1.5B | -| `windows` | `[]` | — | CPU | Qwen 2.5 3B | -| `linux` | `[]` | — | CPU | Qwen 2.5 3B | - -`hf-hub` and `mistralrs-core` are required on all **sandboxed** platforms -(iOS, tvOS, Android) for the `GLOBAL_HF_CACHE` workaround — `~/.cache` is -outside the app container on iOS/tvOS, and `dirs::home_dir()` panics on Android. -The re-exports in `src/lib.rs` (`pub use hf_hub; pub use mistralrs_core;`) are -gated to `#[cfg(any(target_os = "android", target_os = "ios", target_os = "tvos"))]`. - ---- - -## Common Regression Patterns - -### 1. New model added but not to all 6 platform `cfg` blocks - -`engine.rs` has separate `#[cfg(target_os = "...")]` blocks for model builder -logic. A new model constructor added only under `macos` will fail on `ios`. - -**Detection:** Tier 1 checks catch iOS; Tier 2 catches tvOS; Tier 3 catches -Android/Windows/Linux. - -### 2. Dependency added without platform gating - -Adding a dep that uses `SystemConfiguration.framework` (e.g., `hyper_util` -proxy detection) works on macOS but causes linker errors on iOS/tvOS unless the -podspec declares `s.frameworks = 'SystemConfiguration'`. - -**Detection:** Tier 1 `cargo check` passes (no linking), but `flutter build ios` -fails. Must also test a full `flutter build` periodically. - -### 3. Fork branch divergence - -All `mistralrs` deps point to a fork: -`git = "https://github.com/setoelkahfi/mistral.rs"`, branch -`fix/all-platform-fixes`. If that branch is rebased, deleted, or diverges from -upstream, **all** platforms break. - -**Detection:** Any `cargo check` fails with "could not find branch". - -### 4. UniFFI version mismatch - -`uniffi = "=0.31.0"` must be pinned identically in three places: -- `onde/Cargo.toml` dependencies -- `onde/Cargo.toml` build-dependencies -- `onde/uniffi-bindgen/Cargo.toml` - -Mixing versions causes bindgen panics at codegen time. - -**Detection:** `cargo build` in `uniffi-bindgen/` or `build.rs` panics. - -### 5. Android `home_dir()` sandbox panic - -`dirs::home_dir()` panics in the Android sandbox. The `hf-hub` dep is added -explicitly on Android so `HF_HOME` can be set programmatically. Never call -`dirs::home_dir()` or `home::home_dir()` in code paths reachable on Android. - -**Detection:** Tier 3 `cargo check --target aarch64-linux-android` catches -compile-time issues; runtime panics need an Android emulator test. - ---- - -## Dart SDK Cross-Build Checks - -When changes touch `sdk/dart/`, also verify the Flutter plugin builds: - -```bash -cd sdk/dart/example - -# macOS (requires Xcode) -flutter build macos --debug - -# iOS (requires Xcode + iOS SDK) -flutter build ios --debug --no-codesign -``` - -### Known environment pitfall - -**Never export Android NDK toolchain binaries globally in `.zshrc`:** - -```bash -# ❌ BREAKS Xcode builds — NDK's ld/ar/strip shadow Apple's tools -export LD=$TOOLCHAIN/bin/ld -export AR=$TOOLCHAIN/bin/llvm-ar - -# ✅ Safe — env vars only, no PATH/tool overrides -export ANDROID_NDK_HOME="$HOME/Library/Android/sdk/ndk/29.0.14206865" -``` - -If Xcode invokes the NDK's `ld` instead of Apple's, you get: -`ld: error: unknown argument '-Xlinker'` - -The fix: comment out the tool exports, use Cargo's -`sdk/dart/rust/.cargo/config.toml` for Android linker configuration instead. - ---- - -## Cache Path Consistency (Cross-SDK) - -All Onde-powered apps share downloaded models via the App Group -`group.com.ondeinference.apps`. The cache layout inside the shared container -**must be identical** across every SDK and app type: - -``` -/ -├── models/ ← HF_HOME -│ └── hub/ ← HF_HUB_CACHE (GGUF files live here) -└── tmp/ ← TMPDIR (iOS sandbox restricts system TMPDIR) -``` - -**The subdirectory is `models/`, NOT `huggingface/`.** - -Files that set this path (all must agree): - -| File | Variable | Expected value | -|------|----------|----------------| -| `onde/sdk/dart/rust/src/api.rs` (`configure_cache_dir`) | `hf_home` | `data_dir.join("models")` | -| `onde/src/hf_cache.rs` (`download_model` — general) | `hf_home` | `data_dir.join("models")` | -| `onde/src/hf_cache.rs` (`download_model` — Android) | `hf_home` | `resolved_app_data.join("models")` | -| Tauri apps (`setup_application_filesystem.rs`) | `models_home` | `container_dir.join("models")` | -| `onde/src/hf_cache.rs` (`hf_cache_dir` — fallback) | default | `~/.cache/huggingface/hub` (non-sandboxed only, OK to differ) | - -**Regression check:** `grep -rn 'join("huggingface")' src/hf_cache.rs sdk/dart/rust/src/api.rs` -should return **only** the non-sandboxed fallback in `hf_cache_dir()` (line ~164). -Any other match means a sandboxed path is using the wrong subdirectory. - ---- - ---- - -## Publishing to crates.io - -### Why we don't use upstream `mistralrs` directly - -The short version: our PRs aren't merged yet. - -`onde` depends on a personal fork of Eric Buehler's `mistral.rs`, kept at -`github.com/setoelkahfi/mistral.rs` on the `fix/all-platform-fixes` branch. -The fork is ahead of upstream by ~41 commits carrying fixes that only matter -for Apple and Android targets — things upstream hasn't needed to care about: - -| Fix | Status | -|-----|--------| -| iOS Metal 3.0 support | PR open | -| Android 32-bit memory limit constants | PR open | -| HF_HOME propagation in diffusion/flux pipelines | PR open | -| `metallib` link step `--sdk` / `-std` flags | PR open | - -Dropping the fork and pointing at upstream `mistralrs 0.8.1` would silently -break iOS on entry-level devices and Android on 32-bit targets. Don't do it -until those PRs land. - -**Legal note:** The fork is a derivative of Eric Buehler's MIT-licensed -`mistral.rs`. His `LICENSE` file — including `Copyright (c) 2024 Eric Buehler` -— must remain intact and unmodified in the fork at all times. See -[`legal-and-trademarks/SKILL.md`](../legal-and-trademarks/SKILL.md) for full -attribution obligations. - -### The `onde-mistralrs` workaround - -> For the full inventory of every fork and `[patch.crates-io]` override in the -> stack (mistral.rs, candle, sysctl, mio, tqdm, core2) and how the -> git-vs-published tiers resolve differently, see the **forked-dependencies** -> skill. This section covers only the mistral.rs publish mechanics. - -`cargo publish` strips every `git =` field before uploading to crates.io. -That means a git-only dep is useless to anyone who installs `onde` from the -registry — they can't resolve it. - -The solution: publish the fork to crates.io under the name `onde-mistralrs` -(and `onde-mistralrs-core`, etc.), then reference it with `package =` so the -rest of the code still compiles unchanged with `use mistralrs::...`: - -```toml -# In onde/Cargo.toml — each platform target section looks like this. -# Cargo resolves it as "onde-mistralrs" from crates.io, -# but the Rust code sees it as `mistralrs`. -mistralrs = { version = "0.8.2", package = "onde-mistralrs", features = ["metal"] } -``` - -This is the only supported publish path until the upstream PRs merge. Don't -swap it for plain `mistralrs` from crates.io without checking every platform -target first. - ---- - -### Step 0 — check if the candle snapshot needs an `onde-candle-*` publish - -Since 0.9.x, upstream mistral.rs pins `candle` to a git rev past the latest -released candle (e.g. rev `27f20fea`, 19 commits past `0.11.0`, adding -`QTensor::indexed_gemv`, `gemv_fused_shared_lhs`, `BarrierPool::execute_chunked`). -`cargo publish` strips git pins, so publish verification resolves candle from -crates.io and fails with E0599 "method not found" if the pinned rev has APIs the -released candle lacks. - -When that happens, publish the pinned rev under `onde-candle-*` names from the -local candle checkout (`~/Repositories/candle`, branch pattern -`onde/candle--`): - -```bash -# Which candle crates actually diverged from the released version? -git diff --stat -- candle-core candle-nn candle-metal-kernels - -# Rename ONLY the diverged crates (0.11.0 era: candle-core + candle-nn; the -# rest are byte-identical, official crates serve them). candle-nn must be -# republished whenever candle-core is, even if unchanged, or the official -# candle-nn drags the official candle-core into the graph -> two candle-core -# crates -> type mismatch. -cargo publish -p onde-candle-core -cargo publish -p onde-candle-nn -``` - -Then in the mistral.rs fork workspace `Cargo.toml`: - -```toml -candle-core = { version = "0.11.0", package = "onde-candle-core" } -candle-nn = { version = "0.11.0", package = "onde-candle-nn" } -candle-flash-attn-v3 = { version = "0.11.0" } # unchanged at rev -> official -candle-metal-kernels = { version = "0.11.0" } # unchanged at rev -> official -``` - -### Step 1 — check if `onde-mistralrs` needs a new publish - -First, see how far the fork has drifted from upstream: - -```bash -curl -s "https://api.github.com/repos/setoelkahfi/mistral.rs/compare/EricLBuehler:mistral.rs:master...setoelkahfi:mistral.rs:fix%2Fall-platform-fixes" \ - | python3 -c "import sys,json; d=json.load(sys.stdin); print('ahead:', d['ahead_by'], 'behind:', d['behind_by'])" -``` - -If there are new commits since the last `onde-mistralrs` publish, or if -upstream cut a new release that the fork has rebased onto, bump the workspace -version in the fork and publish. As of 0.9.1 the set is **12 crates** (upstream -split out flash-attn/metal-compile/code-exec/sandbox, and vision/audio/mcp must -be onde-published too because crates.io only has upstream's old versions). -The rename lives in the workspace dep table (`package = "onde-..."` on each -entry, local dep keys unchanged so imports stay `mistralrs_*`). - -```bash -# 1. Edit the root Cargo.toml in setoelkahfi/mistral.rs -# bump version, e.g. 0.9.0 → 0.9.1 - -# 2. Publish in dependency order (leaves first): -cargo publish -p onde-mistralrs-macros -cargo publish -p onde-mistralrs-metal-compile -cargo publish -p onde-mistralrs-sandbox -cargo publish -p onde-mistralrs-flash-attn -cargo publish -p onde-mistralrs-vision -cargo publish -p onde-mistralrs-audio -cargo publish -p onde-mistralrs-mcp -cargo publish -p onde-mistralrs-paged-attn -cargo publish -p onde-mistralrs-quant -cargo publish -p onde-mistralrs-code-exec -cargo publish -p onde-mistralrs-core -cargo publish -p onde-mistralrs - -# 3. cargo waits for index propagation automatically after each publish. -``` - -Known publish-verification snags (hit during 0.9.1): - -| Error | Fix | -|-------|-----| -| E0599 on candle APIs (`indexed_gemv`, ...) | Step 0 — publish `onde-candle-*` and repoint | -| `dependency tqdm does not specify a version` | tqdm must be a registry dep, not git — it is plain `tqdm = "0.8.1"` since the crossterm bump landed upstream | -| `core2 0.4.0 is yanked` | `cargo update -p bitstream-io` (4.10.0 dropped core2) | - ---- - -### Step 2 — update `onde/Cargo.toml` - -Every platform target section has its own `onde-mistralrs` line. They all need -to move to the new version together — leaving one target on an older version -causes a dependency conflict. - -```bash -# All occurrences should print the same version number: -grep 'onde-mistralrs' onde/Cargo.toml -``` - ---- - -### Step 3 — dry-run first - -```bash -cd onde -cargo publish --dry-run --allow-dirty 2>&1 | tail -20 -``` - -A clean run ends with `Finished` and no errors. Common things that go wrong: - -| Error | What to do | -|-------|------------| -| `all dependencies must have a version` | add `version = "..."` to the dep | -| `no matching package named onde-mistralrs` | publish the fork first (step 1) | -| `git specification will be removed` | expected — cargo is just telling you the git field gets stripped | -| `files in working directory contain changes` | add `--allow-dirty` | - ---- - -### Step 4 — publish `onde`, then update `sigit` - -```bash -cargo publish -``` - -Then bump the version in `sigit/Cargo.toml`: - -```toml -# git = is used for local dev; crates.io consumers resolve via version = -onde = { version = "0.1.9", git = "https://github.com/ondeinference/onde", branch = "development" } -``` - -Keep both fields. The `git =` field takes precedence during local development; -crates.io strips it and falls back to `version =` for anyone installing from -the registry. - ---- - -### GresIQ credentials and `HF_TOKEN` - -People building `onde` from crates.io don't need to do anything about secrets. -Here's why. - -`build.rs` passes credentials to the compiler via `option_env!()`. That macro -resolves at compile time — if the env var isn't set, it compiles to `None` and -the feature that needs it is silently disabled. No crash, no partial state, no -mystery error at runtime. - -```rust -// pulse/client.rs — GresIQ telemetry (Onde Inference internal infrastructure) -const EMBEDDED_API_KEY_DEV: Option<&str> = option_env!("GRESIQ_API_KEY_DEV"); -const EMBEDDED_API_SECRET_DEV: Option<&str> = option_env!("GRESIQ_API_SECRET_DEV"); - -// token.rs — HuggingFace Hub authentication -const BUILD_TIME_HF_TOKEN: Option<&str> = option_env!("HF_TOKEN"); -``` - -Who gets what: - -| Who is building | Env vars present? | What happens | -|-----------------|-------------------|--------------| -| Our CI (official builds) | yes, via GitHub secrets | telemetry on; HF token baked into binary | -| Someone who ran `cargo add onde` | no | telemetry off; HF token read from `~/.cache/huggingface/token` at runtime | -| iOS/tvOS builds via XCFramework | yes, via `.env` file | HF token baked in — required because there's no writable filesystem on device | - -The GresIQ credentials are Onde Inference's own infrastructure secrets — they -exist so the SDK can phone home for usage telemetry on official builds. External -consumers don't have them, don't need them, and won't be affected by not having -them. - -Never put `GRESIQ_API_KEY_*` or `HF_TOKEN` in `README.md`, `.env.example`, or -any committed file. The `.env` in the crate root is git-ignored for a reason: - -```bash -# onde/.env — git-ignored, never commit -GRESIQ_API_KEY_DEV=... -GRESIQ_API_SECRET_DEV=... -GRESIQ_API_KEY_PRODUCTION=... -GRESIQ_API_SECRET_PRODUCTION=... -HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -``` - ---- - -### Working against the git fork locally - -If you're making changes to the fork and want `onde` to pick them up without -publishing, uncomment the patch block in `onde/Cargo.toml`: - -```toml -[patch.crates-io] -onde-mistralrs = { git = "https://github.com/setoelkahfi/mistral.rs", branch = "fix/all-platform-fixes", package = "mistralrs" } -onde-mistralrs-core = { git = "https://github.com/setoelkahfi/mistral.rs", branch = "fix/all-platform-fixes", package = "mistralrs-core" } -``` - -Re-comment it before running `cargo publish`. Cargo strips `[patch.crates-io]` -from the published manifest automatically, so it won't affect registry -consumers either way — but leaving it active makes the dry-run confusing. - ---- - -### Pre-publish checklist - -- [ ] all `onde-mistralrs` version numbers in `onde/Cargo.toml` are the same -- [ ] `onde/Cargo.toml` `[package] version` is bumped -- [ ] `sigit/Cargo.toml` `onde` version matches -- [ ] `[patch.crates-io]` git override is commented out -- [ ] `cargo publish --dry-run --allow-dirty` passes clean -- [ ] `.env` is not listed in the dry-run's packaged file output -- [ ] Eric Buehler's `LICENSE` in the `mistral.rs` fork is unmodified - ---- - -*Last updated: July 2026 — added Colima/Docker Linux and Windows GNU -cross-check guidance for macOS hosts without cross GCC, plus the MSVC CI -caveat.* diff --git a/.agents/skills/sdk-releases/SKILL.md b/.agents/skills/sdk-releases/SKILL.md deleted file mode 100644 index 9344be9..0000000 --- a/.agents/skills/sdk-releases/SKILL.md +++ /dev/null @@ -1,670 +0,0 @@ ---- -name: sdk-releases -description: End-to-end SDK release process for all Onde distribution channels. Covers version bump checklist, CI/CD pipelines for Rust (crates.io), Swift (XCFramework → onde-swift), Dart (pub.dev), and npm, tag validation, GitHub Release creation, manual publish fallback, and common pitfalls. Apply whenever bumping versions or troubleshooting release workflows. -allowed-tools: Read, Write, Edit, Glob, Grep, Bash -user-invocable: true ---- - -# Skill: SDK Releases - -## What This Skill Covers - -The complete release pipeline for shipping Onde across all registries: - -- **Rust crate** → crates.io (`onde`) -- **Swift package** → Swift Package Index (`onde-swift`) -- **Dart/Flutter package** → pub.dev (`onde_inference`) -- **React Native** → npm (`@ondeinference/react-native`) -- **Kotlin Multiplatform** → Maven Central (`com.ondeinference:onde-inference`) - -All five SDK workflows share the same tag trigger. A single tag push fires all -five in parallel. When only a subset needs (re-)publishing, use the **manual -publish fallback** documented below to avoid collateral re-runs. - ---- - -## Version Bump Checklist - -Every release starts with a version bump. **All version sources must be updated -in a single commit before tagging.** - -| # | File | Field | Example | -|---|------|-------|---------| -| 1 | `Cargo.toml` (root) | `version` | `version = "0.1.3"` | -| 2 | `sdk/dart/pubspec.yaml` | `version` | `version: 0.1.3` | -| 3 | `sdk/dart/CHANGELOG.md` | New `## 0.1.3` section | Prepend at top of file | -| 4 | `sdk/react-native/package.json` | `version` | `"version": "0.1.3"` | -| 5 | `sdk/react-native/rust/Cargo.toml` | `version` | `version = "0.1.3"` | -| 6 | `sdk/react-native/CHANGELOG.md` | New `## 0.1.3` section | Prepend at top of file | -| 7 | `sdk/kotlin/gradle.properties` | `VERSION_NAME` | `VERSION_NAME=0.1.3` | -| 8 | `sdk/dart/rust/Cargo.lock` | `onde` package version | Run `cd sdk/dart/rust && cargo update -p onde` | -| 9 | `sdk/react-native/rust/Cargo.lock` | `onde` package version | Run `cd sdk/react-native/rust && cargo update -p onde` | -| 10 | `Cargo.lock` (root) | `onde` package version | Run `cargo check` at repo root | -| 11 | `CHANGELOG.md` (root) | New `## 0.1.3` section | Prepend at top of file. `sync-release-version.py --check` gates on it since 1.2.5; before that nothing did, which is why the file jumps from 1.1.1 to 1.2.5 | - -### Files you do NOT manually edit - -| File | Why | -|------|-----| -| `onde-swift/Package.swift` | CI rewrites the `url:` + `checksum:` automatically | -| `onde-swift/Sources/Onde/onde.swift` | CI copies the freshly generated UniFFI glue | - -### Quick bump commands - -```bash -# 1. Edit Cargo.toml version (manual) -# 2. Edit sdk/dart/pubspec.yaml version (manual) -# 3. Prepend new section to sdk/dart/CHANGELOG.md (manual) -# 4. Edit sdk/react-native/package.json version (manual) -# 5. Edit sdk/react-native/rust/Cargo.toml version (manual) -# 6. Prepend new section to sdk/react-native/CHANGELOG.md (manual) -# 7. Edit sdk/kotlin/gradle.properties VERSION_NAME (manual) -# 8. Prepend new section to CHANGELOG.md at the repo root (manual) - -# 9. Sync lockfiles -cargo check # updates root Cargo.lock -cd sdk/dart/rust && cargo update -p onde # updates Dart SDK's Cargo.lock -cd sdk/react-native/rust && cargo update -p onde # updates React Native SDK's Cargo.lock - -# 10. Verify -grep '^version' Cargo.toml # "0.1.3" -grep '^version:' sdk/dart/pubspec.yaml # 0.1.3 -grep '"version"' sdk/react-native/package.json # "0.1.3" -grep '^version' sdk/react-native/rust/Cargo.toml # "0.1.3" -grep 'VERSION_NAME' sdk/kotlin/gradle.properties # 1.0.0 -grep 'name = "onde"' -A1 Cargo.lock # version = "0.1.3" -grep 'name = "onde"' -A1 sdk/dart/rust/Cargo.lock # version = "0.1.3" -grep 'name = "onde"' -A1 sdk/react-native/rust/Cargo.lock # version = "0.1.3" - -# 11. Commit and tag -git add -A -git commit -m "0.1.3" -git tag 0.1.3 -git push origin main 0.1.3 -``` - ---- - -## Tag Format - -All tags are **bare semver** — no `v` prefix. - -``` -0.1.3 ✅ correct -v0.1.3 ❌ will NOT trigger CI -0.1.3-beta ❌ will NOT match the tag pattern -``` - -All five CI workflows use the same trigger pattern: - -```yaml -on: - push: - tags: - - "[0-9]+.[0-9]+.[0-9]+" -``` - ---- - -## CI Pipeline: Rust SDK (crates.io) - -**Workflow:** `.github/workflows/release-sdk-rust.yml` -**Trigger:** Tag push matching `[0-9]+.[0-9]+.[0-9]+` or `workflow_dispatch` -**Runner:** `macos-26` - -### Flow - -``` -Tag push (e.g. 1.0.0) - │ - ├─ 1. Checkout + install stable Rust - │ - ├─ 2. Validate tag == Cargo.toml version - │ └─ Fails fast if mismatched - │ - ├─ 3. cargo publish --dry-run - │ └─ Compiles and packages; catches errors before real publish - │ - └─ 4. cargo publish (tag push only) - └─ Authenticates via CARGO_REGISTRY_TOKEN secret -``` - -### Key details - -- **`workflow_dispatch`** runs the dry-run only (the publish step has - `if: startsWith(github.ref, 'refs/tags/')`) — useful for validation. -- **Runner is `macos-26`** because the crate depends on `mistralrs` with Metal - features, which requires macOS + Xcode to compile. -- **crates.io is immutable** — once a version is published, it cannot be - re-published. You must bump to the next version. `cargo yank` hides a version - from dependency resolution but does not delete it. - -### Required secret - -`CARGO_REGISTRY_TOKEN` — a crates.io API token scoped to `publish-update` for -the `onde` crate. Create at https://crates.io/settings/tokens. Best practice: -scope it to a single crate rather than using a global token. - ---- - -## CI Pipeline: Swift SDK - -**Workflow:** `.github/workflows/release-sdk-swift.yml` -**Trigger:** Tag push matching `[0-9]+.[0-9]+.[0-9]+` -**Runner:** `macos-15` - -### Flow - -``` -Tag push (e.g. 0.1.3) - │ - ├─ 1. Build XCFramework - │ └─ .github/scripts/build-swift-xcframework.sh - │ ├─ Extracts version from Cargo.toml via tomllib - │ ├─ Compiles staticlibs for 5 Apple targets - │ ├─ Generates UniFFI Swift bindings - │ ├─ Assembles XCFramework, zips, computes SHA-256 - │ └─ Writes dist/swift/{version.txt, OndeFramework.checksum.txt} - │ - ├─ 2. Read version + checksum into step outputs - │ - ├─ 3. Validate tag == Cargo.toml version - │ └─ Fails fast if mismatched (e.g. tagged 0.1.3 but Cargo.toml says 0.1.2) - │ - ├─ 4. Upload CI artifacts (every run, including workflow_dispatch) - │ - ├─ 5. Create GitHub Release on `onde` (tag push only) - │ └─ Attaches OndeFramework.xcframework.zip + checksum - │ - ├─ 6. Checkout ondeinference/onde-swift (using ONDE_SWIFT_PAT) - │ - ├─ 7. Rewrite Package.swift - │ └─ Python regex replaces .binaryTarget url: + checksum: - │ - ├─ 8. Copy generated onde.swift → onde-swift/Sources/Onde/ - │ - └─ 9. Commit + tag onde-swift - ├─ git commit -m "Release 0.1.3" (skipped if no changes) - ├─ git push origin HEAD:main - └─ git tag -a "0.1.3" -m "Release 0.1.3" (skipped if tag exists) - └─ Annotated tag, never force-pushed -``` - -### What happens on `onde-swift` after the push - -The `onde` CI pushes a commit (to `main`) and a tag (e.g. `0.1.3`) to `onde-swift`. -This triggers **two** workflows on `onde-swift`: - -1. **`ci.yml`** (on push to `main`) — validates manifest + builds all 5 platforms -2. **`release.yml`** (on tag push) — validates package, then creates a GitHub - Release with installation instructions and a link to the upstream `onde` changelog - -Both run concurrently without conflict — they use different concurrency groups -(`ci-refs/heads/main` vs the tag ref). - -### Tag immutability - -Tags on `onde-swift` are **never force-pushed**. SPM caches the resolved commit -SHA per tag in `Package.resolved`. Moving a tag silently breaks every consumer's -lockfile. If a release needs to be redone: - -1. Delete the remote tag: `git push origin :refs/tags/0.1.3` -2. Delete the GitHub Release on both `onde` and `onde-swift` -3. Fix the issue, re-tag, push again - ---- - -## CI Pipeline: npm SDK (React Native) - -**Workflow:** `.github/workflows/release-sdk-npm.yml` -**Trigger:** Tag push matching `[0-9]+.[0-9]+.[0-9]+` or `workflow_dispatch` -**Runner:** `ubuntu-latest` - -### Flow - -``` -Tag push (e.g. 1.0.0) - │ - ├─ validate job: - │ ├─ Set up Node.js 22 - │ ├─ Read version from sdk/react-native/package.json - │ ├─ Validate tag == package.json version - │ ├─ npm install + npm run build - │ └─ npm publish --dry-run --access public - │ - └─ publish job (tag push only, needs: validate): - ├─ npm install + npm run build - ├─ Check if version already exists on npm - │ └─ If yes → skip publish (idempotent, exits 0) - └─ npm publish --access public -``` - -### npm idempotency guard - -The publish step checks `npm view @` before publishing. If the -version already exists, it **skips** with a success exit code. This makes the npm -workflow safe to re-run — unlike Dart/Rust, it won't fail on a duplicate version. - -### No secret: trusted publishing - -Since 1.2.5 the npm workflow publishes over OIDC rather than a long-lived token, -so there is no `NPM_TOKEN` to rotate. Two things have to line up instead: - -- The `publish` job declares `permissions: id-token: write`. -- The trusted publisher on - npmjs.com/package/@ondeinference/react-native/access names repo - `ondeinference/onde`, workflow `release-sdk-npm.yml`, and no environment. - -If those drift apart, npm answers the publish with a 404 on PUT rather than an -auth error, which reads as a missing package and sends you looking in the wrong -place. Adding an `environment:` to the job without setting the matching -Environment name field on that npm page is the usual way to cause it. - -The job also upgrades npm before publishing: Node 22 ships npm 10, and trusted -publishing needs 11.5.1 or newer. - ---- - -## CI Pipeline: Dart SDK - -**Workflow:** `.github/workflows/release-sdk-dart.yml` -**Trigger:** Tag push matching `[0-9]+.[0-9]+.[0-9]+` -**Runner:** `ubuntu-latest` - -### Flow - -``` -Tag push (e.g. 0.1.3) - │ - ├─ validate job: - │ ├─ Set up Flutter (version from sdk/dart/.flutter-version) - │ ├─ Read version from pubspec.yaml - │ ├─ Validate tag == pubspec.yaml version - │ │ └─ Fails fast if mismatched - │ ├─ flutter pub get - │ ├─ flutter analyze --no-fatal-infos - │ └─ flutter pub publish --dry-run - │ - └─ publish job (tag push only, needs: validate): - ├─ Set up Flutter - ├─ flutter pub get - ├─ Write PUB_CREDENTIALS to ~/.config/dart/pub-credentials.json - └─ flutter pub publish --force -``` - -### Tag validation (Dart) - -The Dart workflow independently validates the tag against `pubspec.yaml`: - -```yaml -if [ "$TAG" != "$PUBSPEC_VERSION" ]; then - echo "::error::Tag '$TAG' does not match pubspec.yaml version '$PUBSPEC_VERSION'." - exit 1 -fi -``` - -This means **both** `Cargo.toml` and `pubspec.yaml` must have the same version -as the git tag, or both workflows fail. - -### Required secret - -`PUB_CREDENTIALS` — the full JSON content of `~/.config/dart/pub-credentials.json` -from a machine that has run `dart pub login`. Add as a repository secret on `onde`. - ---- - -## GitHub Releases (two repos) - -| Repo | Created by | Contains | -|------|-----------|----------| -| `onde` | `release-sdk-swift.yml` (step 5) | XCFramework zip + checksum, auto-generated release notes | -| `onde-swift` | `onde-swift` `release.yml` | Installation snippet, platform table, link to upstream changelog | - -Both repos need GitHub Releases because they serve different audiences: - -- **`onde`** — Rust consumers, asset hosting for the XCFramework binary -- **`onde-swift`** — Swift developers, Swift Package Index surfaces these as version notes - ---- - -## Version Sources and Validation Matrix - -| Source | Read by | Validated against | -|--------|---------|-------------------| -| `Cargo.toml` `version` | `build-swift-xcframework.sh` (tomllib) | Git tag (Swift workflow) | -| `sdk/dart/pubspec.yaml` `version` | `release-sdk-dart.yml` (grep + awk) | Git tag (Dart workflow) | -| Git tag (`github.ref_name`) | Both workflows | Both version files above | -| `onde-swift` tag | Created by `release-sdk-swift.yml` | Same value as the `onde` tag | - -All four must match. If any pair diverges, the relevant CI job fails. - ---- - -## Secrets Required - -| Secret | Used by | Purpose | -|--------|---------|---------| -| `CARGO_REGISTRY_TOKEN` | `release-sdk-rust.yml` | Authenticate with crates.io for `cargo publish`. Scoped to `publish-update` for the `onde` crate. Create at https://crates.io/settings/tokens. | -| `ONDE_SWIFT_PAT` | `release-sdk-swift.yml` | Push commits + tags to `ondeinference/onde-swift`. Must be a PAT (not `GITHUB_TOKEN`) so it triggers workflows on `onde-swift`. Needs `contents: write` scope. | -| `PUB_CREDENTIALS` | `release-sdk-dart.yml` | Authenticate with pub.dev for `flutter pub publish`. Full JSON from `~/.config/dart/pub-credentials.json`. | -| `ORG_GRADLE_PROJECT_MAVENCENTRALUSERNAME` | `release-sdk-kotlin.yml` | Sonatype Central Portal token username. Generate at central.sonatype.com → Profile → Generate User Token. | -| `ORG_GRADLE_PROJECT_MAVENCENTRALPASSWORD` | `release-sdk-kotlin.yml` | Sonatype Central Portal token password. Same dialog as above. | -| `ORG_GRADLE_PROJECT_SIGNINGKEYID` | `release-sdk-kotlin.yml` | Last 8 chars of GPG key fingerprint used to sign Maven Central artifacts. | -| `ORG_GRADLE_PROJECT_SIGNINGKEY` | `release-sdk-kotlin.yml` | ASCII-armored GPG private key. Export with `gpg --armor --export-secret-keys `. | -| `ORG_GRADLE_PROJECT_SIGNINGPASSWORD` | `release-sdk-kotlin.yml` | Passphrase for the GPG signing key. | -| `GRESIQ_API_KEY_DEV` | All release workflows | Build-time env for dev environment API key. | -| `GRESIQ_API_SECRET_DEV` | All release workflows | Build-time env for dev environment API secret. | -| `GRESIQ_API_KEY_PRODUCTION` | All release workflows | Build-time env for production API key. | -| `GRESIQ_API_SECRET_PRODUCTION` | All release workflows | Build-time env for production API secret. | -| `HF_TOKEN` | All release workflows | HuggingFace token baked in at build time (`option_env!`). | - ---- - -## Common Pitfalls - -| Pitfall | Symptom | Fix | -|---------|---------|-----| -| Forgot to bump `pubspec.yaml` | Dart CI fails: "Tag '0.1.3' does not match pubspec.yaml version '0.1.2'" | Bump `sdk/dart/pubspec.yaml`, amend commit, re-tag | -| Forgot to bump `sdk/react-native/package.json` | npm CI fails: "Tag '0.1.3' does not match package.json version '0.1.2'" | Bump `sdk/react-native/package.json`, amend commit, re-tag | -| Forgot to bump `sdk/react-native/rust/Cargo.toml` | npm SDK's Rust bridge crate version drifts from the main crate | Bump `sdk/react-native/rust/Cargo.toml`, run `cd sdk/react-native/rust && cargo update -p onde` | -| Forgot to update `sdk/dart/rust/Cargo.lock` | Dart SDK's Rust bridge builds against stale `onde` version | Run `cd sdk/dart/rust && cargo update -p onde` | -| Forgot to update `sdk/react-native/rust/Cargo.lock` | npm SDK's Rust bridge builds against stale `onde` version | Run `cd sdk/react-native/rust && cargo update -p onde` | -| Forgot to update `sdk/dart/CHANGELOG.md` | pub.dev shows stale changelog | Prepend new `## 0.1.3` section before tagging | -| Forgot to bump `sdk/react-native/CHANGELOG.md` | npm shows stale changelog | Prepend new `## 0.1.3` section before tagging | -| Forgot to update the root `CHANGELOG.md` | Preflight fails: "CHANGELOG.md: no '## 0.1.3' section" | Prepend the new section before tagging | -| crates.io publish reports success but nothing is published | `cargo publish` step logs "already on crates.io — skipping" for a version that is not there | Fixed in 1.2.5. The guard used `cargo info`, which resolves against the local workspace. If it recurs, check that `scripts/crates-io-published.py` is still what the workflow calls | -| Forgot to bump `sdk/kotlin/gradle.properties` `VERSION_NAME` | Kotlin CI fails: tag vs gradle.properties mismatch | Bump `VERSION_NAME`, amend commit, re-tag | -| Tag has `v` prefix (`v0.1.3`) | CI does not trigger — tag pattern requires bare semver | Delete the tag, re-tag without `v` | -| `CARGO_REGISTRY_TOKEN` missing | `cargo publish` fails with auth error | Create a scoped token at crates.io/settings/tokens, add as repo secret | -| `ONDE_SWIFT_PAT` expired | `onde-swift` push fails with 403 | Regenerate PAT at github.com/settings/tokens, update repo secret | -| `PUB_CREDENTIALS` expired | `flutter pub publish` fails with 401 | Run `dart pub login` locally, copy new credentials JSON to repo secret | -| npm trusted publisher does not match the workflow | `npm publish` fails with a 404 on PUT, not a 401 | Check repo, workflow filename, and environment on npmjs.com/package/@ondeinference/react-native/access against what the `publish` job declares | -| crates.io version already published | `cargo publish` fails with "already uploaded" | crates.io is immutable — bump to next version. `cargo yank` hides but doesn't delete. | -| npm version already published | npm publish step skips (idempotency guard) | Safe — the workflow exits 0. No action needed. | -| pub.dev version already published | `flutter pub publish` fails | pub.dev is immutable — bump to next version | -| Maven Central version already published | `publishAndReleaseToMavenCentral` fails | Maven Central is immutable — bump to next version | -| Maven Central namespace not verified | Deployment fails: "Namespace 'com.ondeinference' is not allowed" | Verify namespace on central.sonatype.com (add DNS TXT record, wait for Sonatype to poll) | -| Tag pushed before workflow exists on `main` | Workflow never fires for that tag | Use `workflow_dispatch` for dry-run, then publish manually. See **Manual Publish Fallback**. | -| All 5 workflows fire but only 1–2 need re-publishing | Collateral re-runs of Swift/npm/Dart/Rust/Kotlin | Don't delete+re-push the tag. Use **Manual Publish Fallback** for the specific SDKs. | -| `onde-swift` tag already exists | Tag push skipped (warning emitted) | Delete remote tag first: `git push origin :refs/tags/0.1.3` | -| Force-pushed a tag on `onde-swift` | SPM users get stale `Package.resolved` | Never force-push. Delete + re-create instead. Advise consumers to run `swift package resolve` | -| XCFramework URL 404 | `swift package resolve` fails on consumer side | Ensure the `onde` GitHub Release was created BEFORE the `onde-swift` tag was pushed (the workflow handles this order automatically) | -| `Package.swift` regex didn't match | Python `RuntimeError` in CI | Check that `onde-swift/Package.swift` still has a `.binaryTarget(name: "OndeFramework", ...)` block | -| Dart `flutter analyze` fails | `validate` job fails, `publish` is skipped | Fix analysis issues, re-tag | - ---- - -## Manual Publish Fallback - -When a tag was pushed before a workflow existed on `main`, or when you need to -publish only a subset of SDKs without re-triggering all four workflows: - -### Rust (crates.io) - -```bash -# Dry-run first -cargo publish --dry-run - -# Publish -cargo publish -``` - -Requires `~/.cargo/credentials.toml` or `CARGO_REGISTRY_TOKEN` env var. - -### Dart (pub.dev) - -```bash -cd sdk/dart - -# Dry-run -flutter pub publish --dry-run - -# Publish -flutter pub publish --force -``` - -Requires `~/.config/dart/pub-credentials.json` from `dart pub login`. - -### Kotlin (Maven Central) - -```bash -cd sdk/kotlin - -# Dry-run -./gradlew :lib:publishToMavenCentral --dry-run --no-configuration-cache - -# Publish and release -./gradlew :lib:publishAndReleaseToMavenCentral --no-configuration-cache -``` - -Requires `~/.gradle/gradle.properties` with `mavenCentralUsername`, -`mavenCentralPassword`, `signingKeyId`, `signingKey`, `signingPassword`. -Also requires the `com.ondeinference` namespace to be verified on -central.sonatype.com. - -### npm (React Native) - -```bash -cd sdk/react-native -npm run build -npm publish --access public -``` - -Requires `npm login`. Trusted publishing is CI-only, so a manual publish falls -back to your own npm credentials. - -### When to use manual publish - -| Scenario | Approach | -|----------|----------| -| Tag exists but workflow wasn't on `main` yet | Merge workflow, then publish manually | -| One SDK's CI failed, others succeeded | Fix the issue, publish that SDK manually | -| Need to avoid re-triggering Swift XCFramework build | Publish Rust/Dart/npm/Kotlin manually | -| Testing a new workflow before first real tag | Use `workflow_dispatch` for dry-run only | - ---- - -## Re-releasing a Version (Emergency Fix) - -If a release was botched and you need to re-publish the same version: - -### Swift (onde-swift) - -```bash -# 1. Delete the remote tag on onde-swift -git -C /path/to/onde-swift push origin :refs/tags/0.1.3 - -# 2. Delete the GitHub Release on onde-swift (via GitHub UI or gh CLI) -gh release delete 0.1.3 --repo ondeinference/onde-swift --yes - -# 3. Delete the GitHub Release on onde -gh release delete 0.1.3 --repo ondeinference/onde --yes - -# 4. Delete the local and remote tag on onde -git tag -d 0.1.3 -git push origin :refs/tags/0.1.3 - -# 5. Fix the issue, commit, re-tag, push -git tag 0.1.3 -git push origin main 0.1.3 -``` - -### Dart (pub.dev) / Rust (crates.io) / Kotlin (Maven Central) - -**pub.dev, crates.io, and Maven Central are all immutable.** Once a version is -published, it cannot be re-published. You must bump to `0.1.4` instead. - -- **crates.io:** `cargo yank --version 0.1.3` hides the version from dependency - resolution but does not delete it. -- **pub.dev:** No yank equivalent. The version remains visible and installable. - -If the `publish` job failed (i.e. the package was NOT published), you can -safely re-tag after fixing the issue. - -### npm - -The npm workflow's idempotency guard means re-running it is safe — it skips -if the version already exists. However, npm also does not allow re-publishing -the same version with different content. Bump to the next version if the -published content was wrong. - ---- - -## End-to-End Release Timeline - -``` -Developer merges feature → development → main (--no-ff) then tags - │ - ├─── release-sdk-rust.yml fires ──────────────────────────────────────┐ - │ ~2 min (dry-run + publish to crates.io) │ - │ │ - ├─── release-sdk-swift.yml fires ──────────────────────────────────────┤ - │ ~15 min (XCFramework build) │ - │ Creates GitHub Release on onde │ - │ Pushes commit + tag to onde-swift │ - │ │ │ - │ ├─ onde-swift ci.yml fires (push to main) │ - │ │ ~5 min (validate + build 5 platforms) │ - │ │ │ - │ └─ onde-swift release.yml fires (tag 1.0.0) │ - │ ~5 min (validate + create GitHub Release) │ - │ │ - ├─── release-sdk-dart.yml fires ───────────────────────────────────────┤ - │ ~3 min (validate + publish to pub.dev) │ - │ │ - ├─── release-sdk-npm.yml fires ───────────────────────────────────────┤ - │ ~3 min (validate + publish to npm) │ - │ │ - ├─── release-sdk-kotlin.yml fires ────────────────────────────────────┤ - │ ~10 min (Android ABIs + JVM dylib + publish to Maven Central) │ - │ │ - └─── Swift Package Index picks up onde-swift tag ──────────────────────┘ - ~30 min (SPI polling interval) - klibs.io picks up Maven Central artifact within ~30 days -``` - -All five CI workflows fire **in parallel** from the single tag push. The only -ordering dependency is that `release-sdk-swift` must create the `onde` GitHub -Release (with XCFramework assets) **before** pushing the tag to `onde-swift` — -this is guaranteed by the step order within the workflow. - -### Merge train - -Always merge through the branch train before tagging. Use `--no-ff` to -create explicit merge commits — they serve as bookmarks in the history -showing where each feature landed. - -```bash -git checkout development && git merge feature/my-branch --no-ff --no-edit -git checkout main && git merge development --no-ff --no-edit -git push origin development main -git tag X.Y.Z && git push origin X.Y.Z -``` - -Never tag from a feature branch. The tag must point to a commit on `main` that -contains all workflow files and fixes. If a workflow is added on a feature branch -and the tag is pushed before merging, the workflow won't exist at the tagged -commit and CI won't fire. - -**Why `--no-ff`:** Multi-commit feature branches (like the Kotlin SDK with 35 -files across 6 commits) look like they were committed directly to `main` with -fast-forward merges. `--no-ff` preserves the branch context. It also allows -reverting an entire feature with a single `git revert -m 1 `. - ---- - -## Distribution Registry Reference - -| Registry | Package Name | Import | Workflow | Immutable? | -|----------|-------------|--------|----------|------------| -| crates.io | `onde` | `onde = "1.0"` | `release-sdk-rust.yml` | Yes (yank hides, doesn't delete) | -| Swift Package Index | `onde-swift` (org: `ondeinference`) | `import Onde` | `release-sdk-swift.yml` → `onde-swift/release.yml` | Tags are immutable by convention | -| pub.dev | `onde_inference` | `import 'package:onde_inference/onde_inference.dart'` | `release-sdk-dart.yml` | Yes (no yank) | -| npm | `@ondeinference/react-native` | `import { OndeChatEngine } from "@ondeinference/react-native"` | `release-sdk-npm.yml` | Yes (unpublish within 72h only) | -| Maven Central | `com.ondeinference:onde-inference` | `implementation("com.ondeinference:onde-inference:1.0.0")` | `release-sdk-kotlin.yml` | Yes (cannot re-publish same version) | -| klibs.io | `onde-inference` | Same Maven Central coordinate | Auto-indexed from Maven Central | N/A (discovery only) | - ---- - ---- - -## React Native npm SDK - -**Package:** `@ondeinference/react-native` (scoped under `@ondeinference` org on npm) -**Location:** `sdk/react-native/` -**Architecture:** Expo native module wrapping Rust via C FFI - -### How it works - -``` -TypeScript (React Native) - │ import { OndeChatEngine } from "@ondeinference/react-native" - ▼ -Expo Module (Swift / Kotlin) - │ OndeInferenceModule — calls C FFI functions via @_silgen_name (iOS) / JNI (Android) - ▼ -Rust C FFI bridge (sdk/react-native/rust/) - │ extern "C" functions with JSON serialization, global tokio::Runtime - ▼ -onde crate (src/) - │ ChatEngine — tokio::sync::Mutex, mistral.rs inference - ▼ -mistral.rs → Metal (iOS) / CPU (Android) -``` - -### Key files - -| File | Purpose | -|------|---------| -| `sdk/react-native/package.json` | npm package — `@ondeinference/react-native` | -| `sdk/react-native/expo-module.config.json` | Expo autolinking config | -| `sdk/react-native/src/index.ts` | Public TypeScript API — `OndeChatEngine`, free functions, JSON ↔ camelCase conversion | -| `sdk/react-native/src/types.ts` | TypeScript type definitions mirroring Rust types | -| `sdk/react-native/src/OndeInferenceModule.ts` | `requireNativeModule("OndeInference")` bridge | -| `sdk/react-native/rust/src/lib.rs` | C FFI exports (`extern "C"`) + Android JNI wrappers | -| `sdk/react-native/ios/OndeInferenceModule.swift` | Swift Expo module — calls Rust via `@_silgen_name` | -| `sdk/react-native/android/.../OndeInferenceModule.kt` | Kotlin Expo module — calls Rust via JNI `external fun` | -| `sdk/react-native/scripts/build-rust.sh` | Cross-compile Rust for iOS (staticlib) + Android (cdylib) | - -### Building native libraries - -```bash -# iOS (requires rustup targets: aarch64-apple-ios, aarch64-apple-ios-sim) -cd sdk/react-native && ./scripts/build-rust.sh ios - -# Android (requires ANDROID_NDK_HOME) -cd sdk/react-native && ./scripts/build-rust.sh android - -# Both -cd sdk/react-native && ./scripts/build-rust.sh all -``` - -### Publishing to npm - -```bash -cd sdk/react-native -npm run build # TypeScript → build/ -npm publish --access public -``` - -The `--access public` flag is required for scoped packages on first publish. - ---- - -## Registry Immutability Summary - -All four registries are effectively immutable once a version is published: - -- **crates.io** — `cargo yank` removes from resolver but the tarball stays forever -- **pub.dev** — No retraction mechanism at all -- **npm** — `npm unpublish` works within 72 hours, but only if no dependents -- **Maven Central** — No retraction mechanism. Once published, it's permanent. -- **Swift (onde-swift tags)** — Convention: never force-push tags. Delete + re-create if needed. - -**Bottom line:** get the release right before publishing. Use `--dry-run` locally -and via `workflow_dispatch` before pushing a real tag. - ---- - -*Update this skill when adding new SDK targets (Python, Ruby, Kotlin), changing -CI runners, or modifying the tag/version validation logic.* \ No newline at end of file From bc0231b3748c4f8edbfff9e8aba7fe3ecf0870b0 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:45:17 +0200 Subject: [PATCH 25/28] chore: update copyright holder to Onde Inference Remove "Splitfire AB" from all user-facing copyright notices in LICENSE-APACHE, LICENSE-MIT, README.md files across the workspace, and from the workspace authors field in Cargo.toml. Co-Authored-By: siGit Code v1.6.6-acp --- Cargo.toml | 2 +- LICENSE-APACHE | 2 +- LICENSE-MIT | 2 +- README.md | 2 +- crates/ed-acp-testkit/LICENSE-APACHE | 2 +- crates/ed-acp-testkit/LICENSE-MIT | 2 +- crates/ed-acp-testkit/README.md | 2 +- crates/ed-acp-tui/LICENSE-APACHE | 2 +- crates/ed-acp-tui/LICENSE-MIT | 2 +- crates/ed-acp-tui/README.md | 2 +- crates/ed-acp/LICENSE-APACHE | 2 +- crates/ed-acp/LICENSE-MIT | 2 +- crates/ed-acp/README.md | 2 +- crates/ed-agent-tauri/LICENSE-APACHE | 2 +- crates/ed-agent-tauri/LICENSE-MIT | 2 +- crates/ed-agent-tauri/README.md | 2 +- crates/ed-agent/LICENSE-APACHE | 2 +- crates/ed-agent/LICENSE-MIT | 2 +- crates/ed-agent/README.md | 2 +- crates/ed-mcp/LICENSE-APACHE | 2 +- crates/ed-mcp/LICENSE-MIT | 2 +- crates/ed-mcp/README.md | 2 +- 22 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4aa3099..da0a8c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ version = "1.2.1" edition = "2021" rust-version = "1.94" license = "MIT OR Apache-2.0" -authors = ["Splitfire AB"] +authors = ["Onde Inference"] repository = "https://github.com/ondeinference/ed" homepage = "https://ondeinference.com" diff --git a/LICENSE-APACHE b/LICENSE-APACHE index 58885ec..bdc140a 100644 --- a/LICENSE-APACHE +++ b/LICENSE-APACHE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 Splitfire AB (Onde Inference) + Copyright 2026 Onde Inference Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/LICENSE-MIT b/LICENSE-MIT index 1b62ae8..4b6964c 100644 --- a/LICENSE-MIT +++ b/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Splitfire AB (Onde Inference) +Copyright (c) 2026 Onde Inference Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index d3250d9..b342736 100644 --- a/README.md +++ b/README.md @@ -90,5 +90,5 @@ Free the intelligence. ## Copyright -© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)). +© 2026 [Onde Inference](https://ondeinference.com). Licensed under MIT or Apache-2.0. diff --git a/crates/ed-acp-testkit/LICENSE-APACHE b/crates/ed-acp-testkit/LICENSE-APACHE index 58885ec..bdc140a 100644 --- a/crates/ed-acp-testkit/LICENSE-APACHE +++ b/crates/ed-acp-testkit/LICENSE-APACHE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 Splitfire AB (Onde Inference) + Copyright 2026 Onde Inference Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/crates/ed-acp-testkit/LICENSE-MIT b/crates/ed-acp-testkit/LICENSE-MIT index 1b62ae8..4b6964c 100644 --- a/crates/ed-acp-testkit/LICENSE-MIT +++ b/crates/ed-acp-testkit/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Splitfire AB (Onde Inference) +Copyright (c) 2026 Onde Inference Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/crates/ed-acp-testkit/README.md b/crates/ed-acp-testkit/README.md index e297af9..34b1254 100644 --- a/crates/ed-acp-testkit/README.md +++ b/crates/ed-acp-testkit/README.md @@ -13,5 +13,5 @@ The scriptable MCP server for tests is in [`ed-mcp`](https://crates.io/crates/ed ## Copyright -© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)). +© 2026 [Onde Inference](https://ondeinference.com). Licensed under MIT or Apache-2.0. diff --git a/crates/ed-acp-tui/LICENSE-APACHE b/crates/ed-acp-tui/LICENSE-APACHE index 58885ec..bdc140a 100644 --- a/crates/ed-acp-tui/LICENSE-APACHE +++ b/crates/ed-acp-tui/LICENSE-APACHE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 Splitfire AB (Onde Inference) + Copyright 2026 Onde Inference Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/crates/ed-acp-tui/LICENSE-MIT b/crates/ed-acp-tui/LICENSE-MIT index 1b62ae8..4b6964c 100644 --- a/crates/ed-acp-tui/LICENSE-MIT +++ b/crates/ed-acp-tui/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Splitfire AB (Onde Inference) +Copyright (c) 2026 Onde Inference Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/crates/ed-acp-tui/README.md b/crates/ed-acp-tui/README.md index d40072a..7dfdea0 100644 --- a/crates/ed-acp-tui/README.md +++ b/crates/ed-acp-tui/README.md @@ -9,5 +9,5 @@ permission requests with `y` (allow), `a` (always), `n` (reject) or `r` (always ## Copyright -© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)). +© 2026 [Onde Inference](https://ondeinference.com). Licensed under MIT or Apache-2.0. diff --git a/crates/ed-acp/LICENSE-APACHE b/crates/ed-acp/LICENSE-APACHE index 58885ec..bdc140a 100644 --- a/crates/ed-acp/LICENSE-APACHE +++ b/crates/ed-acp/LICENSE-APACHE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 Splitfire AB (Onde Inference) + Copyright 2026 Onde Inference Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/crates/ed-acp/LICENSE-MIT b/crates/ed-acp/LICENSE-MIT index 1b62ae8..4b6964c 100644 --- a/crates/ed-acp/LICENSE-MIT +++ b/crates/ed-acp/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Splitfire AB (Onde Inference) +Copyright (c) 2026 Onde Inference Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/crates/ed-acp/README.md b/crates/ed-acp/README.md index d0ff660..eb9b149 100644 --- a/crates/ed-acp/README.md +++ b/crates/ed-acp/README.md @@ -13,5 +13,5 @@ drive it from a terminal with [`ed-acp-tui`](https://crates.io/crates/ed-acp-tui ## Copyright -© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)). +© 2026 [Onde Inference](https://ondeinference.com). Licensed under MIT or Apache-2.0. diff --git a/crates/ed-agent-tauri/LICENSE-APACHE b/crates/ed-agent-tauri/LICENSE-APACHE index 58885ec..bdc140a 100644 --- a/crates/ed-agent-tauri/LICENSE-APACHE +++ b/crates/ed-agent-tauri/LICENSE-APACHE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 Splitfire AB (Onde Inference) + Copyright 2026 Onde Inference Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/crates/ed-agent-tauri/LICENSE-MIT b/crates/ed-agent-tauri/LICENSE-MIT index 1b62ae8..4b6964c 100644 --- a/crates/ed-agent-tauri/LICENSE-MIT +++ b/crates/ed-agent-tauri/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Splitfire AB (Onde Inference) +Copyright (c) 2026 Onde Inference Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/crates/ed-agent-tauri/README.md b/crates/ed-agent-tauri/README.md index 96df0fe..ffcc2fd 100644 --- a/crates/ed-agent-tauri/README.md +++ b/crates/ed-agent-tauri/README.md @@ -46,5 +46,5 @@ Without `EdSessions`, tool calling needs both halves from you: build the agent w ## Copyright -© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)). +© 2026 [Onde Inference](https://ondeinference.com). Licensed under MIT or Apache-2.0. diff --git a/crates/ed-agent/LICENSE-APACHE b/crates/ed-agent/LICENSE-APACHE index 58885ec..bdc140a 100644 --- a/crates/ed-agent/LICENSE-APACHE +++ b/crates/ed-agent/LICENSE-APACHE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 Splitfire AB (Onde Inference) + Copyright 2026 Onde Inference Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/crates/ed-agent/LICENSE-MIT b/crates/ed-agent/LICENSE-MIT index 1b62ae8..4b6964c 100644 --- a/crates/ed-agent/LICENSE-MIT +++ b/crates/ed-agent/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Splitfire AB (Onde Inference) +Copyright (c) 2026 Onde Inference Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/crates/ed-agent/README.md b/crates/ed-agent/README.md index f031631..28aa75c 100644 --- a/crates/ed-agent/README.md +++ b/crates/ed-agent/README.md @@ -66,5 +66,5 @@ support list silently disables chat on a platform that would have worked. ## Copyright -© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)). +© 2026 [Onde Inference](https://ondeinference.com). Licensed under MIT or Apache-2.0. diff --git a/crates/ed-mcp/LICENSE-APACHE b/crates/ed-mcp/LICENSE-APACHE index 58885ec..bdc140a 100644 --- a/crates/ed-mcp/LICENSE-APACHE +++ b/crates/ed-mcp/LICENSE-APACHE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 Splitfire AB (Onde Inference) + Copyright 2026 Onde Inference Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/crates/ed-mcp/LICENSE-MIT b/crates/ed-mcp/LICENSE-MIT index 1b62ae8..4b6964c 100644 --- a/crates/ed-mcp/LICENSE-MIT +++ b/crates/ed-mcp/LICENSE-MIT @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 Splitfire AB (Onde Inference) +Copyright (c) 2026 Onde Inference Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/crates/ed-mcp/README.md b/crates/ed-mcp/README.md index efdd3bf..8841d3d 100644 --- a/crates/ed-mcp/README.md +++ b/crates/ed-mcp/README.md @@ -29,5 +29,5 @@ let tools = McpToolset::connect_specs( ## Copyright -© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)). +© 2026 [Onde Inference](https://ondeinference.com). Licensed under MIT or Apache-2.0. From d8fc53e3487df2ae619610e3a7f0b349bed574b6 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:51:01 +0200 Subject: [PATCH 26/28] Add ed-onde-account, an Onde account client Signs a user in through the ondeinference.com API, finds or creates their app, activates it and returns the Onde Cloud key. ensure_key reuses an app by name, so calling it twice never makes a second one. The crate holds no Onde secrets: the API adds the smbCloud credentials on the server. Covered by tests against an in-process mock of the API. Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: siGit Code v1.6.6 --- Cargo.lock | 12 + Cargo.toml | 2 + README.md | 1 + crates/ed-onde-account/Cargo.toml | 22 ++ crates/ed-onde-account/LICENSE-APACHE | 202 +++++++++++++ crates/ed-onde-account/LICENSE-MIT | 21 ++ crates/ed-onde-account/README.md | 35 +++ crates/ed-onde-account/src/lib.rs | 393 ++++++++++++++++++++++++++ crates/ed-onde-account/tests/api.rs | 241 ++++++++++++++++ 9 files changed, 929 insertions(+) create mode 100644 crates/ed-onde-account/Cargo.toml create mode 100644 crates/ed-onde-account/LICENSE-APACHE create mode 100644 crates/ed-onde-account/LICENSE-MIT create mode 100644 crates/ed-onde-account/README.md create mode 100644 crates/ed-onde-account/src/lib.rs create mode 100644 crates/ed-onde-account/tests/api.rs diff --git a/Cargo.lock b/Cargo.lock index 0b19fff..dbd4a68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2062,6 +2062,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "ed-onde-account" +version = "1.2.1" +dependencies = [ + "axum", + "reqwest 0.13.5", + "serde", + "serde_json", + "thiserror 2.0.21", + "tokio", +] + [[package]] name = "ed-uniffi-bindgen" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index da0a8c1..0075ce4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/ed-acp", "crates/ed-acp-tui", "crates/ed-acp-testkit", + "crates/ed-onde-account", "uniffi-bindgen", ] @@ -62,6 +63,7 @@ ed-mcp = { version = "1.2.1", path = "crates/ed-mcp" } ed-acp = { version = "1.2.1", path = "crates/ed-acp" } ed-acp-tui = { version = "1.2.1", path = "crates/ed-acp-tui" } ed-acp-testkit = { version = "1.2.1", path = "crates/ed-acp-testkit" } +ed-onde-account = { version = "1.2.1", path = "crates/ed-onde-account" } # gemm-common (via candle, via onde) depends on sysctl for every # `target_vendor = "apple"`, but sysctl 0.6.0 only has a `sys` module behind diff --git a/README.md b/README.md index b342736..fba4de1 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ The Onde Agent Platform crates, for agents that editors drive over the Agent Cli | [`ed-acp`](crates/ed-acp) | An ACP v1 server: sessions, auth, the turn loop on Onde Cloud, tool approval, workspace tools and MCP. A product supplies a `Profile`. | | [`ed-acp-tui`](crates/ed-acp-tui) | A terminal UI for any ACP agent. | | [`ed-mcp`](crates/ed-mcp) | MCP client glue over `rmcp`, the official Rust SDK: stdio and streamable HTTP servers, bearer tokens, tool namespacing, timeouts, progress and cancellation. With the `agent` feature, `McpExecutor` serves a toolset to Ed as tools. | +| [`ed-onde-account`](crates/ed-onde-account) | Onde Inference account client: sign in, activate an app and get its Onde Cloud API key through ondeinference.com, with no Onde secrets in the host app. | | [`ed-acp-testkit`](crates/ed-acp-testkit) | Mock Onde Cloud endpoint, raw ACP client, the ACP registry probe and the ACP v1 conformance suite. | Free the intelligence. diff --git a/crates/ed-onde-account/Cargo.toml b/crates/ed-onde-account/Cargo.toml new file mode 100644 index 0000000..76f551f --- /dev/null +++ b/crates/ed-onde-account/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ed-onde-account" +description = "Onde Inference account client for Onde agents: sign in, list and activate apps, and mint an Onde Cloud API key through the ondeinference.com API. Holds no Onde secrets." +keywords = ["onde", "agent", "auth", "api-key"] +categories = ["api-bindings", "authentication"] +version.workspace = true +edition = "2024" +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +axum = "0.8" +tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread"] } diff --git a/crates/ed-onde-account/LICENSE-APACHE b/crates/ed-onde-account/LICENSE-APACHE new file mode 100644 index 0000000..58885ec --- /dev/null +++ b/crates/ed-onde-account/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Splitfire AB (Onde Inference) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/ed-onde-account/LICENSE-MIT b/crates/ed-onde-account/LICENSE-MIT new file mode 100644 index 0000000..1b62ae8 --- /dev/null +++ b/crates/ed-onde-account/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Splitfire AB (Onde Inference) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/ed-onde-account/README.md b/crates/ed-onde-account/README.md new file mode 100644 index 0000000..b78c05b --- /dev/null +++ b/crates/ed-onde-account/README.md @@ -0,0 +1,35 @@ +# ed-onde-account + +An Onde Inference account client for [Ed](https://crates.io/crates/ed-agent) agents. + +It signs a user in to their Onde account, finds or creates their app, activates it, and returns +the Onde Cloud API key (`app_id:app_secret`) an agent authenticates with. Every call goes through +the ondeinference.com API, which keeps Onde's smbCloud credentials on the server, so nothing +secret ships in the app that embeds this crate. + +```rust +use ed_onde_account::{OndeAccount, SignIn}; + +let account = OndeAccount::new(); // https://ondeinference.com, production +match account.sign_in("me@example.com", "password").await? { + SignIn::Ready(token) => { + // Reuses the app named "My Agent", or creates and activates it. + let key = account.ensure_key(&token, "My Agent").await?; + println!("{}", key.as_str()); // app_id:app_secret + } + SignIn::NotFound => { /* no account yet */ } + SignIn::Incomplete { .. } => { /* e.g. the email isn't confirmed */ } +} +``` + +- `sign_in`, `me`, `apps`, `create_app`, `activate` and `ensure_key`. +- `ensure_key` is idempotent: it never creates a second app with the same name. +- Errors are typed. `Error::Unauthorized` means the token expired and the user must sign in again; + `Error::Forbidden` means the account may not create apps. +- `AccessToken` and `ApiKey` redact themselves in `Debug` output. + +Creating apps needs an Onde account that is allowed to, which every signed-up user is. + +## License + +MIT OR Apache-2.0 diff --git a/crates/ed-onde-account/src/lib.rs b/crates/ed-onde-account/src/lib.rs new file mode 100644 index 0000000..e747dc9 --- /dev/null +++ b/crates/ed-onde-account/src/lib.rs @@ -0,0 +1,393 @@ +//! A small client for the Onde Inference account API at ondeinference.com. +//! +//! It signs a user in, finds or creates their app, activates it and returns the Onde Cloud +//! API key (`app_id:app_secret`) an agent authenticates with. Every call goes through +//! onde-web's own API, which adds the smbCloud client credentials on the server, so no +//! Onde secret ships in whatever embeds this crate. +//! +//! ```no_run +//! # async fn run() -> Result<(), ed_onde_account::Error> { +//! use ed_onde_account::{OndeAccount, SignIn}; +//! +//! let account = OndeAccount::new(); +//! if let SignIn::Ready(token) = account.sign_in("me@example.com", "password").await? { +//! let key = account.ensure_key(&token, "My Agent").await?; +//! // key.as_str() is "app_id:app_secret" +//! } +//! # Ok(()) +//! # } +//! ``` + +use std::fmt; + +use reqwest::{Method, RequestBuilder, StatusCode, Url}; +use serde::{Deserialize, Deserializer}; +use serde_json::{Value, json}; + +/// The production API host. +pub const DEFAULT_BASE_URL: &str = "https://ondeinference.com"; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The access token is missing, expired or revoked. Sign in again. + #[error("not signed in, or the session expired")] + Unauthorized, + /// Sign-in was refused: the email and password don't match. + #[error("incorrect email or password")] + InvalidCredentials, + /// Signed in, but this account may not do that. + #[error("this Onde account is not allowed to do that")] + Forbidden, + /// The user's app with this name exists but is suspended, so it cannot get a key. + #[error("the app \"{0}\" is suspended")] + Suspended(String), + /// The API answered with another failure status. + #[error("Onde API error {status}: {message}")] + Http { status: u16, message: String }, + /// The API answered, but not with the shape this client expects. + #[error("unexpected response from the Onde API: {0}")] + Unexpected(String), + /// The request never completed. + #[error("could not reach the Onde API: {0}")] + Transport(#[from] reqwest::Error), +} + +/// Which Onde backend to talk to. Production unless a host is testing against the dev stack. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum OndeEnv { + #[default] + Production, + Development, +} + +impl OndeEnv { + fn as_str(self) -> &'static str { + match self { + Self::Production => "production", + Self::Development => "development", + } + } +} + +/// The smbCloud access token for a signed-in user. Debug output is redacted. +#[derive(Clone, PartialEq, Eq)] +pub struct AccessToken(String); + +impl AccessToken { + /// Wrap a token, stripping surrounding whitespace and any `Bearer ` prefixes. + pub fn new(token: impl AsRef) -> Self { + let mut token = token.as_ref().trim(); + while let Some(prefix) = token.get(..7) { + if !prefix.eq_ignore_ascii_case("bearer ") { + break; + } + token = token[7..].trim_start(); + } + Self(token.to_owned()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for AccessToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AccessToken()") + } +} + +/// An Onde Cloud API key, `app_id:app_secret`. Debug output is redacted. +#[derive(Clone, PartialEq, Eq)] +pub struct ApiKey(String); + +impl ApiKey { + pub fn new(app_id: &str, app_secret: &str) -> Self { + Self(format!("{app_id}:{app_secret}")) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for ApiKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ApiKey()") + } +} + +/// How a sign-in attempt ended. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SignIn { + Ready(AccessToken), + /// No account for this email. + NotFound, + /// The account exists but cannot sign in yet, for instance an unconfirmed email. + Incomplete { + error_code: Option, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AppStatus { + Provisioning, + Active, + Suspended, + #[serde(other)] + Unknown, +} + +/// An Onde app, the thing an API key belongs to. +#[derive(Clone, Deserialize)] +pub struct OndeApp { + #[serde(deserialize_with = "string_or_number")] + pub id: String, + pub name: String, + pub status: AppStatus, + #[serde(default, deserialize_with = "opt_string_or_number")] + pub app_id: Option, + pub app_secret: Option, +} + +impl fmt::Debug for OndeApp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OndeApp") + .field("id", &self.id) + .field("name", &self.name) + .field("status", &self.status) + .field( + "app_secret", + &self.app_secret.as_ref().map(|_| ""), + ) + .finish() + } +} + +impl OndeApp { + /// The key for this app, if the secret is present. + pub fn api_key(&self) -> Option { + let secret = self.app_secret.as_deref().filter(|s| !s.is_empty())?; + Some(ApiKey::new( + self.app_id.as_deref().unwrap_or(&self.id), + secret, + )) + } +} + +/// The signed-in user. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Profile { + pub email: String, + pub name: Option, + /// Whether the account may create apps (and so keys). + pub can_create: bool, +} + +#[derive(Debug, Clone)] +pub struct OndeAccount { + http: reqwest::Client, + base_url: Url, + env: OndeEnv, +} + +impl Default for OndeAccount { + fn default() -> Self { + Self::new() + } +} + +impl OndeAccount { + /// Production at ondeinference.com. + pub fn new() -> Self { + Self::with_base_url(DEFAULT_BASE_URL).expect("the default base URL is valid") + } + + /// Point at another host, such as a local onde-web or a test server. + pub fn with_base_url(base_url: &str) -> Result { + let base_url = Url::parse(base_url) + .map_err(|e| Error::Unexpected(format!("invalid base URL: {e}")))?; + Ok(Self { + http: reqwest::Client::new(), + base_url, + env: OndeEnv::default(), + }) + } + + pub fn with_env(mut self, env: OndeEnv) -> Self { + self.env = env; + self + } + + pub async fn sign_in(&self, email: &str, password: &str) -> Result { + let body = json!({ "email": email.trim(), "password": password }); + let value = self + .send(self.request(Method::POST, "/api/auth/sign-in").json(&body)) + .await + // There is no token to expire yet, so a 401 here is a wrong password. + .map_err(|e| match e { + Error::Unauthorized => Error::InvalidCredentials, + e => e, + })?; + match value.get("status").and_then(Value::as_str) { + Some("ready") => match value.get("accessToken").and_then(Value::as_str) { + Some(token) if !AccessToken::new(token).as_str().is_empty() => { + Ok(SignIn::Ready(AccessToken::new(token))) + } + _ => Err(Error::Unexpected( + "sign-in was ready without a token".into(), + )), + }, + Some("notFound") => Ok(SignIn::NotFound), + Some("incomplete") => Ok(SignIn::Incomplete { + error_code: value.get("errorCode").and_then(Value::as_i64), + }), + _ => Err(Error::Unexpected("unknown sign-in status".into())), + } + } + + pub async fn me(&self, token: &AccessToken) -> Result { + let value = self + .send( + self.request(Method::GET, "/api/auth/me") + .bearer_auth(token.as_str()), + ) + .await?; + let flag = |key: &str| value.get(key).and_then(Value::as_bool); + Ok(Profile { + email: value + .get("email") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + name: value.get("name").and_then(Value::as_str).map(str::to_owned), + can_create: flag("can_create_gresiq_apps") + .or_else(|| flag("can_manage_all_gresiq_apps")) + .unwrap_or(false), + }) + } + + pub async fn apps(&self, token: &AccessToken) -> Result> { + let value = self + .send( + self.request(Method::GET, "/api/gresiq/apps") + .bearer_auth(token.as_str()), + ) + .await?; + decode(value) + } + + pub async fn create_app(&self, token: &AccessToken, name: &str) -> Result { + let value = self + .send( + self.request(Method::POST, "/api/gresiq/apps") + .bearer_auth(token.as_str()) + .json(&json!({ "gresiq_app": { "name": name } })), + ) + .await?; + decode(value) + } + + /// Move a provisioning app to `active`. Onde Cloud refuses its key until then. + pub async fn activate(&self, token: &AccessToken, id: &str) -> Result { + let value = self + .send( + self.request(Method::PATCH, &format!("/api/gresiq/apps/{id}")) + .bearer_auth(token.as_str()) + .json(&json!({ "gresiq_app": { "status": "active" } })), + ) + .await?; + decode(value) + } + + /// Reuse the user's app named `name` (or create it), activate it if needed and return + /// its key. Safe to call again: it never creates a second app with the same name. + pub async fn ensure_key(&self, token: &AccessToken, name: &str) -> Result { + let apps = self.apps(token).await?; + let named = || apps.iter().filter(|a| a.name == name); + let existing = named() + .find(|a| a.status == AppStatus::Active) + .or_else(|| named().find(|a| a.status != AppStatus::Suspended)) + .cloned(); + + let mut app = match existing { + Some(app) => app, + // Whatever is left under this name is suspended. + None if named().next().is_some() => return Err(Error::Suspended(name.to_owned())), + None => self.create_app(token, name).await?, + }; + if app.status != AppStatus::Active { + app = self.activate(token, &app.id).await?; + } + app.api_key() + .ok_or_else(|| Error::Unexpected("the app came back without a secret".into())) + } + + fn request(&self, method: Method, path: &str) -> RequestBuilder { + let mut url = self.base_url.clone(); + url.set_path(path); + url.query_pairs_mut().append_pair("env", self.env.as_str()); + self.http.request(method, url) + } + + async fn send(&self, request: RequestBuilder) -> Result { + let response = request.send().await?; + let status = response.status(); + let text = response.text().await?; + let body: Value = serde_json::from_str(&text).unwrap_or(Value::Null); + if status.is_success() { + return Ok(body); + } + Err(match status { + StatusCode::UNAUTHORIZED => Error::Unauthorized, + StatusCode::FORBIDDEN => Error::Forbidden, + _ => Error::Http { + status: status.as_u16(), + message: ["message", "error"] + .iter() + .find_map(|k| body.get(*k).and_then(Value::as_str)) + .map(str::to_owned) + .or_else(|| { + body.get("errors") + .and_then(Value::as_array) + .map(|e| { + e.iter() + .filter_map(Value::as_str) + .collect::>() + .join("; ") + }) + .filter(|m| !m.is_empty()) + }) + .unwrap_or_else(|| "request failed".to_owned()), + }, + }) + } +} + +fn decode Deserialize<'de>>(value: Value) -> Result { + serde_json::from_value(value).map_err(|e| Error::Unexpected(e.to_string())) +} + +fn string_or_number<'de, D: Deserializer<'de>>(d: D) -> Result { + match Value::deserialize(d)? { + Value::String(s) => Ok(s), + Value::Number(n) => Ok(n.to_string()), + other => Err(serde::de::Error::custom(format!( + "expected an id, got {other}" + ))), + } +} + +fn opt_string_or_number<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + match Option::::deserialize(d)? { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) => Ok(Some(s)), + Some(Value::Number(n)) => Ok(Some(n.to_string())), + Some(other) => Err(serde::de::Error::custom(format!( + "expected an id, got {other}" + ))), + } +} diff --git a/crates/ed-onde-account/tests/api.rs b/crates/ed-onde-account/tests/api.rs new file mode 100644 index 0000000..14a3817 --- /dev/null +++ b/crates/ed-onde-account/tests/api.rs @@ -0,0 +1,241 @@ +//! The client against an in-process stand-in for onde-web's API. + +use std::sync::{Arc, Mutex}; + +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::{get, patch, post}; +use axum::{Json, Router}; +use ed_onde_account::{AccessToken, Error, OndeAccount, OndeEnv, SignIn}; +use serde_json::{Value, json}; +use std::collections::HashMap; + +const TOKEN: &str = "jwt-123"; + +#[derive(Default)] +struct Mock { + apps: Vec, + /// Every `(method, path, env)` the server saw. + calls: Vec, +} + +type Shared = Arc>; + +fn app(id: &str, name: &str, status: &str) -> Value { + json!({ "id": id, "name": name, "status": status, "app_id": id, "app_secret": format!("sec-{id}") }) +} + +fn authorized(headers: &HeaderMap) -> bool { + headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v == format!("Bearer {TOKEN}")) +} + +async fn serve(apps: Vec) -> (OndeAccount, Shared) { + let state: Shared = Arc::new(Mutex::new(Mock { + apps, + calls: vec![], + })); + + async fn sign_in( + State(s): State, + Query(q): Query>, + Json(body): Json, + ) -> (StatusCode, Json) { + s.lock() + .unwrap() + .calls + .push(format!("sign-in env={}", q["env"])); + if body["email"] == "wrong@x.io" { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ "message": "Invalid password." })), + ); + } + ( + StatusCode::OK, + Json(match body["email"].as_str().unwrap() { + "ready@x.io" => { + json!({ "status": "ready", "accessToken": format!("Bearer {TOKEN}") }) + } + "new@x.io" => json!({ "status": "notFound" }), + _ => json!({ "status": "incomplete", "errorCode": 100 }), + }), + ) + } + async fn me(headers: HeaderMap) -> (StatusCode, Json) { + if !authorized(&headers) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Unauthorized" })), + ); + } + ( + StatusCode::OK, + Json(json!({ "email": "ready@x.io", "name": "Ready", "can_create_gresiq_apps": true })), + ) + } + async fn list(State(s): State, headers: HeaderMap) -> (StatusCode, Json) { + if !authorized(&headers) { + return (StatusCode::UNAUTHORIZED, Json(json!({}))); + } + ( + StatusCode::OK, + Json(Value::Array(s.lock().unwrap().apps.clone())), + ) + } + async fn create(State(s): State, Json(body): Json) -> (StatusCode, Json) { + let mut s = s.lock().unwrap(); + s.calls.push("create".into()); + let created = app( + "new-1", + body["gresiq_app"]["name"].as_str().unwrap(), + "provisioning", + ); + s.apps.push(created.clone()); + (StatusCode::CREATED, Json(created)) + } + async fn activate( + State(s): State, + Path(id): Path, + Json(body): Json, + ) -> Json { + let mut s = s.lock().unwrap(); + s.calls + .push(format!("activate {id} {}", body["gresiq_app"]["status"])); + let found = s.apps.iter_mut().find(|a| a["id"] == id).unwrap(); + found["status"] = json!("active"); + Json(found.clone()) + } + + let router = Router::new() + .route("/api/auth/sign-in", post(sign_in)) + .route("/api/auth/me", get(me)) + .route("/api/gresiq/apps", get(list).post(create)) + .route("/api/gresiq/apps/{id}", patch(activate)) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + + let account = OndeAccount::with_base_url(&format!("http://{addr}")).unwrap(); + (account, state) +} + +#[tokio::test] +async fn sign_in_outcomes_and_token_normalization() { + let (account, state) = serve(vec![]).await; + + let SignIn::Ready(token) = account.sign_in("ready@x.io", "pw").await.unwrap() else { + panic!("expected ready"); + }; + assert_eq!(token.as_str(), TOKEN, "the Bearer prefix is stripped"); + assert_eq!(format!("{token:?}"), "AccessToken()"); + + assert_eq!( + account.sign_in("new@x.io", "pw").await.unwrap(), + SignIn::NotFound + ); + assert_eq!( + account.sign_in("unconfirmed@x.io", "pw").await.unwrap(), + SignIn::Incomplete { + error_code: Some(100) + } + ); + + assert!(matches!( + account.sign_in("wrong@x.io", "pw").await, + Err(Error::InvalidCredentials) + )); + + let dev = account.clone().with_env(OndeEnv::Development); + dev.sign_in("new@x.io", "pw").await.unwrap(); + let calls = state.lock().unwrap().calls.clone(); + assert_eq!(calls[0], "sign-in env=production"); + assert_eq!(calls[4], "sign-in env=development"); +} + +#[test] +fn access_token_strips_repeated_prefixes() { + assert_eq!(AccessToken::new(" bearer Bearer abc ").as_str(), "abc"); + assert_eq!(AccessToken::new("abc").as_str(), "abc"); +} + +#[tokio::test] +async fn me_reads_the_profile() { + let (account, _) = serve(vec![]).await; + let profile = account.me(&AccessToken::new(TOKEN)).await.unwrap(); + assert_eq!(profile.email, "ready@x.io"); + assert!(profile.can_create); +} + +#[tokio::test] +async fn ensure_key_reuses_an_active_app() { + let (account, state) = serve(vec![ + app("a1", "Other", "active"), + app("a2", "KaroKowe", "active"), + ]) + .await; + let key = account + .ensure_key(&AccessToken::new(TOKEN), "KaroKowe") + .await + .unwrap(); + assert_eq!(key.as_str(), "a2:sec-a2"); + assert_eq!(format!("{key:?}"), "ApiKey()"); + assert!( + state.lock().unwrap().calls.is_empty(), + "nothing was created or patched" + ); +} + +#[tokio::test] +async fn ensure_key_activates_a_provisioning_app() { + let (account, state) = serve(vec![app("a1", "KaroKowe", "provisioning")]).await; + let key = account + .ensure_key(&AccessToken::new(TOKEN), "KaroKowe") + .await + .unwrap(); + assert_eq!(key.as_str(), "a1:sec-a1"); + assert_eq!(state.lock().unwrap().calls, vec![r#"activate a1 "active""#]); +} + +#[tokio::test] +async fn ensure_key_creates_then_activates_when_none_exists() { + let (account, state) = serve(vec![app("a1", "Other", "active")]).await; + let token = AccessToken::new(TOKEN); + let key = account.ensure_key(&token, "KaroKowe").await.unwrap(); + assert_eq!(key.as_str(), "new-1:sec-new-1"); + assert_eq!( + state.lock().unwrap().calls, + vec!["create".to_owned(), r#"activate new-1 "active""#.to_owned()] + ); + // A second call finds it instead of creating another. + account.ensure_key(&token, "KaroKowe").await.unwrap(); + assert_eq!(state.lock().unwrap().calls.len(), 2); +} + +#[tokio::test] +async fn ensure_key_refuses_a_suspended_app() { + let (account, _) = serve(vec![app("a1", "KaroKowe", "suspended")]).await; + let err = account + .ensure_key(&AccessToken::new(TOKEN), "KaroKowe") + .await + .unwrap_err(); + assert!(matches!(err, Error::Suspended(name) if name == "KaroKowe")); +} + +#[tokio::test] +async fn an_expired_token_is_unauthorized() { + let (account, _) = serve(vec![]).await; + let stale = AccessToken::new("expired"); + assert!(matches!(account.me(&stale).await, Err(Error::Unauthorized))); + assert!(matches!( + account.apps(&stale).await, + Err(Error::Unauthorized) + )); + assert!(matches!( + account.ensure_key(&stale, "KaroKowe").await, + Err(Error::Unauthorized) + )); +} From ba341cfddafd984930a9d2cd2e9248dfb1f958fb Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:24:22 +0200 Subject: [PATCH 27/28] Make ensure_key settle on one app when two first calls race After creating an app, ensure_key lists again and picks the oldest active-or-provisioning app with that name, so two concurrent first calls return the same key. The doc comment now says what holds: the extra app can still exist, since the client has no delete call. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: siGit Code v1.6.3 --- crates/ed-onde-account/src/lib.rs | 42 +++++++++++++++++++++-------- crates/ed-onde-account/tests/api.rs | 26 ++++++++++++++++++ 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/crates/ed-onde-account/src/lib.rs b/crates/ed-onde-account/src/lib.rs index e747dc9..57fe3e4 100644 --- a/crates/ed-onde-account/src/lib.rs +++ b/crates/ed-onde-account/src/lib.rs @@ -304,20 +304,24 @@ impl OndeAccount { } /// Reuse the user's app named `name` (or create it), activate it if needed and return - /// its key. Safe to call again: it never creates a second app with the same name. + /// its key. Calls made one after another reuse the app. Nothing makes list-then-create + /// atomic, so two concurrent first calls can each create one; both then list again and + /// settle on the same (oldest) app, so they return the same key. The extra app stays in + /// the account, because this client has no delete call. pub async fn ensure_key(&self, token: &AccessToken, name: &str) -> Result { let apps = self.apps(token).await?; - let named = || apps.iter().filter(|a| a.name == name); - let existing = named() - .find(|a| a.status == AppStatus::Active) - .or_else(|| named().find(|a| a.status != AppStatus::Suspended)) - .cloned(); - - let mut app = match existing { - Some(app) => app, + let mut app = match pick(&apps, name) { + Some(app) => app.clone(), // Whatever is left under this name is suspended. - None if named().next().is_some() => return Err(Error::Suspended(name.to_owned())), - None => self.create_app(token, name).await?, + None if apps.iter().any(|a| a.name == name) => { + return Err(Error::Suspended(name.to_owned())); + } + None => { + let created = self.create_app(token, name).await?; + // Another caller may have created one too: settle on the same app they do. + let apps = self.apps(token).await?; + pick(&apps, name).cloned().unwrap_or(created) + } }; if app.status != AppStatus::Active { app = self.activate(token, &app.id).await?; @@ -391,3 +395,19 @@ fn opt_string_or_number<'de, D: Deserializer<'de>>(d: D) -> Result(apps: &'a [OndeApp], name: &str) -> Option<&'a OndeApp> { + apps.iter() + .filter(|a| a.name == name && a.status != AppStatus::Suspended) + .min_by_key(|a| { + let numeric = a.id.parse::().ok(); + ( + a.status != AppStatus::Active, + numeric.is_none(), + numeric, + a.id.as_str(), + ) + }) +} diff --git a/crates/ed-onde-account/tests/api.rs b/crates/ed-onde-account/tests/api.rs index 14a3817..680a17d 100644 --- a/crates/ed-onde-account/tests/api.rs +++ b/crates/ed-onde-account/tests/api.rs @@ -15,6 +15,8 @@ const TOKEN: &str = "jwt-123"; #[derive(Default)] struct Mock { apps: Vec, + /// An app another caller creates just before this server handles a `POST`. + rival: Option, /// Every `(method, path, env)` the server saw. calls: Vec, } @@ -35,6 +37,7 @@ fn authorized(headers: &HeaderMap) -> bool { async fn serve(apps: Vec) -> (OndeAccount, Shared) { let state: Shared = Arc::new(Mutex::new(Mock { apps, + rival: None, calls: vec![], })); @@ -88,6 +91,9 @@ async fn serve(apps: Vec) -> (OndeAccount, Shared) { async fn create(State(s): State, Json(body): Json) -> (StatusCode, Json) { let mut s = s.lock().unwrap(); s.calls.push("create".into()); + if let Some(rival) = s.rival.take() { + s.apps.push(rival); + } let created = app( "new-1", body["gresiq_app"]["name"].as_str().unwrap(), @@ -215,6 +221,26 @@ async fn ensure_key_creates_then_activates_when_none_exists() { assert_eq!(state.lock().unwrap().calls.len(), 2); } +#[tokio::test] +async fn ensure_key_converges_when_a_create_races() { + let (account, state) = serve(vec![]).await; + // The other caller's create lands first, so its app has the lower id. + state.lock().unwrap().rival = Some(app("7", "KaroKowe", "provisioning")); + let key = account + .ensure_key(&AccessToken::new(TOKEN), "KaroKowe") + .await + .unwrap(); + assert_eq!( + key.as_str(), + "7:sec-7", + "the oldest app wins, not the one just created" + ); + assert_eq!( + state.lock().unwrap().calls, + vec!["create".to_owned(), r#"activate 7 "active""#.to_owned()] + ); +} + #[tokio::test] async fn ensure_key_refuses_a_suspended_app() { let (account, _) = serve(vec![app("a1", "KaroKowe", "suspended")]).await; From c23988fb7ed67cb13f50cb0924848079e4e06386 Mon Sep 17 00:00:00 2001 From: Seto Elkahfi <1797197+setoelkahfi@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:23:35 +0200 Subject: [PATCH 28/28] Release 1.2.2: bump the workspace Co-Authored-By: OndeCode v1.0.1-acp --- Cargo.lock | 16 ++++++++-------- Cargo.toml | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dbd4a68..3a14fba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1958,7 +1958,7 @@ checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" [[package]] name = "ed-acp" -version = "1.2.1" +version = "1.2.2" dependencies = [ "agent-client-protocol", "anyhow", @@ -1981,7 +1981,7 @@ dependencies = [ [[package]] name = "ed-acp-testkit" -version = "1.2.1" +version = "1.2.2" dependencies = [ "serde_json", "tokio", @@ -1990,7 +1990,7 @@ dependencies = [ [[package]] name = "ed-acp-tui" -version = "1.2.1" +version = "1.2.2" dependencies = [ "agent-client-protocol", "anyhow", @@ -2002,7 +2002,7 @@ dependencies = [ [[package]] name = "ed-agent" -version = "1.2.1" +version = "1.2.2" dependencies = [ "async-trait", "futures-core", @@ -2016,7 +2016,7 @@ dependencies = [ [[package]] name = "ed-agent-ffi" -version = "1.2.1" +version = "1.2.2" dependencies = [ "async-trait", "cc", @@ -2031,7 +2031,7 @@ dependencies = [ [[package]] name = "ed-agent-tauri" -version = "1.2.1" +version = "1.2.2" dependencies = [ "async-trait", "ed-agent", @@ -2045,7 +2045,7 @@ dependencies = [ [[package]] name = "ed-mcp" -version = "1.2.1" +version = "1.2.2" dependencies = [ "agent-client-protocol", "anyhow", @@ -2064,7 +2064,7 @@ dependencies = [ [[package]] name = "ed-onde-account" -version = "1.2.1" +version = "1.2.2" dependencies = [ "axum", "reqwest 0.13.5", diff --git a/Cargo.toml b/Cargo.toml index 0075ce4..ef4c663 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ # Shared across every crate in the workspace so a release moves one number. # `sync-version` tooling (see the Makefile) reads these. [workspace.package] -version = "1.2.1" +version = "1.2.2" edition = "2021" rust-version = "1.94" license = "MIT OR Apache-2.0" @@ -58,12 +58,12 @@ directories = "6" base64 = "0.23" # Internal -ed-agent = { version = "1.2.1", path = "crates/ed-agent" } -ed-mcp = { version = "1.2.1", path = "crates/ed-mcp" } -ed-acp = { version = "1.2.1", path = "crates/ed-acp" } -ed-acp-tui = { version = "1.2.1", path = "crates/ed-acp-tui" } -ed-acp-testkit = { version = "1.2.1", path = "crates/ed-acp-testkit" } -ed-onde-account = { version = "1.2.1", path = "crates/ed-onde-account" } +ed-agent = { version = "1.2.2", path = "crates/ed-agent" } +ed-mcp = { version = "1.2.2", path = "crates/ed-mcp" } +ed-acp = { version = "1.2.2", path = "crates/ed-acp" } +ed-acp-tui = { version = "1.2.2", path = "crates/ed-acp-tui" } +ed-acp-testkit = { version = "1.2.2", path = "crates/ed-acp-testkit" } +ed-onde-account = { version = "1.2.2", path = "crates/ed-onde-account" } # gemm-common (via candle, via onde) depends on sysctl for every # `target_vendor = "apple"`, but sysctl 0.6.0 only has a `sys` module behind