yapi_core/extensions/mod.rs
1//! Extensions: code that registers tools and commands with a session and
2//! handles its events, as pi's extension runner (`core/extensions` in pi
3//! `v1.0.0`) runs them.
4//!
5//! This is the part of pi's `ExtensionAPI` that the built-in extensions use.
6//! The wasm host in `yapi-ext` implements [`Extension`] for loaded packages with
7//! the same contract. Each session gets its own extension instances. A
8//! replaced session receives `session_shutdown` before its successor starts.
9
10pub mod codemode;
11pub mod discovery;
12mod events;
13mod grants;
14pub mod tool_search;
15mod ui;
16
17pub use events::{EventPolicy, delivers};
18pub use grants::{CAPABILITIES, Grants, receives, tool_granted};
19pub use ui::{
20 ComponentHost, CustomOptions, DialogOptions, ExtensionUi, NoUi, NotifyKind, Placement,
21 RemoteComponent, TerminalState, Widget, WorkingIndicator,
22};
23
24use std::path::PathBuf;
25use std::sync::{Arc, Mutex};
26
27use futures_util::future::BoxFuture;
28use indexmap::IndexMap;
29use serde_json::Value;
30use tokio_util::sync::CancellationToken;
31use yapi_ai::model_catalog::RefreshOptions;
32use yapi_ai::registry::{Availability, ModelRegistry};
33use yapi_types::autocomplete::ArgumentCompletions;
34use yapi_types::model::Model;
35use yapi_types::models::ModelDefinition;
36use yapi_types::rpc::SourceInfo;
37
38use crate::agent_session::WeakSession;
39use crate::tools::RegisteredTool;
40use crate::tools::registry::ToolRegistry;
41
42/// pi's `ExtensionMode`: the mode a session runs in.
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum Mode {
45 /// The interactive terminal UI.
46 Tui,
47 /// RPC over stdin and stdout.
48 Rpc,
49 /// Print mode.
50 #[default]
51 Print,
52 /// JSON event mode.
53 Json,
54}
55
56/// pi's `ToolInfo`: a tool as extensions see it.
57#[derive(Clone, Debug, PartialEq)]
58pub struct ToolInfo {
59 /// Name.
60 pub name: String,
61 /// Description.
62 pub description: String,
63 /// Argument schema.
64 pub parameters: Value,
65 /// How it reaches the model.
66 pub exposure: crate::tools::Exposure,
67 /// Its group.
68 pub namespace: Option<crate::tools::Namespace>,
69}
70
71/// The session's tools, shared with its extensions.
72#[derive(Clone, Default)]
73pub struct Tools(Arc<Mutex<ToolRegistry>>);
74
75impl Tools {
76 /// Wraps a registry.
77 pub fn new(registry: ToolRegistry) -> Tools {
78 Tools(Arc::new(Mutex::new(registry)))
79 }
80
81 fn lock(&self) -> std::sync::MutexGuard<'_, ToolRegistry> {
82 yapi_types::sync::lock(&self.0)
83 }
84
85 /// Runs `f` with the registry.
86 pub fn with<T>(&self, f: impl FnOnce(&mut ToolRegistry) -> T) -> T {
87 f(&mut self.lock())
88 }
89
90 /// pi's `registerTool`.
91 pub fn register(&self, tool: RegisteredTool) {
92 self.lock().register(tool);
93 }
94
95 /// pi's `getActiveTools`.
96 pub fn active(&self) -> Vec<String> {
97 self.lock().active()
98 }
99
100 /// pi's `setActiveTools`.
101 pub fn set_active(&self, names: Vec<String>) {
102 self.lock().set_active(names);
103 }
104
105 /// pi's `getAllTools`.
106 pub fn all(&self) -> Vec<ToolInfo> {
107 self.lock().all().iter().map(RegisteredTool::info).collect()
108 }
109}
110
111/// What an extension can reach while handling an event or command.
112#[derive(Clone)]
113pub struct Context {
114 /// The session's working directory.
115 pub cwd: PathBuf,
116 /// The agent directory.
117 pub agent_dir: PathBuf,
118 /// The directory the session's project files come from: the working
119 /// directory unless the run names another.
120 pub project_dir: PathBuf,
121 /// Whether the project's resources are trusted.
122 pub project_trusted: bool,
123 /// In a project-only run, the patterns of the user's MCP servers it
124 /// keeps, from the `projectOnly` setting. The user's others stay out.
125 pub project_only_mcp: Option<Vec<String>>,
126 /// The mode.
127 pub mode: Mode,
128 /// Dialogs and notifications.
129 pub ui: Arc<dyn ExtensionUi>,
130 /// The session's tools.
131 pub tools: Tools,
132 /// Cancelled when the current run is aborted.
133 pub cancel: CancellationToken,
134 /// The session, for extensions that act on it later.
135 pub session: WeakSession,
136}
137
138/// A slash command an extension registered.
139#[derive(Clone, Debug, PartialEq, Eq)]
140pub struct Command {
141 /// Name without the slash.
142 pub name: String,
143 /// What it does.
144 pub description: String,
145}
146
147/// pi's `ResolvedCommand`: an extension command with the name it is
148/// invoked by.
149#[derive(Clone)]
150pub struct ResolvedCommand {
151 /// `name`, or `name:N` when several extensions register `name`.
152 pub invocation: String,
153 /// The command as registered.
154 pub command: Command,
155 /// The extension that handles it.
156 pub extension: Arc<dyn Extension>,
157}
158
159/// A key an extension binds with pi's `registerShortcut`.
160#[derive(Clone, Debug, PartialEq, Eq)]
161pub struct Shortcut {
162 /// The key id, such as `alt+u`.
163 pub key: String,
164 /// What it does, for `/hotkeys`.
165 pub description: Option<String>,
166}
167
168/// An extension shortcut the interactive mode honors.
169#[derive(Clone)]
170pub struct ShortcutBinding {
171 /// The key id, lowercased.
172 pub key: String,
173 /// The key id as the extension registered it.
174 pub registered: String,
175 /// What it does.
176 pub description: Option<String>,
177 /// The extension's path.
178 pub path: String,
179 /// The extension.
180 pub extension: Arc<dyn Extension>,
181}
182
183/// pi's `RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS`: actions whose keys
184/// extensions may not take.
185pub const RESERVED_ACTIONS: [&str; 18] = [
186 "app.interrupt",
187 "app.clear",
188 "app.exit",
189 "app.suspend",
190 "app.thinking.cycle",
191 "app.model.cycleForward",
192 "app.model.cycleBackward",
193 "app.model.select",
194 "app.tools.expand",
195 "app.thinking.toggle",
196 "app.editor.external",
197 "app.message.copy",
198 "app.message.followUp",
199 "tui.input.submit",
200 "tui.select.confirm",
201 "tui.select.cancel",
202 "tui.input.copy",
203 "tui.editor.deleteToLineEnd",
204];
205
206/// pi's `getShortcuts`: the shortcuts of `extensions` in load order, given
207/// the keys bound to each built-in action. A key a reserved action uses is
208/// refused. Another built-in key, or one an earlier extension took, goes to
209/// the later extension. Returns the bindings and pi's warnings, each with the
210/// path of the extension it concerns.
211pub fn resolve_shortcuts(
212 extensions: &[Arc<dyn Extension>],
213 builtin: &[(String, Vec<String>)],
214) -> (Vec<ShortcutBinding>, Vec<(String, String)>) {
215 let mut taken: std::collections::HashMap<String, (&str, bool)> =
216 std::collections::HashMap::new();
217 for (action, keys) in builtin {
218 let reserved = RESERVED_ACTIONS.contains(&action.as_str());
219 for key in keys {
220 let key = key.to_lowercase();
221 if taken
222 .get(&key)
223 .is_some_and(|(_, existing)| *existing && !reserved)
224 {
225 continue;
226 }
227 taken.insert(key, (action.as_str(), reserved));
228 }
229 }
230 let mut bindings: Vec<ShortcutBinding> = Vec::new();
231 let mut warnings = Vec::new();
232 for extension in extensions {
233 let path = extension.source().path;
234 for shortcut in extension.shortcuts() {
235 let key = shortcut.key.to_lowercase();
236 match taken.get(&key) {
237 Some((_, true)) => {
238 warnings.push((
239 path.clone(),
240 format!(
241 "Extension shortcut '{}' from {path} conflicts with built-in shortcut. Skipping.",
242 shortcut.key
243 ),
244 ));
245 continue;
246 }
247 Some((action, false)) => warnings.push((
248 path.clone(),
249 format!(
250 "Extension shortcut conflict: '{}' is built-in shortcut for {action} and {path}. Using {path}.",
251 shortcut.key
252 ),
253 )),
254 None => {}
255 }
256 let binding = ShortcutBinding {
257 key: key.clone(),
258 registered: shortcut.key.clone(),
259 description: shortcut.description,
260 path: path.clone(),
261 extension: extension.clone(),
262 };
263 // Like a `Map`, the later binding keeps the earlier one's place.
264 match bindings.iter_mut().find(|existing| existing.key == key) {
265 Some(existing) => {
266 warnings.push((
267 path.clone(),
268 format!(
269 "Extension shortcut conflict: '{}' registered by both {} and {path}. Using {path}.",
270 shortcut.key, existing.path
271 ),
272 ));
273 *existing = binding;
274 }
275 None => bindings.push(binding),
276 }
277 }
278 }
279 (bindings, warnings)
280}
281
282/// pi's `renderCall`, `renderResult` and `renderShell`: how an extension
283/// draws one of its tools.
284#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
285pub struct ToolRenderers {
286 /// It draws the call.
287 pub call: bool,
288 /// It draws the result.
289 pub result: bool,
290 /// It draws its own frame (`renderShell: "self"`).
291 pub own_shell: bool,
292}
293
294/// pi's `ToolLoadout`: the tools a loadout hook sees.
295#[derive(Clone, Default)]
296pub struct Loadout {
297 /// The active tools in activation order, with their own descriptions.
298 pub declared: Vec<RegisteredTool>,
299 /// The tools other tools may call, in registration order.
300 pub callable: Vec<RegisteredTool>,
301}
302
303/// pi's `ToolLoadoutChanges`: what a loadout hook changes.
304#[derive(Clone, Debug, Default, PartialEq, Eq)]
305pub struct LoadoutChanges {
306 /// Descriptions for declared tools, by name, replacing their own.
307 pub descriptions: std::collections::HashMap<String, String>,
308 /// Declared tools that stay callable but are left out of requests and
309 /// of the prompt's tool list.
310 pub hidden: std::collections::HashSet<String>,
311}
312
313/// What an extension draws in the transcript.
314#[derive(Clone, Debug, Default, PartialEq, Eq)]
315pub struct Renderers {
316 /// Its tools, by name.
317 pub tools: std::collections::HashMap<String, ToolRenderers>,
318 /// Custom message types it draws (`registerMessageRenderer`).
319 pub messages: Vec<String>,
320 /// Custom entry types it draws (`registerEntryRenderer`).
321 pub entries: Vec<String>,
322 /// It transforms Markdown before it renders (`registerMarkdownTransformer`).
323 pub markdown: bool,
324 /// It renders code blocks (`pi.yapi.registerCodeRenderer`, yapi's own).
325 pub code: bool,
326}
327
328/// An extension. Every hook has a no-op default.
329pub trait Extension: Send + Sync {
330 /// Where the extension comes from, as `get_commands` reports it.
331 fn source(&self) -> SourceInfo;
332
333 /// What the extension may reach. The session applies its event
334 /// handlers' results only within these.
335 fn grants(&self) -> Grants {
336 Grants::default()
337 }
338
339 /// Reports `message`, a warning from the host about the extension, such
340 /// as a handler result the session dropped for lack of a grant, with
341 /// the extension's own output. Built-in extensions, which have every
342 /// grant, report nothing.
343 fn warn(&self, _message: &str) {}
344
345 /// Registers the tools it offers from the start, before the session's
346 /// active tools are chosen.
347 fn load(&self, _tools: &Tools) {}
348
349 /// The commands it handles.
350 fn commands(&self) -> Vec<Command> {
351 Vec::new()
352 }
353
354 /// Completions for the argument of `command`. While they are
355 /// [`ArgumentCompletions::Pending`], the UI's `refresh_completions`
356 /// follows once they are ready.
357 fn complete(&self, _command: &str, _prefix: &str) -> ArgumentCompletions {
358 ArgumentCompletions::Ready(None)
359 }
360
361 /// The keys it binds.
362 fn shortcuts(&self) -> Vec<Shortcut> {
363 Vec::new()
364 }
365
366 /// Runs the handler of shortcut `key` and returns the handler's error.
367 fn run_shortcut<'a>(
368 &'a self,
369 _key: &'a str,
370 _ctx: &'a Context,
371 ) -> BoxFuture<'a, Result<(), String>> {
372 Box::pin(async { Ok(()) })
373 }
374
375 /// Runs `command` with its argument text.
376 fn run_command<'a>(
377 &'a self,
378 _command: &'a str,
379 _args: &'a str,
380 _ctx: &'a Context,
381 ) -> BoxFuture<'a, ()> {
382 Box::pin(async {})
383 }
384
385 /// Resolves once the extension code that is running has finished and
386 /// delivered its results. pi runs extension code to completion before it
387 /// reads the next input. Returns a count that grows with every call into
388 /// the extension's code.
389 fn settle(&self) -> BoxFuture<'_, u64> {
390 Box::pin(async { 0 })
391 }
392
393 /// The session started or was bound to a mode.
394 fn session_start<'a>(&'a self, _ctx: &'a Context) -> BoxFuture<'a, ()> {
395 Box::pin(async {})
396 }
397
398 /// The session ends or is being replaced.
399 fn session_shutdown<'a>(&'a self, _ctx: &'a Context) -> BoxFuture<'a, ()> {
400 Box::pin(async {})
401 }
402
403 /// Session `session_id` ended or another replaced it, after its
404 /// `session_shutdown`, but not on reload. pi disposes the session's
405 /// resources then, with pi-ai's `cleanupSessionResources`.
406 fn end_session<'a>(&'a self, _session_id: &'a str) -> BoxFuture<'a, ()> {
407 Box::pin(async {})
408 }
409
410 /// A prompt is about to run. `sections` are extra system prompt sections
411 /// by tag name, which the extension may change.
412 fn before_agent_start<'a>(
413 &'a self,
414 _ctx: &'a Context,
415 _sections: &'a mut IndexMap<String, String>,
416 ) -> BoxFuture<'a, ()> {
417 Box::pin(async {})
418 }
419
420 /// Whether it handles pi events of type `kind` through [`Extension::handle`].
421 fn handles(&self, _kind: &str) -> bool {
422 false
423 }
424
425 /// Runs its handlers for pi event `event`, a JSON object with a `type`,
426 /// and returns their combined result as pi's runner combines one
427 /// extension's handlers. The session combines results across extensions.
428 fn handle<'a>(&'a self, _ctx: &'a Context, _event: &'a Value) -> BoxFuture<'a, Option<Value>> {
429 Box::pin(async { None })
430 }
431
432 /// pi's `prepareLoadout` hook of a tool it registered: how the next
433 /// request presents the declared tools. Runs before every request.
434 fn prepare_loadout(&self, _loadout: &Loadout) -> LoadoutChanges {
435 LoadoutChanges::default()
436 }
437
438 /// Runs before a request's [`Extension::prepare_loadout`] for
439 /// `loadout`, for hooks that run elsewhere and answer later, so that
440 /// `prepare_loadout` can give what they made of it.
441 fn refresh_loadout<'a>(&'a self, _loadout: &'a Loadout) -> BoxFuture<'a, ()> {
442 Box::pin(async {})
443 }
444
445 /// What it draws in the transcript.
446 fn renderers(&self) -> Renderers {
447 Renderers::default()
448 }
449
450 /// Builds the component for a transcript item it draws. `request` is
451 /// `{"kind": "toolCall" | "toolResult", "name", "toolCallId", "args",
452 /// "result", "options", "context"}`, `{"kind": "message", "key",
453 /// "message", "options"}` or `{"kind": "entry", "key", "entry",
454 /// "options"}`. `None` keeps the built-in rendering, which shows nothing
455 /// for an entry.
456 fn component<'a>(&'a self, _request: &'a Value) -> BoxFuture<'a, Option<RemoteComponent>> {
457 Box::pin(async { None })
458 }
459
460 /// The Markdown its transformer makes of `request`, `{"markdown",
461 /// "context"}` with pi's `MarkdownTransformContext`. `Ok(None)` keeps
462 /// the Markdown, as when the transformer fails or returns no string.
463 fn transform_markdown<'a>(
464 &'a self,
465 _request: &'a Value,
466 ) -> BoxFuture<'a, Result<Option<String>, String>> {
467 Box::pin(async { Ok(None) })
468 }
469
470 /// The lines its code renderer draws for `request`, `{"code", "lang",
471 /// "width"}` with `lang` possibly `null`, as text with escape sequences.
472 /// `Ok(None)` leaves the code to yapi, and an error is the renderer's.
473 fn render_code<'a>(
474 &'a self,
475 _request: &'a Value,
476 ) -> BoxFuture<'a, Result<Option<Vec<String>>, String>> {
477 Box::pin(async { Ok(None) })
478 }
479
480 /// A tool is about to run. `Some(reason)` blocks it.
481 fn tool_call<'a>(
482 &'a self,
483 _ctx: &'a Context,
484 _tool: &'a str,
485 _input: &'a Value,
486 ) -> BoxFuture<'a, Option<String>> {
487 Box::pin(async { None })
488 }
489
490 /// Runs `command` in `cwd` through the operations its `user_bash`
491 /// handler returned as `handle`, sending output to `output`, until the
492 /// command ends or `cancel` fires, and returns the exit code.
493 fn run_bash<'a>(
494 &'a self,
495 _handle: &'a Value,
496 _command: &'a str,
497 _cwd: &'a std::path::Path,
498 _output: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
499 _cancel: CancellationToken,
500 ) -> BoxFuture<'a, Result<Option<i32>, String>> {
501 Box::pin(async { Err("This extension has no bash operations".to_owned()) })
502 }
503
504 /// Refreshes the model lists of the providers it registered with
505 /// `refreshModels` that `options` selects, as pi does: first offline
506 /// with the registry's runtime key or stored credential
507 /// ([`ModelRegistry::read_credential`]), then, when `options` allow the network
508 /// and a credential resolves, online with it. Then those with the legacy
509 /// `oauth.modifyModels` project their models for the stored credential,
510 /// and pi-ai `Provider`s check their API key and filter their models for
511 /// it. Returns each step's update, or why it failed, by provider in order.
512 fn refresh_models<'a>(
513 &'a self,
514 _registry: &'a ModelRegistry,
515 _options: &'a RefreshOptions,
516 ) -> BoxFuture<'a, Vec<(String, ModelList)>> {
517 Box::pin(async { Vec::new() })
518 }
519}
520
521/// pi's `newSession` `setup`: runs with the replacement session once it is
522/// built, before its extensions start.
523pub type Setup =
524 Box<dyn FnOnce(crate::agent_session::AgentSession) -> BoxFuture<'static, ()> + Send>;
525
526/// A session change an extension command asks for, one of pi's
527/// `ExtensionCommandContextActions`.
528pub enum SessionAction {
529 /// `newSession`, linked to a parent session file when given.
530 New {
531 /// The parent session file.
532 parent: Option<String>,
533 /// What to write to the new session before it starts.
534 setup: Option<Setup>,
535 },
536 /// `fork` at an entry. `at` keeps the entry, as `/clone` does.
537 Fork {
538 /// The entry.
539 entry_id: String,
540 /// Keep the entry.
541 at: bool,
542 },
543 /// `navigateTree` to an entry.
544 Tree {
545 /// The entry.
546 target_id: String,
547 /// How.
548 options: crate::agent_session::TreeNavigation,
549 },
550 /// `switchSession` to a session file.
551 Switch {
552 /// The file, as the extension gave it.
553 path: String,
554 },
555 /// `reload`.
556 Reload,
557 /// The extension finished its `withSession` work after a replacement.
558 /// The mode reports the change now, as pi does once it returns.
559 Replaced,
560}
561
562/// How a mode carries out [`SessionAction`]s: whether an extension
563/// cancelled the change, or why it failed.
564pub type SessionActions =
565 Arc<dyn Fn(SessionAction) -> BoxFuture<'static, Result<bool, String>> + Send + Sync>;
566
567/// pi's `BashOperations`: operations a `user_bash` handler returned to run a
568/// `!` command with.
569#[derive(Clone)]
570pub struct BashOperations {
571 /// The extension whose handler returned them.
572 pub extension: Arc<dyn Extension>,
573 /// What the extension knows them by.
574 pub handle: Value,
575}
576
577/// A provider's new models from a refresh, or why it failed.
578pub type ModelList = Result<ModelUpdate, String>;
579
580/// A provider's new models from a refresh.
581#[derive(Debug)]
582pub enum ModelUpdate {
583 /// The list its `refreshModels` returned.
584 Definitions(Vec<ModelDefinition>),
585 /// Its chat models as its legacy `oauth.modifyModels` made them for the
586 /// stored OAuth credential, or `None` without one.
587 Modified(Option<Vec<Model>>),
588 /// What its API key `check`, `filterModels` and `filterAllModels` make
589 /// available for the stored credential.
590 Availability(Availability),
591}
592
593/// The built-in extensions each session starts with.
594pub fn builtins() -> Vec<Arc<dyn Extension>> {
595 vec![
596 Arc::new(tool_search::ToolSearchExtension),
597 Arc::new(crate::mcp::extension::McpExtension::new()),
598 ]
599}
600
601/// pi's source info for a built-in extension named `name`.
602pub fn builtin_source(name: &str) -> SourceInfo {
603 SourceInfo {
604 path: format!("builtin:{name}"),
605 source: "builtin".into(),
606 scope: "temporary".into(),
607 origin: "top-level".into(),
608 base_dir: None,
609 }
610}