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(