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    /// 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}