Skip to main content

oino_core/extensions/
api.rs

1//! Native contract between the runner and a loaded extension instance.
2//!
3//! This mirrors `oino-extension-api` (extension-api.md §6) closely so the WASM host can
4//! implement it directly; once `oino-extension-api` lands these definitions are replaced by
5//! re-exports. Event payloads and results are fixed-shape JSON (D5).
6
7use std::path::PathBuf;
8
9use futures::future::BoxFuture;
10use futures::stream::BoxStream;
11use oino_agent::{AgentToolResult, ToolInvocation, UpdateSink};
12use oino_ai::{JsonObject, JsonValue, Model, ProviderEvent};
13use serde::{Deserialize, Serialize};
14use tokio_util::sync::CancellationToken;
15
16/// Stable extension id (manifest `id`).
17pub type ExtensionId = String;
18
19/// Manifest `kind` (D15): `-ne` disables only `Extension`.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum ExtensionKind {
23    /// A provider component.
24    Provider,
25    /// Any other extension.
26    #[default]
27    Extension,
28}
29
30/// Extension mode plus ACP.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum ExtensionMode {
34    /// Interactive terminal.
35    Tui,
36    /// RPC.
37    Rpc,
38    /// JSON event stream.
39    Json,
40    /// Print mode.
41    #[default]
42    Print,
43    /// ACP adapter.
44    Acp,
45    /// The GPUI desktop app (in-process backend).
46    Desktop,
47    /// The GPUI mobile app (in-process backend).
48    Mobile,
49}
50
51/// What kind of guest call is being served (runtime §7.3).
52#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum CallKind {
55    /// Event handler.
56    #[default]
57    Event,
58    /// Tool execution.
59    Tool,
60    /// Slash command.
61    Command,
62    /// Shortcut.
63    Shortcut,
64    /// Service request.
65    Service,
66    /// Transcript row rendering (`guest.render`, ui-protocol §6.4).
67    Render,
68}
69
70/// Context carried by every guest call.
71#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct CallContext {
74    /// Session the call belongs to.
75    pub session_id: String,
76    /// Run mode.
77    pub mode: ExtensionMode,
78    /// Dialog-capable UI attached.
79    pub has_ui: bool,
80    /// Session cwd.
81    pub cwd: PathBuf,
82    /// Nested (child) session.
83    pub is_child: bool,
84    /// Child role, for nested sessions.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub child_role: Option<String>,
87    /// Call kind.
88    pub call_kind: CallKind,
89}
90
91impl CallContext {
92    /// The same context for another call kind.
93    #[must_use]
94    pub fn with_kind(&self, call_kind: CallKind) -> Self {
95        Self {
96            call_kind,
97            ..self.clone()
98        }
99    }
100}
101
102/// Failure of a guest call.
103#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
104#[error("{message}")]
105pub struct ExtensionCallError {
106    /// Error text.
107    pub message: String,
108    /// The guest trapped (instance restarted lazily).
109    pub trap: bool,
110}
111
112impl ExtensionCallError {
113    /// A non-trap error.
114    #[must_use]
115    pub fn new(message: impl Into<String>) -> Self {
116        Self {
117            message: message.into(),
118            trap: false,
119        }
120    }
121}
122
123/// How a tool is exposed to the model.
124#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "kebab-case")]
126pub enum ToolExposure {
127    /// Declared to the model and callable by tools.
128    #[default]
129    Direct,
130    /// Declared to the model only.
131    ModelOnly,
132    /// Callable from code mode only.
133    Codemode,
134    /// Declared through tool search.
135    Deferred,
136    /// Not declared.
137    Hidden,
138}
139
140/// Tool registered by an extension (`ToolDefinition` data part).
141#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
142#[serde(rename_all = "camelCase")]
143pub struct ToolSpec {
144    /// Model-facing name.
145    pub name: String,
146    /// Frontend label.
147    #[serde(default)]
148    pub label: String,
149    /// Model-facing description.
150    pub description: String,
151    /// JSON schema.
152    pub parameters: JsonValue,
153    /// One-line prompt snippet.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub prompt_snippet: Option<String>,
156    /// Prompt guidelines.
157    #[serde(default, skip_serializing_if = "Vec::is_empty")]
158    pub prompt_guidelines: Vec<String>,
159    /// Exposure.
160    #[serde(default)]
161    pub exposure: ToolExposure,
162    /// Activated on registration (default `true`).
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub default_active: Option<bool>,
165    /// `sequential` forces sequential batches.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub execution_mode: Option<oino_agent::ExecutionMode>,
168    /// Tool namespace.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub namespace: Option<JsonValue>,
171    /// Annotations (ACP kind, read-only hints, …).
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub annotations: Option<JsonValue>,
174    /// Tools this tool may call (`ctx.executeTool`).
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub reaches_tools: Option<JsonValue>,
177    /// Output schema.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub output_schema: Option<JsonValue>,
180    /// The guest implements `prepare-arguments` for this tool.
181    #[serde(default)]
182    pub prepare_arguments: bool,
183}
184
185/// Slash command (`RegisteredCommand` data part).
186#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "camelCase")]
188pub struct CommandSpec {
189    /// Command name without `/`.
190    pub name: String,
191    /// Description.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub description: Option<String>,
194    /// Hidden commands are invokable but not listed.
195    #[serde(default)]
196    pub hidden: bool,
197    /// The guest completes arguments.
198    #[serde(default)]
199    pub completions: bool,
200}
201
202/// Shortcut (ui-protocol §8).
203#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct ShortcutSpec {
206    /// Stable action id.
207    pub id: String,
208    /// Default key ids.
209    #[serde(default)]
210    pub default_keys: Vec<String>,
211    /// Description.
212    #[serde(default)]
213    pub description: String,
214}
215
216/// Flag type.
217#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "lowercase")]
219pub enum FlagType {
220    /// `--name` / `--no-name`.
221    Boolean,
222    /// `--name <value>`.
223    String,
224}
225
226/// CLI flag registered by an extension.
227#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
228#[serde(rename_all = "camelCase")]
229pub struct FlagSpec {
230    /// Long name without `--`.
231    pub name: String,
232    /// Type.
233    #[serde(rename = "type")]
234    pub kind: FlagType,
235    /// Default value.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub default: Option<JsonValue>,
238    /// Help text.
239    #[serde(default)]
240    pub description: String,
241}
242
243/// Everything an extension registers at `init` (dynamic registration uses [`super::host::HostApi`]).
244#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
245#[serde(rename_all = "camelCase")]
246pub struct Registration {
247    /// Tools.
248    #[serde(default)]
249    pub tools: Vec<ToolSpec>,
250    /// Commands.
251    #[serde(default)]
252    pub commands: Vec<CommandSpec>,
253    /// Shortcuts.
254    #[serde(default)]
255    pub shortcuts: Vec<ShortcutSpec>,
256    /// Flags.
257    #[serde(default)]
258    pub flags: Vec<FlagSpec>,
259    /// Subscribed event kinds (`type` strings or `oino_*`).
260    #[serde(default)]
261    pub events: Vec<String>,
262    /// Provider registrations (extension-api §4.4 JSON).
263    #[serde(default)]
264    pub providers: Vec<JsonValue>,
265    /// Virtual model definitions.
266    #[serde(default)]
267    pub virtual_models: Vec<JsonValue>,
268    /// Render specs (ui-protocol §6.4).
269    #[serde(default)]
270    pub renderers: Vec<JsonValue>,
271    /// APIs this provider component implements (`provides.apis`).
272    #[serde(default)]
273    pub apis: Vec<String>,
274    /// Turn arbiter roles (runtime §7.6).
275    #[serde(default)]
276    pub arbiter: ArbiterRoles,
277    /// Settings namespaces (ui-protocol §6.6 declarations, runtime §10.3).
278    #[serde(default)]
279    pub settings: Vec<JsonValue>,
280}
281
282/// Turn arbiter roles of an extension (runtime §7.6, D9).
283#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct ArbiterRoles {
286    /// Implements `propose-nudge` / `nudge-delivered`.
287    #[serde(default)]
288    pub nudge_provider: bool,
289    /// Implements `wait-reason`.
290    #[serde(default)]
291    pub wait_source: bool,
292}
293
294/// Argument completion item.
295#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
296pub struct CompletionItem {
297    /// Inserted text.
298    pub value: String,
299    /// Label shown.
300    pub label: String,
301    /// Extra description.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub description: Option<String>,
304}
305
306/// Streaming provider implementation of a provider component.
307pub trait ProviderInstance: Send + Sync {
308    /// Starts a request (`{model, context, options, apiKey?, headers?}` JSON) and streams
309    /// provider events.
310    fn stream(
311        &self,
312        request: JsonValue,
313        cancel: CancellationToken,
314    ) -> BoxFuture<
315        '_,
316        Result<BoxStream<'static, Result<ProviderEvent, ExtensionCallError>>, ExtensionCallError>,
317    >;
318
319    /// Lists models of `provider_id`.
320    fn list_models(
321        &self,
322        provider_id: &str,
323        credential: Option<JsonValue>,
324    ) -> BoxFuture<'_, Result<Vec<Model>, ExtensionCallError>>;
325
326    /// Runs the provider's OAuth sign-in (`oauth-login`) in `cx` (its dialogs reach that
327    /// session's frontend); returns the credential JSON to store.
328    fn oauth_login(
329        &self,
330        provider_id: &str,
331        cx: CallContext,
332    ) -> BoxFuture<'_, Result<JsonValue, ExtensionCallError>> {
333        let _ = (provider_id, cx);
334        Box::pin(async { Err(ExtensionCallError::new("OAuth sign-in is not supported")) })
335    }
336
337    /// Exchanges the refresh token of a stored OAuth credential (`oauth-refresh`).
338    fn oauth_refresh(
339        &self,
340        provider_id: &str,
341        credential: JsonValue,
342    ) -> BoxFuture<'_, Result<JsonValue, ExtensionCallError>> {
343        let _ = (provider_id, credential);
344        Box::pin(async { Err(ExtensionCallError::new("OAuth refresh is not supported")) })
345    }
346}
347
348/// Skill, prompt and theme directories of an extension package (manifest `resources`).
349#[derive(Clone, Debug, Default, PartialEq, Eq)]
350pub struct PackageResourceDirs {
351    /// Skill directories.
352    pub skills: Vec<PathBuf>,
353    /// Prompt template directories.
354    pub prompts: Vec<PathBuf>,
355    /// Theme directories.
356    pub themes: Vec<PathBuf>,
357}
358
359/// One loaded extension instance (≈ `oino_extension_api::ExtensionInstance`).
360pub trait ExtensionInstance: Send + Sync {
361    /// Manifest id.
362    fn id(&self) -> &str;
363
364    /// Manifest kind.
365    fn kind(&self) -> ExtensionKind {
366        ExtensionKind::Extension
367    }
368
369    /// Path or label used in `extension_error.extensionPath`.
370    fn path(&self) -> String {
371        self.id().to_owned()
372    }
373
374    /// Resource directories the package declares (manifest `resources`, absolute paths under
375    /// the package root); loaded like package resources (runtime §9.6).
376    fn resource_dirs(&self) -> PackageResourceDirs {
377        PackageResourceDirs::default()
378    }
379
380    /// Initialises the instance and returns its static registrations.
381    fn init(&self, cx: CallContext) -> BoxFuture<'_, Result<Registration, ExtensionCallError>>;
382
383    /// Handles one subscribed event; `Ok(None)` means "no result".
384    fn on_event(
385        &self,
386        kind: &str,
387        payload: JsonValue,
388        cx: CallContext,
389    ) -> BoxFuture<'_, Result<Option<JsonValue>, ExtensionCallError>>;
390
391    /// Runs a registered tool.
392    fn run_tool(
393        &self,
394        tool: &str,
395        invocation: ToolInvocation,
396        updates: UpdateSink,
397        cancel: CancellationToken,
398        cx: CallContext,
399    ) -> BoxFuture<'_, Result<AgentToolResult, ExtensionCallError>>;
400
401    /// `prepareArguments` for a registered tool.
402    fn prepare_arguments(&self, _tool: &str, raw: JsonObject) -> JsonObject {
403        raw
404    }
405
406    /// Runs a registered command.
407    fn run_command(
408        &self,
409        name: &str,
410        args: &str,
411        cancel: CancellationToken,
412        cx: CallContext,
413    ) -> BoxFuture<'_, Result<Option<JsonValue>, ExtensionCallError>>;
414
415    /// Completes command arguments.
416    fn complete_command(
417        &self,
418        _name: &str,
419        _prefix: &str,
420        _cx: CallContext,
421    ) -> BoxFuture<'_, Vec<CompletionItem>> {
422        Box::pin(async { Vec::new() })
423    }
424
425    /// Runs a registered shortcut.
426    fn run_shortcut(
427        &self,
428        _id: &str,
429        _cx: CallContext,
430    ) -> BoxFuture<'_, Result<(), ExtensionCallError>> {
431        Box::pin(async { Ok(()) })
432    }
433
434    /// Autocomplete contributor (`complete-input`, ui-protocol §7.3): `{text, cursor}` in,
435    /// `[CompletionGroup]` JSON out.
436    fn complete_input(
437        &self,
438        _request: JsonValue,
439        _cx: CallContext,
440    ) -> BoxFuture<'_, Option<JsonValue>> {
441        Box::pin(async { None })
442    }
443
444    /// Service names this instance serves (`registry.register-service`).
445    fn services(&self) -> Vec<String> {
446        Vec::new()
447    }
448
449    /// Serves a service request (`handle-service`) from `caller`.
450    fn handle_service(
451        &self,
452        name: &str,
453        _payload: JsonValue,
454        _caller: &str,
455        _cx: CallContext,
456    ) -> BoxFuture<'_, Result<JsonValue, ExtensionCallError>> {
457        let message = format!("service {name} is not implemented");
458        Box::pin(async move { Err(ExtensionCallError::new(message)) })
459    }
460
461    /// Built-in tools whose rendering this extension may override (manifest
462    /// `tools.override`); `render-only` — no tool registration needed (U1). `"*"` covers all.
463    fn tool_overrides(&self) -> Vec<String> {
464        Vec::new()
465    }
466
467    /// `guest.render`: an OUS node for one transcript row (ui-protocol §6.4), or `None` for the
468    /// default rendering. Called synchronously from the render path; the request JSON is
469    /// `{target, key, entryId?, density, expanded, width, data}`.
470    fn render(&self, _request: JsonValue, _cx: CallContext) -> Option<JsonValue> {
471        None
472    }
473
474    /// Routes a virtual model (`route-model` export): a route request in, a route result
475    /// out.
476    fn route_model(
477        &self,
478        _request: JsonValue,
479        _cx: CallContext,
480    ) -> BoxFuture<'_, Result<JsonValue, ExtensionCallError>> {
481        Box::pin(async { Err(ExtensionCallError::new("route-model is not implemented")) })
482    }
483
484    /// Arbiter `propose-nudge`: `{id?, priority, customType, content, display, details?}`.
485    fn propose_nudge(&self, _cx: CallContext) -> BoxFuture<'_, Option<JsonValue>> {
486        Box::pin(async { None })
487    }
488
489    /// Arbiter `nudge-delivered` for the winning proposal.
490    fn nudge_delivered(&self, _id: &str, _cx: CallContext) {}
491
492    /// Arbiter `wait-reason`: why the session should wait instead of settling.
493    fn wait_reason(&self, _cx: CallContext) -> BoxFuture<'_, Option<String>> {
494        Box::pin(async { None })
495    }
496
497    /// Manifest deadline for handlers of `kind` (`limits.hookTimeoutMs`,
498    /// `toolCallHookTimeoutMs`); the runner never goes below its own default.
499    fn hook_deadline(&self, _kind: &str) -> Option<std::time::Duration> {
500        None
501    }
502
503    /// The provider implementation, for provider components.
504    fn provider(&self) -> Option<&dyn ProviderInstance> {
505        None
506    }
507
508    /// Shuts the instance down.
509    fn shutdown(&self, _reason: &str) -> BoxFuture<'_, ()> {
510        Box::pin(async {})
511    }
512}