refactor: consolidate module loading utilities and add plugin lifecycle improvements
- Move load_classes_from_file and load_classes_from_folder from extract_tools to new modules helper - Update all imports across api, extension, files, and plugins to use helpers.modules - Add namespace purging to refresh_plugin_modules for selective plugin reload on Python changes - Implement embed trimming in history based on model config max_embeds and vision support - Add pre_update hook documentation to plugin
frdel committed
Mar 23, 2026 at 21:13 UTC
89d4b8913f400d92ac00e4d143c17d3e76ad9f25
25 files changed
+417
-178
docs/agents/AGENTS.plugins.md
+4
-2
@@ -116,12 +116,14 @@ Design guidance:
116
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(...)`.
117
118
- `hooks.py` runs inside the **Agent Zero framework runtime and Python environment**, not the separate agent execution environment.
119
-- 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.
119
+- Use it for framework-internal operations such as install-time setup, pre-update cleanup or preparation, plugin registration work, filesystem preparation, cache updates, or other tasks that need access to Agent Zero internals.
120
- Hook functions may be synchronous or async. Async hooks are awaited by the framework.
121
- Hook modules are cached until plugin caches are cleared, so changes may require a plugin refresh/reload cycle.
122
- Plugin hooks should be cleanup-safe. A plugin should not leave behind permanent system modifications, symlinks, files outside its owned paths, or background services that survive plugin removal unless that behavior is explicitly part of the user-facing contract.
123
124
-Current example: the plugin installer calls `install()` from `hooks.py` after a plugin is copied into place.
124
+Current built-in usage:
125
+- the plugin installer calls `install()` from `hooks.py` after a plugin is copied into place
126
+- the plugin updater calls `pre_update()` from `hooks.py` immediately before pulling new plugin code into place
127
128
### Runtime and dependency implications
129
docs/developer/plugins.md
+4
-2
@@ -137,14 +137,16 @@ framework patches, or unmanaged files outside plugin-owned locations.
137
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(...)`.
138
139
- `hooks.py` executes inside the **Agent Zero framework runtime and Python environment**.
140
-- 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.
140
+- Use it for framework-internal operations such as install hooks, pre-update hooks, registration, cache preparation, file setup, or other work that needs direct access to framework internals.
141
- Hook functions may be synchronous or async.
142
- Hook modules are cached, so edits may require a plugin refresh or cache clear before changes are picked up.
143
- Hooks should be reversible and cleanup-safe. Prefer plugin-owned paths and framework-managed state over permanent system modifications.
144
145
Use `execute.py` when the user should explicitly decide when the operation runs. Use `hooks.py` or lifecycle extensions when the work belongs to framework-managed behavior.
146
147
-Current built-in usage: the plugin installer calls `install()` from `hooks.py` after copying a plugin into place.
147
+Current built-in usage:
148
+- the plugin installer calls `install()` from `hooks.py` after copying a plugin into place
149
+- the plugin updater calls `pre_update()` from `hooks.py` immediately before pulling new plugin code into place
150
151
### Dependency and environment behavior
152
helpers/api.py
+1
-1
@@ -217,7 +217,7 @@ def csrf_protect(f):
217
218
219
def register_api_route(app: Flask, lock: ThreadLockType) -> None:
220
- from helpers.extract_tools import load_classes_from_file
220
+ from helpers.modules import load_classes_from_file
221
from helpers import plugins
222
223
async def _dispatch(path: str) -> BaseResponse:
helpers/extension.py
+2
-2
@@ -1,6 +1,6 @@
1
from abc import abstractmethod
2
from typing import Any, Awaitable, Type, cast
3
-from helpers import extract_tools, files
3
+from helpers import modules, files
4
from helpers import cache, subagents
5
from typing import TYPE_CHECKING
6
from functools import wraps
@@ -316,7 +316,7 @@ def _get_extensions(folder: str):
316
if not files.exists(folder):
317
return []
318
319
- classes = extract_tools.load_classes_from_folder(folder, "*", Extension)
319
+ classes = modules.load_classes_from_folder(folder, "*", Extension)
320
cache.add(_EXTENSIONS_CACHE_AREA, folder, classes)
321
return classes
322
helpers/extract_tools.py
+17
-108
@@ -1,14 +1,10 @@
1
-import re, os, importlib, importlib.util, inspect
2
-from types import ModuleType
3
-from typing import Any, Type, TypeVar
4
-from .dirty_json import DirtyJson
5
-from .files import get_abs_path, deabsolute_path
6
-import regex
7
-from fnmatch import fnmatch
8
-import inspect
1
2
+from .dirty_json import DirtyJson
3
+import regex, re
4
+from helpers.modules import load_classes_from_file, load_classes_from_folder # keep here for backwards compatibility
5
+from typing import Any
6
11
-def json_parse_dirty(json:str) -> dict[str,Any] | None:
7
+def json_parse_dirty(json: str) -> dict[str, Any] | None:
8
if not json or not isinstance(json, str):
9
return None
10
@@ -16,25 +12,28 @@ def json_parse_dirty(json:str) -> dict[str,Any] | None:
12
if ext_json:
13
try:
14
data = DirtyJson.parse_string(ext_json)
19
- if isinstance(data,dict): return data
15
+ if isinstance(data, dict):
16
+ return data
17
except Exception:
18
# If parsing fails, return None instead of crashing
19
return None
20
return None
21
22
+
23
def extract_json_object_string(content):
26
- start = content.find('{')
24
+ start = content.find("{")
25
if start == -1:
26
return ""
27
28
# Find the first '{'
31
- end = content.rfind('}')
29
+ end = content.rfind("}")
30
if end == -1:
31
# If there's no closing '}', return from start to the end
32
return content[start:]
33
else:
34
# If there's a closing '}', return the substring from start to end
37
- return content[start:end+1]
35
+ return content[start : end + 1]
36
+
37
38
def extract_json_string(content):
39
# Regular expression pattern to match a JSON object
@@ -49,106 +48,16 @@ def extract_json_string(content):
48
else:
49
return ""
50
51
+
52
def fix_json_string(json_string):
53
# Function to replace unescaped line breaks within JSON string values
54
def replace_unescaped_newlines(match):
55
- return match.group(0).replace('\n', '\\n')
55
+ return match.group(0).replace("\n", "\\n")
56
57
# Use regex to find string values and apply the replacement function
58
- fixed_string = re.sub(r'(?<=: ")(.*?)(?=")', replace_unescaped_newlines, json_string, flags=re.DOTALL)
59
- return fixed_string
60
-
61
-
62
-T = TypeVar('T') # Define a generic type variable
63
-
64
-def import_module(file_path: str) -> ModuleType:
65
- # Handle file paths with periods in the name using importlib.util
66
- abs_path = get_abs_path(file_path)
67
- module_name = os.path.basename(abs_path).replace('.py', '')
68
-
69
- # Create the module spec and load the module
70
- spec = importlib.util.spec_from_file_location(module_name, abs_path)
71
- if spec is None or spec.loader is None:
72
- raise ImportError(f"Could not load module from {abs_path}")
73
-
74
- module = importlib.util.module_from_spec(spec)
75
- spec.loader.exec_module(module)
76
- return module
77
-
78
-def load_classes_from_folder(folder: str, name_pattern: str, base_class: Type[T], one_per_file: bool = True) -> list[Type[T]]:
79
- classes = []
80
- abs_folder = get_abs_path(folder)
81
-
82
- # Get all .py files in the folder that match the pattern, sorted alphabetically
83
- py_files = sorted(
84
- [file_name for file_name in os.listdir(abs_folder) if fnmatch(file_name, name_pattern) and file_name.endswith(".py")]
58
+ fixed_string = re.sub(
59
+ r'(?<=: ")(.*?)(?=")', replace_unescaped_newlines, json_string, flags=re.DOTALL
60
)
61
+ return fixed_string
62
87
- # Iterate through the sorted list of files
88
- for file_name in py_files:
89
- file_path = os.path.join(abs_folder, file_name)
90
- # Use the new import_module function
91
- module = import_module(file_path)
92
-
93
- # Get all classes in the module
94
- class_list = inspect.getmembers(module, inspect.isclass)
95
-
96
- # Filter for classes that are subclasses of the given base_class
97
- # iterate backwards to skip imported superclasses
98
- for cls in reversed(class_list):
99
- if cls[1] is not base_class and issubclass(cls[1], base_class):
100
- classes.append(cls[1])
101
- if one_per_file:
102
- break
103
-
104
- return classes
105
-
106
-def load_classes_from_file(file: str, base_class: type[T], one_per_file: bool = True) -> list[type[T]]:
107
- classes = []
108
- # Use the new import_module function
109
- module = import_module(file)
110
-
111
- # Get all classes in the module
112
- class_list = inspect.getmembers(module, inspect.isclass)
113
-
114
- # Filter for classes that are subclasses of the given base_class
115
- # iterate backwards to skip imported superclasses
116
- for cls in reversed(class_list):
117
- if cls[1] is not base_class and issubclass(cls[1], base_class):
118
- classes.append(cls[1])
119
- if one_per_file:
120
- break
121
-
122
- return classes
123
-
124
-def safe_call(func, *args, **kwargs):
125
- sig = inspect.signature(func)
126
-
127
- bound_args = []
128
- bound_kwargs = {}
129
-
130
- params = sig.parameters
131
-
132
- # Check if function accepts *args / **kwargs
133
- accepts_var_args = any(p.kind == p.VAR_POSITIONAL for p in params.values())
134
- accepts_var_kwargs = any(p.kind == p.VAR_KEYWORD for p in params.values())
135
-
136
- # Handle positional args
137
- if accepts_var_args:
138
- bound_args = args
139
- else:
140
- max_positional = sum(
141
- 1 for p in params.values()
142
- if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
143
- )
144
- bound_args = args[:max_positional]
145
-
146
- # Handle kwargs
147
- if accepts_var_kwargs:
148
- bound_kwargs = kwargs
149
- else:
150
- bound_kwargs = {
151
- k: v for k, v in kwargs.items() if k in params
152
- }
63
154
- return func(*bound_args, **bound_kwargs)
\ No newline at end of file
helpers/files.py
+2
-2
@@ -47,9 +47,9 @@ def load_plugin_variables(
47
48
if plugin_file and exists(plugin_file):
49
50
- from helpers import extract_tools
50
+ from helpers import modules
51
52
- classes = extract_tools.load_classes_from_file(
52
+ classes = modules.load_classes_from_file(
53
plugin_file, VariablesPlugin, one_per_file=False
54
)
55
for cls in classes:
helpers/functions.py
new
+32
@@ -0,0 +1,32 @@
1
+import inspect
2
+
3
+def safe_call(func, *args, **kwargs):
4
+ sig = inspect.signature(func)
5
+
6
+ bound_args = []
7
+ bound_kwargs = {}
8
+
9
+ params = sig.parameters
10
+
11
+ # Check if function accepts *args / **kwargs
12
+ accepts_var_args = any(p.kind == p.VAR_POSITIONAL for p in params.values())
13
+ accepts_var_kwargs = any(p.kind == p.VAR_KEYWORD for p in params.values())
14
+
15
+ # Handle positional args
16
+ if accepts_var_args:
17
+ bound_args = args
18
+ else:
19
+ max_positional = sum(
20
+ 1
21
+ for p in params.values()
22
+ if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
23
+ )
24
+ bound_args = args[:max_positional]
25
+
26
+ # Handle kwargs
27
+ if accepts_var_kwargs:
28
+ bound_kwargs = kwargs
29
+ else:
30
+ bound_kwargs = {k: v for k, v in kwargs.items() if k in params}
31
+
32
+ return func(*bound_args, **bound_kwargs)
helpers/history.py
+88
-13
@@ -8,6 +8,8 @@ from typing import Coroutine, Literal, TypedDict, cast, Union, Dict, List, Any
8
from helpers import messages, tokens, settings, call_llm
9
from enum import Enum
10
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage, AIMessage
11
+from plugins._model_config.helpers.model_config import get_chat_model_config
12
+
13
14
BULK_MERGE_COUNT = 3
15
TOPICS_MERGE_COUNT = 3
@@ -316,7 +318,7 @@ class History(Record):
318
)
319
320
def is_over_limit(self):
319
- limit = _get_ctx_size_for_history()
321
+ limit = self._get_ctx_size_for_history()
322
total = self.get_tokens()
323
return total > limit
324
@@ -341,12 +343,70 @@ class History(Record):
343
self.current = Topic(history=self)
344
345
def output(self) -> list[OutputMessage]:
346
+ self.trim_embeds(self._get_max_embeds())
347
result: list[OutputMessage] = []
348
result += [m for b in self.bulks for m in b.output()]
349
result += [m for t in self.topics for m in t.output()]
350
result += self.current.output()
351
return result
352
353
+ def trim_embeds(self, max_embeds: int) -> int:
354
+ if max_embeds == -1:
355
+ return 0
356
+
357
+ embeds_count = 0
358
+ removed = 0
359
+
360
+ for record in reversed(self.bulks + self.topics + [self.current]):
361
+ embeds_count, removed_now = self._trim_embeds_in_record(record, embeds_count, max_embeds)
362
+ removed += removed_now
363
+
364
+ return removed
365
+
366
+ def remove_all_embeds(self) -> int:
367
+ return self.trim_embeds(0)
368
+
369
+ def _trim_embeds_in_record(
370
+ self, record: Record, embeds_count: int, max_embeds: int
371
+ ) -> tuple[int, int]:
372
+ if isinstance(record, Message):
373
+ if record.summary:
374
+ return embeds_count, 0
375
+
376
+ if not _is_raw_message(record.content):
377
+ return embeds_count, 0
378
+
379
+ raw_message = cast(dict[str, Any], record.content)
380
+ raw_content = raw_message.get("raw_content", [])
381
+ if not isinstance(raw_content, list):
382
+ return embeds_count, 0
383
+
384
+ embeds_in_message = sum(1 for item in raw_content if _is_embedded_data(item))
385
+ if embeds_in_message <= 0:
386
+ return embeds_count, 0
387
+
388
+ if embeds_count + embeds_in_message > max_embeds:
389
+ record.set_summary("embedded data removed")
390
+ return embeds_count + embeds_in_message, embeds_in_message
391
+
392
+ return embeds_count + embeds_in_message, 0
393
+
394
+ if isinstance(record, Topic):
395
+ removed = 0
396
+ for message in reversed(record.messages):
397
+ embeds_count, removed_now = self._trim_embeds_in_record(message, embeds_count, max_embeds)
398
+ removed += removed_now
399
+ return embeds_count, removed
400
+
401
+ if isinstance(record, Bulk):
402
+ removed = 0
403
+ for nested in reversed(record.records):
404
+ embeds_count, removed_now = self._trim_embeds_in_record(nested, embeds_count, max_embeds)
405
+ removed += removed_now
406
+ return embeds_count, removed
407
+
408
+ return embeds_count, 0
409
+
410
@staticmethod
411
def from_dict(data: dict, history: "History"):
412
history.counter = data.get("counter", 0)
@@ -370,7 +430,7 @@ class History(Record):
430
431
async def compress(self):
432
compressed = False
373
- total = _get_ctx_size_for_history()
433
+ total = self._get_ctx_size_for_history()
434
curr, hist, bulk = (
435
self.get_current_topic_tokens(),
436
self.get_topics_tokens(),
@@ -472,6 +532,23 @@ class History(Record):
532
await bulk.summarize()
533
return bulk
534
535
+ def _get_ctx_size_for_history(self) -> int:
536
+ chat_cfg = get_chat_model_config(self.agent)
537
+ ctx_length = int(chat_cfg.get("ctx_length", 128000))
538
+ ctx_history = float(chat_cfg.get("ctx_history", 0.7))
539
+ return int(ctx_length * ctx_history)
540
+
541
+ def _get_max_embeds(self) -> int:
542
+ chat_cfg = get_chat_model_config(self.agent)
543
+ if not chat_cfg.get("vision", False):
544
+ return 0
545
+
546
+ max_embeds = int(chat_cfg.get("max_embeds", 10))
547
+ if max_embeds <= 0:
548
+ max_embeds = -1
549
+ return max_embeds
550
+
551
+
552
553
def deserialize_history(json_data: str, agent) -> History:
554
history = History(agent=agent)
@@ -481,14 +558,6 @@ def deserialize_history(json_data: str, agent) -> History:
558
return history
559
560
484
-def _get_ctx_size_for_history() -> int:
485
- from plugins._model_config.helpers.model_config import get_chat_model_config
486
- chat_cfg = get_chat_model_config()
487
- ctx_length = int(chat_cfg.get("ctx_length", 128000))
488
- ctx_history = float(chat_cfg.get("ctx_history", 0.7))
489
- return int(ctx_length * ctx_history)
490
-
491
-
561
def _stringify_output(output: OutputMessage, ai_label="ai", human_label="human"):
562
return f'{ai_label if output["ai"] else human_label}: {_stringify_content(output["content"])}'
563
@@ -500,8 +569,9 @@ def _stringify_content(content: MessageContent) -> str:
569
570
# raw messages return preview or trimmed json
571
if _is_raw_message(content):
503
- preview: str = content.get("preview", "") # type: ignore
504
- if preview:
572
+ raw_message = cast(dict[str, Any], content)
573
+ preview = raw_message.get("preview")
574
+ if isinstance(preview, str) and preview:
575
return preview
576
text = _json_dumps(content)
577
if len(text) > RAW_MESSAGE_OUTPUT_TEXT_TRIM:
@@ -516,7 +586,8 @@ def _output_content_langchain(content: MessageContent):
586
if isinstance(content, str):
587
return content
588
if _is_raw_message(content):
519
- return content["raw_content"] # type: ignore
589
+ raw_content = cast(dict[str, Any], content).get("raw_content")
590
+ return raw_content if raw_content is not None else _json_dumps(content)
591
try:
592
return _json_dumps(content)
593
except Exception as e:
@@ -601,6 +672,10 @@ def _is_raw_message(obj: object) -> bool:
672
return isinstance(obj, Mapping) and "raw_content" in obj
673
674
675
+def _is_embedded_data(obj: object) -> bool:
676
+ return isinstance(obj, Mapping) and obj.get("type") == "image_url"
677
+
678
+
679
def _json_dumps(obj):
680
return json.dumps(obj, ensure_ascii=False)
681
helpers/modules.py
new
+97
@@ -0,0 +1,97 @@
1
+
2
+import re, os, importlib, importlib.util, inspect, sys
3
+from types import ModuleType
4
+from typing import Any, Type, TypeVar
5
+from helpers.files import get_abs_path
6
+from fnmatch import fnmatch
7
+
8
+
9
+T = TypeVar("T") # Define a generic type variable
10
+
11
+
12
+def import_module(file_path: str) -> ModuleType:
13
+ # Handle file paths with periods in the name using importlib.util
14
+ abs_path = get_abs_path(file_path)
15
+ module_name = os.path.basename(abs_path).replace(".py", "")
16
+
17
+ # Create the module spec and load the module
18
+ spec = importlib.util.spec_from_file_location(module_name, abs_path)
19
+ if spec is None or spec.loader is None:
20
+ raise ImportError(f"Could not load module from {abs_path}")
21
+
22
+ module = importlib.util.module_from_spec(spec)
23
+ spec.loader.exec_module(module)
24
+ return module
25
+
26
+
27
+def load_classes_from_folder(
28
+ folder: str, name_pattern: str, base_class: Type[T], one_per_file: bool = True
29
+) -> list[Type[T]]:
30
+ classes = []
31
+ abs_folder = get_abs_path(folder)
32
+
33
+ # Get all .py files in the folder that match the pattern, sorted alphabetically
34
+ py_files = sorted(
35
+ [
36
+ file_name
37
+ for file_name in os.listdir(abs_folder)
38
+ if fnmatch(file_name, name_pattern) and file_name.endswith(".py")
39
+ ]
40
+ )
41
+
42
+ # Iterate through the sorted list of files
43
+ for file_name in py_files:
44
+ file_path = os.path.join(abs_folder, file_name)
45
+ # Use the new import_module function
46
+ module = import_module(file_path)
47
+
48
+ # Get all classes in the module
49
+ class_list = inspect.getmembers(module, inspect.isclass)
50
+
51
+ # Filter for classes that are subclasses of the given base_class
52
+ # iterate backwards to skip imported superclasses
53
+ for cls in reversed(class_list):
54
+ if cls[1] is not base_class and issubclass(cls[1], base_class):
55
+ classes.append(cls[1])
56
+ if one_per_file:
57
+ break
58
+
59
+ return classes
60
+
61
+
62
+def load_classes_from_file(
63
+ file: str, base_class: type[T], one_per_file: bool = True
64
+) -> list[type[T]]:
65
+ classes = []
66
+ # Use the new import_module function
67
+ module = import_module(file)
68
+
69
+ # Get all classes in the module
70
+ class_list = inspect.getmembers(module, inspect.isclass)
71
+
72
+ # Filter for classes that are subclasses of the given base_class
73
+ # iterate backwards to skip imported superclasses
74
+ for cls in reversed(class_list):
75
+ if cls[1] is not base_class and issubclass(cls[1], base_class):
76
+ classes.append(cls[1])
77
+ if one_per_file:
78
+ break
79
+
80
+ return classes
81
+
82
+
83
+def purge_namespace(namespace: str):
84
+ to_delete = [
85
+ name
86
+ for name in sys.modules
87
+ if name == namespace or name.startswith(namespace + ".")
88
+ ]
89
+
90
+ # delete deepest first just to be tidy
91
+ to_delete.sort(key=lambda n: n.count("."), reverse=True)
92
+
93
+ for name in to_delete:
94
+ del sys.modules[name]
95
+
96
+ importlib.invalidate_caches()
97
+ return to_delete
\ No newline at end of file
helpers/plugins.py
+31
-14
@@ -26,7 +26,8 @@ from helpers import (
26
cache,
27
extension,
28
watchdog,
29
- extract_tools,
29
+ modules,
30
+ functions,
31
)
32
from pydantic import BaseModel, Field
33
@@ -129,7 +130,8 @@ def register_watchdogs():
130
if plugin_name and plugin_name not in plugin_names:
131
plugin_names.append(plugin_name)
132
print_style.PrintStyle.debug("Plugins watchdog triggered", plugin_names)
132
- after_plugin_change(plugin_names or None)
133
+ python_change = any(path.endswith('.py') for path, _event in events)
134
+ after_plugin_change(plugin_names or None, python_change=python_change)
135
136
relevant_patterns = ["**/extensions/**/*", TOGGLE_FILE_PATTERN, HOOKS_SCRIPT]
137
@@ -175,15 +177,31 @@ def register_watchdogs():
177
178
179
@extension.extensible
178
-def after_plugin_change(plugin_names: list[str] | None = None):
179
- clear_plugin_cache()
180
+def after_plugin_change(plugin_names: list[str] | None = None, python_change:bool=False):
181
+ clear_plugin_cache(plugin_names)
182
+ if python_change:
183
+ refresh_plugin_modules(plugin_names)
184
send_frontend_reload_notification(plugin_names)
185
186
183
-def clear_plugin_cache():
187
+def refresh_plugin_modules(plugin_names: list[str] | None = None):
188
+ if plugin_names:
189
+ clear_plugins = any(name.startswith("_") for name in plugin_names)
190
+ clear_usr_plugins = any(not name.startswith("_") for name in plugin_names)
191
+ if clear_plugins:
192
+ modules.purge_namespace("plugins")
193
+ if clear_usr_plugins:
194
+ modules.purge_namespace("usr.plugins")
195
+ else:
196
+ modules.purge_namespace("plugins")
197
+ modules.purge_namespace("usr.plugins")
198
+
199
+
200
+def clear_plugin_cache(plugin_names: list[str] | None = None):
201
areas = ["*(plugins)*", "*(extensions)*", "*(api)*"]
202
for area in areas:
203
cache.clear(area)
204
+
205
from helpers.websocket_manager import send_data
206
207
DeferredTask().start_task(
@@ -378,7 +396,7 @@ def delete_plugin(plugin_name: str):
396
raise ValueError("Only custom plugins can be deleted")
397
398
# delete additional plugin folders
381
- assets = find_plugin_assets("", plugin_name=plugin_name)
399
+ assets = [asset for asset in find_plugin_assets("", plugin_name=plugin_name) if not asset["path"].startswith(plugin_dir)]
400
for asset in assets:
401
files.delete_dir(asset["path"])
402
@@ -386,10 +404,13 @@ def delete_plugin(plugin_name: str):
404
[plugin_name]
405
) # send before deletion to properly check the extensions, second notification will be skipped automatically
406
407
+ # does it have python files?
408
+ python_change = bool(files.find_existing_paths_by_pattern(plugin_dir+"/**/*.py"))
409
+
410
# delete main plugin folder
411
files.delete_dir(plugin_dir)
412
392
- after_plugin_change([plugin_name])
413
+ after_plugin_change([plugin_name], python_change=python_change)
414
415
416
def get_plugin_paths(*subpaths: str) -> List[str]:
@@ -854,9 +875,7 @@ def call_plugin_hook(
875
return default # plugin directory not found, skip hooks
876
hooks_script = files.get_abs_path(plugin_dir, HOOKS_SCRIPT)
877
hooks = (
857
- extract_tools.import_module(hooks_script)
858
- if files.exists(hooks_script)
859
- else None
878
+ modules.import_module(hooks_script) if files.exists(hooks_script) else None
879
)
880
cache.add(HOOKS_CACHE_AREA, plugin_name, hooks)
881
else:
@@ -870,11 +889,9 @@ def call_plugin_hook(
889
return default
890
891
if asyncio.iscoroutinefunction(hook):
873
- return asyncio.run(
874
- extract_tools.safe_call(hook, *args, default=default, **kwargs)
875
- )
892
+ return asyncio.run(functions.safe_call(hook, *args, default=default, **kwargs))
893
877
- return extract_tools.safe_call(hook, *args, default=default, **kwargs)
894
+ return functions.safe_call(hook, *args, default=default, **kwargs)
895
896
897
def _apply_defaults_from_env(plugin_name: str, config: dict[str, Any]):
helpers/ws.py
+1
-1
@@ -119,7 +119,7 @@ def register_ws_namespace(
119
webapp: Flask,
120
lock: ThreadLockType,
121
) -> None:
122
- from helpers.extract_tools import load_classes_from_file
122
+ from helpers.modules import load_classes_from_file
123
from helpers import plugins, runtime
124
125
def _resolve_handler(path: str) -> type[WsHandler] | None:
plugins/_error_retry/default_config.yaml
+2
-1
@@ -1 +1,2 @@
1
-retries: 1
\ No newline at end of file
1
+retries: 1
2
+try_clear_embeds: true
\ No newline at end of file
plugins/_error_retry/extensions/python/_functions/agent/Agent/handle_exception/end/_80_retry_critical_exception.py
+36
-1
@@ -1,10 +1,11 @@
1
import asyncio
2
from datetime import datetime, timezone
3
+import litellm
4
from helpers.extension import Extension
5
from agent import LoopData
6
from helpers.localization import Localization
7
from helpers.errors import RepairableException, HandledException
7
-from helpers import errors
8
+from helpers import errors, plugins
9
from helpers.print_style import PrintStyle
10
11
from plugins._error_retry.extensions.python._functions.agent.Agent.monologue.start._10_reset_critical_exception_counter import DATA_NAME_COUNTER
@@ -28,6 +29,7 @@ class RetryCriticalException(Extension):
29
30
counter = self.agent.get_data(DATA_NAME_COUNTER) or 0
31
if counter >= max_retries:
32
+ self.when_critical(data)
33
return
34
35
self.agent.set_data(DATA_NAME_COUNTER, counter + 1)
@@ -51,4 +53,37 @@ class RetryCriticalException(Extension):
53
54
data["exception"] = None
55
56
+
57
+ def when_critical(self, data: dict = {}):
58
+ if not self.agent:
59
+ return
60
+
61
+ self.try_clear_embeds(data)
62
+
63
+ def try_clear_embeds(self, data: dict = {}):
64
+ """Try to clear embeds before failing on LiteLLM errors"""
65
66
+ if not self.agent:
67
+ return
68
+
69
+ exc = data.get("exception")
70
+ if not isinstance(exc, litellm.exceptions.BadRequestError):
71
+ return
72
+
73
+ cfg = plugins.get_plugin_config("_error_retry", agent=self.agent) or {}
74
+ if not cfg.get("try_clear_embeds", False):
75
+ return
76
+
77
+ removed = self.agent.history.remove_all_embeds()
78
+ if removed <= 0:
79
+ return
80
+
81
+ data["exception"] = None
82
+ self.agent.context.log.log(
83
+ type="warning",
84
+ heading="Cleared embedded media from history",
85
+ content=f"Persistent LiteLLM bad request detected. Removed {removed} embedded media messages from history to help recover.",
86
+ )
87
+ PrintStyle(font_color="orange", padding=True).print(
88
+ f"Cleared {removed} embedded media messages from history after persistent LiteLLM bad request."
89
+ )
\ No newline at end of file
plugins/_error_retry/webui/config.html
+15
@@ -24,6 +24,21 @@
24
x-model.number="config.retries" />
25
</div>
26
</div>
27
+
28
+ <div class="field">
29
+ <div class="field-label">
30
+ <div class="field-title">Try clearing embedded media</div>
31
+ <div class="field-description">
32
+ If LiteLLM errors keep happening, the framework can remove embedded media from history and try again to help recover from stuck states.
33
+ </div>
34
+ </div>
35
+ <div class="field-control">
36
+ <label class="toggle">
37
+ <input type="checkbox" x-model="config.try_clear_embeds" x-init="if (config.try_clear_embeds === undefined || config.try_clear_embeds === null) config.try_clear_embeds = true" />
38
+ <span class="toggler"></span>
39
+ </label>
40
+ </div>
41
+ </div>
42
</div>
43
</template>
44
</div>
plugins/_model_config/default_config.yaml
+1
@@ -7,6 +7,7 @@ chat_model:
7
ctx_length: 128000
8
ctx_history: 0.7
9
vision: true
10
+ max_embeds: 10
11
rl_requests: 0
12
rl_input: 0
13
rl_output: 0
plugins/_model_config/webui/config.html
+13
-11
@@ -188,6 +188,19 @@
188
</label>
189
</div>
190
</div>
191
+ <template x-if="config.chat_model.vision">
192
+ <div class="field">
193
+ <div class="field-label">
194
+ <div class="field-title">Max embeds</div>
195
+ <div class="field-description">
196
+ Maximum number of embedded images used by the chat model. Set to 0 for unlimited.
197
+ </div>
198
+ </div>
199
+ <div class="field-control">
200
+ <input type="number" min="0" x-model.number="config.chat_model.max_embeds" x-init="if (!config.chat_model.max_embeds) config.chat_model.max_embeds = 10" />
201
+ </div>
202
+ </div>
203
+ </template>
204
</div>
205
</template>
206
@@ -258,17 +271,6 @@
271
</div>
272
</div>
273
261
- <!-- Browser HTTP Headers -->
262
- <template x-if="section.key === 'chat_model'">
263
- <div class="field field-full">
264
- <div class="field-label">
265
- <div class="field-title">Browser HTTP Headers</div>
266
- <div class="field-description">
267
- Custom HTTP headers sent with browser requests. The browser agent uses the main model. Format is KEY=VALUE, one per line.
268
- </div>
269
- </div>
270
- </div>
271
- </template>
274
275
</div>
276
</template>
plugins/_model_config/webui/main.html
-6
@@ -183,12 +183,6 @@
183
@change="preset.chat.kwargs = $store.modelConfig.textToKwargs(preset.chat._kwargs_text)"></textarea>
184
</div>
185
</div>
186
- <div class="field field-full">
187
- <div class="field-label">
188
- <div class="field-title">Browser HTTP Headers</div>
189
- <div class="field-description">Custom HTTP headers sent with browser requests. The browser agent uses the main model. Format is KEY=VALUE, one per line.</div>
190
- </div>
191
- </div>
186
187
<div class="preset-subheader">Utility Model <span style="opacity:0.5; font-size:0.75rem;">(optional — falls back to the configured Utility Model)</span></div>
188
<div class="field">
plugins/_plugin_installer/helpers/install.py
+19
-2
@@ -134,7 +134,11 @@ def install_from_zip(zip_path: str, original_filename: str | None = None) -> dic
134
files.delete_dir(dest)
135
raise
136
137
- after_plugin_change([plugin_name])
137
+
138
+ # does it have python files?
139
+ python_change = bool(files.find_existing_paths_by_pattern(dest+"/**/*.py"))
140
+
141
+ after_plugin_change([plugin_name], python_change=python_change)
142
143
return {
144
"success": True,
@@ -209,7 +213,10 @@ def install_from_git(url: str, token: str | None = None, plugin_name: str = "",
213
files.delete_dir(final_dir)
214
raise
215
212
- after_plugin_change([plugin_name])
216
+ # does it have python files?
217
+ python_change = bool(files.find_existing_paths_by_pattern(final_dir+"/**/*.py"))
218
+
219
+ after_plugin_change([plugin_name],python_change=python_change)
220
221
return {
222
"success": True,
@@ -232,6 +239,14 @@ def update_from_git(plugin_name: str) -> dict:
239
if not files.is_in_dir(plugin_dir, custom_plugins_dir):
240
raise ValueError("Only custom plugins can be updated")
241
242
+ try:
243
+ run_pre_update_hook(plugin_name)
244
+ except Exception as e:
245
+ print_style.PrintStyle.error(
246
+ f"Failed to run pre-update hook for {plugin_name}: {e}"
247
+ )
248
+ raise
249
+
250
try:
251
repo = git.update_repo(plugin_dir)
252
meta = plugins.get_plugin_meta(plugin_name)
@@ -268,6 +283,8 @@ def update_from_git(plugin_name: str) -> dict:
283
def run_install_hook(plugin_name: str):
284
return plugins.call_plugin_hook(plugin_name, "install")
285
286
+def run_pre_update_hook(plugin_name: str):
287
+ return plugins.call_plugin_hook(plugin_name, "pre_update")
288
289
def get_plugin_hub_index() -> dict[str, Any]:
290
"""Return the plugin index plus installed Plugin Hub keys."""
skills/a0-create-plugin/SKILL.md
+4
-2
@@ -275,9 +275,11 @@ Users trigger it from the Plugins UI. Treat it as a manual, rerunnable operation
275
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(...)`.
276
277
- `hooks.py` runs inside the **Agent Zero framework runtime**, not the separate agent execution environment.
278
-- Use it for things like install hooks, plugin registration work, cache setup, file preparation, or other internal framework operations.
278
+- Use it for things like install hooks, pre-update hooks, plugin registration work, cache setup, file preparation, or other internal framework operations.
279
+- Current built-in usage:
280
+ - the plugin installer calls `install()` in `hooks.py` after placing a plugin in `usr/plugins/`
281
+ - the plugin updater calls `pre_update()` in `hooks.py` immediately before pulling new plugin code into place
282
- Hook functions may be sync or async.
280
-- Current example: the plugin installer calls `install()` in `hooks.py` after placing a plugin in `usr/plugins/`.
283
- Hooks should be reversible and cleanup-safe. Prefer framework-managed state and plugin-owned paths over permanent system modifications.
284
285
### Environment targeting rules
skills/a0-debug-plugin/SKILL.md
+14
@@ -119,6 +119,20 @@ print('Done')
119
"
120
```
121
122
+If the `pre_update()` hook is not running before plugin updates:
123
+- Check the function is named exactly `pre_update`
124
+- Check for exceptions in the function
125
+- Manually trigger it in the **framework runtime** the same way:
126
+
127
+```bash
128
+cd /a0 && /opt/venv-a0/bin/python -c "
129
+import asyncio
130
+from helpers.plugins import call_plugin_hook
131
+asyncio.run(call_plugin_hook('<plugin_name>', 'pre_update'))
132
+print('Done')
133
+"
134
+```
135
+
136
---
137
138
## 8. Check Agent Zero logs
skills/a0-manage-plugin/SKILL.md
+1
-1
@@ -234,7 +234,7 @@ Or simply restart Agent Zero - on startup it re-scans `usr/plugins/` fresh.
234
235
## Update a Plugin
236
237
-> A dedicated update endpoint is being added to the framework. Until it lands, use the flow below.
237
+> The framework update flow now calls `pre_update()` from `hooks.py` immediately before pulling new plugin code into place, then re-runs `install()` after the update if that hook exists.
238
239
### Checking for updates
240
skills/a0-plugin-router/SKILL.md
+1
-1
@@ -76,7 +76,7 @@ always_enabled: false # forces ON, disables toggle (framework use only)
76
| `extensions/webui/<point>/` | HTML/JS injected into UI breakpoints |
77
| `webui/config.html` | Plugin settings UI |
78
| `webui/*.html`, `webui/*.js` | Full plugin pages and Alpine stores |
79
-| `hooks.py` | Framework runtime hooks (install, cache, registration) |
79
+| `hooks.py` | Framework runtime hooks (install, pre_update, cache, registration) |
80
| `execute.py` | User-triggered script (setup, maintenance, repair) |
81
| `default_config.yaml` | Settings defaults |
82
| `README.md` | Optional locally; strongly recommended for community plugins so Plugin Hub users can inspect the plugin |
skills/a0-review-plugin/SKILL.md
+1
-1
@@ -52,7 +52,7 @@ Inspect the plugin directory layout:
52
- [ ] If `agents/` exists: agent profiles with `<profile>/agent.yaml` (standard directory)
53
- [ ] If `conf/` exists: configuration files such as `model_providers.yaml` (standard directory)
54
- [ ] If `webui/config.html` exists: plugin must declare at least one `settings_sections` entry
55
-- [ ] If `hooks.py` exists: warn if it does NOT contain an `install` function (common oversight)
55
+- [ ] If `hooks.py` exists: review whether it defines the lifecycle hook functions the plugin appears to rely on, especially `install` and `pre_update` when the plugin needs install-time or update-time behavior
56
- [ ] If `execute.py` exists: check it has a `main()` function and `if __name__ == "__main__": sys.exit(main())`
57
- [ ] `LICENSE` at plugin root: Agent Zero does not require it for local plugins, but it is **required** at the repo root before submitting to the Plugin Index. If missing → **WARN** — `LICENSE absent — required for community contribution (Plugin Index); optional for local-only use`
58
- [ ] `default_config.yaml` (if present): valid YAML
skills/a0-review-plugin/checklists.md
+6
-1
@@ -206,7 +206,7 @@ save_plugin_config(
206
## hooks.py Environment Targeting
207
208
```python
209
-# hooks.py - install hook example
209
+# hooks.py - install/pre_update hook example
210
import subprocess
211
import sys
212
@@ -215,6 +215,11 @@ def install():
215
# This installs into the Agent Zero FRAMEWORK runtime (/opt/venv-a0)
216
subprocess.run([sys.executable, "-m", "pip", "install", "some-package==1.0.0"], check=True)
217
218
+def pre_update():
219
+ """Called by framework immediately before plugin update pulls new code into place."""
220
+ # This installs into the Agent Zero FRAMEWORK runtime (/opt/venv-a0)
221
+ subprocess.run([sys.executable, "-m", "pip", "install", "some-package==1.0.0"], check=True)
222
+
223
async def async_hook():
224
"""Async hooks are also supported."""
225
pass
tools/vision_load.py
+25
-6
@@ -1,7 +1,7 @@
1
import base64
2
from helpers.print_style import PrintStyle
3
from helpers.tool import Tool, Response
4
-from helpers import runtime, files, images
4
+from helpers import runtime, files, images, plugins
5
from mimetypes import guess_type
6
from helpers import history
7
@@ -15,9 +15,15 @@ class VisionLoad(Tool):
15
async def execute(self, paths: list[str] = [], **kwargs) -> Response:
16
17
self.images_dict = {}
18
+ self.loaded_paths: list[str] = []
19
+ self.skipped_paths: list[str] = []
20
template: list[dict[str, str]] = [] # type: ignore
21
20
- for path in paths:
22
+ max_embeds = self._get_max_embeds()
23
+ limited_paths = paths if max_embeds <= 0 else paths[-max_embeds:]
24
+ self.skipped_paths = paths[:-max_embeds] if max_embeds > 0 and len(paths) > max_embeds else []
25
+
26
+ for path in limited_paths:
27
if not await runtime.call_development_function(files.exists, str(path)):
28
continue
29
@@ -44,6 +50,7 @@ class VisionLoad(Tool):
50
51
# Construct the data URL (always JPEG after compression)
52
self.images_dict[path] = file_content_b64
53
+ self.loaded_paths.append(path)
54
except Exception as e:
55
self.images_dict[path] = None
56
PrintStyle().error(f"Error processing image {path}: {e}")
@@ -51,12 +58,24 @@ class VisionLoad(Tool):
58
59
return Response(message="dummy", break_loop=False)
60
61
+ def _get_max_embeds(self) -> int:
62
+ cfg = plugins.get_plugin_config("_model_config", agent=self.agent) or {}
63
+ chat_cfg = cfg.get("chat_model", {})
64
+ max_embeds = chat_cfg.get("max_embeds", 10)
65
+ return int(max_embeds or 0)
66
+
67
async def after_execution(self, response: Response, **kwargs):
68
69
# build image data messages for LLMs, or error message
70
content = []
71
+ loaded_summary = "\n".join(self.loaded_paths) if self.loaded_paths else "none"
72
+ skipped_summary = "\n".join(self.skipped_paths) if self.skipped_paths else "none"
73
+ summary = (
74
+ f"Loaded images:\n{loaded_summary}\n\n"
75
+ f"Skipped images (max {self._get_max_embeds()} loaded at a time according to model configuration):\n{skipped_summary}"
76
+ )
77
if self.images_dict:
59
- self.agent.hist_add_tool_result(self.name, f"Processed {len(self.images_dict)} images") # some model providers break when they get have images in message history without text
78
+ self.agent.hist_add_tool_result(self.name, summary)
79
for path, image in self.images_dict.items():
80
if image:
81
content.append(
@@ -78,13 +97,13 @@ class VisionLoad(Tool):
97
False, content=msg, tokens=TOKENS_ESTIMATE * len(content)
98
)
99
else:
81
- self.agent.hist_add_tool_result(self.name, "No images processed")
100
+ self.agent.hist_add_tool_result(self.name, summary if self.skipped_paths else "No images processed")
101
102
# print and log short version
103
message = (
104
"No images processed"
86
- if not self.images_dict
87
- else f"{len(self.images_dict)} images processed"
105
+ if not self.images_dict and not self.skipped_paths
106
+ else f"{len(self.loaded_paths)} images loaded, {len(self.skipped_paths)} skipped"
107
)
108
PrintStyle(
109
font_color="#1B4F72", background_color="white", padding=True, bold=True