diff --git a/sdk/guides/plugins.mdx b/sdk/guides/plugins.mdx index 2e7b3fe5..e2c7d7f7 100644 --- a/sdk/guides/plugins.mdx +++ b/sdk/guides/plugins.mdx @@ -196,28 +196,48 @@ Brief explanation on how to use a plugin with an agent. ## Ready-to-run Example +The example below demonstrates plugin loading via Conversation and plugin management utilities (install, list, update, uninstall). + This example is available on GitHub: [examples/05_skills_and_plugins/02_loading_plugins/main.py](https://github.com/OpenHands/software-agent-sdk/blob/main/examples/05_skills_and_plugins/02_loading_plugins/main.py) ```python icon="python" expandable examples/05_skills_and_plugins/02_loading_plugins/main.py -"""Example: Loading Plugins via Conversation +"""Example: Loading and Managing Plugins + +This example demonstrates plugin loading and management in the SDK: + +1. Loading plugins via Conversation (PluginSource) +2. Installing plugins to persistent storage +3. Listing, updating, and uninstalling plugins -Demonstrates the recommended way to load plugins using the `plugins` parameter -on Conversation. Plugins bundle skills, hooks, and MCP config together. +Plugins bundle skills, hooks, and MCP config together. + +Supported plugin sources: +- Local path: /path/to/plugin +- GitHub shorthand: github:owner/repo +- Git URL: https://github.com/owner/repo.git +- With ref: branch, tag, or commit SHA +- With repo_path: subdirectory for monorepos For full documentation, see: https://docs.all-hands.dev/sdk/guides/plugins """ import os -import sys import tempfile from pathlib import Path from pydantic import SecretStr from openhands.sdk import LLM, Agent, Conversation -from openhands.sdk.plugin import PluginSource +from openhands.sdk.plugin import ( + PluginFetchError, + PluginSource, + install_plugin, + list_installed_plugins, + load_installed_plugins, + uninstall_plugin, +) from openhands.sdk.tool import Tool from openhands.tools.file_editor import FileEditorTool from openhands.tools.terminal import TerminalTool @@ -225,64 +245,194 @@ from openhands.tools.terminal import TerminalTool # Locate example plugin directory script_dir = Path(__file__).parent -plugin_path = script_dir / "example_plugins" / "code-quality" - -# Define plugins to load -# Supported sources: local path, "github:owner/repo", or git URL -# Optional: ref (branch/tag/commit), repo_path (for monorepos) -plugins = [ - PluginSource(source=str(plugin_path)), - # PluginSource(source="github:org/security-plugin", ref="v2.0.0"), - # PluginSource(source="github:org/monorepo", repo_path="plugins/logging"), -] - -# Check for API key -api_key = os.getenv("LLM_API_KEY") -if not api_key: - print("Set LLM_API_KEY to run this example") - print("EXAMPLE_COST: 0") - sys.exit(0) - -# Configure LLM and Agent -model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") -llm = LLM( - usage_id="plugin-demo", - model=model, - api_key=SecretStr(api_key), - base_url=os.getenv("LLM_BASE_URL"), -) -agent = Agent( - llm=llm, tools=[Tool(name=TerminalTool.name), Tool(name=FileEditorTool.name)] -) +local_plugin_path = script_dir / "example_plugins" / "code-quality" -# Create conversation with plugins - skills, MCP config, and hooks are merged -# Note: Plugins are loaded lazily on first send_message() or run() call -with tempfile.TemporaryDirectory() as tmpdir: - conversation = Conversation( - agent=agent, - workspace=tmpdir, - plugins=plugins, - ) - # Test: The "lint" keyword triggers the python-linting skill - # This first send_message() call triggers lazy plugin loading - conversation.send_message("How do I lint Python code? Brief answer please.") +def demo_conversation_with_plugins(llm: LLM) -> None: + """Demo 1: Load plugins via Conversation's plugins parameter. + + This is the recommended way to use plugins - they are loaded lazily + when the conversation starts. + """ + print("\n" + "=" * 60) + print("DEMO 1: Loading plugins via Conversation") + print("=" * 60) + + # Define plugins to load + plugins = [ + PluginSource(source=str(local_plugin_path)), + # Examples of other sources: + # PluginSource(source="github:owner/repo", ref="v1.0.0"), + # PluginSource(source="github:owner/monorepo", repo_path="plugins/my-plugin"), + ] - # Verify skills were loaded from the plugin (after lazy loading) - skills = ( - conversation.agent.agent_context.skills - if conversation.agent.agent_context - else [] + agent = Agent( + llm=llm, + tools=[Tool(name=TerminalTool.name), Tool(name=FileEditorTool.name)], ) - print(f"Loaded {len(skills)} skill(s) from plugins") - conversation.run() + with tempfile.TemporaryDirectory() as tmpdir: + conversation = Conversation( + agent=agent, + workspace=tmpdir, + plugins=plugins, + ) + + # The "lint" keyword triggers the python-linting skill + conversation.send_message("How do I lint Python code? Brief answer please.") + + # Verify skills were loaded + skills = ( + conversation.agent.agent_context.skills + if conversation.agent.agent_context + else [] + ) + print(f"✓ Loaded {len(skills)} skill(s) from plugins") + + conversation.run() + + +def demo_install_local_plugin(installed_dir: Path) -> None: + """Demo 2: Install a plugin from a local path. - print(f"EXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}") + Useful for development or local-only plugins. + """ + print("\n" + "=" * 60) + print("DEMO 2: Installing plugin from local path") + print("=" * 60) + + info = install_plugin(source=str(local_plugin_path), installed_dir=installed_dir) + print(f"✓ Installed: {info.name} v{info.version}") + print(f" Source: {info.source}") + print(f" Path: {info.install_path}") + + +def demo_install_github_plugin(installed_dir: Path) -> None: + """Demo 3: Install a plugin from GitHub. + + Demonstrates the github:owner/repo shorthand with repo_path for monorepos. + """ + print("\n" + "=" * 60) + print("DEMO 3: Installing plugin from GitHub") + print("=" * 60) + + try: + # Install from anthropics/skills repository + info = install_plugin( + source="github:anthropics/skills", + repo_path="skills/pptx", + ref="main", + installed_dir=installed_dir, + ) + print(f"✓ Installed: {info.name} v{info.version}") + print(f" Source: {info.source}") + print(f" Resolved ref: {info.resolved_ref}") + + except PluginFetchError as e: + print(f"⚠ Could not fetch from GitHub: {e}") + print(" (Network or rate limiting issue)") + + +def demo_list_and_load_plugins(installed_dir: Path) -> None: + """Demo 4: List and load installed plugins.""" + print("\n" + "=" * 60) + print("DEMO 4: List and load installed plugins") + print("=" * 60) + + # List installed plugins + print("Installed plugins:") + for info in list_installed_plugins(installed_dir=installed_dir): + print(f" - {info.name} v{info.version} ({info.source})") + + # Load plugins as Plugin objects + plugins = load_installed_plugins(installed_dir=installed_dir) + print(f"\nLoaded {len(plugins)} plugin(s):") + for plugin in plugins: + skills = plugin.get_all_skills() + print(f" - {plugin.name}: {len(skills)} skill(s)") + + +def demo_uninstall_plugins(installed_dir: Path) -> None: + """Demo 5: Uninstall plugins.""" + print("\n" + "=" * 60) + print("DEMO 5: Uninstalling plugins") + print("=" * 60) + + for info in list_installed_plugins(installed_dir=installed_dir): + uninstall_plugin(info.name, installed_dir=installed_dir) + print(f"✓ Uninstalled: {info.name}") + + remaining = list_installed_plugins(installed_dir=installed_dir) + print(f"\nRemaining plugins: {len(remaining)}") + + +# Main execution +if __name__ == "__main__": + api_key = os.getenv("LLM_API_KEY") + if not api_key: + print("Set LLM_API_KEY to run the full example") + print("Running install/uninstall demos only...") + llm = None + else: + model = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929") + llm = LLM( + usage_id="plugin-demo", + model=model, + api_key=SecretStr(api_key), + base_url=os.getenv("LLM_BASE_URL"), + ) + + with tempfile.TemporaryDirectory() as tmpdir: + installed_dir = Path(tmpdir) / "installed" + installed_dir.mkdir() + + # Demo 1: Conversation with plugins (requires LLM) + if llm: + demo_conversation_with_plugins(llm) + + # Demo 2-5: Plugin management (no LLM required) + demo_install_local_plugin(installed_dir) + demo_install_github_plugin(installed_dir) + demo_list_and_load_plugins(installed_dir) + demo_uninstall_plugins(installed_dir) + + print("\n" + "=" * 60) + print("EXAMPLE COMPLETED SUCCESSFULLY") + print("=" * 60) + + if llm: + print(f"EXAMPLE_COST: {llm.metrics.accumulated_cost:.4f}") + else: + print("EXAMPLE_COST: 0") ``` +## Installing Plugins to Persistent Storage + +The SDK provides utilities to install plugins to a local directory (`~/.openhands/plugins/installed/` by default). Installed plugins are tracked in `.installed.json`. + +```python icon="python" +from openhands.sdk.plugin import ( + install_plugin, + list_installed_plugins, + load_installed_plugins, + uninstall_plugin, +) + +# Install from local path or GitHub +install_plugin(source="/path/to/plugin") +install_plugin(source="github:owner/repo", ref="v1.0.0") + +# List and load installed plugins +for info in list_installed_plugins(): + print(f"{info.name} v{info.version}") + +plugins = load_installed_plugins() + +# Uninstall +uninstall_plugin("plugin-name") +``` ## Next Steps