Skip to main content

oino_core/session/
host.rs

1//! Host operations performed by extensions against this session (`ExtensionActions`,
2//! `ExtensionContextActions`, `ExtensionCommandContextActions`, UI context).
3
4use oino_ai::{JsonValue, ThinkingLevel};
5use oino_session::EntryId;
6use serde_json::json;
7
8use super::{AgentSession, lock};
9use crate::events::OinoEvent;
10use crate::extensions::api::{CallContext, CallKind};
11use crate::extensions::host::{HostError, HostOp, SessionQuery};
12use crate::ui::{UiAnswer, is_dialog, prompt_kind};
13
14fn to_json<T: serde::Serialize>(value: T) -> Result<JsonValue, HostError> {
15    serde_json::to_value(value).map_err(|e| HostError::Failed(e.to_string()))
16}
17
18fn failed(error: impl std::fmt::Display) -> HostError {
19    HostError::Failed(error.to_string())
20}
21
22/// A render spec for one of the built-in tools is a *render-only override* (U1–U2): it needs the
23/// manifest's `tools.override` for that name, and registers no tool.
24pub(crate) fn check_render_override(
25    spec: &JsonValue,
26    overrides: &[String],
27) -> Result<(), HostError> {
28    let field = |name: &str| {
29        spec.get(name)
30            .and_then(JsonValue::as_str)
31            .unwrap_or_default()
32    };
33    if field("target") != "tool" {
34        return Ok(());
35    }
36    let key = field("key");
37    if oino_tools::BuiltinToolName::parse(key).is_none() {
38        return Ok(());
39    }
40    if overrides.iter().any(|name| name == "*" || name == key) {
41        return Ok(());
42    }
43    Err(HostError::Invalid(format!(
44        "rendering the built-in tool `{key}` needs the manifest permission tools.override"
45    )))
46}
47
48impl AgentSession {
49    /// Performs one host operation for `extension` (runtime §7.3 context rules apply).
50    ///
51    /// # Errors
52    ///
53    /// [`HostError::StaleContext`] after replacement, [`HostError::CommandContextRequired`] for
54    /// session-replacing operations outside commands, and operation failures.
55    #[allow(clippy::too_many_lines, reason = "one arm per host operation")]
56    pub async fn host_call(
57        &self,
58        extension: &str,
59        cx: &CallContext,
60        op: HostOp,
61    ) -> Result<JsonValue, HostError> {
62        let runner = self.runner();
63        if let Some(message) = runner.stale_message() {
64            return Err(HostError::StaleContext(message));
65        }
66        let command_context = matches!(cx.call_kind, CallKind::Command | CallKind::Shortcut);
67        let loaded = runner.set().get(extension).cloned();
68        let registry_changed = matches!(
69            op,
70            HostOp::RegisterTool(_)
71                | HostOp::UnregisterTool(_)
72                | HostOp::Subscribe(_)
73                | HostOp::Unsubscribe(_)
74        );
75        let commands_changed = matches!(
76            op,
77            HostOp::RegisterCommand(_) | HostOp::UnregisterCommand(_)
78        );
79        let shortcuts_changed = matches!(
80            op,
81            HostOp::RegisterShortcut(_) | HostOp::UnregisterShortcut(_)
82        );
83        let result = match op {
84            HostOp::Subscribe(kinds) => {
85                let loaded = loaded
86                    .ok_or_else(|| HostError::Invalid(format!("Unknown extension {extension}")))?;
87                loaded.update(|state| state.events.extend(kinds));
88                Ok(JsonValue::Null)
89            }
90            HostOp::Unsubscribe(kinds) => {
91                let loaded = loaded
92                    .ok_or_else(|| HostError::Invalid(format!("Unknown extension {extension}")))?;
93                loaded.update(|state| state.events.retain(|k| !kinds.contains(k)));
94                Ok(JsonValue::Null)
95            }
96            HostOp::RegisterTool(spec) => {
97                let owner = runner
98                    .tools()
99                    .into_iter()
100                    .find(|t| t.spec.name == spec.name)
101                    .map(|t| t.extension.instance.id().to_owned());
102                let builtin = self
103                    .all_tools()
104                    .iter()
105                    .any(|t| t.name == spec.name && t.source_info.source == "builtin");
106                if owner.as_deref().is_some_and(|o| o != extension) || builtin {
107                    return Err(HostError::Conflict(format!(
108                        "Tool \"{}\" is already registered",
109                        spec.name
110                    )));
111                }
112                let loaded = loaded
113                    .ok_or_else(|| HostError::Invalid(format!("Unknown extension {extension}")))?;
114                loaded.update(|state| state.tools.insert(spec.name.clone(), spec));
115                Ok(JsonValue::Null)
116            }
117            HostOp::UnregisterTool(name) => {
118                if let Some(loaded) = loaded {
119                    loaded.update(|state| state.tools.shift_remove(&name));
120                }
121                Ok(JsonValue::Null)
122            }
123            HostOp::RegisterCommand(spec) => {
124                let loaded = loaded
125                    .ok_or_else(|| HostError::Invalid(format!("Unknown extension {extension}")))?;
126                let name = spec.name.clone();
127                loaded.update(|state| {
128                    state.commands.retain(|c| c.name != spec.name);
129                    state.commands.push(spec);
130                });
131                let invocation = runner
132                    .commands()
133                    .into_iter()
134                    .find(|c| c.extension.instance.id() == extension && c.spec.name == name)
135                    .map_or(name, |c| c.invocation_name);
136                Ok(json!(invocation))
137            }
138            HostOp::UnregisterCommand(name) => {
139                if let Some(loaded) = loaded {
140                    loaded.update(|state| state.commands.retain(|c| c.name != name));
141                }
142                Ok(JsonValue::Null)
143            }
144            HostOp::RegisterShortcut(spec) => {
145                let loaded = loaded
146                    .ok_or_else(|| HostError::Invalid(format!("Unknown extension {extension}")))?;
147                loaded.update(|state| state.shortcuts.insert(spec.id.clone(), spec));
148                Ok(JsonValue::Null)
149            }
150            HostOp::UnregisterShortcut(id) => {
151                if let Some(loaded) = loaded {
152                    loaded.update(|state| state.shortcuts.shift_remove(&id));
153                }
154                Ok(JsonValue::Null)
155            }
156            HostOp::RegisterFlag(spec) => {
157                let loaded = loaded
158                    .ok_or_else(|| HostError::Invalid(format!("Unknown extension {extension}")))?;
159                loaded.update(|state| state.flags.insert(spec.name.clone(), spec));
160                Ok(JsonValue::Null)
161            }
162            HostOp::GetFlag(name) => Ok(runner.flag_value(&name).unwrap_or(JsonValue::Null)),
163            HostOp::RegisterRenderer(spec) => {
164                let loaded = loaded
165                    .ok_or_else(|| HostError::Invalid(format!("Unknown extension {extension}")))?;
166                check_render_override(&spec, &loaded.instance.tool_overrides())?;
167                if spec.get("target").and_then(JsonValue::as_str) == Some("turn-footer")
168                    && let Some(key) = spec.get("key").and_then(JsonValue::as_str)
169                {
170                    self.ui_register_turn_footer(extension, key);
171                }
172                loaded.update(|state| state.renderers.push(spec));
173                Ok(JsonValue::Null)
174            }
175            HostOp::RegisterProvider(config) => {
176                let registration: oino_models::ProviderRegistration =
177                    serde_json::from_value(config)
178                        .map_err(|e| HostError::Invalid(e.to_string()))?;
179                self.inner
180                    .models
181                    .register_provider(extension, registration)
182                    .map_err(failed)?;
183                self.refresh_current_model();
184                Ok(JsonValue::Null)
185            }
186            HostOp::UnregisterProvider(id) => {
187                self.inner.models.unregister_provider(&id);
188                self.refresh_current_model();
189                Ok(JsonValue::Null)
190            }
191            HostOp::RegisterVirtualModel(definition) => {
192                let definition = serde_json::from_value(definition)
193                    .map_err(|e| HostError::Invalid(e.to_string()))?;
194                self.inner
195                    .models
196                    .register_virtual_model(extension, definition)
197                    .map_err(failed)?;
198                self.refresh_current_model();
199                Ok(JsonValue::Null)
200            }
201            HostOp::UnregisterVirtualModel { provider, id } => {
202                self.inner.models.unregister_virtual_model(&provider, &id);
203                self.refresh_current_model();
204                Ok(JsonValue::Null)
205            }
206            HostOp::SessionRead(query) => self.session_read(&query),
207            HostOp::AppendEntry { custom_type, data } => {
208                let id = lock(&self.inner.manager)
209                    .append_custom_entry(&custom_type, data)
210                    .map_err(failed)?;
211                self.publish_entry(&id);
212                Ok(json!(id.as_str()))
213            }
214            HostOp::SendMessage {
215                message,
216                trigger_turn,
217                deliver_as,
218            } => {
219                let session = self.clone();
220                tokio::spawn(async move {
221                    if let Err(error) = session
222                        .send_custom_message(message, trigger_turn, deliver_as)
223                        .await
224                    {
225                        session
226                            .runner()
227                            .emit_error("<runtime>", "send_message", error.to_string());
228                    }
229                });
230                Ok(JsonValue::Null)
231            }
232            HostOp::SendUserMessage {
233                content,
234                deliver_as,
235            } => {
236                let session = self.clone();
237                tokio::spawn(async move {
238                    if let Err(error) = session.send_user_message(content, deliver_as, false).await
239                    {
240                        session.runner().emit_error(
241                            "<runtime>",
242                            "send_user_message",
243                            error.to_string(),
244                        );
245                    }
246                });
247                Ok(JsonValue::Null)
248            }
249            HostOp::SetSessionName(name) => {
250                self.set_session_name(Some(name)).await.map_err(failed)?;
251                Ok(JsonValue::Null)
252            }
253            HostOp::SetLabel { entry_id, label } => {
254                self.set_label(&EntryId::new(entry_id), label)
255                    .await
256                    .map_err(failed)?;
257                Ok(JsonValue::Null)
258            }
259            HostOp::GetActiveTools => to_json(self.active_tool_names()),
260            HostOp::GetAllTools => to_json(self.all_tools()),
261            HostOp::SetActiveTools(names) => {
262                self.set_active_tools_by_name(&names);
263                Ok(JsonValue::Null)
264            }
265            HostOp::GetCommands => to_json(self.commands()),
266            HostOp::SetModel { provider, id } => {
267                let Some(model) = self.inner.models.find(&provider, &id) else {
268                    return Ok(json!(false));
269                };
270                if !self.inner.models.has_configured_auth(&model.provider) {
271                    return Ok(json!(false));
272                }
273                self.set_model(model, false).await.map_err(failed)?;
274                Ok(json!(true))
275            }
276            HostOp::SetDefaultModel {
277                provider,
278                id,
279                thinking,
280            } => {
281                let Some(model) = self.inner.models.find(&provider, &id) else {
282                    return Ok(json!(false));
283                };
284                if !self.inner.models.has_configured_auth(&model.provider) {
285                    return Ok(json!(false));
286                }
287                let written = self.with_settings(|s| {
288                    s.set_default_model_and_provider(&model.provider, &model.id)
289                        .and_then(|()| match thinking {
290                            Some(level) => {
291                                s.set_model_thinking_level(&model.provider, &model.id, level)
292                            }
293                            None => Ok(()),
294                        })
295                });
296                written.map_err(failed)?;
297                Ok(json!(true))
298            }
299            HostOp::GetModel => to_json(self.model()),
300            HostOp::GetThinkingLevel => to_json(self.thinking_level()),
301            HostOp::SetThinkingLevel(level) => {
302                let level: ThinkingLevel = level;
303                to_json(self.set_thinking_level(level, false).await)
304            }
305            HostOp::IsIdle => Ok(json!(self.is_idle())),
306            HostOp::Abort => {
307                let session = self.clone();
308                tokio::spawn(async move { session.abort().await });
309                Ok(JsonValue::Null)
310            }
311            HostOp::HasPendingMessages => Ok(json!(self.pending_message_count() > 0)),
312            HostOp::GetContextUsage => to_json(self.context_usage()),
313            HostOp::Compact {
314                custom_instructions,
315            } => {
316                let session = self.clone();
317                tokio::spawn(async move {
318                    if let Err(error) = session.compact(custom_instructions).await {
319                        tracing::debug!("extension compaction failed: {error}");
320                    }
321                });
322                Ok(JsonValue::Null)
323            }
324            HostOp::GetSystemPrompt => Ok(json!(self.system_prompt())),
325            HostOp::GetSettings => {
326                Ok(JsonValue::Object(self.with_settings(|settings| {
327                    settings.settings().merged().clone()
328                })))
329            }
330            HostOp::GetAvailableModels => to_json(self.inner.models.available()),
331            HostOp::GetScopedModels => to_json(self.scoped_models()),
332            HostOp::GetRoutedModel => to_json(self.routed_model()),
333            HostOp::GetSessionStats => to_json(self.session_stats()),
334            HostOp::EditorText => Ok(json!(self.editor_text())),
335            HostOp::CredentialFor(provider) => self.credential_for(&provider).await,
336            HostOp::RunningBash => to_json(self.running_bash().await),
337            HostOp::GetFrontends => Ok(json!(self.frontends())),
338            op @ (HostOp::BusPublish { .. } | HostOp::BusGet(_)) => {
339                let router = self
340                    .inner
341                    .agents
342                    .router()
343                    .ok_or_else(|| HostError::Failed("the bus is not available".into()))?;
344                router.bus_call(extension, op)
345            }
346            HostOp::ModelComplete {
347                model,
348                context,
349                options,
350                cancel,
351            } => {
352                self.model_complete(model.as_deref(), context, options, cancel)
353                    .await
354            }
355            HostOp::SummarizeDefault {
356                messages,
357                custom_instructions,
358                previous_summary,
359            } => {
360                let messages: Vec<oino_ai::AgentMessage> = serde_json::from_value(messages)
361                    .map_err(|e| HostError::Invalid(format!("messages: {e}")))?;
362                let model = self
363                    .model()
364                    .ok_or_else(|| HostError::Failed("No model selected".into()))?;
365                let summarizer = self
366                    .summarizer(
367                        &model,
368                        tokio_util::sync::CancellationToken::new(),
369                        crate::events::SummarizationSource::BranchSummary,
370                    )
371                    .await
372                    .map_err(HostError::Failed)?;
373                let output = crate::compaction::summarize_default(
374                    &messages,
375                    custom_instructions.as_deref(),
376                    previous_summary.as_deref(),
377                    &summarizer,
378                )
379                .await
380                .map_err(failed)?;
381                to_json(output)
382            }
383            HostOp::IsProjectTrusted => Ok(json!(
384                self.with_settings(|settings| settings.is_project_trusted())
385            )),
386            HostOp::WaitForIdle => {
387                if !command_context {
388                    return Err(HostError::CommandContextRequired);
389                }
390                self.wait_for_idle().await;
391                Ok(JsonValue::Null)
392            }
393            HostOp::NavigateTree {
394                target_id,
395                summarize,
396                custom_instructions,
397                replace_instructions,
398                label,
399            } => {
400                if !command_context {
401                    return Err(HostError::CommandContextRequired);
402                }
403                let result = self
404                    .navigate_tree(
405                        &EntryId::new(target_id),
406                        super::NavigateTreeOptions {
407                            summarize,
408                            custom_instructions,
409                            replace_instructions,
410                            label,
411                        },
412                    )
413                    .await
414                    .map_err(failed)?;
415                Ok(json!({"cancelled": result.cancelled}))
416            }
417            HostOp::UiDialog {
418                method,
419                params,
420                cancel,
421            } => {
422                if !is_dialog(&method) {
423                    return Err(HostError::Invalid(format!("{method} is not a dialog")));
424                }
425                if let Some(link) = &self.inner.agents.link {
426                    return Ok(child_dialog(link, extension, &method, params, cancel).await);
427                }
428                Ok(self.ui_dialog(extension, &method, params, cancel).await)
429            }
430            HostOp::UiFire { method, params } => {
431                match &self.inner.agents.link {
432                    None => {
433                        self.ui_fire(extension, &method, params)
434                            .map_err(|error| HostError::Invalid(error.to_string()))?;
435                    }
436                    Some(link) => child_fire(link, extension, &method, params),
437                }
438                Ok(JsonValue::Null)
439            }
440            HostOp::ExecuteTool {
441                parent_tool_call_id,
442                name,
443                args,
444            } => {
445                let outcome = self
446                    .execute_nested_tool(
447                        &parent_tool_call_id,
448                        &name,
449                        args,
450                        tokio_util::sync::CancellationToken::new(),
451                    )
452                    .await;
453                let mut value = json!({
454                    "toolCall": {"type": "toolCall", "id": outcome.tool_call.id, "name": outcome.tool_call.name, "arguments": outcome.tool_call.arguments},
455                    "result": outcome.result,
456                    "isError": outcome.is_error,
457                });
458                if let Some(duration) = outcome.duration_ms {
459                    value["durationMs"] = json!(duration);
460                }
461                Ok(value)
462            }
463            HostOp::InvokeCommand { name, args } => {
464                self.invoke_command(&name, &args, command_context).await
465            }
466            HostOp::SetArbiterRoles(roles) => {
467                let loaded = loaded
468                    .ok_or_else(|| HostError::Invalid(format!("Unknown extension {extension}")))?;
469                loaded.update(|state| state.arbiter = roles);
470                Ok(JsonValue::Null)
471            }
472            HostOp::ListAgents => Ok(self.agents_list()),
473            HostOp::GetRunState => {
474                let mut value = json!({"state": self.run_state()});
475                let waiting = self.waiting_on();
476                if !waiting.is_empty() {
477                    value["waitingOn"] = json!(waiting);
478                }
479                Ok(value)
480            }
481            HostOp::Shutdown => {
482                if let Some(runtime) = self.runtime_core() {
483                    runtime.request_shutdown();
484                }
485                Ok(JsonValue::Null)
486            }
487            HostOp::GetSystemPromptOptions => {
488                if !command_context {
489                    return Err(HostError::CommandContextRequired);
490                }
491                to_json(self.system_prompt_options())
492            }
493            HostOp::NewSession { parent_session } => {
494                let runtime = self.replacement_runtime(command_context)?;
495                let replaced = runtime.new_session(parent_session).await.map_err(failed)?;
496                Ok(json!({"cancelled": replaced.cancelled}))
497            }
498            HostOp::Fork { entry_id, at } => {
499                let runtime = self.replacement_runtime(command_context)?;
500                let position = if at {
501                    crate::runtime::ForkPosition::At
502                } else {
503                    crate::runtime::ForkPosition::Before
504                };
505                let replaced = runtime
506                    .fork_with_reason(
507                        &EntryId::new(entry_id),
508                        position,
509                        crate::events::ReplaceReason::Fork,
510                    )
511                    .await
512                    .map_err(failed)?;
513                let mut value = json!({"cancelled": replaced.cancelled});
514                if let Some(text) = replaced.selected_text {
515                    value["selectedText"] = json!(text);
516                }
517                Ok(value)
518            }
519            HostOp::SwitchSession(path) => {
520                let runtime = self.replacement_runtime(command_context)?;
521                let replaced = runtime.switch_session(path, None).await.map_err(failed)?;
522                Ok(json!({"cancelled": replaced.cancelled}))
523            }
524            HostOp::Reload => {
525                if !command_context {
526                    return Err(HostError::CommandContextRequired);
527                }
528                self.reload().await.map_err(failed)?;
529                Ok(JsonValue::Null)
530            }
531            op @ (HostOp::RegisterSettings(_)
532            | HostOp::GetExtensionSettings { .. }
533            | HostOp::SetExtensionSetting { .. }
534            | HostOp::GetAllExtensionSettings) => {
535                let router = self
536                    .inner
537                    .agents
538                    .router
539                    .get()
540                    .and_then(std::sync::Weak::upgrade)
541                    .ok_or_else(|| {
542                        HostError::Failed("extension settings are not available".into())
543                    })?;
544                router.settings_call(extension, op)
545            }
546        };
547        if registry_changed {
548            self.refresh_tool_registry(None, false);
549        }
550        if commands_changed {
551            self.inner.hub.publish(OinoEvent::CommandsChanged);
552        }
553        if shortcuts_changed {
554            self.publish_shortcuts();
555        }
556        result
557    }
558
559    /// The runtime for session-replacing operations (command contexts of root sessions).
560    fn replacement_runtime(
561        &self,
562        command_context: bool,
563    ) -> Result<std::sync::Arc<crate::runtime::RuntimeCore>, HostError> {
564        if !command_context {
565            return Err(HostError::CommandContextRequired);
566        }
567        self.runtime_core().ok_or_else(|| {
568            HostError::Failed("Session replacement is not available in this session".into())
569        })
570    }
571
572    /// `models.complete`: resolves the model (`provider/id`, default the session model) and
573    /// its credentials, then runs one request without retry events or prompt-cache writes.
574    async fn model_complete(
575        &self,
576        model: Option<&str>,
577        context: JsonValue,
578        options: JsonValue,
579        cancel: Option<tokio_util::sync::CancellationToken>,
580    ) -> Result<JsonValue, HostError> {
581        let model = match model {
582            Some(spec) => spec
583                .split_once('/')
584                .and_then(|(provider, id)| self.inner.models.find(provider, id))
585                .ok_or_else(|| HostError::Invalid(format!("Unknown model {spec}")))?,
586            None => self
587                .model()
588                .ok_or_else(|| HostError::Failed("No model selected".into()))?,
589        };
590        let context: oino_ai::Context = serde_json::from_value(context)
591            .map_err(|e| HostError::Invalid(format!("context: {e}")))?;
592        let options: oino_ai::StreamOptions = if options.is_null() {
593            oino_ai::StreamOptions::default()
594        } else {
595            serde_json::from_value(options)
596                .map_err(|e| HostError::Invalid(format!("options: {e}")))?
597        };
598        let mut summarizer = self
599            .summarizer(
600                &model,
601                cancel.unwrap_or_default(),
602                crate::events::SummarizationSource::BranchSummary,
603            )
604            .await
605            .map_err(HostError::Failed)?;
606        summarizer.observer = None;
607        summarizer.thinking_level = None;
608        to_json(summarizer.complete(context, options).await)
609    }
610
611    /// Resolves a `provider/id` spec or the session model.
612    fn request_model(&self, spec: Option<&str>) -> Result<oino_ai::Model, HostError> {
613        match spec {
614            Some(spec) => spec
615                .split_once('/')
616                .and_then(|(provider, id)| self.inner.models.find(provider, id))
617                .ok_or_else(|| HostError::Invalid(format!("Unknown model {spec}"))),
618            None => self
619                .model()
620                .ok_or_else(|| HostError::Failed("No model selected".into())),
621        }
622    }
623
624    /// `models.stream`: one provider request (credentials and virtual routing as for
625    /// [`HostOp::ModelComplete`], no retries) as `AssistantMessageEvent` JSON items.
626    pub(crate) async fn model_stream(
627        &self,
628        request: crate::extensions::host::ModelRequest,
629    ) -> Result<futures::stream::BoxStream<'static, JsonValue>, HostError> {
630        use futures::StreamExt;
631        let model = self.request_model(request.model.as_deref())?;
632        let context: oino_ai::Context = serde_json::from_value(request.context)
633            .map_err(|e| HostError::Invalid(format!("context: {e}")))?;
634        let mut options: oino_ai::StreamOptions = if request.options.is_null() {
635            oino_ai::StreamOptions::default()
636        } else {
637            serde_json::from_value(request.options)
638                .map_err(|e| HostError::Invalid(format!("options: {e}")))?
639        };
640        let summarizer = self
641            .summarizer(
642                &model,
643                request.cancel.unwrap_or_default(),
644                crate::events::SummarizationSource::BranchSummary,
645            )
646            .await
647            .map_err(HostError::Failed)?;
648        options.cache_retention = Some(oino_ai::CacheRetention::None);
649        if options.headers.is_none() {
650            options.headers.clone_from(&summarizer.headers);
651        }
652        let events = summarizer.stream.stream(oino_agent::StreamRequest {
653            model: summarizer.model.clone(),
654            context,
655            options,
656            api_key: summarizer.api_key.clone(),
657            cancel: summarizer.cancel.clone(),
658        });
659        Ok(events
660            .map(|event| serde_json::to_value(event).unwrap_or(JsonValue::Null))
661            .boxed())
662    }
663
664    /// `models.credential-for`: `{provider, headers, baseUrl?}` of the first model of
665    /// `provider` (bearer header from the resolved key), or `null` when unconfigured.
666    async fn credential_for(&self, provider: &str) -> Result<JsonValue, HostError> {
667        let Some(model) = self
668            .inner
669            .models
670            .all()
671            .into_iter()
672            .find(|m| m.provider == provider)
673        else {
674            return Ok(JsonValue::Null);
675        };
676        let Ok(auth) = self
677            .inner
678            .models
679            .get_api_key_and_headers(&model, &oino_models::AuthOverrides::default())
680            .await
681        else {
682            return Ok(JsonValue::Null);
683        };
684        let mut headers: Vec<(String, String)> =
685            auth.headers.unwrap_or_default().into_iter().collect();
686        match auth.api_key {
687            Some(key) => headers.push(("Authorization".to_owned(), format!("Bearer {key}"))),
688            None if headers.is_empty() => return Ok(JsonValue::Null),
689            None => {}
690        }
691        let mut value = json!({"provider": provider, "headers": headers});
692        if !model.base_url.is_empty() {
693            value["baseUrl"] = json!(model.base_url);
694        }
695        Ok(value)
696    }
697
698    async fn ui_dialog(
699        &self,
700        extension: &str,
701        method: &str,
702        params: oino_ai::JsonObject,
703        cancel: Option<tokio_util::sync::CancellationToken>,
704    ) -> JsonValue {
705        let kind = prompt_kind(method);
706        let title = params
707            .get("title")
708            .and_then(JsonValue::as_str)
709            .map(str::to_owned);
710        let runner = self.runner();
711        let prompt_event = |kind_name: &str| {
712            let mut payload = json!({"type": kind_name, "reason": "ui_prompt", "kind": kind});
713            if let Some(title) = &title {
714                payload["title"] = json!(title);
715            }
716            payload
717        };
718        if runner.has_handlers("ui_prompt_start") {
719            runner.emit(prompt_event("ui_prompt_start")).await;
720        }
721        let answer = self
722            .inner
723            .ui
724            .dialog(Some(extension), method, params, cancel)
725            .await;
726        if runner.has_handlers("ui_prompt_end") {
727            runner.emit(prompt_event("ui_prompt_end")).await;
728        }
729        match answer {
730            UiAnswer::Value(value) => json!({"value": value}),
731            UiAnswer::Confirmed(confirmed) => json!({"confirmed": confirmed}),
732            UiAnswer::Cancelled => json!({"cancelled": true}),
733        }
734    }
735
736    async fn invoke_command(
737        &self,
738        name: &str,
739        args: &str,
740        inline: bool,
741    ) -> Result<JsonValue, HostError> {
742        let runner = self.runner();
743        let command = runner
744            .command(name)
745            .ok_or_else(|| HostError::Invalid(format!("Unknown command {name}")))?;
746        if !inline {
747            let session = self.clone();
748            let text = if args.is_empty() {
749                format!("/{name}")
750            } else {
751                format!("/{name} {args}")
752            };
753            tokio::spawn(async move {
754                session.wait_for_idle().await;
755                let _ = session.prompt(text, super::PromptOptions::default()).await;
756            });
757            return Ok(JsonValue::Null);
758        }
759        let cx = runner.call_context().with_kind(CallKind::Command);
760        command
761            .extension
762            .instance
763            .run_command(
764                &command.spec.name,
765                args,
766                tokio_util::sync::CancellationToken::new(),
767                cx,
768            )
769            .await
770            .map(|value| value.unwrap_or(JsonValue::Null))
771            .map_err(|e| HostError::Failed(e.message))
772    }
773
774    fn session_read(&self, query: &SessionQuery) -> Result<JsonValue, HostError> {
775        let manager = lock(&self.inner.manager);
776        match query {
777            SessionQuery::Header => to_json(manager.header()),
778            SessionQuery::Entries => to_json(manager.entries()),
779            SessionQuery::Branch => to_json(manager.branch(None)),
780            SessionQuery::Entry { id } => to_json(manager.entry(&EntryId::new(id.clone()))),
781            SessionQuery::Tree => to_json(manager.tree()),
782            SessionQuery::Projection => to_json(manager.build_session_projection()),
783            SessionQuery::ContextEntries => to_json(manager.build_context_entries()),
784            SessionQuery::Label { id } => to_json(manager.label(&EntryId::new(id.clone()))),
785            SessionQuery::Name => to_json(manager.session_name()),
786            SessionQuery::Leaf => to_json(manager.leaf_id().map(EntryId::as_str)),
787            SessionQuery::Id => to_json(manager.session_id()),
788            SessionQuery::File => to_json(manager.session_file()),
789        }
790    }
791
792    /// Refreshes the current model from the registry.
793    pub(crate) fn refresh_current_model(&self) {
794        let Some(current) = self.model() else {
795            return;
796        };
797        if let Some(refreshed) = self.inner.models.find(&current.provider, &current.id)
798            && refreshed != current
799        {
800            self.inner.agent.set_model(refreshed);
801        }
802    }
803
804    /// Slash commands: extension commands, prompt templates, skills.
805    #[must_use]
806    pub fn commands(&self) -> Vec<SlashCommandInfo> {
807        let mut commands: Vec<SlashCommandInfo> = self
808            .runner()
809            .commands()
810            .into_iter()
811            .filter(|c| !c.spec.hidden)
812            .map(|c| SlashCommandInfo {
813                name: c.invocation_name,
814                description: c.spec.description,
815                source: "extension".to_owned(),
816                source_info: json!({"path": c.extension.instance.path(), "source": format!("extension:{}", c.extension.instance.id())}),
817            })
818            .collect();
819        let resources = self
820            .inner
821            .resources
822            .read()
823            .unwrap_or_else(std::sync::PoisonError::into_inner);
824        commands.extend(resources.prompts().iter().map(|t| SlashCommandInfo {
825            name: t.name.clone(),
826            description: Some(t.description.clone()).filter(|d| !d.is_empty()),
827            source: "prompt".to_owned(),
828            source_info: serde_json::to_value(&t.source_info).unwrap_or(JsonValue::Null),
829        }));
830        commands.extend(resources.skills().iter().map(|s| SlashCommandInfo {
831            name: format!("skill:{}", s.name),
832            description: Some(s.description.clone()),
833            source: "skill".to_owned(),
834            source_info: serde_json::to_value(&s.source_info).unwrap_or(JsonValue::Null),
835        }));
836        commands
837    }
838}
839
840/// Metadata of one slash command.
841#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
842#[serde(rename_all = "camelCase")]
843pub struct SlashCommandInfo {
844    /// Invocation name.
845    pub name: String,
846    /// Description.
847    #[serde(default, skip_serializing_if = "Option::is_none")]
848    pub description: Option<String>,
849    /// `extension` | `prompt` | `skill`.
850    pub source: String,
851    /// Provenance.
852    pub source_info: JsonValue,
853}
854
855/// Dialog of a nested agent, routed by its [`UiPolicy`](crate::agents::UiPolicy).
856async fn child_dialog(
857    link: &crate::agents::ChildLink,
858    extension: &str,
859    method: &str,
860    mut params: oino_ai::JsonObject,
861    cancel: Option<tokio_util::sync::CancellationToken>,
862) -> JsonValue {
863    use crate::agents::UiPolicy;
864    match &link.ui {
865        UiPolicy::Forward => {
866            let Some(parent) = link.parent.upgrade().map(AgentSession::from_inner) else {
867                return json!({"cancelled": true});
868            };
869            if let Some(title) = params.get("title").and_then(JsonValue::as_str) {
870                let title = format!("[{}] {title}", link.label);
871                params.insert("title".into(), json!(title));
872            }
873            parent.ui_dialog(extension, method, params, cancel).await
874        }
875        UiPolicy::AutoDeny { note } if method == "approval" => {
876            let options = params
877                .get("options")
878                .and_then(JsonValue::as_array)
879                .cloned()
880                .unwrap_or_default();
881            let kind = |o: &JsonValue| o.get("kind").and_then(JsonValue::as_str).map(str::to_owned);
882            let reject = options
883                .iter()
884                .find(|o| kind(o).as_deref() == Some("reject_once"))
885                .or_else(|| {
886                    options
887                        .iter()
888                        .find(|o| kind(o).is_some_and(|k| k.starts_with("reject")))
889                });
890            match reject.and_then(|o| o.get("optionId")).cloned() {
891                Some(id) => json!({"value": {"optionId": id, "feedback": note}}),
892                None => json!({"cancelled": true}),
893            }
894        }
895        UiPolicy::AutoDeny { .. } | UiPolicy::None => json!({"cancelled": true}),
896    }
897}
898
899/// Fire-and-forget request of a nested agent: forwarded notifications only.
900fn child_fire(
901    link: &crate::agents::ChildLink,
902    extension: &str,
903    method: &str,
904    params: oino_ai::JsonObject,
905) {
906    if !matches!(link.ui, crate::agents::UiPolicy::Forward) || method != "notify" {
907        return;
908    }
909    if let Some(parent) = link.parent.upgrade() {
910        let _ = parent.ui.fire(Some(extension), method, params);
911    }
912}