Move exception handling to extensions

Refactors exception handling to use extension points and plugins. Moves InterventionException and HandledException into python/helpers/errors and routes exceptions from Agent (monologue and outer loops) through extension call points (monologue_exception, message_loop_exception, context_chain_exception) instead of inline handling. Removes inlined retry/critical logic and adds an error_retry plugin (reset counter + retry extension) as the retry implementation; also adds monologue_exception extensions to handle repairable, intervention, and critical exceptions. Adjusts extension hook naming in python/helpers/extension, adds agent-presence guards to several memory extensions, prevents a direct agent.handle_critical_exception call in TaskScheduler, renames a test file location, and fixes a webui modal path.

frdel committed Mar 2, 2026 at 16:51 UTC 0372c05f2b5d1830ddf0a4982dfcd61130ef213f
22 files changed +274 -100
agent.py
+52 -93
@@ -17,6 +17,7 @@ from python.helpers import (
17 dirty_json,
18 subagents,
19 )
20 +from python.helpers import extension
21 from python.helpers.print_style import PrintStyle
22
23 from langchain_core.prompts import (
@@ -30,7 +31,7 @@ from python.helpers.defer import DeferredTask
31 from typing import Callable
32 from python.helpers.localization import Localization
33 from python.helpers.extension import call_extensions, extensible
33 -from python.helpers.errors import RepairableException
34 +from python.helpers.errors import RepairableException, InterventionException, HandledException
35
36
37 class AgentContextType(Enum):
@@ -240,6 +241,7 @@ class AgentContext:
241 self.task = self.communicate(UserMessage(self.agent0.read_prompt("fw.msg_nudge.md")))
242 return self.task
243
244 + @extensible
245 def get_agent(self):
246 return self.streaming_agent or self.agent0
247
@@ -298,7 +300,10 @@ class AgentContext:
300
301 return response
302 except Exception as e:
301 - agent.handle_critical_exception(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"]
307
308
309 @dataclass
@@ -346,18 +351,6 @@ class LoopData:
351 setattr(self, key, value)
352
353
349 -# intervention exception class - skips rest of message loop iteration
350 -class InterventionException(Exception):
351 - pass
352 -
353 -
354 -# killer exception class - not forwarded to LLM, cannot be fixed on its own, ends message loop
355 -
356 -
357 -class HandledException(Exception):
358 - pass
359 -
360 -
354 class Agent:
355
356 DATA_NAME_SUPERIOR = "_superior"
@@ -388,7 +381,6 @@ class Agent:
381
382 @extensible
383 async def monologue(self):
391 - error_retries = 0 # counter for critical error retries
384 while True:
385 try:
386 # loop data dictionary to pass to extensions
@@ -498,24 +490,12 @@ class Agent:
490 if tools_result: # final response of message loop available
491 return tools_result # break the execution if the task is done
492
501 - error_retries = 0 # reset retry counter on successful iteration
502 -
493 # exceptions inside message loop:
504 - except InterventionException as e:
505 - error_retries = 0 # reset retry counter on user intervention
506 - pass # intervention message has been handled in handle_intervention(), proceed with conversation loop
507 - except RepairableException as e:
508 - # Forward repairable errors to the LLM, maybe it can fix them
509 - msg = {"message": errors.format_error(e)}
510 - await self.call_extensions("error_format", msg=msg)
511 - self.hist_add_warning(msg["message"])
512 - PrintStyle(font_color="red", padding=True).print(msg["message"])
513 - self.context.log.log(type="warning", content=msg["message"])
494 except Exception as e:
515 - # Retry critical exceptions before failing
516 - error_retries = await self.retry_critical_exception(
517 - e, error_retries
518 - )
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"]
499
500 finally:
501 # call message_loop_end extensions
@@ -527,14 +507,11 @@ class Agent:
507
508
509 # exceptions outside message loop:
530 - except InterventionException as e:
531 - error_retries = 0 # reset retry counter on user intervention
532 - pass # just start over
510 except Exception as e:
534 - # Retry critical exceptions before failing
535 - error_retries = await self.retry_critical_exception(
536 - e, error_retries
537 - )
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"]
515 finally:
516 self.context.streaming_agent = None # unset current streamer
517 # call monologue_end extensions
@@ -594,59 +571,42 @@ class Agent:
571 return full_prompt
572
573 @extensible
597 - async def retry_critical_exception(
598 - self, e: Exception, error_retries: int, delay: int = 3, max_retries: int = 1
599 - ) -> int:
600 - if error_retries >= max_retries:
601 - self.handle_critical_exception(e)
602 -
603 - error_message = errors.format_error(e)
604 -
605 - self.context.log.log(
606 - type="warning", heading="Critical error occurred, retrying...", content=error_message
607 - )
608 - PrintStyle(font_color="orange", padding=True).print(
609 - "Critical error occurred, retrying..."
610 - )
611 - await asyncio.sleep(delay)
612 - await self.handle_intervention()
613 - agent_facing_error = self.read_prompt(
614 - "fw.msg_critical_error.md", error_message=error_message
615 - )
616 - self.hist_add_warning(message=agent_facing_error)
617 - PrintStyle(font_color="orange", padding=True).print(
618 - agent_facing_error
619 - )
620 - return error_retries + 1
621 -
622 - @extensible
623 - def handle_critical_exception(self, exception: Exception):
624 - if isinstance(exception, HandledException):
625 - raise exception # Re-raise the exception to kill the loop
626 - elif isinstance(exception, asyncio.CancelledError):
627 - # Handling for asyncio.CancelledError
628 - PrintStyle(font_color="white", background_color="red", padding=True).print(
629 - f"Context {self.context.id} terminated during message loop"
630 - )
631 - raise HandledException(
632 - exception
633 - ) # Re-raise the exception to cancel the loop
634 - else:
635 - # Handling for general exceptions
636 - error_text = errors.error_text(exception)
637 - error_message = errors.format_error(exception)
638 -
639 - # Mask secrets in error messages
640 - PrintStyle(font_color="red", padding=True).print(error_message)
641 - self.context.log.log(
642 - type="error",
643 - content=error_message,
644 - )
645 - PrintStyle(font_color="red", padding=True).print(
646 - f"{self.agent_name}: {error_text}"
647 - )
648 -
649 - raise HandledException(exception) # Re-raise the exception to kill the loop
574 + async def handle_critical_exception(self, exception: Exception):
575 + pass
576 + # exception_data = {"exception": exception}
577 + # await self.call_extensions(
578 + # "message_loop_exception", exception_data=exception_data
579 + # )
580 +
581 + # # If extensions cleared the exception, continue.
582 + # if not exception_data.get("exception"):
583 + # return
584 +
585 + # # Backwards-compatible fallback (should normally be handled by _90 extension).
586 + # exception = exception_data["exception"]
587 + # if isinstance(exception, HandledException):
588 + # raise exception
589 + # elif isinstance(exception, asyncio.CancelledError):
590 + # PrintStyle(font_color="white", background_color="red", padding=True).print(
591 + # f"Context {self.context.id} terminated during message loop"
592 + # )
593 + # raise HandledException(exception)
594 +
595 + # else:
596 + # error_text = errors.error_text(exception)
597 + # error_message = errors.format_error(exception)
598 +
599 + # # Mask secrets in error messages
600 + # PrintStyle(font_color="red", padding=True).print(error_message)
601 + # self.context.log.log(
602 + # type="error",
603 + # content=error_message,
604 + # )
605 + # PrintStyle(font_color="red", padding=True).print(
606 + # f"{self.agent_name}: {error_text}"
607 + # )
608 +
609 + # raise HandledException(exception) # Re-raise the exception to kill the loop
610
611 @extensible
612 async def get_system_prompt(self, loop_data: LoopData) -> list[str]:
@@ -858,8 +818,7 @@ class Agent:
818
819 @extensible
820 async def handle_intervention(self, progress: str = ""):
861 - while self.context.paused:
862 - await asyncio.sleep(0.1) # wait if paused
821 + await self.wait_if_paused()
822 if (
823 self.intervention
824 ): # if there is an intervention message, but not yet processed
plugins/error_retry/extensions/python/agent_Agent_monologue_start/_10_reset_critical_exception_counter.py new
+18
@@ -0,0 +1,18 @@
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 +DATA_NAME_COUNTER = "_plugin.error_retry.critical_exception_counter"
10 +
11 +class ResetCriticalExceptionCounter(Extension):
12 + async def execute(self, exception_data: dict = {}, **kwargs):
13 + if not self.agent:
14 + return
15 +
16 + self.agent.set_data(DATA_NAME_COUNTER, 0)
17 +
18 +
\ No newline at end of file
plugins/error_retry/extensions/python/message_loop_end/_80_retry_critical_exception.py new
+11
@@ -0,0 +1,11 @@
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 new
+1
@@ -0,0 +1 @@
1 +from plugins.error_retry.extensions.python.monologue_exception._80_retry_critical_exception import RetryCriticalException
plugins/error_retry/extensions/python/monologue_exception/_80_retry_critical_exception.py new
+52
@@ -0,0 +1,52 @@
1 +import asyncio
2 +from datetime import datetime, timezone
3 +from python.helpers.extension import Extension
4 +from agent import LoopData
5 +from python.helpers.localization import Localization
6 +from python.helpers.errors import RepairableException, HandledException
7 +from python.helpers import errors
8 +from python.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
11 +
12 +class RetryCriticalException(Extension):
13 + async def execute(self, exception_data: dict = {}, **kwargs):
14 + if not self.agent:
15 + return
16 +
17 + exception = exception_data.get("exception")
18 + if not exception:
19 + return
20 +
21 + if isinstance(exception, (HandledException, RepairableException)):
22 + return
23 +
24 + max_retries = 1
25 + delay = 3
26 +
27 + counter = self.agent.get_data(DATA_NAME_COUNTER) or 0
28 + if counter >= max_retries:
29 + return
30 +
31 + self.agent.set_data(DATA_NAME_COUNTER, counter + 1)
32 +
33 + error_message = errors.format_error(exception)
34 + self.agent.context.log.log(
35 + type="warning",
36 + heading="Critical error occurred, retrying...",
37 + content=error_message,
38 + )
39 + PrintStyle(font_color="orange", padding=True).print(
40 + "Critical error occurred, retrying..."
41 + )
42 + await asyncio.sleep(delay)
43 + await self.agent.handle_intervention()
44 + agent_facing_error = self.agent.read_prompt(
45 + "fw.msg_critical_error.md", error_message=error_message
46 + )
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
51 +
52 +
plugins/error_retry/plugin.yaml new
+7
@@ -0,0 +1,7 @@
1 +title: Error retry
2 +description: Retry on critical exceptions before failing
3 +version: 1.0.0
4 +settings_sections: ["agent"]
5 +per_project_config: true
6 +per_agent_config: true
7 +always_enabled: false
plugins/memory/extensions/python/message_loop_prompts_after/_50_recall_memories.py
+4
@@ -24,6 +24,8 @@ class RecallMemories(Extension):
24 # THRESHOLD = DEFAULT_MEMORY_THRESHOLD
25
26 async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
27 + if not self.agent:
28 + return
29
30 set = plugins.get_plugin_config("memory", self.agent)
31 if not set:
@@ -56,6 +58,8 @@ class RecallMemories(Extension):
58 self.agent.set_data(DATA_NAME_ITER, loop_data.iteration)
59
60 async def search_memories(self, log_item: log.LogItem, loop_data: LoopData, **kwargs):
61 + if not self.agent:
62 + return
63
64 # cleanup
65 extras = loop_data.extras_persistent
plugins/memory/extensions/python/message_loop_prompts_after/_91_recall_wait.py
+3
@@ -6,6 +6,9 @@ from python.helpers import plugins
6 class RecallWait(Extension):
7 async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
8
9 + if not self.agent:
10 + return
11 +
12 set = plugins.get_plugin_config("memory", self.agent)
13 if not set:
14 return None
plugins/memory/extensions/python/monologue_end/_50_memorize_fragments.py
+4
@@ -15,6 +15,8 @@ class MemorizeMemories(Extension):
15
16 async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
17 # try:
18 + if not self.agent:
19 + return
20
21 set = plugins.get_plugin_config("memory", self.agent)
22 if not set:
@@ -36,6 +38,8 @@ class MemorizeMemories(Extension):
38 return task
39
40 async def memorize(self, loop_data: LoopData, log_item: LogItem, **kwargs):
41 + if not self.agent:
42 + return
43
44 try:
45 set = plugins.get_plugin_config("memory", self.agent)
plugins/memory/extensions/python/monologue_end/_51_memorize_solutions.py
+6
@@ -14,6 +14,8 @@ class MemorizeSolutions(Extension):
14
15 async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
16 # try:
17 + if not self.agent:
18 + return
19
20 set = plugins.get_plugin_config("memory", self.agent)
21 if not set:
@@ -35,7 +37,11 @@ class MemorizeSolutions(Extension):
37 return task
38
39 async def memorize(self, loop_data: LoopData, log_item: LogItem, **kwargs):
40 + if not self.agent:
41 + return
42 +
43 try:
44 +
45 set = plugins.get_plugin_config("memory", self.agent)
46 if not set:
47 return None
plugins/memory/extensions/python/monologue_start/_10_memory_init.py
+3
@@ -8,4 +8,7 @@ from plugins.memory.helpers import memory
8 class MemoryInit(Extension):
9
10 async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
11 + if not self.agent:
12 + return
13 +
14 db = await memory.Memory.get(self.agent)
plugins/memory/extensions/python/system_prompt/_20_behaviour_prompt.py
+3
@@ -9,6 +9,9 @@ from plugins.memory.helpers import memory
9 class BehaviourPrompt(Extension):
10
11 async def execute(self, system_prompt: list[str]=[], loop_data: LoopData = LoopData(), **kwargs):
12 + if not self.agent:
13 + return
14 +
15 prompt = read_rules(self.agent)
16 system_prompt.insert(0, prompt)
17
python/extensions/message_loop_exception/_50_handle_repairable_exception.py new
+3
@@ -0,0 +1,3 @@
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 new
+2
@@ -0,0 +1,2 @@
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/extensions/monologue_exception/_40_handle_intervention_exception.py new
+21
@@ -0,0 +1,21 @@
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 InterventionException
6 +from python.helpers import errors
7 +from python.helpers.print_style import PrintStyle
8 +
9 +
10 +class HandleInterventionException(Extension):
11 + async def execute(self, exception_data: dict = {}, **kwargs):
12 + if not self.agent:
13 + return
14 +
15 + if not exception_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
20 +
21 +
python/extensions/monologue_exception/_50_handle_repairable_exception.py new
+26
@@ -0,0 +1,26 @@
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 +
10 +class HandleRepairableException(Extension):
11 + async def execute(self, exception_data: dict = {}, **kwargs):
12 + if not self.agent:
13 + return
14 +
15 + if not exception_data.get("exception"):
16 + return
17 +
18 + if isinstance(exception_data["exception"], RepairableException):
19 + msg = {"message": errors.format_error(exception_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
25 +
26 +
python/extensions/monologue_exception/_90_handle_critical_exception.py new
+43
@@ -0,0 +1,43 @@
1 +import asyncio
2 +
3 +from python.helpers.extension import Extension
4 +from python.helpers.print_style import PrintStyle
5 +from python.helpers import errors
6 +
7 +from python.helpers.errors import HandledException
8 +
9 +
10 +class HandleCriticalException(Extension):
11 + async def execute(self, exception_data: dict = {}, **kwargs):
12 + if not self.agent:
13 + return
14 +
15 + if not (exception:= exception_data.get("exception")):
16 + return
17 +
18 + # when exception is HandledException, keep it active, no logging here
19 + if isinstance(exception, HandledException):
20 + return
21 +
22 + # asyncio cancel - chat is being terminated, print out and re-raise as handledException
23 + if isinstance(exception, asyncio.CancelledError):
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)
28 + return
29 +
30 + # other exceptions should be logged and re-raised as HandledException
31 + error_text = errors.error_text(exception)
32 + error_message = errors.format_error(exception)
33 +
34 + PrintStyle(font_color="red", padding=True).print(error_message)
35 + self.agent.context.log.log(
36 + type="error",
37 + content=error_message,
38 + )
39 + PrintStyle(font_color="red", padding=True).print(
40 + f"{self.agent.agent_name}: {error_text}"
41 + )
42 +
43 + exception_data["exception"] = HandledException(exception)
python/helpers/errors.py
+8
@@ -78,3 +78,11 @@ def format_error(e: Exception, start_entries=20, end_entries=15, error_message_p
78 class RepairableException(Exception):
79 """An exception type indicating errors that can be surfaced to the LLM for potential self-repair."""
80 pass
81 +
82 +class InterventionException(Exception):
83 + """An exception type raised on user intervention, skipping rest of message loop iteration."""
84 + pass
85 +
86 +
87 +class HandledException(Exception):
88 + pass
\ No newline at end of file
python/helpers/extension.py
+4 -4
@@ -63,15 +63,15 @@ def extensible(func):
63 from agent import Agent
64
65 # prepare extension points data
66 - module_name = getattr(func, "__module__", "")
67 - qual_name = getattr(func, "__qualname__", "")
66 + module_name = getattr(func, "__module__", "").replace(".", "_")
67 + qual_name = getattr(func, "__qualname__", "").replace(".", "_")
68
69 # skip if extension point cannot be determined
70 if not module_name or not qual_name:
71 return await func(*args, **kwargs)
72
73 - start_point = f"{module_name}.{qual_name}-start"
74 - end_point = f"{module_name}.{qual_name}-end"
73 + start_point = f"{module_name}_{qual_name}_start"
74 + end_point = f"{module_name}_{qual_name}_end"
75
76 def _get_agent() -> "Agent|None":
77 candidate = kwargs.get("agent")
python/helpers/task_scheduler.py
+2 -2
@@ -933,8 +933,8 @@ class TaskScheduler:
933 PrintStyle.warning(f"Fixing task state consistency: '{current_task.name}' state is not ERROR after failure")
934 await self.update_task(task_uuid, state=TaskState.ERROR)
935
936 - if agent:
937 - agent.handle_critical_exception(e)
936 + # if agent:
937 + # await agent.handle_critical_exception(e)
938 finally:
939 # Call on_finish for task-specific cleanup
940 try:
tests/test_webui_extension_surfaces.py renamed
webui/components/sidebar/top-section/quick-actions.html
+1 -1
@@ -56,7 +56,7 @@
56 <span>Projects</span>
57 </button>
58
59 - <button class="dropdown-item" @click="openModal('components/scheduler/scheduler-modal.html'); dropdownOpen = false">
59 + <button class="dropdown-item" @click="openModal('components/modals/scheduler/scheduler-modal.html'); dropdownOpen = false">
60 <span class="material-symbols-outlined">schedule</span>
61 <span>Scheduler</span>
62 </button>