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 &#x2014; 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