1use 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
22pub(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 #[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 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 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 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 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 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 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(¤t.provider, ¤t.id)
798 && refreshed != current
799 {
800 self.inner.agent.set_model(refreshed);
801 }
802 }
803
804 #[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#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
842#[serde(rename_all = "camelCase")]
843pub struct SlashCommandInfo {
844 pub name: String,
846 #[serde(default, skip_serializing_if = "Option::is_none")]
848 pub description: Option<String>,
849 pub source: String,
851 pub source_info: JsonValue,
853}
854
855async 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
899fn 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}