Skip to main content

oino_extension_api/
runtime.rs

1//! Runtime traits: [`ExtensionRuntime`] loads packages into [`ExtensionInstance`]s, which call
2//! back into [`HostServices`] (implemented by `oino-core`).
3
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::time::Duration;
7
8use futures::future::BoxFuture;
9use futures::stream::BoxStream;
10use oino_ai::{Model, ProviderEvent};
11use tokio_util::sync::CancellationToken;
12
13use crate::guest::{CompletionItem, InitInput, ToolInvocation};
14use crate::json::RawJson;
15use crate::manifest::Manifest;
16use crate::ops::{
17    ControlOp, ControlReply, DeviceOp, EditorOp, HostContextInfo, ModelsOp, ModelsReply, PanelOp,
18    ProviderHook, ProviderHookReply, SecretOp, SessionQuery, SessionWrite, SessionsIndexQuery,
19    SettingsLayer, SettingsScope, TaskOp,
20};
21use crate::registry::{
22    ArbiterRoles, CommandSpec, EventKind, FlagSpec, Registration, RendererSpec, ShortcutSpec,
23    ToolSpec,
24};
25use crate::types::{CallContext, ExtensionError, ExtensionId, HostError};
26
27/// Boxed future alias used across the contract.
28pub type BoxFut<'a, T> = BoxFuture<'a, T>;
29
30/// A process taken over from a detached `bash` tool call ([`HostServices::take_bash`]).
31pub struct DetachedBash {
32    /// The command line.
33    pub command: String,
34    /// OS process id, when known.
35    pub pid: Option<u32>,
36    /// Output the tool call returned before the detach marker.
37    pub output_so_far: String,
38    /// Temp file with the complete output so far (when the call truncated it).
39    pub full_output_path: Option<String>,
40    /// Further output chunks (stdout and stderr interleaved); ends with the process.
41    pub output: BoxStream<'static, Vec<u8>>,
42    /// Resolves with the exit code once the process ended (`None` when killed by a signal).
43    pub exit: BoxFuture<'static, Option<i32>>,
44    /// Cancel to kill the process tree.
45    pub kill: CancellationToken,
46}
47
48impl std::fmt::Debug for DetachedBash {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("DetachedBash")
51            .field("command", &self.command)
52            .field("pid", &self.pid)
53            .finish_non_exhaustive()
54    }
55}
56
57/// Component bytes or a path to them.
58#[derive(Clone, Debug)]
59pub enum ComponentSource {
60    /// In-memory component (bundled).
61    Bytes(Arc<[u8]>),
62    /// Component file on disk.
63    Path(PathBuf),
64}
65
66/// A validated package ready to load.
67#[derive(Clone, Debug)]
68pub struct LoadedPackage {
69    /// Manifest.
70    pub manifest: Manifest,
71    /// Component.
72    pub component: ComponentSource,
73    /// Package root on disk (resources), when any.
74    pub root: Option<PathBuf>,
75    /// Bundled with Oino (grants such as `"*"` hosts are trusted).
76    pub bundled: bool,
77}
78
79/// Loads packages into instances.
80pub trait ExtensionRuntime: Send + Sync {
81    /// Instantiates `package`; `host` serves its imports.
82    fn load(
83        &self,
84        package: LoadedPackage,
85        host: Arc<dyn HostServices>,
86    ) -> BoxFut<'_, Result<Arc<dyn ExtensionInstance>, ExtensionError>>;
87}
88
89/// One loaded extension (one component instance per process, D-principle 5).
90pub trait ExtensionInstance: Send + Sync {
91    /// Manifest id.
92    fn id(&self) -> &ExtensionId;
93    /// Manifest.
94    fn manifest(&self) -> &Manifest;
95    /// `init`.
96    fn init(
97        &self,
98        input: InitInput,
99        cx: CallContext,
100    ) -> BoxFut<'_, Result<Registration, ExtensionError>>;
101    /// `on-event`; `Ok(None)` = no result.
102    fn on_event(
103        &self,
104        kind: EventKind,
105        payload: RawJson,
106        cx: CallContext,
107    ) -> BoxFut<'_, Result<Option<RawJson>, ExtensionError>>;
108    /// `run-tool` → `AgentToolResult` JSON.
109    fn run_tool(
110        &self,
111        inv: ToolInvocation,
112        cancel: CancellationToken,
113        cx: CallContext,
114    ) -> BoxFut<'_, Result<RawJson, ExtensionError>>;
115    /// `prepare-arguments`.
116    ///
117    /// # Errors
118    ///
119    /// Guest trap.
120    fn prepare_arguments(&self, tool: &str, raw: RawJson) -> Result<RawJson, ExtensionError>;
121    /// `prepare-loadout`.
122    ///
123    /// # Errors
124    ///
125    /// Guest trap.
126    fn prepare_loadout(
127        &self,
128        tool: &str,
129        loadout: RawJson,
130    ) -> Result<Option<RawJson>, ExtensionError>;
131    /// `reaches-tools` → `"all"` | `["name", …]`.
132    ///
133    /// # Errors
134    ///
135    /// Guest trap.
136    fn reaches_tools(&self, tool: &str, input: RawJson) -> Result<RawJson, ExtensionError>;
137    /// `run-command`.
138    fn run_command(
139        &self,
140        name: &str,
141        args: &str,
142        cancel: CancellationToken,
143        cx: CallContext,
144    ) -> BoxFut<'_, Result<Option<RawJson>, ExtensionError>>;
145    /// `complete-command`.
146    fn complete_command(
147        &self,
148        name: &str,
149        prefix: &str,
150        cx: CallContext,
151    ) -> BoxFut<'_, Vec<CompletionItem>>;
152    /// `run-shortcut`.
153    fn run_shortcut(&self, id: &str, cx: CallContext) -> BoxFut<'_, Result<(), ExtensionError>>;
154    /// `handle-service`.
155    fn handle_service(
156        &self,
157        name: &str,
158        payload: RawJson,
159        caller: &ExtensionId,
160        cx: CallContext,
161    ) -> BoxFut<'_, Result<RawJson, ExtensionError>>;
162    /// `propose-nudge`.
163    fn propose_nudge(&self, cx: CallContext) -> BoxFut<'_, Option<RawJson>>;
164    /// `nudge-delivered`.
165    fn nudge_delivered(&self, id: &str, cx: CallContext);
166    /// `wait-reason`.
167    fn wait_reason(&self, cx: CallContext) -> BoxFut<'_, Option<String>>;
168    /// `render` → OUS node JSON.
169    fn render(&self, request: RawJson, cx: CallContext) -> Option<RawJson>;
170    /// `route-model`.
171    fn route_model(
172        &self,
173        request: RawJson,
174        cx: CallContext,
175    ) -> BoxFut<'_, Result<RawJson, ExtensionError>>;
176    /// `complete-input`.
177    fn complete_input(&self, request: RawJson, cx: CallContext) -> BoxFut<'_, Option<RawJson>>;
178    /// Provider exports, for components of world `provider-extension`.
179    fn provider(&self) -> Option<&dyn ProviderInstance>;
180    /// `shutdown`.
181    fn shutdown(&self, reason: &str) -> BoxFut<'_, ()>;
182}
183
184/// Stream of provider events.
185pub type ProviderEventStream = BoxStream<'static, Result<ProviderEvent, ExtensionError>>;
186
187/// Provider exports (`provider.wit`).
188pub trait ProviderInstance: Send + Sync {
189    /// `stream`: `ProviderStreamRequest` JSON in, events out. Dropping the stream cancels.
190    fn stream(&self, request: RawJson) -> BoxFut<'_, Result<ProviderEventStream, ExtensionError>>;
191    /// `list-models`.
192    fn list_models(
193        &self,
194        provider_id: &str,
195        credential: Option<RawJson>,
196    ) -> BoxFut<'_, Result<Vec<Model>, ExtensionError>>;
197    /// `oauth-login`; the flow's `ui` dialogs run in `cx` (the session that asked).
198    fn oauth_login(
199        &self,
200        provider_id: &str,
201        cx: CallContext,
202    ) -> BoxFut<'_, Result<RawJson, ExtensionError>>;
203    /// `oauth-refresh`.
204    fn oauth_refresh(
205        &self,
206        provider_id: &str,
207        credential: RawJson,
208    ) -> BoxFut<'_, Result<RawJson, ExtensionError>>;
209}
210
211/// A nested agent session (`agents.agent` resource).
212pub trait AgentHandle: Send + Sync {
213    /// Agent id.
214    fn id(&self) -> String;
215    /// Child session file.
216    fn session_file(&self) -> Option<String>;
217    /// `prompt` → disposition.
218    fn prompt(
219        &self,
220        text: String,
221        delivery: Option<String>,
222    ) -> BoxFut<'_, Result<String, HostError>>;
223    /// `steer`.
224    fn steer(&self, text: String) -> BoxFut<'_, Result<(), HostError>>;
225    /// `follow-up`.
226    fn follow_up(&self, text: String) -> BoxFut<'_, Result<(), HostError>>;
227    /// `abort`.
228    fn abort(&self);
229    /// `set-thinking` → clamped level.
230    ///
231    /// # Errors
232    ///
233    /// Invalid level.
234    fn set_thinking(&self, level: &str) -> Result<String, HostError>;
235    /// `last-assistant-text`.
236    fn last_assistant_text(&self) -> Option<String>;
237    /// `wait-idle`.
238    fn wait_idle(&self) -> BoxFut<'_, Result<(), HostError>>;
239    /// `usage` (`SessionStats` subset).
240    fn usage(&self) -> RawJson;
241    /// `events`: child `ClientEvent` JSON stream.
242    fn events(&self) -> BoxStream<'static, String>;
243    /// `close`.
244    fn close(&self, grace: Duration) -> BoxFut<'_, ()>;
245}
246
247macro_rules! unsupported {
248    ($name:literal) => {
249        Err(HostError::unsupported($name))
250    };
251}
252
253/// Implemented by `oino-core`: one method per WIT import that needs the runtime (grouped by
254/// interface). Every method has a default that reports the capability as unsupported, so
255/// hosts can grow coverage additively; `testing::RecordingHostServices` (feature `testing`) records all.
256#[allow(unused_variables, reason = "default bodies ignore their arguments")]
257pub trait HostServices: Send + Sync {
258    // ── registry ──
259    /// `registry.subscribe`.
260    fn subscribe(&self, ext: &ExtensionId, events: &[EventKind]) {}
261    /// `registry.unsubscribe`.
262    fn unsubscribe(&self, ext: &ExtensionId, events: &[EventKind]) {}
263    /// `registry.register-tool`.
264    ///
265    /// # Errors
266    ///
267    /// Conflicts, unsupported.
268    fn register_tool(&self, ext: &ExtensionId, spec: ToolSpec) -> Result<(), HostError> {
269        unsupported!("registry.register-tool")
270    }
271    /// `registry.unregister-tool`.
272    fn unregister_tool(&self, ext: &ExtensionId, name: &str) {}
273    /// `registry.register-command` → invocation name.
274    ///
275    /// # Errors
276    ///
277    /// Unsupported.
278    fn register_command(&self, ext: &ExtensionId, spec: CommandSpec) -> Result<String, HostError> {
279        unsupported!("registry.register-command")
280    }
281    /// `registry.unregister-command`.
282    fn unregister_command(&self, ext: &ExtensionId, name: &str) {}
283    /// `registry.register-shortcut`.
284    ///
285    /// # Errors
286    ///
287    /// Unsupported.
288    fn register_shortcut(&self, ext: &ExtensionId, spec: ShortcutSpec) -> Result<(), HostError> {
289        unsupported!("registry.register-shortcut")
290    }
291    /// `registry.unregister-shortcut`.
292    fn unregister_shortcut(&self, ext: &ExtensionId, id: &str) {}
293    /// `registry.register-flag`.
294    ///
295    /// # Errors
296    ///
297    /// Unsupported.
298    fn register_flag(&self, ext: &ExtensionId, spec: FlagSpec) -> Result<(), HostError> {
299        unsupported!("registry.register-flag")
300    }
301    /// `registry.get-flag`.
302    fn get_flag(&self, ext: &ExtensionId, name: &str) -> Option<RawJson> {
303        None
304    }
305    /// `registry.register-renderer`.
306    fn register_renderer(&self, ext: &ExtensionId, spec: RendererSpec) {}
307    /// `registry.register-service`.
308    ///
309    /// # Errors
310    ///
311    /// Unsupported.
312    fn register_service(&self, ext: &ExtensionId, name: &str) -> Result<(), HostError> {
313        unsupported!("registry.register-service")
314    }
315    /// `registry.set-arbiter-roles`.
316    fn set_arbiter_roles(&self, ext: &ExtensionId, roles: ArbiterRoles) {}
317    /// `registry.register-settings`.
318    ///
319    /// # Errors
320    ///
321    /// Unsupported, invalid namespace.
322    fn register_settings(&self, ext: &ExtensionId, ns: RawJson) -> Result<(), HostError> {
323        unsupported!("registry.register-settings")
324    }
325    /// `registry.extension-settings`: one namespace's values as `ext` sees them.
326    ///
327    /// # Errors
328    ///
329    /// Unsupported, invalid namespace.
330    fn extension_settings(
331        &self,
332        ext: &ExtensionId,
333        namespace: &str,
334        layer: SettingsLayer,
335    ) -> Result<RawJson, HostError> {
336        unsupported!("registry.extension-settings")
337    }
338    /// `registry.set-extension-setting`: writes one key of a namespace `ext` registered.
339    ///
340    /// # Errors
341    ///
342    /// Unsupported, not the owner, invalid key or value, untrusted project, I/O.
343    fn set_extension_setting(
344        &self,
345        ext: &ExtensionId,
346        namespace: &str,
347        key: &str,
348        value: Option<RawJson>,
349        scope: SettingsScope,
350    ) -> Result<(), HostError> {
351        unsupported!("registry.set-extension-setting")
352    }
353    /// `registry.register-provider`.
354    ///
355    /// # Errors
356    ///
357    /// Unsupported, invalid config.
358    fn register_provider(&self, ext: &ExtensionId, cfg: RawJson) -> Result<(), HostError> {
359        unsupported!("registry.register-provider")
360    }
361    /// `registry.unregister-provider`.
362    fn unregister_provider(&self, ext: &ExtensionId, id: &str) {}
363    /// `registry.register-virtual-model`.
364    ///
365    /// # Errors
366    ///
367    /// Unsupported, invalid definition.
368    fn register_virtual_model(&self, ext: &ExtensionId, def: RawJson) -> Result<(), HostError> {
369        unsupported!("registry.register-virtual-model")
370    }
371    /// `registry.unregister-virtual-model`.
372    fn unregister_virtual_model(&self, ext: &ExtensionId, provider: &str, id: &str) {}
373
374    // ── session ──
375    /// Session reads.
376    ///
377    /// # Errors
378    ///
379    /// Stale context, unsupported.
380    fn session_read(&self, cx: &CallContext, q: SessionQuery) -> Result<RawJson, HostError> {
381        unsupported!("session reads")
382    }
383    /// Session writes → entry id for `append-entry`.
384    fn session_write(
385        &self,
386        ext: &ExtensionId,
387        cx: &CallContext,
388        w: SessionWrite,
389    ) -> BoxFut<'_, Result<Option<String>, HostError>> {
390        Box::pin(async { unsupported!("session writes") })
391    }
392    /// Sessions index.
393    fn sessions_index(
394        &self,
395        ext: &ExtensionId,
396        q: SessionsIndexQuery,
397    ) -> BoxFut<'_, Result<RawJson, HostError>> {
398        Box::pin(async { unsupported!("session.sessions-list") })
399    }
400
401    // ── control ──
402    /// `control.*`.
403    fn control(
404        &self,
405        ext: &ExtensionId,
406        cx: &CallContext,
407        op: ControlOp,
408    ) -> BoxFut<'_, Result<ControlReply, HostError>> {
409        let name = op.name();
410        Box::pin(async move { Err(HostError::unsupported(&format!("control.{name}"))) })
411    }
412    /// `control.tool-progress`.
413    fn tool_progress(&self, cx: &CallContext, tool_call_id: &str, partial: RawJson) {}
414
415    // ── models ──
416    /// `models.*`.
417    fn models(
418        &self,
419        ext: &ExtensionId,
420        cx: &CallContext,
421        op: ModelsOp,
422    ) -> BoxFut<'_, Result<ModelsReply, HostError>> {
423        Box::pin(async { unsupported!("models") })
424    }
425
426    // ── ui ──
427    /// `ui.dialog`.
428    fn ui_dialog(
429        &self,
430        ext: &ExtensionId,
431        cx: &CallContext,
432        req: RawJson,
433        cancel: Option<CancellationToken>,
434    ) -> BoxFut<'_, Result<RawJson, HostError>> {
435        Box::pin(async { unsupported!("ui.dialog") })
436    }
437    /// `ui.fire`.
438    ///
439    /// # Errors
440    ///
441    /// Unsupported, invalid request.
442    fn ui_fire(&self, ext: &ExtensionId, cx: &CallContext, req: RawJson) -> Result<(), HostError> {
443        unsupported!("ui.fire")
444    }
445    /// Panels → panel id for `Open`.
446    ///
447    /// # Errors
448    ///
449    /// Unsupported.
450    fn ui_panel(
451        &self,
452        ext: &ExtensionId,
453        cx: &CallContext,
454        op: PanelOp,
455    ) -> Result<Option<String>, HostError> {
456        unsupported!("ui panels")
457    }
458    /// Editor → text for `Text`.
459    ///
460    /// # Errors
461    ///
462    /// Unsupported.
463    fn ui_editor(&self, cx: &CallContext, op: EditorOp) -> Result<Option<String>, HostError> {
464        unsupported!("ui editor")
465    }
466    /// Device integration.
467    fn ui_device(
468        &self,
469        ext: &ExtensionId,
470        cx: &CallContext,
471        op: DeviceOp,
472    ) -> BoxFut<'_, Result<Option<RawJson>, HostError>> {
473        Box::pin(async { unsupported!("ui device") })
474    }
475
476    // ── agents ──
477    /// `agents.agent.spawn` (`AgentSpec` JSON).
478    fn agent_spawn(
479        &self,
480        ext: &ExtensionId,
481        cx: &CallContext,
482        spec: RawJson,
483    ) -> BoxFut<'_, Result<Arc<dyn AgentHandle>, HostError>> {
484        Box::pin(async { unsupported!("agents") })
485    }
486    /// `agents.list`.
487    fn agents_list(&self, cx: &CallContext) -> RawJson {
488        RawJson::from_json_value(&serde_json::Value::Array(Vec::new()))
489    }
490
491    // ── bash detach ──
492    /// Takes the running foreground `bash` call `tool_call_id` of the calling session away
493    /// from its tool call (which returns at once with a detach marker naming `task_id`) and
494    /// hands the process over; the background-task service adopts it
495    /// (`control.bash-detach`, `tasks.adopt-bash`).
496    fn take_bash(
497        &self,
498        ext: &ExtensionId,
499        cx: &CallContext,
500        tool_call_id: &str,
501        task_id: &str,
502        reason: &str,
503    ) -> BoxFut<'_, Result<DetachedBash, HostError>> {
504        Box::pin(async { unsupported!("bash detach") })
505    }
506
507    // ── tasks ──
508    /// `tasks.*`.
509    ///
510    /// # Errors
511    ///
512    /// Unsupported, not found.
513    fn tasks(&self, ext: &ExtensionId, cx: &CallContext, op: TaskOp) -> Result<RawJson, HostError> {
514        unsupported!("tasks")
515    }
516
517    // ── bus / services / secrets ──
518    /// `bus.publish`.
519    fn bus_publish(
520        &self,
521        ext: &ExtensionId,
522        cx: &CallContext,
523        topic: &str,
524        data: RawJson,
525        sticky: bool,
526    ) {
527    }
528    /// `bus.subscribe` / `unsubscribe`.
529    fn bus_subscribe(&self, ext: &ExtensionId, cx: &CallContext, pattern: &str, on: bool) {}
530    /// `bus.get`.
531    fn bus_get(&self, cx: &CallContext, topic: &str) -> Option<RawJson> {
532        None
533    }
534    /// `services.call`.
535    fn service_call(
536        &self,
537        ext: &ExtensionId,
538        cx: &CallContext,
539        target: Option<&ExtensionId>,
540        name: &str,
541        payload: RawJson,
542        timeout: Duration,
543    ) -> BoxFut<'_, Result<RawJson, HostError>> {
544        Box::pin(async { unsupported!("services.call") })
545    }
546    /// `services.list`.
547    fn services_list(&self) -> Vec<(ExtensionId, String)> {
548        Vec::new()
549    }
550    /// `secrets.*`.
551    ///
552    /// # Errors
553    ///
554    /// Unsupported, I/O.
555    fn secret(&self, ext: &ExtensionId, op: SecretOp) -> Result<Option<String>, HostError> {
556        unsupported!("secrets")
557    }
558
559    // ── provider-host ──
560    /// Provider-level extension events.
561    fn provider_hook(
562        &self,
563        ext: &ExtensionId,
564        hook: ProviderHook,
565    ) -> BoxFut<'_, ProviderHookReply> {
566        let reply = hook.passthrough();
567        Box::pin(async move { reply })
568    }
569
570    // ── host-info ──
571    /// Runtime parts of host info.
572    fn host_context(&self, cx: &CallContext) -> HostContextInfo {
573        HostContextInfo::default()
574    }
575
576    // ── host-service ──
577    /// `host-service.call`: one op of a host-provided service (`"browser"`, `"cron"`, …)
578    /// in the caller's scope. The WASM host's hub serves this from its registered
579    /// [`crate::host_service::HostService`]s first; an embedding host that does not use
580    /// the hub implements this and [`HostServices::host_services`] instead. A name absent
581    /// from both never reaches this method (the import answers `not-found`).
582    fn host_service(
583        &self,
584        ext: &ExtensionId,
585        cx: &CallContext,
586        name: &str,
587        op: &str,
588        payload: RawJson,
589        cancel: CancellationToken,
590    ) -> BoxFut<'_, Result<RawJson, HostError>> {
591        Box::pin(async { unsupported!("host-service.call") })
592    }
593    /// `host-service.list` / `host-service.available`: names this host provides (sorted;
594    /// not permission-gated — discovery). Implement together with
595    /// [`HostServices::host_service`].
596    fn host_services(&self) -> Vec<String> {
597        Vec::new()
598    }
599
600    // ── host-produced events ──
601    /// Delivers an event produced by the runtime host itself (timers, process exits, tasks).
602    fn deliver_event(
603        &self,
604        ext: &ExtensionId,
605        session_id: &str,
606        kind: EventKind,
607        payload: RawJson,
608    ) {
609    }
610}