Skip to main content

oino_core/extensions/
host.rs

1//! Host calls made by extensions (≈ `HostServices`, extension-api.md §6), as one typed
2//! operation enum. `oino-core` implements [`HostApi`]; a WASM host forwards guest imports to it.
3
4use futures::future::BoxFuture;
5use oino_ai::{JsonValue, ThinkingLevel, UserContentField};
6use serde::{Deserialize, Serialize};
7use tokio_util::sync::CancellationToken;
8
9use super::api::{ArbiterRoles, CallContext, CommandSpec, FlagSpec, ShortcutSpec, ToolSpec};
10use crate::agents::ChildAgent;
11
12/// Delivery of extension messages.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub enum Delivery {
16    /// Steering queue.
17    Steer,
18    /// Follow-up queue.
19    FollowUp,
20    /// With the next user prompt.
21    NextTurn,
22}
23
24/// `sendMessage` draft.
25#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct CustomMessageDraft {
28    /// Extension-defined type.
29    pub custom_type: String,
30    /// Content.
31    #[serde(default)]
32    pub content: UserContentField,
33    /// Render in frontends.
34    #[serde(default)]
35    pub display: bool,
36    /// Metadata.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub details: Option<JsonValue>,
39}
40
41/// Read-only session queries.
42#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(tag = "query", rename_all = "kebab-case")]
44pub enum SessionQuery {
45    /// Header.
46    Header,
47    /// All entries.
48    Entries,
49    /// Root → leaf path.
50    Branch,
51    /// One entry.
52    Entry {
53        /// Entry id.
54        id: String,
55    },
56    /// Tree.
57    Tree,
58    /// Projection at the leaf.
59    Projection,
60    /// Context-selected entries.
61    ContextEntries,
62    /// Label of an entry.
63    Label {
64        /// Entry id.
65        id: String,
66    },
67    /// Session name.
68    Name,
69    /// Leaf id.
70    Leaf,
71    /// Session id.
72    Id,
73    /// Session file.
74    File,
75}
76
77/// Every host operation an extension can perform.
78#[allow(
79    clippy::large_enum_variant,
80    reason = "operations are built once per call and moved, never stored"
81)]
82#[derive(Clone, Debug)]
83#[non_exhaustive]
84pub enum HostOp {
85    /// Subscribe to event kinds.
86    Subscribe(Vec<String>),
87    /// Unsubscribe from event kinds.
88    Unsubscribe(Vec<String>),
89    /// Register (or replace own) tool.
90    RegisterTool(ToolSpec),
91    /// Remove own tool.
92    UnregisterTool(String),
93    /// Register a command; returns its invocation name.
94    RegisterCommand(CommandSpec),
95    /// Remove own command.
96    UnregisterCommand(String),
97    /// Register a shortcut.
98    RegisterShortcut(ShortcutSpec),
99    /// Remove a shortcut.
100    UnregisterShortcut(String),
101    /// Register a CLI flag.
102    RegisterFlag(FlagSpec),
103    /// Read a flag value.
104    GetFlag(String),
105    /// Register a render spec.
106    RegisterRenderer(JsonValue),
107    /// Register a provider (extension-api §4.4 JSON).
108    RegisterProvider(JsonValue),
109    /// Remove a provider.
110    UnregisterProvider(String),
111    /// Register a virtual model.
112    RegisterVirtualModel(JsonValue),
113    /// Remove a virtual model.
114    UnregisterVirtualModel {
115        /// Provider id.
116        provider: String,
117        /// Model id.
118        id: String,
119    },
120    /// Read session data.
121    SessionRead(SessionQuery),
122    /// Appends an entry (`appendEntry(customType, data)`); returns the entry id.
123    AppendEntry {
124        /// Custom type.
125        custom_type: String,
126        /// Payload.
127        data: Option<JsonValue>,
128    },
129    /// Sends a message into the session.
130    SendMessage {
131        /// Draft.
132        message: CustomMessageDraft,
133        /// Trigger a turn.
134        trigger_turn: bool,
135        /// Delivery mode.
136        deliver_as: Option<Delivery>,
137    },
138    /// Sends a user message into the session.
139    SendUserMessage {
140        /// Content.
141        content: UserContentField,
142        /// Delivery mode while streaming.
143        deliver_as: Option<Delivery>,
144    },
145    /// Sets the session name.
146    SetSessionName(String),
147    /// Sets the entry label.
148    SetLabel {
149        /// Entry id.
150        entry_id: String,
151        /// Label (`None` clears).
152        label: Option<String>,
153    },
154    /// Returns the active tools.
155    GetActiveTools,
156    /// Returns every tool.
157    GetAllTools,
158    /// Sets the active tools.
159    SetActiveTools(Vec<String>),
160    /// Returns the registered commands.
161    GetCommands,
162    /// Sets the model; returns `true` when auth is configured and the model was set.
163    SetModel {
164        /// Provider id.
165        provider: String,
166        /// Model id.
167        id: String,
168    },
169    /// `control.set-default-model`: writes the fresh-session model to core settings
170    /// (`defaultProvider`/`defaultModel`/`modelThinkingLevels`) without touching this session.
171    SetDefaultModel {
172        /// Provider id.
173        provider: String,
174        /// Model id.
175        id: String,
176        /// Thinking level remembered for that model.
177        thinking: Option<ThinkingLevel>,
178    },
179    /// Current model (`null` when none).
180    GetModel,
181    /// Returns the current thinking level.
182    GetThinkingLevel,
183    /// Sets the thinking level.
184    SetThinkingLevel(ThinkingLevel),
185    /// Returns whether the session is idle.
186    IsIdle,
187    /// Aborts the current turn.
188    Abort,
189    /// Returns whether messages are pending.
190    HasPendingMessages,
191    /// Returns the context usage.
192    GetContextUsage,
193    /// Triggers compaction.
194    Compact {
195        /// Custom instructions.
196        custom_instructions: Option<String>,
197    },
198    /// Returns the current system prompt.
199    GetSystemPrompt,
200    /// The merged settings object.
201    GetSettings,
202    /// Models with configured credentials (`control.models-list`).
203    GetAvailableModels,
204    /// The `--models` / `enabledModels` scope (`control.scoped-models`).
205    GetScopedModels,
206    /// The model a virtual selection currently routes to (`control.routed-model`).
207    GetRoutedModel,
208    /// Session stats (`control.session-stats`).
209    GetSessionStats,
210    /// Project trust decision (`control.is-project-trusted`).
211    IsProjectTrusted,
212    /// `models.complete`: one non-session completion → `AssistantMessage`.
213    ModelComplete {
214        /// `provider/id` (default: the session model).
215        model: Option<String>,
216        /// `Context` JSON (`{messages}`).
217        context: JsonValue,
218        /// `StreamOptions` JSON.
219        options: JsonValue,
220        /// Caller cancellation.
221        cancel: Option<CancellationToken>,
222    },
223    /// `models.summarize-default`: core's compaction summariser → `{summary, usage}`.
224    SummarizeDefault {
225        /// `Vec<AgentMessage>` JSON.
226        messages: JsonValue,
227        /// Extra focus.
228        custom_instructions: Option<String>,
229        /// Earlier summary to update.
230        previous_summary: Option<String>,
231    },
232    /// Waits for idle (`ctx.waitForIdle`, command context only).
233    WaitForIdle,
234    /// Navigates the tree (`ctx.navigateTree`, command context only).
235    NavigateTree {
236        /// Target entry.
237        target_id: String,
238        /// Summarise the abandoned branch.
239        summarize: bool,
240        /// Custom instructions.
241        custom_instructions: Option<String>,
242        /// Replace the default prompt.
243        replace_instructions: bool,
244        /// Label for the summary or target.
245        label: Option<String>,
246    },
247    /// Raise a dialog (`select`, `confirm`, …) and wait for the answer.
248    UiDialog {
249        /// Method.
250        method: String,
251        /// Fields.
252        params: oino_ai::JsonObject,
253        /// Caller cancel token.
254        cancel: Option<CancellationToken>,
255    },
256    /// Fire-and-forget UI request.
257    UiFire {
258        /// Method.
259        method: String,
260        /// Fields.
261        params: oino_ai::JsonObject,
262    },
263    /// `ctx.executeTool`: runs a callable tool nested under `parent_tool_call_id`.
264    ExecuteTool {
265        /// Running tool call that makes the call.
266        parent_tool_call_id: String,
267        /// Tool name.
268        name: String,
269        /// Arguments.
270        args: oino_ai::JsonObject,
271    },
272    /// Invoke another extension's command (`command-invoke`).
273    InvokeCommand {
274        /// Invocation name.
275        name: String,
276        /// Arguments.
277        args: String,
278    },
279    /// Declare turn arbiter roles (`registry.set-arbiter-roles`).
280    SetArbiterRoles(ArbiterRoles),
281    /// Nested agents of the calling session (`agents.list`).
282    ListAgents,
283    /// `control.run-state`: `{state, waitingOn?}`.
284    GetRunState,
285    /// `control.shutdown`: asks the frontend to exit when idle.
286    Shutdown,
287    /// `control.system-prompt-options` (command context only).
288    GetSystemPromptOptions,
289    /// `control.new-session` (command context only) → `{cancelled}`.
290    NewSession {
291        /// `parentSession` of the new header.
292        parent_session: Option<std::path::PathBuf>,
293    },
294    /// `control.fork` (command context only) → `{cancelled, selectedText?}`.
295    Fork {
296        /// Entry to fork from.
297        entry_id: String,
298        /// Fork at the entry (`true`) or before it.
299        at: bool,
300    },
301    /// `control.switch-session` (command context only) → `{cancelled}`.
302    SwitchSession(std::path::PathBuf),
303    /// `control.reload` (command context only).
304    Reload,
305    /// `registry.register-settings`: a ui-protocol §6.6 namespace declaration.
306    RegisterSettings(JsonValue),
307    /// `registry.extension-settings`: one namespace's values as the caller sees them.
308    GetExtensionSettings {
309        /// Namespace.
310        namespace: String,
311        /// Layer.
312        layer: crate::extensions::settings::SettingsLayer,
313    },
314    /// `registry.set-extension-setting`: writes one key of an owned namespace.
315    SetExtensionSetting {
316        /// Namespace.
317        namespace: String,
318        /// Dot-path key.
319        key: String,
320        /// Value (`None` deletes: "use default").
321        value: Option<JsonValue>,
322        /// Target file.
323        scope: oino_settings::SettingsScope,
324    },
325    /// `{namespace: values}` of every known namespace as the caller sees them (init input).
326    GetAllExtensionSettings,
327    /// `ui.editor-text`: the composer text last reported by the frontend
328    /// (`editor_event`, ui-protocol §7.1).
329    EditorText,
330    /// `models.credential-for`: `{provider, headers, baseUrl?}` for a provider, or `null`.
331    CredentialFor(String),
332    /// `bus.publish` mirror: records sticky topics and feeds subscribed frontends
333    /// (`_oino/topics`). Served by the host router, independent of any session.
334    BusPublish {
335        /// Topic.
336        topic: String,
337        /// Payload.
338        data: JsonValue,
339        /// Sticky.
340        sticky: bool,
341    },
342    /// `bus.get` (hosts without their own bus): last sticky value or `null`.
343    BusGet(String),
344    /// `control.running-bash`: `[{toolCallId, command, startedAt, pid?}]` of the session.
345    RunningBash,
346    /// `host-info.get` frontends: `["desktop", "rpc", …]` attached to the session.
347    GetFrontends,
348}
349
350impl HostOp {
351    /// Bus operations, served by the host router independent of any session.
352    #[must_use]
353    pub fn is_bus_op(&self) -> bool {
354        matches!(self, Self::BusPublish { .. } | Self::BusGet(_))
355    }
356
357    /// Operations served by the settings registry, independent of any session.
358    #[must_use]
359    pub fn is_settings_op(&self) -> bool {
360        matches!(
361            self,
362            Self::RegisterSettings(_)
363                | Self::GetExtensionSettings { .. }
364                | Self::SetExtensionSetting { .. }
365                | Self::GetAllExtensionSettings
366                | Self::GetSettings
367        )
368    }
369}
370
371/// A `models.complete` / `models.stream` request.
372#[derive(Clone, Debug, Default)]
373pub struct ModelRequest {
374    /// `provider/id`; the session model when absent.
375    pub model: Option<String>,
376    /// `Context` JSON.
377    pub context: JsonValue,
378    /// `StreamOptions` JSON (`null` = defaults).
379    pub options: JsonValue,
380    /// Caller cancellation.
381    pub cancel: Option<CancellationToken>,
382}
383
384/// Failure of a host operation.
385#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
386#[non_exhaustive]
387pub enum HostError {
388    /// The caller's session was replaced or disposed.
389    #[error("{0}")]
390    StaleContext(String),
391    /// Session-replacing operations need a command or shortcut context.
392    #[error("This operation is only available in command and shortcut contexts")]
393    CommandContextRequired,
394    /// Invalid input.
395    #[error("{0}")]
396    Invalid(String),
397    /// Name already registered by another extension.
398    #[error("{0}")]
399    Conflict(String),
400    /// The operation failed.
401    #[error("{0}")]
402    Failed(String),
403}
404
405/// Implemented by `oino-core`; extensions call it with their id and call context.
406pub trait HostApi: Send + Sync {
407    /// Performs `op` for extension `extension` in the session of `cx`.
408    fn call<'a>(
409        &'a self,
410        extension: &'a str,
411        cx: &'a CallContext,
412        op: HostOp,
413    ) -> BoxFuture<'a, Result<JsonValue, HostError>>;
414
415    /// `models.stream`: streams one completion (`AssistantMessageEvent` JSON items) in the
416    /// session of `cx`; same model/credential resolution as [`HostOp::ModelComplete`].
417    fn model_stream<'a>(
418        &'a self,
419        _extension: &'a str,
420        _cx: &'a CallContext,
421        _request: ModelRequest,
422    ) -> BoxFuture<'a, Result<futures::stream::BoxStream<'static, JsonValue>, HostError>> {
423        Box::pin(async {
424            Err(HostError::Failed(
425                "models.stream is not supported by this host".into(),
426            ))
427        })
428    }
429
430    /// `control.bash-detach` / `tasks.adopt-bash`: detaches the running `bash` call
431    /// `tool_call_id` of the session of `cx` into background task `task_id`.
432    fn detach_bash<'a>(
433        &'a self,
434        _extension: &'a str,
435        _cx: &'a CallContext,
436        _tool_call_id: &'a str,
437        _task_id: &'a str,
438        _reason: &'a str,
439    ) -> BoxFuture<'a, Result<crate::session::DetachedBash, HostError>> {
440        Box::pin(async {
441            Err(HostError::Failed(
442                "bash detach is not supported by this host".into(),
443            ))
444        })
445    }
446
447    /// `agents.spawn`: creates a nested `AgentSession` under the session of `cx`
448    /// (runtime §12). `spec` is the `AgentSpec` JSON.
449    fn spawn_agent<'a>(
450        &'a self,
451        _extension: &'a str,
452        _cx: &'a CallContext,
453        _spec: JsonValue,
454    ) -> BoxFuture<'a, Result<std::sync::Arc<ChildAgent>, HostError>> {
455        Box::pin(async {
456            Err(HostError::Failed(
457                "agents is not supported by this host".into(),
458            ))
459        })
460    }
461}