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}