Skip to main content

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