feat(plugins): Stage 2.1 — plugin framework and AI tool-use
Introduces a standalone plugin system where every integration lives as an independent Python script in ~/.pyra/plugins/, not hardcoded in core. Plugin framework (src/pyra/plugins/): - base.py: Tool dataclass, PyraPlugin Protocol, BasePlugin helper - loader.py: importlib-based discovery; one bad plugin never crashes pyra - registry.py: singleton aggregating tools, slash commands, system prompts - executor.py: approval gate — scans args, prompts y/N, scans result, logs - install.py: copies bundled_plugins/ to ~/.pyra/plugins/ on install Chat integration: - AI tool-use loop (litellm function calling, up to 10 iterations) - Plugin system prompt additions injected per session - Plugin slash commands merged with static commands CLI additions: - pyra plugin list/install/enable/disable/setup - pyra daemon start/stop/status/restart/install/uninstall (stubs for 2.4) Config: PluginConfig + DaemonConfig added to PyraConfig (backwards-compatible) Bootstrap: ~/.pyra/plugins/ and ~/.pyra/logs/ created on startup Security: tool args and results always injection-scanned; plugin dirs validated with assert_safe_path() before loading (symlink protection) Tests: 37 new tests (loader, registry, executor, plugin isolation security) 161 total, all passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+79
-17
@@ -1,9 +1,8 @@
|
||||
from pathlib import Path
|
||||
from __future__ import annotations
|
||||
|
||||
import litellm
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from rich.console import Console
|
||||
|
||||
from pyra.chat.history import ConversationHistory
|
||||
from pyra.chat.renderer import (
|
||||
@@ -13,17 +12,20 @@ from pyra.chat.renderer import (
|
||||
render_injection_warning,
|
||||
render_streaming_response,
|
||||
render_system,
|
||||
render_text_response,
|
||||
)
|
||||
from pyra.config.manager import load_config
|
||||
from pyra.config.schema import PyraConfig
|
||||
from pyra.memory.reader import list_memories
|
||||
from pyra.plugins.executor import ToolExecutor
|
||||
from pyra.plugins.registry import PluginRegistry
|
||||
from pyra.security.injection import scan_response
|
||||
from pyra.setup.providers import get_provider
|
||||
from pyra.utils.paths import pyra_home
|
||||
|
||||
_HISTORY_FILE = pyra_home() / ".chat_history"
|
||||
|
||||
_SLASH_COMMANDS = {
|
||||
_STATIC_COMMANDS = {
|
||||
"/quit": "Exit Pyra",
|
||||
"/exit": "Exit Pyra",
|
||||
"/clear": "Clear conversation history",
|
||||
@@ -39,12 +41,18 @@ def start_chat() -> None:
|
||||
render_error(str(exc))
|
||||
return
|
||||
|
||||
history = ConversationHistory(cfg)
|
||||
registry = PluginRegistry.instance()
|
||||
registry.load_all(pyra_home() / "plugins", cfg.plugins.enabled)
|
||||
executor = ToolExecutor(registry, console)
|
||||
|
||||
history = ConversationHistory(cfg, registry)
|
||||
session: PromptSession = PromptSession(
|
||||
history=FileHistory(str(_HISTORY_FILE)),
|
||||
multiline=False,
|
||||
)
|
||||
|
||||
plugin_slash = registry.get_slash_commands()
|
||||
|
||||
provider = get_provider(cfg.ai.provider_id)
|
||||
render_system(
|
||||
f"[bold cyan]Pyra[/bold cyan] | {provider.display_name} | {cfg.ai.model}\n"
|
||||
@@ -71,13 +79,20 @@ def start_chat() -> None:
|
||||
continue
|
||||
|
||||
if user_input == "/help":
|
||||
_show_help()
|
||||
_show_help(plugin_slash)
|
||||
continue
|
||||
|
||||
if user_input == "/memory list":
|
||||
_show_memory_list()
|
||||
continue
|
||||
|
||||
if user_input in plugin_slash:
|
||||
try:
|
||||
plugin_slash[user_input]()
|
||||
except Exception as exc:
|
||||
render_error(f"Plugin command error: {exc}")
|
||||
continue
|
||||
|
||||
if user_input.startswith("/"):
|
||||
render_error(f"Unknown command: {user_input!r}. Type /help for commands.")
|
||||
continue
|
||||
@@ -85,10 +100,10 @@ def start_chat() -> None:
|
||||
history.add_user(user_input)
|
||||
|
||||
try:
|
||||
response_text = _call_ai(cfg, history)
|
||||
response_text = _call_ai(cfg, history, registry, executor)
|
||||
except Exception as exc:
|
||||
render_error(f"AI error: {exc}")
|
||||
history._messages.pop() # Remove the failed user message
|
||||
history._messages.pop()
|
||||
continue
|
||||
|
||||
history.add_assistant(response_text)
|
||||
@@ -98,31 +113,78 @@ def start_chat() -> None:
|
||||
render_injection_warning(warnings)
|
||||
|
||||
|
||||
def _call_ai(cfg: PyraConfig, history: ConversationHistory) -> str:
|
||||
def _call_ai(
|
||||
cfg: PyraConfig,
|
||||
history: ConversationHistory,
|
||||
registry: PluginRegistry,
|
||||
executor: ToolExecutor,
|
||||
) -> str:
|
||||
from pyra.vault.reader import get_key
|
||||
|
||||
provider = get_provider(cfg.ai.provider_id)
|
||||
# Local providers don't need a real key but litellm requires the field
|
||||
api_key = get_key(cfg.ai.provider_id) if provider.requires_key else "local"
|
||||
|
||||
kwargs: dict = {
|
||||
base_kwargs: dict = {
|
||||
"model": f"{provider.litellm_prefix}{cfg.ai.model}",
|
||||
"messages": history.build_for_api(),
|
||||
"stream": True,
|
||||
"api_key": api_key,
|
||||
}
|
||||
if cfg.ai.base_url:
|
||||
kwargs["api_base"] = cfg.ai.base_url
|
||||
base_kwargs["api_base"] = cfg.ai.base_url
|
||||
|
||||
litellm.suppress_debug_info = True
|
||||
stream = litellm.completion(**kwargs)
|
||||
return render_streaming_response(stream)
|
||||
|
||||
tools = registry.get_all_tools()
|
||||
tools_spec = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"parameters": t.parameters,
|
||||
},
|
||||
}
|
||||
for t in tools
|
||||
]
|
||||
|
||||
# No plugins active — use streaming (original behavior)
|
||||
if not tools_spec:
|
||||
stream = litellm.completion(
|
||||
**base_kwargs,
|
||||
messages=history.build_for_api(),
|
||||
stream=True,
|
||||
)
|
||||
return render_streaming_response(stream)
|
||||
|
||||
# Plugin tool-use loop (non-streaming for tool calls, renders final response)
|
||||
for _iteration in range(10):
|
||||
response = litellm.completion(
|
||||
**base_kwargs,
|
||||
messages=history.build_for_api(),
|
||||
tools=tools_spec,
|
||||
tool_choice="auto",
|
||||
stream=False,
|
||||
)
|
||||
message = response.choices[0].message
|
||||
|
||||
if not message.tool_calls:
|
||||
return render_text_response(message.content or "")
|
||||
|
||||
history.add_tool_call_message(message)
|
||||
results = executor.execute_tool_call_batch(message.tool_calls)
|
||||
for r in results:
|
||||
history.add_tool_result(r["tool_call_id"], r["result"])
|
||||
|
||||
return render_text_response("Error: tool-use loop exceeded maximum iterations.")
|
||||
|
||||
|
||||
def _show_help() -> None:
|
||||
def _show_help(plugin_slash: dict) -> None:
|
||||
lines = ["[bold]Slash commands:[/bold]"]
|
||||
for cmd, desc in _SLASH_COMMANDS.items():
|
||||
for cmd, desc in _STATIC_COMMANDS.items():
|
||||
lines.append(f" [cyan]{cmd:<20}[/cyan] {desc}")
|
||||
if plugin_slash:
|
||||
lines.append("[bold]Plugin commands:[/bold]")
|
||||
for cmd in sorted(plugin_slash):
|
||||
lines.append(f" [cyan]{cmd:<20}[/cyan]")
|
||||
console.print("\n".join(lines))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user