refactor: migrate extension system to use deep directory paths based on module and qualname

- Change @extensible decorator to generate extension paths from full module and qualname hierarchies instead of flattened names - Update extension path format from `{module}_{qualname}_{start|end}` to `_functions/<module>/<qualname>/{start|end}` - Move all extension files to new deep directory structure under `_functions/` to match new path format - Replace PathSpec.from_lines(GitWildMatchPattern, ...) with PathSpec.from_lines

frdel committed Mar 22, 2026 at 21:48 UTC 7e1d9ad2a4a3d186e337a89433ccc5128bdc754d
19 files changed +61 -22
docs/developer/extensions.md
+18 -1
@@ -77,7 +77,24 @@ The system prompt is built by multiple focused extensions in `extensions/python/
77 | `_13_secrets_prompt.py` | Secrets and variables | `@extensible` |
78 | `_14_project_prompt.py` | Project context | `@extensible` |
79
80 -Each extension exposes a top-level `build_prompt(agent)` function decorated with `@extensible`, which auto-creates `_start` and `_end` hooks. Plugins can hook into these to modify the prompt before or after it's built.
80 +Each extension exposes a top-level `build_prompt(agent)` function decorated with `@extensible`, which auto-creates implicit `start` and `end` extension folders. Plugins can hook into these to modify the prompt before or after it's built.
81 +
82 +The implicit path is composed from the function's full module path and full nested `__qualname__` path:
83 +
84 +- `_functions/<module path>/<qualname path>/start`
85 +- `_functions/<module path>/<qualname path>/end`
86 +
87 +For example, a top-level function `build_prompt` in module `extensions.python.system_prompt._10_main_prompt` maps to:
88 +
89 +- `extensions/python/_functions/extensions/python/system_prompt/_10_main_prompt/build_prompt/start/`
90 +- `extensions/python/_functions/extensions/python/system_prompt/_10_main_prompt/build_prompt/end/`
91 +
92 +For nested callables, every namespace-like segment is kept. For example, `helpers.something -> Outer.Inner.__init__` maps to:
93 +
94 +- `_functions/helpers/something/Outer/Inner/__init__/start`
95 +- `_functions/helpers/something/Outer/Inner/__init__/end`
96 +
97 +This deep directory structure avoids collisions between functions that previously would have been flattened into the same extension point name.
98
99 Numbers `_10`–`_14` run before plugin extensions (which start at `_15`+), ensuring core prompt sections are built first.
100
extensions/python/_functions/__main__/init_a0/end/_10_register_watchdogs.py renamed
extensions/python/_functions/agent/Agent/handle_exception/end/_40_handle_intervention_exception.py renamed
extensions/python/_functions/agent/Agent/handle_exception/end/_50_handle_repairable_exception.py renamed
extensions/python/_functions/agent/Agent/handle_exception/end/_90_handle_critical_exception.py renamed
extensions/python/startup_migration/.gitkeep
helpers/backup.py
+3 -6
@@ -7,7 +7,6 @@ import platform
7 from typing import List, Dict, Any, Optional
8
9 from pathspec import PathSpec
10 -from pathspec.patterns.gitwildmatch import GitWildMatchPattern
10
11 from helpers import files, runtime, git
12 from helpers.print_style import PrintStyle
@@ -262,7 +261,7 @@ class BackupService:
261 processed_count = 0
262
263 try:
265 - spec = PathSpec.from_lines(GitWildMatchPattern, pattern_lines)
264 + spec = PathSpec.from_lines("gitignore", pattern_lines)
265
266 # Walk through base directories
267 for base_pattern_path, base_real_path in self.base_paths.items():
@@ -508,8 +507,7 @@ class BackupService:
507
508 if pattern_lines:
509 from pathspec import PathSpec
511 - from pathspec.patterns.gitwildmatch import GitWildMatchPattern
512 - restore_spec = PathSpec.from_lines(GitWildMatchPattern, pattern_lines)
510 + restore_spec = PathSpec.from_lines("gitignore", pattern_lines)
511
512 # Process each file in archive
513 for archive_path in archive_files:
@@ -665,8 +663,7 @@ class BackupService:
663
664 if pattern_lines:
665 from pathspec import PathSpec
668 - from pathspec.patterns.gitwildmatch import GitWildMatchPattern
669 - restore_spec = PathSpec.from_lines(GitWildMatchPattern, pattern_lines)
666 + restore_spec = PathSpec.from_lines("gitignore", pattern_lines)
667
668 # Process each file in archive
669 for archive_path in archive_files:
helpers/extension.py
+30 -9
@@ -52,10 +52,25 @@ def _log_extension_call(name: str):
52 def extensible(func):
53 """Make a function emit two implicit extension points around its execution.
54
55 - The decorator derives two extension point names from the wrapped function:
55 + The decorator derives two extension point folder paths from the wrapped
56 + function:
57
57 - - ``{func.__module__}_{func.__qualname__}_start`` with `.` replaced by `_`
58 - - ``{func.__module__}_{func.__qualname__}_end`` with `.` replaced by `_`
58 + - ``_functions/<module path>/<qualname path>/start``
59 + - ``_functions/<module path>/<qualname path>/end``
60 +
61 + Module path segments come from ``func.__module__`` split by ``.``.
62 + Qualname path segments come from the full nested ``func.__qualname__`` split
63 + by ``.``, excluding ``<locals>``.
64 +
65 + Example:
66 +
67 + - module ``helpers.something``
68 + - qualname ``Outer.Inner.__init__``
69 +
70 + becomes:
71 +
72 + - ``_functions/helpers/something/Outer/Inner/__init__/start``
73 + - ``_functions/helpers/something/Outer/Inner/__init__/end``
74
75 When the wrapped function is called, the decorator builds a mutable ``data``
76 payload and passes it to both extension points:
@@ -72,11 +87,11 @@ def extensible(func):
87
88 Behavior:
89
75 - - ``-start`` extensions run first and may mutate inputs or set
90 + - ``start`` extensions run first and may mutate inputs or set
91 ``data["result"]`` / ``data["exception"]``.
92 - If ``data["result"]`` is still unset, the decorator calls the wrapped
93 function using the possibly modified ``data["args"]`` / ``data["kwargs"]``.
79 - - ``-end`` extensions run last and may rewrite ``data["result"]`` or replace /
94 + - ``end`` extensions run last and may rewrite ``data["result"]`` or replace /
95 clear ``data["exception"]``.
96
97 Finally, if ``data["exception"]`` contains an exception it is raised;
@@ -97,13 +112,19 @@ def extensible(func):
112 return None
113
114 def _prepare_inputs(args, kwargs):
100 - module_name = getattr(func, "__module__", "").replace(".", "_")
101 - qual_name = getattr(func, "__qualname__", "").replace(".", "_")
115 + module_name = getattr(func, "__module__", "")
116 + qual_name = getattr(func, "__qualname__", "")
117 if not module_name or not qual_name:
118 return None
119
105 - start_point = f"{module_name}_{qual_name}_start"
106 - end_point = f"{module_name}_{qual_name}_end"
120 + module_parts = [part for part in module_name.split(".") if part]
121 + qual_parts = [part for part in qual_name.split(".") if part and part != "<locals>"]
122 + if not module_parts or not qual_parts:
123 + return None
124 +
125 + base_path = os.path.join("_functions", *module_parts, *qual_parts)
126 + start_point = os.path.join(base_path, "start")
127 + end_point = os.path.join(base_path, "end")
128
129 agent = _get_agent(args, kwargs)
130
helpers/file_tree.py
+1 -1
@@ -502,7 +502,7 @@ def _resolve_ignore_patterns(ignore: str | None, root_abs_path: str) -> Optional
502 if not lines:
503 return None
504
505 - return PathSpec.from_lines("gitwildmatch", lines)
505 + return PathSpec.from_lines("gitignore", lines)
506
507
508 def _list_directory_children(
helpers/migration.py
+5 -1
@@ -1,7 +1,7 @@
1 import os
2 import json
3 from helpers import files
4 -from helpers import subagents
4 +from helpers import subagents, extension
5 from helpers import yaml as yaml_helper
6 from helpers.print_style import PrintStyle
7
@@ -10,6 +10,10 @@ def startup_migration() -> None:
10 migrate_user_data()
11 convert_agents_json_yaml()
12
13 + extension.call_extensions_sync("startup_migration", None)
14 +
15 +
16 +
17 def migrate_user_data() -> None:
18 """
19 Migrate user data from /tmp and other locations to /usr.
plugins/_email_integration/helpers/imap_client.py
+1 -1
@@ -390,7 +390,7 @@ def _html_to_text(html_content: str, cid_map: dict[str, str] | None = None) -> s
390 if cid_map:
391 soup = BeautifulSoup(html_content, "html.parser")
392 for img in soup.find_all("img"):
393 - src = str(img.get("src", ""))
393 + src = str(img.get("src", "")) # type: ignore
394 if src.startswith("cid:"):
395 cid = src[4:]
396 if cid in cid_map:
plugins/_error_retry/extensions/python/_functions/agent/Agent/handle_exception/end/_80_retry_critical_exception.py renamed
+1 -1
@@ -7,7 +7,7 @@ from helpers.errors import RepairableException, HandledException
7 from helpers import errors
8 from helpers.print_style import PrintStyle
9
10 -from plugins._error_retry.extensions.python.agent_Agent_monologue_start._10_reset_critical_exception_counter import DATA_NAME_COUNTER
10 +from plugins._error_retry.extensions.python._functions.agent.Agent.monologue.start._10_reset_critical_exception_counter import DATA_NAME_COUNTER
11
12 class RetryCriticalException(Extension):
13 async def execute(self, data: dict = {}, **kwargs):
plugins/_error_retry/extensions/python/_functions/agent/Agent/monologue/start/_10_reset_critical_exception_counter.py renamed
plugins/_model_config/extensions/python/_functions/agent/Agent/get_browser_model/start/_10_model_config.py renamed
plugins/_model_config/extensions/python/_functions/agent/Agent/get_chat_model/start/_10_model_config.py renamed
plugins/_model_config/extensions/python/_functions/agent/Agent/get_embedding_model/start/_10_model_config.py renamed
plugins/_model_config/extensions/python/_functions/agent/Agent/get_utility_model/start/_10_model_config.py renamed
plugins/_model_config/extensions/python/startup_migration/_10_migrate_model_config.py renamed
+1 -1
@@ -28,7 +28,7 @@ class MigrateModelConfig(Extension):
28 "browser_model_rl_output", "browser_model_kwargs", "browser_http_headers",
29 ]
30
31 - async def execute(self, **kwargs):
31 + def execute(self, **kwargs):
32 # Check if global plugin config already exists
33 global_config_path = files.get_abs_path("plugins/_model_config/config.json")
34 if os.path.exists(global_config_path):
plugins/_promptinclude/helpers/scanner.py
+1 -1
@@ -131,7 +131,7 @@ def _build_ignore_spec(gitignore: str) -> PathSpec | None:
131 ]
132 if not lines:
133 return None
134 - return PathSpec.from_lines("gitwildmatch", lines)
134 + return PathSpec.from_lines("gitignore", lines)
135
136
137 def _find_matching_files(