release/1.3.1
rs 746 lines 21.3 KB
Raw
1 //! UniFFI surface for Ed's Swift package.
2
3 use std::sync::Arc;
4
5 use async_trait::async_trait;
6 use ed_agent::{
7 AgentConfig, AgentError, AgentReply, AgentToolDefinition, ApprovalDecision, ApprovalHandler,
8 ApprovalRequest, ChatMessage, Ed, EngineInfo, EngineStatus, EventSink, GgufModelConfig,
9 SamplingConfig, ToolCall, ToolExecutionResult, ToolExecutor, ToolRisk, UqffModelConfig,
10 };
11
12 #[derive(Debug, Clone, uniffi::Record)]
13 pub struct FfiAgentConfig {
14 pub max_tool_rounds: u8,
15 pub max_tool_output_chars: u64,
16 }
17
18 impl Default for FfiAgentConfig {
19 fn default() -> Self {
20 let config = AgentConfig::default();
21 Self {
22 max_tool_rounds: config.max_tool_rounds,
23 max_tool_output_chars: config.max_tool_output_chars as u64,
24 }
25 }
26 }
27
28 #[derive(Debug, Clone, Copy, uniffi::Enum)]
29 pub enum FfiToolRisk {
30 ReadOnly,
31 Mutating,
32 }
33
34 impl From<FfiToolRisk> for ToolRisk {
35 fn from(value: FfiToolRisk) -> Self {
36 match value {
37 FfiToolRisk::ReadOnly => Self::ReadOnly,
38 FfiToolRisk::Mutating => Self::Mutating,
39 }
40 }
41 }
42
43 impl From<ToolRisk> for FfiToolRisk {
44 fn from(value: ToolRisk) -> Self {
45 match value {
46 ToolRisk::ReadOnly => Self::ReadOnly,
47 ToolRisk::Mutating => Self::Mutating,
48 }
49 }
50 }
51
52 #[derive(Debug, Clone, uniffi::Record)]
53 pub struct FfiToolDefinition {
54 pub name: String,
55 pub description: String,
56 pub parameters_schema: String,
57 pub risk: FfiToolRisk,
58 }
59
60 #[derive(Debug, Clone, uniffi::Record)]
61 pub struct FfiToolCall {
62 pub id: String,
63 pub name: String,
64 pub arguments: String,
65 }
66
67 impl From<&ToolCall> for FfiToolCall {
68 fn from(value: &ToolCall) -> Self {
69 Self {
70 id: value.id.clone(),
71 name: value.name.clone(),
72 arguments: value.arguments.clone(),
73 }
74 }
75 }
76
77 /// A tool call waiting on the user, with the risk that made it wait.
78 ///
79 /// The risk travels with the call because it is the reason an approval sheet
80 /// is on screen at all: a host that only received the call would have to look
81 /// the tool up again to tell the user whether it is about to change anything.
82 #[derive(Debug, Clone, uniffi::Record)]
83 pub struct FfiApprovalRequest {
84 pub call: FfiToolCall,
85 pub risk: FfiToolRisk,
86 }
87
88 impl From<&ApprovalRequest> for FfiApprovalRequest {
89 fn from(value: &ApprovalRequest) -> Self {
90 Self {
91 call: (&value.call).into(),
92 risk: value.risk.into(),
93 }
94 }
95 }
96
97 #[derive(Debug, Clone, uniffi::Record)]
98 pub struct FfiToolOutput {
99 pub content: String,
100 pub error: Option<String>,
101 }
102
103 #[derive(Debug, Clone, Copy, uniffi::Enum)]
104 pub enum FfiApprovalDecision {
105 AllowOnce,
106 AllowForSession,
107 Deny,
108 }
109
110 impl From<FfiApprovalDecision> for ApprovalDecision {
111 fn from(value: FfiApprovalDecision) -> Self {
112 match value {
113 FfiApprovalDecision::AllowOnce => Self::AllowOnce,
114 FfiApprovalDecision::AllowForSession => Self::AllowForSession,
115 FfiApprovalDecision::Deny => Self::Deny,
116 }
117 }
118 }
119
120 #[derive(Debug, Clone, Copy, uniffi::Enum)]
121 pub enum FfiEngineStatus {
122 Unloaded,
123 Loading,
124 Ready,
125 Generating,
126 Error,
127 }
128
129 impl From<EngineStatus> for FfiEngineStatus {
130 fn from(value: EngineStatus) -> Self {
131 match value {
132 EngineStatus::Unloaded => Self::Unloaded,
133 EngineStatus::Loading => Self::Loading,
134 EngineStatus::Ready => Self::Ready,
135 EngineStatus::Generating => Self::Generating,
136 EngineStatus::Error => Self::Error,
137 }
138 }
139 }
140
141 #[derive(Debug, Clone, uniffi::Record)]
142 pub struct FfiStatusUpdate {
143 pub status: FfiEngineStatus,
144 pub model_name: Option<String>,
145 pub error: Option<String>,
146 }
147
148 #[derive(Debug, Clone, uniffi::Record)]
149 pub struct FfiAgentReply {
150 pub text: String,
151 pub duration_seconds: f64,
152 pub duration: String,
153 pub tool_rounds: u8,
154 }
155
156 impl From<&AgentReply> for FfiAgentReply {
157 fn from(value: &AgentReply) -> Self {
158 Self {
159 text: value.text.clone(),
160 duration_seconds: value.duration_seconds,
161 duration: value.duration.clone(),
162 tool_rounds: value.tool_rounds,
163 }
164 }
165 }
166
167 #[derive(Debug, Clone, uniffi::Record)]
168 pub struct FfiReply {
169 pub text: String,
170 pub duration: String,
171 }
172
173 #[derive(Debug, Clone, uniffi::Record)]
174 pub struct FfiEngineInfo {
175 pub status: FfiEngineStatus,
176 pub model_name: Option<String>,
177 pub approx_memory: Option<String>,
178 pub history_length: u64,
179 }
180
181 impl From<EngineInfo> for FfiEngineInfo {
182 fn from(value: EngineInfo) -> Self {
183 Self {
184 status: value.status.into(),
185 model_name: value.model_name,
186 approx_memory: value.approx_memory,
187 history_length: value.history_length,
188 }
189 }
190 }
191
192 #[derive(Debug, Clone, Copy, uniffi::Enum)]
193 pub enum FfiChatRole {
194 System,
195 User,
196 Assistant,
197 }
198
199 #[derive(Debug, Clone, uniffi::Record)]
200 pub struct FfiChatMessage {
201 pub role: FfiChatRole,
202 pub content: String,
203 }
204
205 impl From<ChatMessage> for FfiChatMessage {
206 fn from(value: ChatMessage) -> Self {
207 let role = match value.role {
208 ed_agent::ChatRole::System => FfiChatRole::System,
209 ed_agent::ChatRole::User => FfiChatRole::User,
210 ed_agent::ChatRole::Assistant => FfiChatRole::Assistant,
211 };
212 Self {
213 role,
214 content: value.content,
215 }
216 }
217 }
218
219 impl From<FfiChatMessage> for ChatMessage {
220 fn from(value: FfiChatMessage) -> Self {
221 let role = match value.role {
222 FfiChatRole::System => ed_agent::ChatRole::System,
223 FfiChatRole::User => ed_agent::ChatRole::User,
224 FfiChatRole::Assistant => ed_agent::ChatRole::Assistant,
225 };
226 Self {
227 role,
228 content: value.content,
229 }
230 }
231 }
232
233 #[derive(Debug, Clone, uniffi::Record)]
234 pub struct FfiSamplingConfig {
235 pub temperature: Option<f64>,
236 pub top_p: Option<f64>,
237 pub top_k: Option<u64>,
238 pub min_p: Option<f64>,
239 pub max_tokens: Option<u64>,
240 pub frequency_penalty: Option<f32>,
241 pub presence_penalty: Option<f32>,
242 }
243
244 impl From<FfiSamplingConfig> for SamplingConfig {
245 fn from(value: FfiSamplingConfig) -> Self {
246 Self {
247 temperature: value.temperature,
248 top_p: value.top_p,
249 top_k: value.top_k,
250 min_p: value.min_p,
251 max_tokens: value.max_tokens,
252 frequency_penalty: value.frequency_penalty,
253 presence_penalty: value.presence_penalty,
254 }
255 }
256 }
257
258 #[derive(Debug, Clone, uniffi::Record)]
259 pub struct FfiGgufModelConfig {
260 pub model_id: String,
261 pub files: Vec<String>,
262 pub tok_model_id: Option<String>,
263 pub display_name: String,
264 pub approx_memory: String,
265 pub chat_template: Option<String>,
266 }
267
268 impl From<FfiGgufModelConfig> for GgufModelConfig {
269 fn from(value: FfiGgufModelConfig) -> Self {
270 Self {
271 model_id: value.model_id,
272 files: value.files,
273 tok_model_id: value.tok_model_id,
274 display_name: value.display_name,
275 approx_memory: value.approx_memory,
276 chat_template: value.chat_template,
277 }
278 }
279 }
280
281 impl From<GgufModelConfig> for FfiGgufModelConfig {
282 fn from(value: GgufModelConfig) -> Self {
283 Self {
284 model_id: value.model_id,
285 files: value.files,
286 tok_model_id: value.tok_model_id,
287 display_name: value.display_name,
288 approx_memory: value.approx_memory,
289 chat_template: value.chat_template,
290 }
291 }
292 }
293
294 #[derive(Debug, Clone, uniffi::Record)]
295 pub struct FfiUqffModelConfig {
296 pub model_id: String,
297 pub files: Vec<String>,
298 pub display_name: String,
299 pub approx_memory: String,
300 pub chat_template: Option<String>,
301 }
302
303 impl From<FfiUqffModelConfig> for UqffModelConfig {
304 fn from(value: FfiUqffModelConfig) -> Self {
305 Self {
306 model_id: value.model_id,
307 files: value.files,
308 display_name: value.display_name,
309 approx_memory: value.approx_memory,
310 chat_template: value.chat_template,
311 }
312 }
313 }
314
315 #[derive(Debug, Clone, Copy, uniffi::Enum)]
316 pub enum FfiEnvironment {
317 Development,
318 Production,
319 }
320
321 #[derive(Debug, thiserror::Error, uniffi::Error)]
322 pub enum FfiEdError {
323 #[error("{reason}")]
324 Failure { reason: String },
325 }
326
327 impl From<AgentError> for FfiEdError {
328 fn from(value: AgentError) -> Self {
329 Self::Failure {
330 reason: value.to_string(),
331 }
332 }
333 }
334
335 #[uniffi::export(callback_interface)]
336 #[async_trait]
337 pub trait FfiToolExecutor: Send + Sync {
338 async fn execute(&self, tool_name: String, arguments: String) -> FfiToolOutput;
339 }
340
341 #[uniffi::export(callback_interface)]
342 #[async_trait]
343 pub trait FfiApprovalHandler: Send + Sync {
344 async fn approve(&self, request: FfiApprovalRequest) -> FfiApprovalDecision;
345 }
346
347 #[uniffi::export(callback_interface)]
348 pub trait FfiEventListener: Send + Sync {
349 fn status_changed(&self, update: FfiStatusUpdate);
350 /// A piece of the reply as it is generated, during `run`.
351 fn text_delta(&self, delta: String);
352 fn tool_requested(&self, call: FfiToolCall);
353 fn approval_requested(&self, request: FfiApprovalRequest);
354 fn tool_started(&self, call: FfiToolCall);
355 fn tool_finished(&self, tool_call_id: String, content: String, is_error: bool);
356 fn agent_replied(&self, reply: FfiAgentReply);
357 fn warning(&self, message: String);
358 }
359
360 /// Receives a streamed reply from `stream_message`.
361 #[uniffi::export(callback_interface)]
362 pub trait FfiStreamListener: Send + Sync {
363 fn on_delta(&self, delta: String);
364 }
365
366 struct ForeignExecutor(Arc<dyn FfiToolExecutor>);
367
368 #[async_trait]
369 impl ToolExecutor for ForeignExecutor {
370 async fn execute(&self, tool_name: &str, arguments: &str) -> Result<String, String> {
371 let output = self
372 .0
373 .execute(tool_name.to_owned(), arguments.to_owned())
374 .await;
375 match output.error {
376 Some(error) => Err(error),
377 None => Ok(output.content),
378 }
379 }
380 }
381
382 struct ForeignApprovals(Arc<dyn FfiApprovalHandler>);
383
384 #[async_trait]
385 impl ApprovalHandler for ForeignApprovals {
386 async fn approve(&self, request: ApprovalRequest) -> ApprovalDecision {
387 self.0.approve((&request).into()).await.into()
388 }
389 }
390
391 #[derive(Clone)]
392 struct FfiSink(Arc<dyn FfiEventListener>);
393
394 impl EventSink for FfiSink {
395 fn status_changed(&self, status: EngineStatus, model_name: Option<&str>, error: Option<&str>) {
396 self.0.status_changed(FfiStatusUpdate {
397 status: status.into(),
398 model_name: model_name.map(str::to_owned),
399 error: error.map(str::to_owned),
400 });
401 }
402
403 fn text_delta(&self, delta: &str) {
404 self.0.text_delta(delta.to_owned());
405 }
406
407 fn tool_requested(&self, call: &ToolCall) {
408 self.0.tool_requested(call.into());
409 }
410
411 fn approval_requested(&self, request: &ApprovalRequest) {
412 self.0.approval_requested(request.into());
413 }
414
415 fn tool_started(&self, call: &ToolCall) {
416 self.0.tool_started(call.into());
417 }
418
419 fn tool_finished(&self, result: &ToolExecutionResult) {
420 self.0.tool_finished(
421 result.tool_call_id.clone(),
422 result.content.clone(),
423 result.is_error,
424 );
425 }
426
427 fn agent_replied(&self, reply: &AgentReply) {
428 self.0.agent_replied(reply.into());
429 }
430
431 fn warning(&self, message: &str) {
432 self.0.warning(message.to_owned());
433 }
434 }
435
436 #[derive(uniffi::Object)]
437 pub struct FfiEdAgent {
438 inner: Ed<FfiSink>,
439 /// Ends the stream in progress, if any.
440 stream_cancel: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
441 }
442
443 impl FfiEdAgent {
444 fn build(
445 executor: Box<dyn FfiToolExecutor>,
446 approvals: Box<dyn FfiApprovalHandler>,
447 events: Box<dyn FfiEventListener>,
448 config: Option<FfiAgentConfig>,
449 app_id: Option<String>,
450 ) -> Arc<Self> {
451 let config = config.unwrap_or_default();
452 Arc::new(Self {
453 inner: Ed::with_agent_and_app_id(
454 FfiSink(Arc::from(events)),
455 Arc::new(ForeignExecutor(Arc::from(executor))),
456 Arc::new(ForeignApprovals(Arc::from(approvals))),
457 AgentConfig {
458 max_tool_rounds: config.max_tool_rounds,
459 max_tool_output_chars: usize::try_from(config.max_tool_output_chars)
460 .unwrap_or(usize::MAX),
461 },
462 app_id,
463 ),
464 stream_cancel: std::sync::Mutex::new(None),
465 })
466 }
467 }
468
469 #[uniffi::export(async_runtime = "tokio")]
470 impl FfiEdAgent {
471 #[uniffi::constructor]
472 pub fn new(
473 executor: Box<dyn FfiToolExecutor>,
474 approvals: Box<dyn FfiApprovalHandler>,
475 events: Box<dyn FfiEventListener>,
476 config: Option<FfiAgentConfig>,
477 ) -> Arc<Self> {
478 Self::build(executor, approvals, events, config, None)
479 }
480
481 /// Like `new`, with an Onde app id so the engine reports usage under it.
482 #[uniffi::constructor]
483 pub fn new_with_app_id(
484 executor: Box<dyn FfiToolExecutor>,
485 approvals: Box<dyn FfiApprovalHandler>,
486 events: Box<dyn FfiEventListener>,
487 config: Option<FfiAgentConfig>,
488 app_id: Option<String>,
489 ) -> Arc<Self> {
490 Self::build(executor, approvals, events, config, app_id)
491 }
492
493 pub async fn register_tool(&self, tool: FfiToolDefinition) -> Result<(), FfiEdError> {
494 self.inner
495 .register_tool(AgentToolDefinition {
496 name: tool.name,
497 description: tool.description,
498 parameters_schema: tool.parameters_schema,
499 risk: tool.risk.into(),
500 })
501 .await
502 .map_err(Into::into)
503 }
504
505 pub async fn unregister_tool(&self, name: String) -> bool {
506 self.inner.unregister_tool(&name).await
507 }
508
509 pub async fn remove_all_tools(&self) {
510 self.inner.remove_all_tools().await;
511 }
512
513 pub async fn load_default_agent_model(
514 &self,
515 system_prompt: Option<String>,
516 ) -> Result<f64, FfiEdError> {
517 let config: GgufModelConfig = default_agent_model_config().into();
518 let sampling = SamplingConfig {
519 max_tokens: Some(4096),
520 ..SamplingConfig::default()
521 };
522 self.inner
523 .load(config, system_prompt, Some(sampling))
524 .await
525 .map(|duration| duration.as_secs_f64())
526 .map_err(|error| FfiEdError::Failure {
527 reason: error.to_string(),
528 })
529 }
530
531 pub async fn load_gguf_model(
532 &self,
533 config: FfiGgufModelConfig,
534 system_prompt: Option<String>,
535 sampling: Option<FfiSamplingConfig>,
536 ) -> Result<f64, FfiEdError> {
537 self.inner
538 .load(config.into(), system_prompt, sampling.map(Into::into))
539 .await
540 .map(|duration| duration.as_secs_f64())
541 .map_err(|error| FfiEdError::Failure {
542 reason: error.to_string(),
543 })
544 }
545
546 pub async fn load_uqff_model(
547 &self,
548 config: FfiUqffModelConfig,
549 system_prompt: Option<String>,
550 sampling: Option<FfiSamplingConfig>,
551 ) -> Result<f64, FfiEdError> {
552 let config: UqffModelConfig = config.into();
553 let name = config.display_name.clone();
554 self.inner
555 .load_with(&name, |engine| {
556 engine.load_uqff_model(config, system_prompt, sampling.map(Into::into))
557 })
558 .await
559 .map(|duration| duration.as_secs_f64())
560 .map_err(|error| FfiEdError::Failure {
561 reason: error.to_string(),
562 })
563 }
564
565 pub async fn load_assigned_model(
566 &self,
567 environment: FfiEnvironment,
568 app_id: String,
569 app_secret: String,
570 system_prompt: Option<String>,
571 sampling: Option<FfiSamplingConfig>,
572 ) -> Result<f64, FfiEdError> {
573 let environment = match environment {
574 FfiEnvironment::Development => smbcloud_gresiq_sdk::Environment::Dev,
575 FfiEnvironment::Production => smbcloud_gresiq_sdk::Environment::Production,
576 };
577 self.inner
578 .load_with("Assigned model", |engine| {
579 engine.load_assigned_model(
580 environment,
581 &app_id,
582 &app_secret,
583 system_prompt,
584 sampling.map(Into::into),
585 )
586 })
587 .await
588 .map(|duration| duration.as_secs_f64())
589 .map_err(|error| FfiEdError::Failure {
590 reason: error.to_string(),
591 })
592 }
593
594 pub async fn run(&self, message: String) -> Result<FfiAgentReply, FfiEdError> {
595 self.inner
596 .run(message)
597 .await
598 .map(|reply| (&reply).into())
599 .map_err(Into::into)
600 }
601
602 pub async fn send(&self, message: String) -> Result<FfiReply, FfiEdError> {
603 let reply = self.inner.send(message).await;
604 match (reply.reply, reply.duration, reply.error) {
605 (Some(text), Some(duration), None) => Ok(FfiReply { text, duration }),
606 (_, _, Some(reason)) => Err(FfiEdError::Failure { reason }),
607 _ => Err(FfiEdError::Failure {
608 reason: "the model returned an empty reply".to_string(),
609 }),
610 }
611 }
612
613 /// Stream the reply to `message` into `listener`, for plain chat without tools.
614 /// Returns when the reply is complete, on error, or after `cancel_stream`.
615 /// The turn so far stays in history either way.
616 pub async fn stream_message(
617 &self,
618 message: String,
619 listener: Box<dyn FfiStreamListener>,
620 ) -> Result<(), FfiEdError> {
621 let mut stream = self
622 .inner
623 .stream(message)
624 .await
625 .map_err(|error| FfiEdError::Failure {
626 reason: error.to_string(),
627 })?;
628 let (cancel, mut cancelled) = tokio::sync::oneshot::channel();
629 *self.stream_cancel.lock().unwrap_or_else(|e| e.into_inner()) = Some(cancel);
630 loop {
631 tokio::select! {
632 next = stream.next() => match next {
633 Some(Ok(delta)) => listener.on_delta(delta),
634 Some(Err(error)) => {
635 return Err(FfiEdError::Failure { reason: error.to_string() });
636 }
637 None => return Ok(()),
638 },
639 _ = &mut cancelled => return Ok(()),
640 }
641 }
642 }
643
644 /// Stop the `stream_message` in progress. Does nothing when none is.
645 pub fn cancel_stream(&self) {
646 if let Some(cancel) = self
647 .stream_cancel
648 .lock()
649 .unwrap_or_else(|e| e.into_inner())
650 .take()
651 {
652 let _ = cancel.send(());
653 }
654 }
655
656 /// One-shot generation over `messages`. Leaves the conversation untouched.
657 pub async fn generate(
658 &self,
659 messages: Vec<FfiChatMessage>,
660 sampling: Option<FfiSamplingConfig>,
661 ) -> Result<String, FfiEdError> {
662 self.inner
663 .generate(
664 messages.into_iter().map(Into::into).collect(),
665 sampling.map(Into::into),
666 )
667 .await
668 .map_err(|error| FfiEdError::Failure {
669 reason: error.to_string(),
670 })
671 }
672
673 /// Replace the conversation with `messages`. Needs a loaded model.
674 pub async fn restore_history(&self, messages: Vec<FfiChatMessage>) {
675 self.inner
676 .restore_history(messages.into_iter().map(Into::into).collect())
677 .await;
678 }
679
680 pub async fn set_system_prompt(&self, prompt: String) {
681 self.inner.set_system_prompt(prompt).await;
682 }
683
684 pub async fn clear_system_prompt(&self) {
685 self.inner.clear_system_prompt().await;
686 }
687
688 pub async fn set_sampling(&self, sampling: FfiSamplingConfig) {
689 self.inner.set_sampling(sampling.into()).await;
690 }
691
692 pub fn cancel(&self) {
693 self.inner.cancel();
694 }
695
696 pub async fn unload(&self) -> Option<String> {
697 self.inner.unload().await
698 }
699
700 pub async fn info(&self) -> FfiEngineInfo {
701 self.inner.info().await.into()
702 }
703
704 pub async fn is_loaded(&self) -> bool {
705 self.inner.is_loaded().await
706 }
707
708 pub async fn history(&self) -> Vec<FfiChatMessage> {
709 self.inner
710 .history()
711 .await
712 .into_iter()
713 .map(Into::into)
714 .collect()
715 }
716
717 pub async fn clear_history(&self) -> u64 {
718 self.inner.clear_history().await as u64
719 }
720 }
721
722 #[uniffi::export]
723 pub fn configure_cache_dir(path: String) {
724 onde::inference::ffi::configure_cache_dir(path);
725 }
726
727 #[uniffi::export]
728 pub fn default_agent_model_config() -> FfiGgufModelConfig {
729 #[cfg(any(target_os = "tvos", target_os = "watchos"))]
730 let config = GgufModelConfig::qwen3_0_6b();
731 #[cfg(target_os = "ios")]
732 let config = GgufModelConfig::qwen3_1_7b();
733 #[cfg(any(target_os = "macos", target_os = "visionos"))]
734 let config = GgufModelConfig::qwen3_4b();
735 #[cfg(not(any(
736 target_os = "ios",
737 target_os = "macos",
738 target_os = "tvos",
739 target_os = "visionos",
740 target_os = "watchos"
741 )))]
742 let config = GgufModelConfig::qwen3_1_7b();
743 config.into()
744 }
745
746 uniffi::setup_scaffolding!();