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}