Unify exception handling via extensible handle_exception

Replace ad-hoc mutable exception_data flows with a single extensible Agent.handle_exception hook. Agent now delegates monologue/message_loop/process exceptions to handle_exception, and many extensions were renamed/updated to use a unified `data` payload (and new extension point naming) instead of `exception_data`. The extensible decorator was adjusted to initialize data['exception']=None and to derive different start/end names, and call_extensions usage was updated accordingly. Other changes: load_webui_extensions now passes agent to plugins.get_webui_extensions, plugin path resolution was improved to accept patterns via get_enabled_plugin_paths, some duplicate/unused message-loop exception extension files were removed, and task_scheduler references to critical-exception handling were adjusted (commented). These changes centralize and standardize exception handling for extensions and simplify extension implementations.

frdel committed Mar 3, 2026 at 10:06 UTC d7f39cd79ba678f740a5b61ff081618a773453b5
14 files changed +40 -58
agent.py
+12 -14
@@ -300,10 +300,12 @@ class AgentContext:
300
301 return response
302 except Exception as e:
303 - exception_data = {"exception": e}
304 - await extension.call_extensions("context_chain_exception", agent=agent, exception_data=exception_data)
305 - if exception_data.get("exception"):
306 - raise exception_data["exception"]
303 + await self.handle_exception("process_chain", e)
304 +
305 + @extensible
306 + async def handle_exception(self, location: str, exception: Exception):
307 + if exception:
308 + raise exception # exception handling is done by extensions
309
310
311 @dataclass
@@ -492,10 +494,7 @@ class Agent:
494
495 # exceptions inside message loop:
496 except Exception as e:
495 - exception_data = { "exception": e }
496 - await self.call_extensions("message_loop_exception", loop_data=self.loop_data, exception_data=exception_data)
497 - if exception_data["exception"]:
498 - raise exception_data["exception"]
497 + await self.handle_exception("message_loop", e)
498
499 finally:
500 # call message_loop_end extensions
@@ -508,10 +507,7 @@ class Agent:
507
508 # exceptions outside message loop:
509 except Exception as e:
511 - exception_data = { "exception": e }
512 - await self.call_extensions("monologue_exception", exception_data=exception_data)
513 - if exception_data["exception"]:
514 - raise exception_data["exception"]
510 + await self.handle_exception("monologue", e)
511 finally:
512 self.context.streaming_agent = None # unset current streamer
513 # call monologue_end extensions
@@ -571,8 +567,10 @@ class Agent:
567 return full_prompt
568
569 @extensible
574 - async def handle_critical_exception(self, exception: Exception):
575 - pass
570 + async def handle_exception(self, location: str, exception: Exception):
571 + if exception:
572 + raise exception # exception handling is done by extensions
573 +
574 # exception_data = {"exception": exception}
575 # await self.call_extensions(
576 # "message_loop_exception", exception_data=exception_data
plugins/error_retry/extensions/python/agent_Agent_handle_exception_end/_80_retry_critical_exception.py renamed
+3 -3
@@ -10,11 +10,11 @@ from python.helpers.print_style import PrintStyle
10 from plugins.error_retry.extensions.python.agent_Agent_monologue_start._10_reset_critical_exception_counter import DATA_NAME_COUNTER
11
12 class RetryCriticalException(Extension):
13 - async def execute(self, exception_data: dict = {}, **kwargs):
13 + async def execute(self, data: dict = {}, **kwargs):
14 if not self.agent:
15 return
16
17 - exception = exception_data.get("exception")
17 + exception = data.get("exception")
18 if not exception:
19 return
20
@@ -47,6 +47,6 @@ class RetryCriticalException(Extension):
47 self.agent.hist_add_warning(message=agent_facing_error)
48 PrintStyle(font_color="orange", padding=True).print(agent_facing_error)
49
50 - exception_data["exception"] = None
50 + data["exception"] = None
51
52
plugins/error_retry/extensions/python/message_loop_end/_80_retry_critical_exception.py deleted
-11
@@ -1,11 +0,0 @@
1 -from datetime import datetime, timezone
2 -from python.helpers.extension import Extension
3 -from agent import LoopData
4 -from python.helpers.localization import Localization
5 -from python.helpers.errors import RepairableException
6 -from python.helpers import errors
7 -from python.helpers.print_style import PrintStyle
8 -
9 -# we can reuse the monologue exception handler here like this
10 -from plugins.error_retry.extensions.python.monologue_exception._80_retry_critical_exception import RetryCriticalException
11 -
plugins/error_retry/extensions/python/message_loop_exception/_80_retry_critical_exception.py deleted
-1
@@ -1 +0,0 @@
1 -from plugins.error_retry.extensions.python.monologue_exception._80_retry_critical_exception import RetryCriticalException
python/api/load_webui_extensions.py
+1 -1
@@ -15,6 +15,6 @@ class LoadWebuiExtensions(ApiHandler):
15 if not extension_point:
16 return Response(status=400, response="Missing extension_point")
17
18 - exts = plugins.get_webui_extensions(extension_point, filters)
18 + exts = plugins.get_webui_extensions(agent=None, extension_point=extension_point, filters=filters)
19
20 return {"extensions": exts or []}
python/extensions/agent_Agent_handle_exception_end/_40_handle_intervention_exception.py renamed
+4 -4
@@ -8,14 +8,14 @@ from python.helpers.print_style import PrintStyle
8
9
10 class HandleInterventionException(Extension):
11 - async def execute(self, exception_data: dict = {}, **kwargs):
11 + async def execute(self, data: dict = {}, **kwargs):
12 if not self.agent:
13 return
14
15 - if not exception_data.get("exception"):
15 + if not data.get("exception"):
16 return
17
18 - if isinstance(exception_data["exception"], InterventionException):
19 - exception_data["exception"] = None # skip the exception and continue message loop
18 + if isinstance(data["exception"], InterventionException):
19 + data["exception"] = None # skip the exception and continue message loop
20
21
python/extensions/agent_Agent_handle_exception_end/_50_handle_repairable_exception.py renamed
+5 -5
@@ -8,19 +8,19 @@ from python.helpers.print_style import PrintStyle
8
9
10 class HandleRepairableException(Extension):
11 - async def execute(self, exception_data: dict = {}, **kwargs):
11 + async def execute(self, data: dict = {}, **kwargs):
12 if not self.agent:
13 return
14
15 - if not exception_data.get("exception"):
15 + if not data.get("exception"):
16 return
17
18 - if isinstance(exception_data["exception"], RepairableException):
19 - msg = {"message": errors.format_error(exception_data["exception"])}
18 + if isinstance(data["exception"], RepairableException):
19 + msg = {"message": errors.format_error(data["exception"])}
20 await self.agent.call_extensions("error_format", msg=msg)
21 self.agent.hist_add_warning(msg["message"])
22 PrintStyle(font_color="red", padding=True).print(msg["message"])
23 self.agent.context.log.log(type="warning", content=msg["message"])
24 - exception_data["exception"] = None
24 + data["exception"] = None
25
26
python/extensions/agent_Agent_handle_exception_end/_90_handle_critical_exception.py renamed
+4 -4
@@ -8,11 +8,11 @@ from python.helpers.errors import HandledException
8
9
10 class HandleCriticalException(Extension):
11 - async def execute(self, exception_data: dict = {}, **kwargs):
11 + async def execute(self, data: dict = {}, **kwargs):
12 if not self.agent:
13 return
14
15 - if not (exception:= exception_data.get("exception")):
15 + if not (exception:= data.get("exception")):
16 return
17
18 # when exception is HandledException, keep it active, no logging here
@@ -24,7 +24,7 @@ class HandleCriticalException(Extension):
24 PrintStyle(font_color="white", background_color="red", padding=True).print(
25 f"Context {self.agent.context.id} terminated during message loop"
26 )
27 - exception_data["exception"] = HandledException(exception)
27 + data["exception"] = HandledException(exception)
28 return
29
30 # other exceptions should be logged and re-raised as HandledException
@@ -40,4 +40,4 @@ class HandleCriticalException(Extension):
40 f"{self.agent.agent_name}: {error_text}"
41 )
42
43 - exception_data["exception"] = HandledException(exception)
43 + data["exception"] = HandledException(exception)
python/extensions/message_loop_exception/_50_handle_repairable_exception.py deleted
-3
@@ -1,3 +0,0 @@
1 -# we can reuse the monologue exception handler here like this
2 -from python.extensions.monologue_exception._50_handle_repairable_exception import HandleRepairableException
3 -
python/extensions/message_loop_exception/_90_handle_critical_exception.py deleted
-2
@@ -1,2 +0,0 @@
1 -# we can reuse the monologue exception handler here like this
2 -from python.extensions.monologue_exception._90_handle_critical_exception import HandleCriticalException
\ No newline at end of file
python/helpers/errors.py
+3
@@ -82,6 +82,9 @@ class RepairableException(Exception):
82 class InterventionException(Exception):
83 """An exception type raised on user intervention, skipping rest of message loop iteration."""
84 pass
85 +class InterventionException(Exception):
86 + """An exception type raised on user intervention, skipping rest of message loop iteration."""
87 + pass
88
89
90 class HandledException(Exception):
python/helpers/extension.py
+3 -4
@@ -31,8 +31,8 @@ def extensible(func):
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``
34 + - ``{func.__module__}_{func.__qualname__}_start`` with `.` replaced by `_`
35 + - ``{func.__module__}_{func.__qualname__}_end`` with `.` replaced by `_`
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``:
@@ -92,7 +92,7 @@ def extensible(func):
92 "args": args,
93 "kwargs": kwargs,
94 "result": _UNSET,
95 - "exception": _UNSET,
95 + "exception": None,
96 }
97
98 # call start extensions, these can modify inputs, produce output or exception
@@ -114,7 +114,6 @@ def extensible(func):
114 data["exception"] = e
115
116 # call end extensions, these can modify outputs or exception
117 - agent = _get_agent()
117 await call_extensions(end_point, agent=agent, data=data)
118
119 # if there's an exception, raise it
python/helpers/plugins.py
+4 -5
@@ -204,9 +204,8 @@ def get_enabled_plugin_paths(agent: Agent | None, *subpaths: str) -> List[str]:
204 paths.append(base_dir)
205 continue
206
207 - path = files.get_abs_path(base_dir, *subpaths)
208 - if files.exists(path):
209 - paths.append(path)
207 + path_pattern = files.get_abs_path(base_dir, *subpaths)
208 + paths.extend(files.find_existing_paths_by_pattern(path_pattern))
209
210 return paths
211
@@ -339,12 +338,12 @@ def toggle_plugin(
338 files.write_file(disabled_file, "")
339
340
342 -def get_webui_extensions(extension_point: str, filters: List[str] | None = None):
341 +def get_webui_extensions(agent: Agent | None, extension_point: str, filters: List[str] | None = None):
342 entries: List[str] = []
343 effective_filters = filters or ["*"]
344
345 for filter in effective_filters:
347 - extensions = get_plugin_paths("extensions", "webui", extension_point, filter)
346 + extensions = get_enabled_plugin_paths(agent, "extensions", "webui", extension_point, filter)
347 for extension in extensions:
348 rel_path = files.deabsolute_path(extension)
349 entries.append(rel_path)
python/helpers/task_scheduler.py
+1 -1
@@ -934,7 +934,7 @@ class TaskScheduler:
934 await self.update_task(task_uuid, state=TaskState.ERROR)
935
936 # if agent:
937 - # await agent.handle_critical_exception(e)
937 + # await agent.handle_exception("scheduler", e)
938 finally:
939 # Call on_finish for task-specific cleanup
940 try: