1
from abc import abstractmethod
2
from typing import Any
3
from python.helpers import extract_tools, files
4
+from python.helpers import cache
5
from typing import TYPE_CHECKING
6
+from functools import wraps
7
+import asyncio
8
+import inspect
9
10
if TYPE_CHECKING:
11
from agent import Agent
14
DEFAULT_EXTENSIONS_FOLDER = "python/extensions"
15
USER_EXTENSIONS_FOLDER = "usr/extensions"
16
13
-_cache: dict[str, list[type["Extension"]]] = {}
17
+_CACHE_AREA = "extension_folder_classes(extensions)(plugins)"
18
+cache.toggle_area(_CACHE_AREA, False) # cache off for now
19
+
20
+
21
+class _Unset:
22
+ pass
23
+
24
+
25
+_UNSET = _Unset()
26
+
27
+
28
+# decorator to enable implicit extension points in existing functions
29
+def extensible(func):
30
+ """Make a function emit two implicit extension points around its execution.
31
+
32
+ The decorator derives two extension point names from the wrapped function:
33
+
34
+ - ``{func.__module__}.{func.__name__}-start``
35
+ - ``{func.__module__}.{func.__name__}-end``
36
+
37
+ When the wrapped function is called, the decorator builds a mutable ``data``
38
+ payload and passes it to both extension points via ``call_extensions``:
39
+
40
+ - ``data["args"]``: the original positional arguments tuple
41
+ - ``data["kwargs"]``: the original keyword arguments dict
42
+ - ``data["result"]``: initialized to an internal sentinel; extensions may
43
+ set this to short-circuit the wrapped function
44
+ - ``data["exception"]``: initialized to an internal sentinel; extensions may
45
+ set this to a ``BaseException`` instance to force-raise
46
+
47
+ Behavior:
48
+
49
+ - ``-start`` extensions run first and may mutate ``data["args"]`` /
50
+ ``data["kwargs"]``, set ``data["result"]`` to skip calling ``func``, or set
51
+ ``data["exception"]`` to abort by raising.
52
+ - If ``data["result"]`` is still unset, the decorator calls ``func`` (awaiting
53
+ it if it is async) and stores either the return value into ``data["result"]``
54
+ or the raised error into ``data["exception"]``.
55
+ - ``-end`` extensions run last and may further transform the outcome by
56
+ rewriting ``data["result"]`` or replacing/clearing ``data["exception"]``.
57
+
58
+ Finally, if ``data["exception"]`` contains an exception it is raised;
59
+ otherwise ``data["result"]`` is returned.
60
+ """
61
+ @wraps(func)
62
+ async def _inner_async(*args, **kwargs):
63
+ from agent import Agent
64
+
65
+ # prepare extension points data
66
+ module_name = getattr(func, "__module__", "")
67
+ func_name = getattr(func, "__name__", "")
68
+ start_point = f"{module_name}.{func_name}-start"
69
+ end_point = f"{module_name}.{func_name}-end"
70
+
71
+ # try to find agent instance for better extension determination
72
+ agent = kwargs.get("agent")
73
+ if (not agent or not isinstance(agent, Agent)) and args:
74
+ try:
75
+ for a in args:
76
+ if isinstance(a, Agent):
77
+ agent = a
78
+ break
79
+ except Exception:
80
+ agent = None
81
+
82
+ # build extension data object - func input/output
83
+ data = {
84
+ "args": args,
85
+ "kwargs": kwargs,
86
+ "result": _UNSET,
87
+ "exception": _UNSET,
88
+ }
89
+
90
+ # call start extensions, these can modify inputs, produce output or exception
91
+ await call_extensions(start_point, agent=agent, data=data)
92
+
93
+ # if there is an explicit exception set, raise it
94
+ exc = data.get("exception")
95
+ if isinstance(exc, BaseException):
96
+ raise exc
97
+
98
+ # if there is no result set, call the original function
99
+ if data.get("result") is _UNSET:
100
+ try:
101
+ if inspect.iscoroutinefunction(func):
102
+ data["result"] = await func(*args, **kwargs)
103
+ else:
104
+ data["result"] = func(*args, **kwargs)
105
+ except Exception as e:
106
+ data["exception"] = e
107
+
108
+ # call end extensions, these can modify outputs or exception
109
+ await call_extensions(end_point, agent=agent, data=data)
110
+
111
+ # if there's an exception, raise it
112
+ exc = data.get("exception")
113
+ if isinstance(exc, BaseException):
114
+ raise exc
115
+
116
+ # if there's a result, return it
117
+ result = data.get("result")
118
+ return None if result is _UNSET else result
119
+
120
+ if inspect.iscoroutinefunction(func):
121
+ return _inner_async
122
+
123
+ @wraps(func)
124
+ def _inner_sync(*args, **kwargs):
125
+ return asyncio.run(_inner_async(*args, **kwargs))
126
+
127
+ return _inner_sync
128
129
130
class Extension:
131
132
def __init__(self, agent: "Agent|None", **kwargs):
19
- self.agent: "Agent" = agent # type: ignore < here we ignore the type check as there are currently no extensions without an agent
133
+ self.agent: "Agent|None" = agent
134
self.kwargs = kwargs
135
136
@abstractmethod
144
from python.helpers import projects, subagents, plugins
145
146
# search for extension folders in all agent's paths
33
- paths = subagents.get_paths(agent, "extensions", extension_point, default_root="python")
147
+ paths = subagents.get_paths(
148
+ agent, "extensions", extension_point, default_root="python"
149
+ )
150
151
# Add plugin backend extension paths (plugins/*/extensions/python/{extension_point})
36
- plugin_paths = plugins.get_enabled_plugin_paths(agent, "extensions", "python", extension_point)
152
+ plugin_paths = plugins.get_enabled_plugin_paths(
153
+ agent, "extensions", "python", extension_point
154
+ )
155
paths.extend(p for p in plugin_paths if p not in paths)
156
157
all_exts = [cls for path in paths for cls in _get_extensions(path)]
176
177
178
def _get_extensions(folder: str):
61
- global _cache
179
folder = files.get_abs_path(folder)
63
- if folder in _cache:
64
- classes = _cache[folder]
65
- else:
66
- if not files.exists(folder):
67
- return []
68
- classes = extract_tools.load_classes_from_folder(folder, "*", Extension)
69
- _cache[folder] = classes
180
+ cached = cache.get(_CACHE_AREA, folder)
181
+ if cached is not None:
182
+ return cached
183
+
184
+ if not files.exists(folder):
185
+ return []
186
187
+ classes = extract_tools.load_classes_from_folder(folder, "*", Extension)
188
+ cache.add(_CACHE_AREA, folder, classes)
189
return classes