from __future__ import annotations from pathlib import Path from typing import Callable, Coroutine from pyra.plugins.base import AgentSpec, PyraPlugin, Tool from pyra.plugins.loader import _log_error, load_plugins from pyra.vault.reader import get_key class PluginRegistry: _instance: PluginRegistry | None = None def __init__(self) -> None: self._plugins: dict[str, PyraPlugin] = {} self._tools: dict[str, Tool] = {} @classmethod def instance(cls) -> PluginRegistry: if cls._instance is None: cls._instance = cls() return cls._instance @classmethod def reset(cls) -> None: """Reset singleton — for tests only.""" cls._instance = None def load_all(self, plugins_dir: Path, enabled_names: list[str]) -> None: all_plugins = load_plugins(plugins_dir) self._plugins = {} self._tools = {} for plugin in all_plugins: if plugin.name in enabled_names: try: plugin.on_load(get_key) self._plugins[plugin.name] = plugin for tool in plugin.tools(): self._tools[tool.name] = tool except Exception as exc: _log_error(plugin.name, exc) def get_active_plugins(self) -> list[PyraPlugin]: return list(self._plugins.values()) def get_all_tools(self) -> list[Tool]: return list(self._tools.values()) def get_slash_commands(self) -> dict[str, Callable[[], None]]: cmds: dict[str, Callable[[], None]] = {} for plugin in self._plugins.values(): try: cmds.update(plugin.slash_commands()) except Exception: pass return cmds def get_system_prompt_additions(self) -> str: parts: list[str] = [] for plugin in self._plugins.values(): try: addition = plugin.system_prompt_addition() if addition: parts.append(addition.strip()) except Exception: pass return "\n\n".join(parts) def get_daemon_tasks(self) -> list[Coroutine]: # type: ignore[type-arg] tasks: list[Coroutine] = [] # type: ignore[type-arg] for plugin in self._plugins.values(): try: tasks.extend(plugin.daemon_tasks()) except Exception: pass return tasks def get_daemon_task_factories( self, ) -> list[tuple[str, Callable[[], Coroutine]]]: # type: ignore[type-arg] """Return (name, factory) pairs for all plugin daemon tasks. Each factory re-calls plugin.daemon_tasks() to produce a fresh coroutine, enabling the supervisor to restart crashed tasks without changing the plugin protocol. """ factories: list[tuple[str, Callable[[], Coroutine]]] = [] # type: ignore[type-arg] for plugin in self._plugins.values(): try: initial = plugin.daemon_tasks() n_tasks = len(initial) for c in initial: c.close() # prevent "coroutine never awaited" RuntimeWarning except Exception: continue for i in range(n_tasks): name = f"{plugin.name}.task_{i}" # Capture plugin and index by value so each closure is independent. def _factory(p=plugin, idx=i) -> Coroutine: # type: ignore[type-arg] return p.daemon_tasks()[idx] factories.append((name, _factory)) return factories def find_tool(self, name: str) -> Tool | None: return self._tools.get(name) def register_builtin(self, tool: Tool) -> None: """Register a built-in tool independent of plugins. Call after load_all.""" self._tools[tool.name] = tool def get_agent(self, name: str) -> tuple[AgentSpec, list[Tool]] | None: """Return (AgentSpec, tools) for a named plugin agent, or None.""" plugin = self._plugins.get(name) if plugin is None: return None spec = plugin.agent_spec() if spec is None: return None return (spec, plugin.tools()) def list_agents(self) -> list[tuple[str, AgentSpec]]: """Return (plugin_name, AgentSpec) for all plugins that have agents.""" return [ (name, plugin.agent_spec()) for name, plugin in self._plugins.items() if plugin.agent_spec() is not None ]