Add extensible hooks and improve agent detection

Annotate many Agent and AgentContext methods with @extensible to expose extension points and enable runtime hooks. Refactor python/helpers/extension.py to derive extension points from __qualname__, add a _get_agent helper that more robustly finds an Agent instance in args/kwargs, and skip extension handling when module/qualname cannot be determined. Apply extensible to relevant Flask handlers in run_ui and import the extension helper. Also bump the default sleep_time in the code execution tool from 0.1 to 0.5s to reduce polling frequency.

frdel committed Feb 25, 2026 at 20:32 UTC f7b7683918b7148e113d7ffd3e6372b28de96fd6
4 files changed +62 -16
agent.py
+34 -2
@@ -15,7 +15,7 @@ from python.helpers import (
15 tokens,
16 context as context_helper,
17 dirty_json,
18 - subagents
18 + subagents,
19 )
20 from python.helpers.print_style import PrintStyle
21
@@ -29,7 +29,7 @@ from python.helpers.dirty_json import DirtyJson
29 from python.helpers.defer import DeferredTask
30 from typing import Callable
31 from python.helpers.localization import Localization
32 -from python.helpers.extension import call_extensions
32 +from python.helpers.extension import call_extensions, extensible
33 from python.helpers.errors import RepairableException
34
35
@@ -46,6 +46,7 @@ class AgentContext:
46 _counter: int = 0
47 _notification_manager = None
48
49 + @extensible
50 def __init__(
51 self,
52 config: "AgentConfig",
@@ -151,6 +152,7 @@ class AgentContext:
152 return cls._notification_manager
153
154 @staticmethod
155 + @extensible
156 def remove(id: str):
157 with AgentContext._contexts_lock:
158 context = AgentContext._contexts.pop(id, None)
@@ -174,6 +176,7 @@ class AgentContext:
176 # recursive is not used now, prepared for context hierarchy
177 self.output_data[key] = value
178
179 + @extensible
180 def output(self):
181 return {
182 "id": self.id,
@@ -217,10 +220,12 @@ class AgentContext:
220 )
221 return items
222
223 + @extensible
224 def kill_process(self):
225 if self.task:
226 self.task.kill()
227
228 + @extensible
229 def reset(self):
230 self.kill_process()
231 self.log.reset()
@@ -228,6 +233,7 @@ class AgentContext:
233 self.streaming_agent = None
234 self.paused = False
235
236 + @extensible
237 def nudge(self):
238 self.kill_process()
239 self.paused = False
@@ -240,6 +246,7 @@ class AgentContext:
246 def is_running(self) -> bool:
247 return (self.task and self.task.is_alive()) or False
248
249 + @extensible
250 def communicate(self, msg: "UserMessage", broadcast_level: int = 1):
251 self.paused = False # unpause if paused
252
@@ -259,6 +266,7 @@ class AgentContext:
266
267 return self.task
268
269 + @extensible
270 def run_task(
271 self, func: Callable[..., Coroutine[Any, Any, Any]], *args: Any, **kwargs: Any
272 ):
@@ -270,6 +278,7 @@ class AgentContext:
278 return self.task
279
280 # this wrapper ensures that superior agents are called back if the chat was loaded from file and original callstack is gone
281 + @extensible
282 async def _process_chain(self, agent: "Agent", msg: "UserMessage|str", user=True):
283 try:
284 msg_template = (
@@ -355,6 +364,7 @@ class Agent:
364 DATA_NAME_SUBORDINATE = "_subordinate"
365 DATA_NAME_CTX_WINDOW = "ctx_window"
366
367 + @extensible
368 def __init__(
369 self, number: int, config: AgentConfig, context: AgentContext | None = None
370 ):
@@ -376,6 +386,7 @@ class Agent:
386
387 asyncio.run(self.call_extensions("agent_init"))
388
389 + @extensible
390 async def monologue(self):
391 error_retries = 0 # counter for critical error retries
392 while True:
@@ -530,6 +541,7 @@ class Agent:
541 if self.context.task and self.context.task.is_alive(): # don't call extensions post mortem
542 await self.call_extensions("monologue_end", loop_data=self.loop_data) # type: ignore
543
544 + @extensible
545 async def prepare_prompt(self, loop_data: LoopData) -> list[BaseMessage]:
546 self.context.log.set_progress("Building prompt")
547
@@ -581,6 +593,7 @@ class Agent:
593
594 return full_prompt
595
596 + @extensible
597 async def retry_critical_exception(
598 self, e: Exception, error_retries: int, delay: int = 3, max_retries: int = 1
599 ) -> int:
@@ -606,6 +619,7 @@ class Agent:
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
@@ -634,6 +648,7 @@ class Agent:
648
649 raise HandledException(exception) # Re-raise the exception to kill the loop
650
651 + @extensible
652 async def get_system_prompt(self, loop_data: LoopData) -> list[str]:
653 system_prompt: list[str] = []
654 await self.call_extensions(
@@ -641,6 +656,7 @@ class Agent:
656 )
657 return system_prompt
658
659 + @extensible
660 def parse_prompt(self, _prompt_file: str, **kwargs):
661 dirs = subagents.get_paths(self, "prompts")
662
@@ -649,6 +665,7 @@ class Agent:
665 )
666 return prompt
667
668 + @extensible
669 def read_prompt(self, file: str, **kwargs) -> str:
670 dirs = subagents.get_paths(self, "prompts")
671
@@ -663,6 +680,7 @@ class Agent:
680 def set_data(self, field: str, value):
681 self.data[field] = value
682
683 + @extensible
684 def hist_add_message(
685 self, ai: bool, content: history.MessageContent, tokens: int = 0
686 ):
@@ -676,6 +694,7 @@ class Agent:
694 ai=ai, content=content_data["content"], tokens=tokens
695 )
696
697 + @extensible
698 def hist_add_user_message(self, message: UserMessage, intervention: bool = False):
699 self.history.new_topic() # user message starts a new topic in history
700
@@ -704,15 +723,18 @@ class Agent:
723 self.last_user_message = msg
724 return msg
725
726 + @extensible
727 def hist_add_ai_response(self, message: str):
728 self.loop_data.last_response = message
729 content = self.parse_prompt("fw.ai_response.md", message=message)
730 return self.hist_add_message(True, content=content)
731
732 + @extensible
733 def hist_add_warning(self, message: history.MessageContent):
734 content = self.parse_prompt("fw.warning.md", message=message)
735 return self.hist_add_message(False, content=content)
736
737 + @extensible
738 def hist_add_tool_result(self, tool_name: str, tool_result: str, **kwargs):
739 data = {
740 "tool_name": tool_name,
@@ -727,6 +749,7 @@ class Agent:
749 ): # TODO add param for message range, topic, history
750 return self.history.output_text(human_label="user", ai_label="assistant")
751
752 + @extensible
753 def get_chat_model(self):
754 return models.get_chat_model(
755 self.config.chat_model.provider,
@@ -735,6 +758,7 @@ class Agent:
758 **self.config.chat_model.build_kwargs(),
759 )
760
761 + @extensible
762 def get_utility_model(self):
763 return models.get_chat_model(
764 self.config.utility_model.provider,
@@ -743,6 +767,7 @@ class Agent:
767 **self.config.utility_model.build_kwargs(),
768 )
769
770 + @extensible
771 def get_browser_model(self):
772 return models.get_browser_model(
773 self.config.browser_model.provider,
@@ -751,6 +776,7 @@ class Agent:
776 **self.config.browser_model.build_kwargs(),
777 )
778
779 + @extensible
780 def get_embedding_model(self):
781 return models.get_embedding_model(
782 self.config.embeddings_model.provider,
@@ -759,6 +785,7 @@ class Agent:
785 **self.config.embeddings_model.build_kwargs(),
786 )
787
788 + @extensible
789 async def call_utility_model(
790 self,
791 system: str,
@@ -794,6 +821,7 @@ class Agent:
821
822 return response
823
824 + @extensible
825 async def call_chat_model(
826 self,
827 messages: list[BaseMessage],
@@ -820,6 +848,7 @@ class Agent:
848
849 return response, reasoning
850
851 + @extensible
852 async def rate_limiter_callback(
853 self, message: str, key: str, total: int, limit: int
854 ):
@@ -827,6 +856,7 @@ class Agent:
856 self.context.log.set_progress(message, True)
857 return False
858
859 + @extensible
860 async def handle_intervention(self, progress: str = ""):
861 while self.context.paused:
862 await asyncio.sleep(0.1) # wait if paused
@@ -852,6 +882,7 @@ class Agent:
882 while self.context.paused:
883 await asyncio.sleep(0.1)
884
885 + @extensible
886 async def process_tools(self, msg: str):
887 # search for tool usage requests in agent message
888 tool_request = extract_tools.json_parse_dirty(msg)
@@ -971,6 +1002,7 @@ class Agent:
1002 except Exception as e:
1003 pass
1004
1005 + @extensible
1006 def get_tool(
1007 self,
1008 name: str,
python/helpers/extension.py
+21 -12
@@ -64,20 +64,28 @@ def extensible(func):
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"
67 + qual_name = getattr(func, "__qualname__", "")
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"
75 +
76 + def _get_agent() -> "Agent|None":
77 + candidate = kwargs.get("agent")
78 + if isinstance(candidate, Agent) and bool(getattr(candidate, "__dict__", None)):
79 + return candidate
80 +
81 + for a in args:
82 + if isinstance(a, Agent) and bool(getattr(a, "__dict__", None)):
83 + return a
84 +
85 + return None
86
87 # 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
88 + agent = _get_agent()
89
90 # build extension data object - func input/output
91 data = {
@@ -106,6 +114,7 @@ def extensible(func):
114 data["exception"] = e
115
116 # call end extensions, these can modify outputs or exception
117 + agent = _get_agent()
118 await call_extensions(end_point, agent=agent, data=data)
119
120 # if there's an exception, raise it
python/tools/code_execution_tool.py
+1 -1
@@ -241,7 +241,7 @@ class CodeExecution(Tool):
241 between_output_timeout=15, # Wait up to x seconds between outputs
242 dialog_timeout=5, # potential dialog detection timeout
243 max_exec_timeout=180, # hard cap on total runtime
244 - sleep_time=0.1,
244 + sleep_time=0.5,
245 prefix="",
246 timeouts: dict | None = None,
247 ):
run_ui.py
+6 -1
@@ -11,7 +11,7 @@ from flask import Flask, request, Response, session, redirect, url_for, render_t
11 from werkzeug.wrappers.request import Request as WerkzeugRequest
12
13 import initialize
14 -from python.helpers import files, git, mcp_server, fasta2a_server, settings as settings_helper
14 +from python.helpers import files, git, mcp_server, fasta2a_server, settings as settings_helper, extension
15 from python.helpers.files import get_abs_path
16 from python.helpers import runtime, dotenv, process
17 from python.helpers.websocket import WebSocketHandler, validate_ws_origin
@@ -83,6 +83,7 @@ websocket_manager.set_server_restart_broadcast(
83
84
85 @webapp.route("/login", methods=["GET", "POST"])
86 +@extension.extensible
87 async def login_handler():
88 error = None
89 if request.method == 'POST':
@@ -101,6 +102,7 @@ async def login_handler():
102
103
104 @webapp.route("/logout")
105 +@extension.extensible
106 async def logout_handler():
107 session.pop('authentication', None)
108 return redirect(url_for('login_handler'))
@@ -109,6 +111,7 @@ async def logout_handler():
111 # handle default address, load index
112 @webapp.route("/", methods=["GET"])
113 @requires_auth
114 +@extension.extensible
115 async def serve_index():
116 gitinfo = None
117 try:
@@ -142,6 +145,7 @@ async def serve_plugin_asset(plugin_name, asset_path):
145 return await _serve_plugin_asset(plugin_name, asset_path)
146
147
148 +@extension.extensible
149 async def _serve_plugin_asset(plugin_name, asset_path):
150 """
151 Serve static assets from plugin directories.
@@ -452,6 +456,7 @@ def wait_for_health(host: str, port: int):
456 time.sleep(1)
457
458
459 +@extension.extensible
460 def init_a0():
461 # initialize contexts and MCP
462 init_chats = initialize.initialize_chats()