mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-04-28 03:30:23 +00:00
feat(plugins): Add extensibility and hooks system to plugin API
Add @extension.extensible decorators to all plugin API handler methods and core plugin functions to enable extension points. Implement plugin hooks system allowing plugins to define custom behavior via hooks.py file. Add call_plugin_hook function to execute plugin-specific hooks for events like uninstall, save_plugin_config, and get_plugin_config. Introduce uninstall_plugin function that calls uninstall hook before deletion. Move circular
This commit is contained in:
parent
bcbce781b8
commit
a48ac95a29
15 changed files with 339 additions and 79 deletions
|
|
@ -128,6 +128,9 @@ Key Files:
|
|||
- Location: Always develop new plugins in usr/plugins/.
|
||||
- Manifest: Every plugin requires a plugin.yaml with name, description, version, and optionally settings_sections, per_project_config, per_agent_config, and always_enabled.
|
||||
- Discovery: Conventions based on folder names (api/, tools/, webui/, extensions/).
|
||||
- Runtime hooks: Plugins may also expose hooks in hooks.py, callable by the framework through helpers.plugins.call_plugin_hook(...).
|
||||
- Hook runtime: hooks.py executes inside the Agent Zero framework Python environment, so sys.executable -m pip installs dependencies into that same framework runtime.
|
||||
- Environment targeting: If a plugin needs packages or binaries for the separate agent execution runtime or system environment, it must explicitly switch environments in a subprocess by targeting the correct interpreter, virtualenv, or package manager.
|
||||
- Settings: Use get_plugin_config(plugin_name, agent=agent) to retrieve settings. Plugins can expose a UI for settings via webui/config.html. Plugin settings modals instantiate a local context from $store.pluginSettingsPrototype; bind plugin fields to config.* and use context.* for modal-level state and actions. For plugins wrapping core settings, set context.saveMode = 'core' in x-init.
|
||||
- Activation: Global and scoped activation rules are stored as .toggle-1 (ON) and .toggle-0 (OFF). Scoped rules are handled via the plugin "Switch" modal.
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import sys
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from helpers.api import ApiHandler, Request, Response
|
||||
from helpers import plugins, files
|
||||
from helpers import plugins, files, extension
|
||||
|
||||
|
||||
class Plugins(ApiHandler):
|
||||
|
|
@ -53,6 +53,7 @@ class Plugins(ApiHandler):
|
|||
|
||||
return Response(status=400, response=f"Unknown action: {action}")
|
||||
|
||||
@extension.extensible
|
||||
def _get_config(self, input: dict) -> dict | Response:
|
||||
plugin_name = input.get("plugin_name", "")
|
||||
project_name = input.get("project_name", "")
|
||||
|
|
@ -90,6 +91,7 @@ class Plugins(ApiHandler):
|
|||
"data": settings,
|
||||
}
|
||||
|
||||
@extension.extensible
|
||||
def _get_toggle_status(self, input: dict) -> dict | Response:
|
||||
plugin_name = input.get("plugin_name", "")
|
||||
project_name = input.get("project_name", "")
|
||||
|
|
@ -140,6 +142,7 @@ class Plugins(ApiHandler):
|
|||
"loaded_path": "",
|
||||
}
|
||||
|
||||
@extension.extensible
|
||||
def _list_configs(self, input: dict) -> dict | Response:
|
||||
plugin_name = input.get("plugin_name", "")
|
||||
asset_type = input.get("asset_type", "config")
|
||||
|
|
@ -160,6 +163,7 @@ class Plugins(ApiHandler):
|
|||
|
||||
return {"ok": True, "data": configs}
|
||||
|
||||
@extension.extensible
|
||||
def _delete_config(self, input: dict) -> dict | Response:
|
||||
plugin_name = input.get("plugin_name", "")
|
||||
path = input.get("path", "")
|
||||
|
|
@ -196,12 +200,13 @@ class Plugins(ApiHandler):
|
|||
|
||||
return {"ok": True}
|
||||
|
||||
@extension.extensible
|
||||
def _delete_plugin(self, input: dict) -> dict | Response:
|
||||
plugin_name = input.get("plugin_name", "")
|
||||
if not plugin_name:
|
||||
return Response(status=400, response="Missing plugin_name")
|
||||
try:
|
||||
plugins.delete_plugin(plugin_name)
|
||||
plugins.uninstall_plugin(plugin_name)
|
||||
except FileNotFoundError as e:
|
||||
return Response(status=404, response=str(e))
|
||||
except ValueError as e:
|
||||
|
|
@ -210,6 +215,7 @@ class Plugins(ApiHandler):
|
|||
return Response(status=500, response=f"Failed to delete plugin: {str(e)}")
|
||||
return {"ok": True}
|
||||
|
||||
@extension.extensible
|
||||
def _get_default_config(self, input: dict) -> dict | Response:
|
||||
plugin_name = input.get("plugin_name", "")
|
||||
if not plugin_name:
|
||||
|
|
@ -217,6 +223,7 @@ class Plugins(ApiHandler):
|
|||
settings = plugins.get_default_plugin_config(plugin_name)
|
||||
return {"ok": True, "data": settings or {}}
|
||||
|
||||
@extension.extensible
|
||||
def _save_config(self, input: dict) -> dict | Response:
|
||||
plugin_name = input.get("plugin_name", "")
|
||||
project_name = input.get("project_name", "")
|
||||
|
|
@ -229,6 +236,7 @@ class Plugins(ApiHandler):
|
|||
plugins.save_plugin_config(plugin_name, project_name, agent_profile, settings)
|
||||
return {"ok": True}
|
||||
|
||||
@extension.extensible
|
||||
def _toggle_plugin(self, input: dict) -> dict | Response:
|
||||
plugin_name = input.get("plugin_name", "")
|
||||
enabled = input.get("enabled")
|
||||
|
|
@ -246,6 +254,7 @@ class Plugins(ApiHandler):
|
|||
)
|
||||
return {"ok": True}
|
||||
|
||||
@extension.extensible
|
||||
def _get_doc(self, input: dict) -> dict | Response:
|
||||
plugin_name = input.get("plugin_name", "")
|
||||
doc = input.get("doc", "")
|
||||
|
|
@ -265,6 +274,7 @@ class Plugins(ApiHandler):
|
|||
|
||||
return {"ok": True, "content": files.read_file(file_path), "filename": filename}
|
||||
|
||||
@extension.extensible
|
||||
def _run_init_script(self, input: dict) -> dict | Response:
|
||||
plugin_name = input.get("plugin_name", "")
|
||||
if not plugin_name:
|
||||
|
|
@ -311,6 +321,7 @@ class Plugins(ApiHandler):
|
|||
"executed_at": executed_at,
|
||||
}
|
||||
|
||||
@extension.extensible
|
||||
def _get_init_exec(self, input: dict) -> dict | Response:
|
||||
plugin_name = input.get("plugin_name", "")
|
||||
if not plugin_name:
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ Each plugin lives in usr/plugins/<plugin_name>/.
|
|||
usr/plugins/<plugin_name>/
|
||||
├── plugin.yaml # Required: Title, version, settings + activation metadata
|
||||
├── initialize.py # Optional: one-time setup script (dependencies, models, etc.)
|
||||
├── hooks.py # Optional: runtime hook functions callable by the framework
|
||||
├── default_config.yaml # Optional: fallback settings defaults
|
||||
├── README.md # Optional: shown in Plugin List UI
|
||||
├── LICENSE # Optional: shown in Plugin List UI
|
||||
|
|
@ -68,6 +69,36 @@ Field reference:
|
|||
- `per_agent_config`: Enables agent-profile-scoped settings and toggle rules
|
||||
- `always_enabled`: Forces ON and disables toggle controls in the UI (reserved for framework use)
|
||||
|
||||
### hooks.py (framework runtime hooks)
|
||||
|
||||
Plugins can include an optional `hooks.py` file at the plugin root. Agent Zero loads this module on demand and calls exported functions by name through `helpers.plugins.call_plugin_hook(...)`.
|
||||
|
||||
- `hooks.py` runs inside the **Agent Zero framework runtime and Python environment**, not the separate agent execution environment.
|
||||
- Use it for framework-internal operations such as install-time setup, plugin registration work, filesystem preparation, cache updates, or other tasks that need access to Agent Zero internals.
|
||||
- Hook functions may be synchronous or async. Async hooks are awaited by the framework.
|
||||
- Hook modules are cached until plugin caches are cleared, so changes may require a plugin refresh/reload cycle.
|
||||
|
||||
Current example: the plugin installer calls `install()` from `hooks.py` after a plugin is copied into place.
|
||||
|
||||
### Runtime and dependency implications
|
||||
|
||||
- If `hooks.py` installs Python packages with `sys.executable -m pip`, those packages are installed into the **same Python environment that runs Agent Zero itself**.
|
||||
- This is the correct place for Python dependencies that your plugin's backend code needs while running inside the framework runtime.
|
||||
- It is **not** the right place for dependencies meant only for the separate agent execution runtime or for arbitrary system-level tooling.
|
||||
|
||||
If your plugin needs to install packages or binaries for the agent execution environment instead of the framework runtime, launch a subprocess that explicitly activates or targets that other environment first. In practice this means invoking the correct interpreter or shell for that environment rather than relying on the current process environment. For example:
|
||||
|
||||
- target a specific Python interpreter path for that runtime
|
||||
- activate the desired virtualenv inside a subprocess shell command before running `pip`
|
||||
- invoke the appropriate package manager from a subprocess prepared for that environment
|
||||
|
||||
In Docker deployments, this distinction is especially important:
|
||||
|
||||
- Framework runtime: `/opt/venv-a0`
|
||||
- Agent execution runtime: `/opt/venv`
|
||||
|
||||
So a `hooks.py` install step affects `/opt/venv-a0` unless you intentionally switch to `/opt/venv` (or another target) inside your subprocess.
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend Extensions
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ Field reference:
|
|||
usr/plugins/<plugin_name>/
|
||||
├── plugin.yaml
|
||||
├── initialize.py # optional one-time setup script
|
||||
├── hooks.py # optional runtime hook functions callable by the framework
|
||||
├── default_config.yaml # optional defaults
|
||||
├── README.md # optional, shown in Plugin List UI
|
||||
├── LICENSE # optional, shown in Plugin List UI
|
||||
|
|
@ -97,6 +98,33 @@ if __name__ == "__main__":
|
|||
|
||||
Return `0` on success, non-zero on failure. Print progress for user feedback. Use `sys.executable` for pip commands.
|
||||
|
||||
## Runtime Hooks (`hooks.py`)
|
||||
|
||||
Plugins can also include an optional `hooks.py` at the plugin root. Agent Zero loads this module on demand and calls exported functions by name through `helpers.plugins.call_plugin_hook(...)`.
|
||||
|
||||
- `hooks.py` executes inside the **Agent Zero framework runtime and Python environment**.
|
||||
- Use it for framework-internal operations such as install hooks, registration, cache preparation, file setup, or other work that needs direct access to framework internals.
|
||||
- Hook functions may be synchronous or async.
|
||||
- Hook modules are cached, so edits may require a plugin refresh or cache clear before changes are picked up.
|
||||
|
||||
Current built-in usage: the plugin installer calls `install()` from `hooks.py` after copying a plugin into place.
|
||||
|
||||
### Dependency and environment behavior
|
||||
|
||||
- If `hooks.py` runs `sys.executable -m pip install ...`, it installs into the **same Python environment that is currently running Agent Zero**.
|
||||
- That is the correct target for dependencies needed by your plugin's backend code inside the framework runtime.
|
||||
- It is not automatically the right target for packages intended only for the separate agent execution runtime or for system-level binaries.
|
||||
|
||||
If you need to install into a different environment, do it explicitly from a subprocess. In practice, that means targeting the correct interpreter or activating the correct environment inside the subprocess before running `pip` or another package manager.
|
||||
|
||||
Examples of the right approach:
|
||||
|
||||
- call a specific Python executable for the target runtime
|
||||
- activate the target virtualenv in a subprocess shell command before invoking `pip`
|
||||
- run OS-level package installation from a subprocess prepared for the intended environment
|
||||
|
||||
In Docker deployments, `hooks.py` normally affects the framework runtime at `/opt/venv-a0`, while the agent execution runtime is `/opt/venv`.
|
||||
|
||||
## Settings Resolution
|
||||
|
||||
Plugin settings are resolved by scope. Higher priority overrides lower priority:
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from helpers import files, cache
|
|||
|
||||
ThreadLockType = Union[threading.Lock, threading.RLock]
|
||||
|
||||
CACHE_AREA = "api_handlers(api)(plugins)"
|
||||
CACHE_AREA = "api_handlers(api)(plugins)(extensions)"
|
||||
cache.toggle_area(CACHE_AREA, False) # cache off for now
|
||||
|
||||
Input = dict
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ def toggle_area(area: str, enabled: bool) -> None:
|
|||
_enabled_areas[area] = enabled
|
||||
|
||||
|
||||
def has(area: str, key: str) -> bool:
|
||||
if not _is_enabled(area):
|
||||
return False
|
||||
with _lock:
|
||||
return key in _cache.get(area, {})
|
||||
|
||||
|
||||
def add(area: str, key: str, data: Any) -> None:
|
||||
if not _is_enabled(area):
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from abc import abstractmethod
|
||||
from typing import Any, Awaitable, Type, cast
|
||||
from helpers import extract_tools, files
|
||||
from helpers import cache, plugins, subagents
|
||||
from helpers import cache, subagents
|
||||
from typing import TYPE_CHECKING
|
||||
from functools import wraps
|
||||
import inspect
|
||||
|
|
@ -262,4 +262,4 @@ def _get_extensions(folder: str):
|
|||
|
||||
classes = extract_tools.load_classes_from_folder(folder, "*", Extension)
|
||||
cache.add(_CACHE_AREA, folder, classes)
|
||||
return classes
|
||||
return classes
|
||||
|
|
@ -15,7 +15,15 @@ from typing import (
|
|||
TypedDict,
|
||||
)
|
||||
|
||||
from helpers import files, notification, print_style, yaml as yaml_helper, cache
|
||||
from helpers import (
|
||||
files,
|
||||
notification,
|
||||
print_style,
|
||||
yaml as yaml_helper,
|
||||
cache,
|
||||
extension,
|
||||
extract_tools,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from helpers.defer import DeferredTask
|
||||
|
|
@ -29,6 +37,7 @@ _META_TARGET_RE = re.compile(
|
|||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
type ToggleState = Literal["enabled", "disabled", "advanced"]
|
||||
|
||||
|
||||
|
|
@ -44,6 +53,10 @@ CONFIG_DEFAULT_FILE_NAME = "default_config.yaml"
|
|||
DISABLED_FILE_NAME = ".toggle-0"
|
||||
ENABLED_FILE_NAME = ".toggle-1"
|
||||
TOGGLE_FILE_PATTERN = ".toggle-[01]"
|
||||
|
||||
HOOKS_SCRIPT = "hooks.py"
|
||||
HOOKS_CACHE_AREA = "plugin_hooks(plugins)"
|
||||
|
||||
_last_frontend_reload_notification_at = 0.0
|
||||
|
||||
|
||||
|
|
@ -77,6 +90,7 @@ class PluginListItem(BaseModel):
|
|||
toggle_state: ToggleState = "disabled"
|
||||
|
||||
|
||||
@extension.extensible
|
||||
def after_plugin_change(plugin_names: list[str] | None = None):
|
||||
clear_plugin_cache()
|
||||
send_frontend_reload_notification(plugin_names)
|
||||
|
|
@ -192,6 +206,14 @@ def find_plugin_dir(plugin_name: str):
|
|||
return None
|
||||
|
||||
|
||||
@extension.extensible
|
||||
def uninstall_plugin(plugin_name):
|
||||
# call the uninstall hook if any
|
||||
call_plugin_hook(plugin_name, "uninstall")
|
||||
# then delete
|
||||
delete_plugin(plugin_name)
|
||||
|
||||
@extension.extensible
|
||||
def delete_plugin(plugin_name: str):
|
||||
plugin_dir = find_plugin_dir(plugin_name)
|
||||
if not plugin_dir:
|
||||
|
|
@ -199,7 +221,9 @@ def delete_plugin(plugin_name: str):
|
|||
custom_plugins_dir = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR)
|
||||
if not files.is_in_dir(plugin_dir, custom_plugins_dir):
|
||||
raise ValueError("Only custom plugins can be deleted")
|
||||
send_frontend_reload_notification([plugin_name]) # send before deletion to properly check the extensions, second notification will be skipped automatically
|
||||
send_frontend_reload_notification(
|
||||
[plugin_name]
|
||||
) # send before deletion to properly check the extensions, second notification will be skipped automatically
|
||||
files.delete_dir(plugin_dir)
|
||||
after_plugin_change([plugin_name])
|
||||
|
||||
|
|
@ -298,7 +322,6 @@ def get_toggle_state(plugin_name: str) -> ToggleState:
|
|||
else "disabled"
|
||||
)
|
||||
|
||||
|
||||
# additional toggles in project/agent directories, return advanced
|
||||
if meta.per_agent_config or meta.per_project_config:
|
||||
configs = find_plugin_assets(
|
||||
|
|
@ -316,6 +339,7 @@ def get_toggle_state(plugin_name: str) -> ToggleState:
|
|||
return state
|
||||
|
||||
|
||||
@extension.extensible
|
||||
def toggle_plugin(
|
||||
plugin_name: str,
|
||||
enabled: bool,
|
||||
|
|
@ -352,6 +376,7 @@ def toggle_plugin(
|
|||
after_plugin_change([plugin_name])
|
||||
|
||||
|
||||
@extension.extensible
|
||||
def get_plugin_config(
|
||||
plugin_name: str,
|
||||
agent: Agent | None = None,
|
||||
|
|
@ -380,35 +405,75 @@ def get_plugin_config(
|
|||
file_path = files.get_abs_path(
|
||||
find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME
|
||||
)
|
||||
|
||||
result = None
|
||||
if file_path and files.exists(file_path):
|
||||
return (
|
||||
result = (
|
||||
json.loads if file_path.lower().endswith(".json") else yaml_helper.loads
|
||||
)(files.read_file(file_path))
|
||||
return None
|
||||
|
||||
# call plugin hook to modify the standard result if needed
|
||||
new_result = call_plugin_hook(
|
||||
plugin_name,
|
||||
"save_plugin_config",
|
||||
result=result,
|
||||
agent=agent,
|
||||
project_name=project_name,
|
||||
agent_profile=agent_profile,
|
||||
)
|
||||
|
||||
if new_result is not None:
|
||||
return new_result
|
||||
return result
|
||||
|
||||
|
||||
def get_default_plugin_config(plugin_name: str):
|
||||
file_path = files.get_abs_path(
|
||||
find_plugin_dir(plugin_name), CONFIG_DEFAULT_FILE_NAME
|
||||
)
|
||||
if file_path and files.exists(file_path):
|
||||
return (
|
||||
|
||||
# call plugin hook to get the result
|
||||
result = call_plugin_hook(
|
||||
plugin_name,
|
||||
"save_plugin_config",
|
||||
file_path = file_path
|
||||
)
|
||||
|
||||
# or do standard load
|
||||
if result is None and file_path and files.exists(file_path):
|
||||
result = (
|
||||
json.loads if file_path.lower().endswith(".json") else yaml_helper.loads
|
||||
)(files.read_file(file_path))
|
||||
return None
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@extension.extensible
|
||||
def save_plugin_config(
|
||||
plugin_name: str, project_name: str, agent_profile: str, settings: dict
|
||||
):
|
||||
file_path = determine_plugin_asset_path(
|
||||
plugin_name, project_name, agent_profile, CONFIG_FILE_NAME
|
||||
)
|
||||
if file_path:
|
||||
files.write_file(file_path, json.dumps(settings))
|
||||
|
||||
# call plugin hook to get the result first
|
||||
new_settings = call_plugin_hook(
|
||||
plugin_name,
|
||||
"save_plugin_config",
|
||||
result=None,
|
||||
project_name=project_name,
|
||||
agent_profile=agent_profile,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
# or do standard load
|
||||
if new_settings is not None and file_path:
|
||||
files.write_file(file_path, json.dumps(new_settings))
|
||||
after_plugin_change([plugin_name])
|
||||
|
||||
|
||||
|
||||
|
||||
def find_plugin_asset(
|
||||
plugin_name: str, *subpaths: str, project_name="", agent_profile=""
|
||||
):
|
||||
|
|
@ -593,3 +658,31 @@ def send_frontend_reload_notification(plugin_names: list[str] | None = None):
|
|||
)
|
||||
|
||||
DeferredTask().start_task(_send_later)
|
||||
|
||||
|
||||
def call_plugin_hook(plugin_name: str, hook_name: str, *args, **kwargs):
|
||||
hooks = None
|
||||
|
||||
# use cached hooks if enabled
|
||||
if not cache.has(HOOKS_CACHE_AREA, plugin_name):
|
||||
hooks_script = files.get_abs_path(find_plugin_dir(plugin_name), HOOKS_SCRIPT)
|
||||
hooks = (
|
||||
extract_tools.import_module(hooks_script)
|
||||
if files.exists(hooks_script)
|
||||
else None
|
||||
)
|
||||
cache.add(HOOKS_CACHE_AREA, plugin_name, hooks)
|
||||
else:
|
||||
hooks = cache.get(HOOKS_CACHE_AREA, plugin_name)
|
||||
|
||||
if not hooks:
|
||||
return
|
||||
|
||||
hook = getattr(hooks, hook_name, None)
|
||||
if not hook:
|
||||
return
|
||||
|
||||
if asyncio.iscoroutinefunction(hook):
|
||||
return asyncio.run(hook(*args, **kwargs))
|
||||
|
||||
return hook(*args, **kwargs)
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ def _merge_agent_list_items(
|
|||
|
||||
|
||||
def get_agents_roots() -> list[str]:
|
||||
from helpers import plugins
|
||||
# from helpers import plugins
|
||||
|
||||
plugin_agents = plugins.get_enabled_plugin_paths(None, "agents")
|
||||
project_agents = files.find_existing_paths_by_pattern("usr/projects/*/.a0proj/agents")
|
||||
|
|
@ -378,7 +378,7 @@ def get_paths(
|
|||
|
||||
# plugin agents/<profile>/...
|
||||
if include_plugins:
|
||||
from helpers import plugins
|
||||
# from helpers import plugins
|
||||
for plugin_dir in plugins.get_enabled_plugin_paths(agent, "agents", profile_name):
|
||||
path = files.get_abs_path(plugin_dir, *subpaths)
|
||||
if (not must_exist_completely) or files.exists(files.get_abs_path(plugin_dir, *check_subpaths)):
|
||||
|
|
@ -397,7 +397,7 @@ def get_paths(
|
|||
|
||||
if include_plugins:
|
||||
# plugins/*/subpaths...
|
||||
from helpers import plugins
|
||||
# from helpers import plugins
|
||||
|
||||
for plugin_dir in plugins.get_enabled_plugin_paths(agent):
|
||||
path = files.get_abs_path(plugin_dir, *subpaths)
|
||||
|
|
@ -412,3 +412,7 @@ def get_paths(
|
|||
paths.append(path)
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
# end-of-file imports to prevent circular imports
|
||||
from helpers import plugins
|
||||
|
|
@ -45,6 +45,17 @@ always_enabled: false
|
|||
|
||||
Plugins can include an optional `initialize.py` at the plugin root for one-time setup such as installing dependencies or downloading models. Users trigger it via the **Init** button in the Plugin List UI. The script should return `0` on success and print progress messages for user feedback.
|
||||
|
||||
## Runtime Hooks (`hooks.py`)
|
||||
|
||||
Plugins can also include an optional `hooks.py` at the plugin root. The framework loads it on demand and can call exported hook functions by name through `helpers.plugins.call_plugin_hook(...)`.
|
||||
|
||||
- `hooks.py` runs inside the **Agent Zero framework runtime and Python environment**.
|
||||
- Use it for framework-internal work such as install hooks, cache preparation, registration, or filesystem setup.
|
||||
- If it runs `sys.executable -m pip install ...`, packages are installed into the same Python environment that runs Agent Zero.
|
||||
- If you need to install into the separate agent runtime or into the system environment, explicitly target that environment from a subprocess by selecting the correct interpreter, virtualenv, or package manager.
|
||||
|
||||
In Docker, `hooks.py` normally affects `/opt/venv-a0`; the agent execution runtime is `/opt/venv`.
|
||||
|
||||
## Plugin Index & Community Sharing
|
||||
|
||||
The **Plugin Index** at https://github.com/agent0ai/a0-plugins is the community-maintained registry of plugins available to all Agent Zero users.
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from turtle import stamp
|
||||
import urllib.request
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from helpers import files
|
||||
from helpers import files, print_style, plugins
|
||||
from helpers import yaml as yaml_helper
|
||||
from helpers.plugins import (
|
||||
META_FILE_NAME,
|
||||
|
|
@ -21,6 +21,7 @@ from helpers.plugins import (
|
|||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
|
||||
def _get_user_plugins_dir() -> str:
|
||||
"""Return absolute path to usr/plugins/."""
|
||||
return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR)
|
||||
|
|
@ -33,7 +34,7 @@ def _get_plugin_name(meta: PluginMetadata) -> str:
|
|||
return plugin_name
|
||||
|
||||
|
||||
def validate_plugin_dir(path: str, plugin_name:str="") -> PluginMetadata:
|
||||
def validate_plugin_dir(path: str, plugin_name: str = "") -> PluginMetadata:
|
||||
"""Check directory contains plugin.yaml and return parsed metadata.
|
||||
Raises ValueError if plugin.yaml is missing or invalid."""
|
||||
meta_path = os.path.join(path, META_FILE_NAME)
|
||||
|
|
@ -44,7 +45,9 @@ def validate_plugin_dir(path: str, plugin_name:str="") -> PluginMetadata:
|
|||
data = yaml_helper.loads(content)
|
||||
model = PluginMetadata.model_validate(data)
|
||||
if plugin_name and plugin_name != model.name:
|
||||
raise ValueError(f"Plugin name is incorrect: expected '{plugin_name}', got '{model.name}'. The author needs to correct this in the plugin.yaml file.")
|
||||
raise ValueError(
|
||||
f"Plugin name is incorrect: expected '{plugin_name}', got '{model.name}'. The author needs to correct this in the plugin.yaml file."
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
|
|
@ -89,36 +92,47 @@ def install_from_zip(zip_path: str, original_filename: str | None = None) -> dic
|
|||
"""Extract ZIP, find plugin.yaml, move its parent to usr/plugins/.
|
||||
Returns dict with plugin name and metadata.
|
||||
Cleans up tmp files regardless of outcome."""
|
||||
base_tmp = files.get_abs_path("tmp", "plugin_installs")
|
||||
os.makedirs(base_tmp, exist_ok=True)
|
||||
stamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
extract_dir = os.path.join(base_tmp, f"extract_{stamp}")
|
||||
os.makedirs(extract_dir, exist_ok=True)
|
||||
temp_name = f"tmp_plugin_{time.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
|
||||
extract_dir = files.get_abs_path(files.TEMP_DIR, "plugin_installs", temp_name)
|
||||
extract_dir = files.create_dir_safe(extract_dir)
|
||||
dest = ""
|
||||
|
||||
try:
|
||||
# Extract with path traversal protection
|
||||
try:
|
||||
# Extract with path traversal protection
|
||||
with zipfile.ZipFile(zip_path, "r") as z:
|
||||
real_extract = os.path.realpath(extract_dir)
|
||||
for member in z.namelist():
|
||||
member_path = os.path.realpath(os.path.join(extract_dir, member))
|
||||
if not member_path.startswith(real_extract + os.sep) and member_path != real_extract:
|
||||
if not (files.is_in_dir(member_path, extract_dir)):
|
||||
raise ValueError(f"Unsafe path in archive: {member}")
|
||||
z.extractall(extract_dir)
|
||||
except zipfile.BadZipFile:
|
||||
raise ValueError("The uploaded file is not a valid ZIP archive")
|
||||
|
||||
# Find plugin.yaml
|
||||
plugin_root = _find_plugin_root(extract_dir)
|
||||
meta = validate_plugin_dir(plugin_root)
|
||||
plugin_name = _get_plugin_name(meta)
|
||||
# Find plugin.yaml
|
||||
plugin_root = _find_plugin_root(extract_dir)
|
||||
meta = validate_plugin_dir(plugin_root)
|
||||
plugin_name = _get_plugin_name(meta)
|
||||
|
||||
check_plugin_conflict(plugin_name)
|
||||
check_plugin_conflict(plugin_name)
|
||||
|
||||
# Move to usr/plugins/
|
||||
dest = os.path.join(_get_user_plugins_dir(), plugin_name)
|
||||
files.create_dir(os.path.dirname(dest))
|
||||
files.move_dir(plugin_root, dest)
|
||||
except Exception as e:
|
||||
print_style.PrintStyle.error(f"Failed to validate plugin: {e}")
|
||||
files.delete_dir(extract_dir)
|
||||
raise
|
||||
|
||||
# run installation hook
|
||||
try:
|
||||
run_install_hook(plugin_name)
|
||||
except Exception as e:
|
||||
print_style.PrintStyle.error(
|
||||
f"Failed to run installation hook for {plugin_name}: {e}"
|
||||
)
|
||||
files.delete_dir(dest)
|
||||
raise
|
||||
|
||||
# Move to usr/plugins/
|
||||
dest = os.path.join(_get_user_plugins_dir(), plugin_name)
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
shutil.move(plugin_root, dest)
|
||||
after_plugin_change([plugin_name])
|
||||
|
||||
return {
|
||||
|
|
@ -129,50 +143,59 @@ def install_from_zip(zip_path: str, original_filename: str | None = None) -> dic
|
|||
}
|
||||
finally:
|
||||
# Cleanup: extracted files and the archive
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
try:
|
||||
os.unlink(zip_path)
|
||||
except OSError:
|
||||
files.delete_dir(extract_dir)
|
||||
files.delete_file(zip_path)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
|
||||
def install_from_git(url: str, token: str | None = None, plugin_name: str="") -> dict:
|
||||
def install_from_git(url: str, token: str | None = None, plugin_name: str = "") -> dict:
|
||||
"""Clone git repo into usr/plugins/, validate plugin.yaml.
|
||||
Returns dict with plugin name and metadata."""
|
||||
from helpers.git import clone_repo
|
||||
|
||||
temp_name = f"tmp_plugin_{time.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
|
||||
dest = files.get_abs_path(files.TEMP_DIR, "plugins_installer", temp_name)
|
||||
files.create_dir_safe(dest)
|
||||
git_dir = files.get_abs_path(files.TEMP_DIR, "plugins_installer", temp_name)
|
||||
|
||||
try:
|
||||
clone_repo(url, dest, token=token or None)
|
||||
files.create_dir_safe(git_dir)
|
||||
clone_repo(url, git_dir, token=token or None)
|
||||
meta = validate_plugin_dir(git_dir, plugin_name=plugin_name)
|
||||
plugin_name = _get_plugin_name(meta)
|
||||
check_plugin_conflict(plugin_name)
|
||||
final_dir = os.path.join(_get_user_plugins_dir(), plugin_name)
|
||||
files.move_dir(git_dir, final_dir)
|
||||
except Exception as e:
|
||||
# Cleanup partial clone
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
raise ValueError(f"Git clone failed: {e}") from e
|
||||
|
||||
try:
|
||||
meta = validate_plugin_dir(dest, plugin_name=plugin_name)
|
||||
except ValueError:
|
||||
# No plugin.yaml — remove cloned repo
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
print_style.PrintStyle.error(f"Failed to validate plugin: {e}")
|
||||
files.delete_dir(git_dir)
|
||||
raise
|
||||
|
||||
# run installation hook
|
||||
try:
|
||||
run_install_hook(plugin_name)
|
||||
except Exception as e:
|
||||
print_style.PrintStyle.error(
|
||||
f"Failed to run installation hook for {plugin_name}: {e}"
|
||||
)
|
||||
files.delete_dir(final_dir)
|
||||
raise
|
||||
|
||||
plugin_name = _get_plugin_name(meta)
|
||||
check_plugin_conflict(plugin_name)
|
||||
final_dest = os.path.join(_get_user_plugins_dir(), plugin_name)
|
||||
files.move_dir(dest, final_dest)
|
||||
after_plugin_change([plugin_name])
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"plugin_name": plugin_name,
|
||||
"title": meta.title or plugin_name,
|
||||
"path": files.deabsolute_path(final_dest),
|
||||
"path": files.deabsolute_path(final_dir),
|
||||
}
|
||||
|
||||
|
||||
def run_install_hook(plugin_name: str):
|
||||
return plugins.call_plugin_hook(plugin_name, "install")
|
||||
|
||||
|
||||
def get_marketplace_index() -> dict[str, Any]:
|
||||
"""Return the plugin index plus installed marketplace keys."""
|
||||
index_data = fetch_plugin_index()
|
||||
|
|
@ -200,4 +223,4 @@ def fetch_plugin_index() -> dict:
|
|||
req = urllib.request.Request(index_url, headers={"User-Agent": "AgentZero"})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
return data
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -61,36 +61,36 @@
|
|||
<div class="pi-hero-manage">
|
||||
<template x-if="$store.pluginInstallStore.installedPluginInfo.has_main_screen">
|
||||
<button type="button" class="button"
|
||||
@click="$store.pluginInstallStore.manageOpenPlugin()">
|
||||
@click="$store.pluginInstallStore.handleOpenPlugin()">
|
||||
<span class="icon material-symbols-outlined">dashboard</span> Open
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="$store.pluginInstallStore.installedPluginInfo.has_config_screen">
|
||||
<button type="button" class="button"
|
||||
@click="$store.pluginInstallStore.manageOpenConfig()">
|
||||
@click="$store.pluginInstallStore.handleOpenConfig()">
|
||||
<span class="icon material-symbols-outlined">settings</span> Config
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="$store.pluginInstallStore.installedPluginInfo.has_readme">
|
||||
<button type="button" class="button"
|
||||
@click="$store.pluginInstallStore.manageOpenDoc('readme')">
|
||||
@click="$store.pluginInstallStore.handleOpenDoc('readme')">
|
||||
<span class="icon material-symbols-outlined">description</span> README
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="$store.pluginInstallStore.installedPluginInfo.has_license">
|
||||
<button type="button" class="button"
|
||||
@click="$store.pluginInstallStore.manageOpenDoc('license')">
|
||||
@click="$store.pluginInstallStore.handleOpenDoc('license')">
|
||||
<span class="icon material-symbols-outlined">gavel</span> License
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="$store.pluginInstallStore.installedPluginInfo.has_init_script">
|
||||
<button type="button" class="button"
|
||||
@click="$store.pluginInstallStore.manageOpenInit()">
|
||||
@click="$store.pluginInstallStore.handleOpenInit()">
|
||||
<span class="icon material-symbols-outlined">terminal</span> Init
|
||||
</button>
|
||||
</template>
|
||||
<button type="button" class="button"
|
||||
@click="$store.pluginInstallStore.manageOpenInfo()">
|
||||
@click="$store.pluginInstallStore.handleOpenInfo()">
|
||||
<span class="icon material-symbols-outlined">info</span> Info
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -135,9 +135,18 @@
|
|||
</template>
|
||||
<template x-if="$store.pluginInstallStore.selectedPlugin.installed && $store.pluginInstallStore.installedPluginInfo?.is_custom">
|
||||
<button type="button" class="pi-btn-uninstall"
|
||||
@click="$confirmClick($event, () => $store.pluginInstallStore.manageDeletePlugin())">
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
<span>Uninstall</span>
|
||||
@click="$confirmClick($event, () => $store.pluginInstallStore.handleDeletePlugin())"
|
||||
:disabled="$store.pluginInstallStore.loading">
|
||||
<span class="pi-btn-loading" x-show="$store.pluginInstallStore.loading">
|
||||
<span class="spinner"></span>
|
||||
<span x-text="$store.pluginInstallStore.loadingMessage || 'Uninstalling...'"></span>
|
||||
</span>
|
||||
<span x-show="!$store.pluginInstallStore.loading">
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</span>
|
||||
<span x-show="!$store.pluginInstallStore.loading">
|
||||
<span>Uninstall</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="$store.pluginInstallStore.selectedPlugin.discussion">
|
||||
|
|
@ -416,6 +425,11 @@
|
|||
color: #fff;
|
||||
}
|
||||
|
||||
.pi-btn-uninstall:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pi-btn-uninstall .material-symbols-outlined {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -452,38 +452,43 @@ const model = {
|
|||
},
|
||||
|
||||
|
||||
manageOpenPlugin() {
|
||||
handleOpenPlugin() {
|
||||
const info = this.installedPluginInfo;
|
||||
if (!info || !info.name || !info.has_main_screen) return;
|
||||
openModal(`/plugins/${info.name}/webui/main.html`);
|
||||
},
|
||||
|
||||
async manageOpenConfig() {
|
||||
async handleOpenConfig() {
|
||||
if (this.installedPluginInfo) {
|
||||
await pluginListStore.openPluginConfig(this.installedPluginInfo);
|
||||
}
|
||||
},
|
||||
|
||||
async manageOpenDoc(doc) {
|
||||
async handleOpenDoc(doc) {
|
||||
if (this.installedPluginInfo) {
|
||||
await pluginListStore.openPluginDoc(this.installedPluginInfo, doc);
|
||||
}
|
||||
},
|
||||
|
||||
manageOpenInfo() {
|
||||
handleOpenInfo() {
|
||||
if (this.installedPluginInfo) {
|
||||
pluginListStore.openPluginInfo(this.installedPluginInfo);
|
||||
}
|
||||
},
|
||||
|
||||
manageOpenInit() {
|
||||
handleOpenInit() {
|
||||
if (this.installedPluginInfo) {
|
||||
pluginInitStore.open(this.installedPluginInfo);
|
||||
}
|
||||
},
|
||||
|
||||
async manageDeletePlugin() {
|
||||
if (this.installedPluginInfo) {
|
||||
async handleDeletePlugin() {
|
||||
if (!this.installedPluginInfo) return;
|
||||
|
||||
try {
|
||||
this.loading = true;
|
||||
this.loadingMessage = "Uninstalling plugin...";
|
||||
|
||||
await pluginListStore.deletePlugin(this.installedPluginInfo);
|
||||
const currentPlugin = this.selectedPlugin;
|
||||
if (currentPlugin) {
|
||||
|
|
@ -493,6 +498,9 @@ const model = {
|
|||
);
|
||||
}
|
||||
this.installedPluginInfo = null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.loadingMessage = "";
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ save_plugin_config(
|
|||
/a0/usr/plugins/<name>/
|
||||
plugin.yaml # Required manifest
|
||||
initialize.py # Optional one-time setup script
|
||||
hooks.py # Optional framework runtime hook functions
|
||||
default_config.yaml # Optional default settings fallback
|
||||
README.md # Optional, shown in Plugin List UI
|
||||
LICENSE # Optional, shown in Plugin List UI
|
||||
|
|
@ -195,7 +196,6 @@ save_plugin_config(
|
|||
```
|
||||
|
||||
## Plugin Initialization Script (`initialize.py`)
|
||||
|
||||
If your plugin requires one-time setup (e.g., installing dependencies, downloading models), add an `initialize.py` at the plugin root:
|
||||
|
||||
```python
|
||||
|
|
@ -220,6 +220,26 @@ if __name__ == "__main__":
|
|||
|
||||
Users trigger it via the **Init** button in the Plugin List UI. Return `0` on success, non-zero on failure.
|
||||
|
||||
## Runtime Hooks (`hooks.py`)
|
||||
If your plugin needs framework-internal hook points, add a `hooks.py` file at the plugin root. The framework can call exported functions by name via `helpers.plugins.call_plugin_hook(...)`.
|
||||
|
||||
- `hooks.py` runs inside the **Agent Zero framework runtime**, not the separate agent execution environment.
|
||||
- Use it for things like install hooks, plugin registration work, cache setup, file preparation, or other internal framework operations.
|
||||
- Hook functions may be sync or async.
|
||||
- Current example: the plugin installer calls `install()` in `hooks.py` after placing a plugin in `usr/plugins/`.
|
||||
|
||||
### Environment targeting rules
|
||||
- If `hooks.py` runs `sys.executable -m pip install ...`, it installs into the same Python environment that is running Agent Zero.
|
||||
- That is correct for dependencies needed by the plugin inside the framework runtime.
|
||||
- If the dependency is meant for the separate agent runtime or for OS-level tools, do **not** assume the current environment is correct.
|
||||
|
||||
Instead, explicitly switch targets in a subprocess:
|
||||
- invoke the exact Python interpreter for the target runtime
|
||||
- activate the target virtualenv in the subprocess before running `pip`
|
||||
- run the relevant OS package manager from a subprocess configured for the intended environment
|
||||
|
||||
In Docker, this usually means `hooks.py` affects `/opt/venv-a0` unless you intentionally target `/opt/venv` or another environment.
|
||||
|
||||
---
|
||||
|
||||
## Community Plugin: GitHub Repo + Plugin Index Submission
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ export function toggle_area(area, enabled) {
|
|||
enabledAreas.set(area, !!enabled);
|
||||
}
|
||||
|
||||
export function has(area, key) {
|
||||
if (!isEnabled(area)) return false;
|
||||
const areaCache = cache.get(area);
|
||||
if (!areaCache) return false;
|
||||
return areaCache.has(key);
|
||||
}
|
||||
|
||||
export function add(area, key, data) {
|
||||
if (!isEnabled(area)) return;
|
||||
let areaCache = cache.get(area);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue